diff --git a/AGENTS.md b/AGENTS.md index 763b3f10e5..997c4bbaff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,6 +62,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST: | Action | Skill | |--------|-------| | Add changelog entry for a PR or feature | `prowler-changelog` | +| Adding ConfigRequirements guardrails to compliance requirements | `prowler-compliance` | | Adding DRF pagination or permissions | `django-drf` | | Adding a compliance output formatter (per-provider class + table dispatcher) | `prowler-compliance` | | Adding indexes or constraints to database tables | `django-migration-psql` | @@ -84,6 +85,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST: | Creating ViewSets, serializers, or filters in api/ | `django-drf` | | Creating Zod schemas | `zod-4` | | Creating a git commit | `prowler-commit` | +| Creating a universal (multi-provider) compliance framework | `prowler-compliance` | | Creating new checks | `prowler-sdk-check` | | Creating new skills | `skill-creator` | | Creating or reviewing Django migrations | `django-migration-psql` | diff --git a/README.md b/README.md index c58d903c08..76e6534551 100644 --- a/README.md +++ b/README.md @@ -123,12 +123,12 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically | Provider | Checks | Services | [Compliance Frameworks](https://docs.prowler.com/user-guide/compliance/tutorials/compliance) | [Categories](https://docs.prowler.com/user-guide/cli/tutorials/misc#categories) | Support | Interface | |---|---|---|---|---|---|---| -| AWS | 615 | 86 | 47 | 19 | Official | UI, API, CLI | -| Azure | 190 | 22 | 21 | 16 | Official | UI, API, CLI | +| AWS | 621 | 86 | 47 | 19 | Official | UI, API, CLI | +| Azure | 191 | 22 | 21 | 16 | Official | UI, API, CLI | | GCP | 109 | 20 | 19 | 12 | Official | UI, API, CLI | -| Kubernetes | 90 | 7 | 8 | 11 | Official | UI, API, CLI | +| Kubernetes | 92 | 7 | 8 | 11 | Official | UI, API, CLI | | GitHub | 24 | 3 | 2 | 5 | Official | UI, API, CLI | -| M365 | 109 | 10 | 6 | 10 | Official | UI, API, CLI | +| M365 | 111 | 10 | 6 | 10 | Official | UI, API, CLI | | OCI | 52 | 14 | 5 | 10 | Official | UI, API, CLI | | Alibaba Cloud | 63 | 9 | 6 | 9 | Official | UI, API, CLI | | Cloudflare | 29 | 3 | 2 | 5 | Official | UI, API, CLI | diff --git a/api/changelog.d/compliance-overview-single-transaction.changed.md b/api/changelog.d/compliance-overview-single-transaction.changed.md new file mode 100644 index 0000000000..0e7a6714b3 --- /dev/null +++ b/api/changelog.d/compliance-overview-single-transaction.changed.md @@ -0,0 +1 @@ +Compliance overview ingest now runs in a single transaction per scan with a configurable `COPY` batch size (`DJANGO_COMPLIANCE_COPY_BATCH_SIZE`, default 2000), reducing write pressure on the database diff --git a/api/src/backend/tasks/jobs/scan.py b/api/src/backend/tasks/jobs/scan.py index 39db32abe4..36dc806ff3 100644 --- a/api/src/backend/tasks/jobs/scan.py +++ b/api/src/backend/tasks/jobs/scan.py @@ -6,7 +6,7 @@ import re import time import uuid from collections import defaultdict -from collections.abc import Iterable +from collections.abc import Callable, Iterable from datetime import UTC, datetime from typing import Any @@ -49,6 +49,7 @@ from celery.utils.log import get_task_logger from config.django.base import DJANGO_FINDINGS_BATCH_SIZE from config.env import env from config.settings.celery import CELERY_DEADLOCK_ATTEMPTS +from django.core.exceptions import ImproperlyConfigured from django.db import DatabaseError, IntegrityError, OperationalError, transaction from django.db.models import ( Case, @@ -99,6 +100,16 @@ COMPLIANCE_REQUIREMENT_COPY_COLUMNS = ( FINDINGS_MICRO_BATCH_SIZE = env.int("DJANGO_FINDINGS_MICRO_BATCH_SIZE", default=3000) # Controls how many rows each ORM bulk_create/bulk_update call sends to Postgres. SCAN_DB_BATCH_SIZE = env.int("DJANGO_SCAN_DB_BATCH_SIZE", default=1000) +# Rows per COPY statement when ingesting compliance requirement overviews. All +# batches of a scan share one transaction/commit; the batch size only bounds the +# client-side CSV buffer and how long each individual COPY statement runs on the +# writer (memory footprint, lock time and slow-statement logging under load). +COMPLIANCE_COPY_BATCH_SIZE = env.int("DJANGO_COMPLIANCE_COPY_BATCH_SIZE", default=2000) +if COMPLIANCE_COPY_BATCH_SIZE < 1: + raise ImproperlyConfigured( + "DJANGO_COMPLIANCE_COPY_BATCH_SIZE must be a positive integer, got " + f"{COMPLIANCE_COPY_BATCH_SIZE}" + ) # Throttle scan progress persistence: minimum progress delta (fraction 0-1) # between two persisted progress updates. PROGRESS_THROTTLE_DELTA = env.float("DJANGO_SCAN_PROGRESS_THROTTLE_DELTA", default=0.01) @@ -356,30 +367,36 @@ def _bulk_update_resource_failed_findings_counts( raise -def _copy_compliance_requirement_rows( - tenant_id: str, rows: list[dict[str, Any]] -) -> None: - """Stream compliance requirement rows into Postgres using COPY. +class ComplianceRowScopeError(ValueError): + """A compliance requirement row does not belong to the scan being ingested.""" - We leverage the admin connection (when available) to bypass the COPY + RLS - restriction, writing only the fields required by - ``ComplianceRequirementOverview``. - Args: - tenant_id: Target tenant UUID. - rows: List of row dictionaries prepared by - :func:`create_compliance_requirements`. +def _compliance_requirement_rows_to_csv( + rows: list[dict[str, Any]], tenant_id: str, scan_id: str +) -> io.StringIO: + """Serialize compliance requirement rows into a CSV buffer for COPY. + + COPY runs on the admin connection, which bypasses RLS, so every row is + checked against the expected tenant/scan before it is written: a mismatched + row would otherwise be inserted verbatim into another tenant's data. """ - csv_buffer = io.StringIO() writer = csv.writer(csv_buffer) datetime_now = datetime.now(tz=UTC) for row in rows: + row_tenant_id = str(row.get("tenant_id")) + row_scan_id = str(row.get("scan_id")) + if row_tenant_id != tenant_id or row_scan_id != scan_id: + raise ComplianceRowScopeError( + "Compliance requirement row does not belong to the scan being " + f"ingested (expected tenant {tenant_id} / scan {scan_id}, got " + f"tenant {row_tenant_id} / scan {row_scan_id})" + ) writer.writerow( [ str(row.get("id")), - str(row.get("tenant_id")), + row_tenant_id, (row.get("inserted_at") or datetime_now).isoformat(), row.get("compliance_id") or "", row.get("framework") or "", @@ -393,65 +410,100 @@ def _copy_compliance_requirement_rows( row.get("total_checks", 0), row.get("passed_findings", 0), row.get("total_findings", 0), - str(row.get("scan_id")), + row_scan_id, ] ) csv_buffer.seek(0) + return csv_buffer + + +def _copy_compliance_requirement_rows( + tenant_id: str, scan_id: str, rows: Iterable[dict[str, Any]], batch_size: int +) -> int: + """Replace a scan's compliance requirement rows using batched COPY. + + We leverage the admin connection (when available) to bypass the COPY + RLS + restriction. The scan's DELETE and every COPY batch run on one connection + inside a single transaction with a single commit, so the writer takes one + fsync per scan instead of one per batch, and a failed ingest rolls back + without committing a partial delete/insert (which a retry would otherwise + delete again, feeding dead rows to autovacuum). + + Args: + tenant_id: Target tenant UUID. + scan_id: Scan whose previous rows are replaced. + rows: Iterable of row dictionaries, consumed lazily batch by batch. + batch_size: Number of rows per COPY statement. + + Returns: + int: total number of rows staged and committed. + + Raises: + ComplianceRowScopeError: A row belongs to another tenant or scan. + """ + # Normalized once so the per-row scope check compares like with like even if + # the caller passes UUID instances instead of strings. + tenant_id = str(tenant_id) + scan_id = str(scan_id) + total_rows = 0 + batch_num = 0 copy_sql = ( "COPY compliance_requirements_overviews (" + ", ".join(COMPLIANCE_REQUIREMENT_COPY_COLUMNS) + ") FROM STDIN WITH (FORMAT CSV, DELIMITER ',', QUOTE '\"', ESCAPE '\"', NULL '\\N')" ) - try: - with psycopg_connection(MainRouter.admin_db) as connection: - connection.autocommit = False - try: - with connection.cursor() as cursor: - cursor.execute(SET_CONFIG_QUERY, [POSTGRES_TENANT_VAR, tenant_id]) - cursor.copy_expert(copy_sql, csv_buffer) - connection.commit() - except Exception: - connection.rollback() - raise - finally: - csv_buffer.close() + with psycopg_connection(MainRouter.admin_db) as connection: + connection.autocommit = False + try: + with connection.cursor() as cursor: + cursor.execute(SET_CONFIG_QUERY, [POSTGRES_TENANT_VAR, tenant_id]) + # Idempotent re-run: clearing this scan's rows inside the same + # transaction keeps delete + reinsert atomic. + cursor.execute( + "DELETE FROM compliance_requirements_overviews " + "WHERE tenant_id = %s AND scan_id = %s", + [tenant_id, scan_id], + ) + for batch, _is_last in batched(rows, batch_size): + if not batch: + continue + batch_num += 1 + csv_buffer = _compliance_requirement_rows_to_csv( + batch, tenant_id, scan_id + ) + try: + cursor.copy_expert(copy_sql, csv_buffer) + finally: + csv_buffer.close() + total_rows += len(batch) + logger.info( + f"Compliance COPY batch {batch_num}: staged {len(batch)} rows " + f"({total_rows} total)" + ) + connection.commit() + except Exception: + connection.rollback() + raise + + return total_rows -def _persist_compliance_requirement_rows( - tenant_id: str, rows: Iterable[dict[str, Any]], batch_size: int = 10000 +def _bulk_create_compliance_requirement_rows( + tenant_id: str, scan_id: str, rows: Iterable[dict[str, Any]], batch_size: int ) -> int: - """Persist compliance requirement rows using batched COPY with ORM fallback. + """Replace a scan's compliance requirement rows via the ORM. - ``rows`` is consumed lazily in batches, so peak memory stays at ~``batch_size`` - rows instead of the full set. A batch that fails COPY falls back to an ORM - ``bulk_create`` of just that batch. - - Args: - tenant_id: Target tenant UUID. - rows: Iterable of row dictionaries reflecting the compliance overview - state for a scan. - batch_size: Number of rows per COPY batch (default: 10000). - - Returns: - int: total number of rows persisted. + Fallback for when COPY is unavailable; the delete and every ``bulk_create`` + share one RLS transaction so the replacement stays atomic. """ total_rows = 0 - batch_num = 0 - - for batch, _is_last in batched(rows, batch_size): - if not batch: - continue - batch_num += 1 - try: - _copy_compliance_requirement_rows(tenant_id, batch) - except Exception as error: - logger.exception( - f"COPY bulk insert for compliance requirements batch {batch_num} " - "failed; falling back to ORM bulk_create for this batch", - exc_info=error, - ) + with rls_transaction(tenant_id): + ComplianceRequirementOverview.objects.filter(scan_id=scan_id).delete() + for batch, _is_last in batched(rows, batch_size): + if not batch: + continue fallback_objects = [ ComplianceRequirementOverview( id=row["id"], @@ -473,20 +525,58 @@ def _persist_compliance_requirement_rows( ) for row in batch ] - with rls_transaction(tenant_id): - ComplianceRequirementOverview.objects.bulk_create( - fallback_objects, batch_size=500 - ) - - total_rows += len(batch) - logger.info( - f"Compliance COPY batch {batch_num}: inserted {len(batch)} rows " - f"({total_rows} total)" - ) - + ComplianceRequirementOverview.objects.bulk_create( + fallback_objects, batch_size=500 + ) + total_rows += len(batch) return total_rows +def _persist_compliance_requirement_rows( + tenant_id: str, + scan_id: str, + rows_factory: Callable[[], Iterable[dict[str, Any]]], + batch_size: int | None = None, +) -> int: + """Persist a scan's compliance requirement rows, replacing any previous ones. + + ``rows_factory`` must return a fresh row iterator on every call: the COPY + path consumes it lazily in batches (peak memory ~``batch_size`` rows), and + if COPY fails the whole ingest falls back to a single ORM transaction that + re-iterates the rows. + + Args: + tenant_id: Target tenant UUID. + scan_id: Scan whose compliance overview rows are being replaced. + rows_factory: Callable returning an iterable of row dictionaries. + batch_size: Rows per COPY/bulk_create batch (default: + ``COMPLIANCE_COPY_BATCH_SIZE``). + + Returns: + int: total number of rows persisted. + """ + if batch_size is None: + batch_size = COMPLIANCE_COPY_BATCH_SIZE + + try: + return _copy_compliance_requirement_rows( + tenant_id, scan_id, rows_factory(), batch_size + ) + except ComplianceRowScopeError: + # Cross-tenant/scan rows are a bug in the caller, not a COPY failure: + # retrying through the ORM would persist the very rows we rejected. + raise + except Exception as error: + logger.exception( + "COPY bulk insert for compliance requirements failed; " + "falling back to ORM bulk_create", + exc_info=error, + ) + return _bulk_create_compliance_requirement_rows( + tenant_id, scan_id, rows_factory(), batch_size + ) + + def _create_compliance_summaries( tenant_id: str, scan_id: str, requirement_statuses: dict ) -> None: @@ -885,15 +975,19 @@ def _process_finding_micro_batch( # Denormalized resource arrays populated directly on insert # (was previously a separate bulk_update; saves a CASE WHEN # over thousands of rows per micro-batch). - resource_regions=[resource_instance.region] - if resource_instance.region - else [], - resource_services=[resource_instance.service] - if resource_instance.service - else [], - resource_types=[resource_instance.type] - if resource_instance.type - else [], + resource_regions=( + [resource_instance.region] + if resource_instance.region + else [] + ), + resource_services=( + [resource_instance.service] + if resource_instance.service + else [] + ), + resource_types=( + [resource_instance.type] if resource_instance.type else [] + ), ) findings_to_create.append(finding_instance) resource_denormalized_data.append( @@ -1708,8 +1802,10 @@ def create_compliance_requirements(tenant_id: str, scan_id: str): ) # Yield rows lazily (consumed batch-by-batch by COPY) so peak memory - # stays bounded; tally requirement_statuses in the same pass. + # stays bounded; tally requirement_statuses in the same pass. The + # ORM fallback re-iterates from scratch, so the tally resets first. def _iter_compliance_requirement_rows(): + requirement_statuses.clear() for region in regions: region_stats = region_requirement_stats.get(region, {}) region_findings = findings_count_by_compliance.get(region, {}) @@ -1773,12 +1869,10 @@ def create_compliance_requirements(tenant_id: str, scan_id: str): "total_findings": total_findings, } - # Idempotent re-run: clear this scan's rows before re-inserting. - with rls_transaction(tenant_id): - ComplianceRequirementOverview.objects.filter(scan_id=scan_id).delete() - + # The delete of the scan's previous rows happens inside the same + # transaction as the inserts (see _copy_compliance_requirement_rows). requirements_created = _persist_compliance_requirement_rows( - tenant_id, _iter_compliance_requirement_rows() + tenant_id_str, scan_id_str, _iter_compliance_requirement_rows ) # Create pre-aggregated summaries for fast compliance overview lookups diff --git a/api/src/backend/tasks/tests/test_scan.py b/api/src/backend/tasks/tests/test_scan.py index 2a66985276..cad5e3d343 100644 --- a/api/src/backend/tasks/tests/test_scan.py +++ b/api/src/backend/tasks/tests/test_scan.py @@ -26,6 +26,7 @@ from prowler.lib.check.models import Severity from prowler.lib.outputs.finding import Status from tasks.jobs.scan import ( _ATTACK_SURFACE_MAPPING_CACHE, + ComplianceRowScopeError, _aggregate_findings_by_region, _bulk_update_resource_failed_findings_counts, _copy_compliance_requirement_rows, @@ -2314,9 +2315,9 @@ class TestCreateComplianceRequirements: create_compliance_requirements(tenant_id, scan_id) mock_persist.assert_called_once() - persisted_rows = mock_persist.call_args[0][1] + rows_factory = mock_persist.call_args[0][2] requirement_row = next( - row for row in persisted_rows if row["requirement_id"] == "1.1" + row for row in rows_factory() if row["requirement_id"] == "1.1" ) assert requirement_row["requirement_status"] == "FAIL" @@ -2454,18 +2455,26 @@ class TestComplianceRequirementCopy: } with patch.object(MainRouter, "admin_db", "admin"): - _copy_compliance_requirement_rows(str(row["tenant_id"]), [row]) + _copy_compliance_requirement_rows( + str(row["tenant_id"]), str(row["scan_id"]), [row], 2000 + ) mock_psycopg_connection.assert_called_once_with("admin") connection.cursor.assert_called_once() - cursor.execute.assert_called_once() + # One execute for set_config plus one for the scan's DELETE. + assert cursor.execute.call_count == 2 + delete_sql, delete_params = cursor.execute.call_args_list[1][0] + assert "DELETE FROM compliance_requirements_overviews" in delete_sql + assert delete_params == [str(row["tenant_id"]), str(row["scan_id"])] cursor.copy_expert.assert_called_once() + connection.commit.assert_called_once() csv_rows = list(csv.reader(StringIO(captured["data"]))) assert csv_rows[0][0] == str(row["id"]) assert csv_rows[0][5] == "" assert csv_rows[0][-1] == str(row["scan_id"]) + @patch("tasks.jobs.scan.ComplianceRequirementOverview.objects.filter") @patch("tasks.jobs.scan.ComplianceRequirementOverview.objects.bulk_create") @patch("tasks.jobs.scan.rls_transaction") @patch( @@ -2473,7 +2482,7 @@ class TestComplianceRequirementCopy: side_effect=Exception("copy failed"), ) def test_persist_compliance_requirement_rows_fallback( - self, mock_copy, mock_rls_transaction, mock_bulk_create + self, mock_copy, mock_rls_transaction, mock_bulk_create, mock_filter ): inserted_at = datetime.now(UTC) row = { @@ -2494,16 +2503,22 @@ class TestComplianceRequirementCopy: } tenant_id = row["tenant_id"] + scan_id = str(row["scan_id"]) ctx = MagicMock() ctx.__enter__.return_value = None ctx.__exit__.return_value = False mock_rls_transaction.return_value = ctx - _persist_compliance_requirement_rows(tenant_id, [row]) + _persist_compliance_requirement_rows(tenant_id, scan_id, lambda: [row]) - mock_copy.assert_called_once_with(tenant_id, [row]) + mock_copy.assert_called_once() + assert mock_copy.call_args[0][0] == tenant_id + assert mock_copy.call_args[0][1] == scan_id mock_rls_transaction.assert_called_once_with(tenant_id) + # The fallback replaces the scan's rows: delete + insert atomically. + mock_filter.assert_called_once_with(scan_id=scan_id) + mock_filter.return_value.delete.assert_called_once() mock_bulk_create.assert_called_once() args, kwargs = mock_bulk_create.call_args @@ -2515,13 +2530,18 @@ class TestComplianceRequirementCopy: @patch("tasks.jobs.scan.ComplianceRequirementOverview.objects.bulk_create") @patch("tasks.jobs.scan.rls_transaction") - @patch("tasks.jobs.scan._copy_compliance_requirement_rows") + @patch("tasks.jobs.scan._copy_compliance_requirement_rows", return_value=0) def test_persist_compliance_requirement_rows_no_rows( self, mock_copy, mock_rls_transaction, mock_bulk_create ): - _persist_compliance_requirement_rows(str(uuid.uuid4()), []) + # Even with no rows the COPY path runs: it must clear the scan's + # previous rows so a re-run with fewer findings drops stale data. + total = _persist_compliance_requirement_rows( + str(uuid.uuid4()), str(uuid.uuid4()), lambda: [] + ) - mock_copy.assert_not_called() + assert total == 0 + mock_copy.assert_called_once() mock_rls_transaction.assert_not_called() mock_bulk_create.assert_not_called() @@ -2610,11 +2630,12 @@ class TestComplianceRequirementCopy: ] with patch.object(MainRouter, "admin_db", "admin"): - _copy_compliance_requirement_rows(tenant_id, rows) + _copy_compliance_requirement_rows(tenant_id, str(scan_id), rows, 2000) mock_psycopg_connection.assert_called_once_with("admin") connection.cursor.assert_called_once() - cursor.execute.assert_called_once() + # set_config + DELETE of the scan's previous rows. + assert cursor.execute.call_count == 2 cursor.copy_expert.assert_called_once() csv_rows = list(csv.reader(StringIO(captured["data"]))) @@ -2644,6 +2665,60 @@ class TestComplianceRequirementCopy: assert csv_rows[2][5] == "2.0" assert csv_rows[2][9] == "MANUAL" + @patch("tasks.jobs.scan.psycopg_connection") + def test_copy_compliance_requirement_rows_batches_share_one_transaction( + self, mock_psycopg_connection, settings + ): + """Every COPY batch runs on the same connection with a single commit.""" + settings.DATABASES.setdefault("admin", settings.DATABASES["default"]) + + connection = MagicMock() + cursor = MagicMock() + cursor_context = MagicMock() + cursor_context.__enter__.return_value = cursor + cursor_context.__exit__.return_value = False + connection.cursor.return_value = cursor_context + connection.__enter__.return_value = connection + connection.__exit__.return_value = False + + context_manager = MagicMock() + context_manager.__enter__.return_value = connection + context_manager.__exit__.return_value = False + mock_psycopg_connection.return_value = context_manager + + tenant_id = str(uuid.uuid4()) + scan_id = str(uuid.uuid4()) + inserted_at = datetime.now(UTC) + rows = [ + { + "id": uuid.uuid4(), + "tenant_id": tenant_id, + "inserted_at": inserted_at, + "compliance_id": "cisa_aws", + "framework": "CISA", + "version": "1.0", + "description": f"Requirement {index}", + "region": "us-east-1", + "requirement_id": f"req-{index}", + "requirement_status": "PASS", + "passed_checks": 1, + "failed_checks": 0, + "total_checks": 1, + "scan_id": scan_id, + } + for index in range(3) + ] + + with patch.object(MainRouter, "admin_db", "admin"): + total = _copy_compliance_requirement_rows(tenant_id, scan_id, rows, 1) + + assert total == 3 + # One connection, three COPY statements, one commit for the whole scan. + mock_psycopg_connection.assert_called_once_with("admin") + assert cursor.copy_expert.call_count == 3 + connection.commit.assert_called_once() + connection.rollback.assert_not_called() + @patch("tasks.jobs.scan.psycopg_connection") def test_copy_compliance_requirement_rows_null_values( self, mock_psycopg_connection, settings @@ -2691,7 +2766,9 @@ class TestComplianceRequirementCopy: } with patch.object(MainRouter, "admin_db", "admin"): - _copy_compliance_requirement_rows(str(row["tenant_id"]), [row]) + _copy_compliance_requirement_rows( + str(row["tenant_id"]), str(row["scan_id"]), [row], 2000 + ) csv_rows = list(csv.reader(StringIO(captured["data"]))) assert len(csv_rows) == 1 @@ -2747,7 +2824,9 @@ class TestComplianceRequirementCopy: } with patch.object(MainRouter, "admin_db", "admin"): - _copy_compliance_requirement_rows(str(row["tenant_id"]), [row]) + _copy_compliance_requirement_rows( + str(row["tenant_id"]), str(row["scan_id"]), [row], 2000 + ) # Verify CSV was generated (csv module handles escaping automatically) csv_rows = list(csv.reader(StringIO(captured["data"]))) @@ -2808,7 +2887,9 @@ class TestComplianceRequirementCopy: before_call = datetime.now(UTC) with patch.object(MainRouter, "admin_db", "admin"): - _copy_compliance_requirement_rows(str(row["tenant_id"]), [row]) + _copy_compliance_requirement_rows( + str(row["tenant_id"]), str(row["scan_id"]), [row], 2000 + ) after_call = datetime.now(UTC) csv_rows = list(csv.reader(StringIO(captured["data"]))) @@ -2861,12 +2942,84 @@ class TestComplianceRequirementCopy: with patch.object(MainRouter, "admin_db", "admin"): with pytest.raises(Exception, match="COPY command failed"): - _copy_compliance_requirement_rows(str(row["tenant_id"]), [row]) + _copy_compliance_requirement_rows( + str(row["tenant_id"]), str(row["scan_id"]), [row], 2000 + ) # Verify rollback was called connection.rollback.assert_called_once() connection.commit.assert_not_called() + @pytest.mark.parametrize("mismatched_field", ["tenant_id", "scan_id"]) + @patch("tasks.jobs.scan.psycopg_connection") + def test_copy_compliance_requirement_rows_rejects_out_of_scope_rows( + self, mock_psycopg_connection, mismatched_field, settings + ): + """COPY bypasses RLS, so rows from another tenant/scan must be rejected.""" + settings.DATABASES.setdefault("admin", settings.DATABASES["default"]) + + connection = MagicMock() + cursor = MagicMock() + cursor_context = MagicMock() + cursor_context.__enter__.return_value = cursor + cursor_context.__exit__.return_value = False + connection.cursor.return_value = cursor_context + connection.__enter__.return_value = connection + connection.__exit__.return_value = False + + context_manager = MagicMock() + context_manager.__enter__.return_value = connection + context_manager.__exit__.return_value = False + mock_psycopg_connection.return_value = context_manager + + tenant_id = str(uuid.uuid4()) + scan_id = str(uuid.uuid4()) + row = { + "id": uuid.uuid4(), + "tenant_id": tenant_id, + "compliance_id": "test", + "framework": "Test", + "version": "1.0", + "description": "desc", + "region": "us-east-1", + "requirement_id": "req-1", + "requirement_status": "PASS", + "passed_checks": 1, + "failed_checks": 0, + "total_checks": 1, + "scan_id": scan_id, + } + row[mismatched_field] = str(uuid.uuid4()) + + with patch.object(MainRouter, "admin_db", "admin"): + with pytest.raises(ComplianceRowScopeError): + _copy_compliance_requirement_rows(tenant_id, scan_id, [row], 2000) + + cursor.copy_expert.assert_not_called() + connection.rollback.assert_called_once() + connection.commit.assert_not_called() + + @patch("tasks.jobs.scan.ComplianceRequirementOverview") + @patch("tasks.jobs.scan.rls_transaction") + @patch( + "tasks.jobs.scan._copy_compliance_requirement_rows", + side_effect=ComplianceRowScopeError("out of scope"), + ) + def test_persist_compliance_requirement_rows_does_not_fall_back_on_scope_error( + self, mock_copy, mock_rls_transaction, mock_model + ): + """A scope violation is a caller bug: the ORM fallback must not persist it.""" + tenant_id = str(uuid.uuid4()) + scan_id = str(uuid.uuid4()) + + with pytest.raises(ComplianceRowScopeError): + _persist_compliance_requirement_rows(tenant_id, scan_id, lambda: []) + + mock_copy.assert_called_once() + mock_rls_transaction.assert_not_called() + mock_model.objects.filter.assert_not_called() + mock_model.objects.bulk_create.assert_not_called() + @patch("tasks.jobs.scan.psycopg_connection") def test_copy_compliance_requirement_rows_transaction_rollback_on_set_config_error( self, mock_psycopg_connection, settings @@ -2909,7 +3062,9 @@ class TestComplianceRequirementCopy: with patch.object(MainRouter, "admin_db", "admin"): with pytest.raises(Exception, match="SET prowler.tenant_id failed"): - _copy_compliance_requirement_rows(str(row["tenant_id"]), [row]) + _copy_compliance_requirement_rows( + str(row["tenant_id"]), str(row["scan_id"]), [row], 2000 + ) # Verify rollback was called connection.rollback.assert_called_once() @@ -2955,7 +3110,9 @@ class TestComplianceRequirementCopy: } with patch.object(MainRouter, "admin_db", "admin"): - _copy_compliance_requirement_rows(str(row["tenant_id"]), [row]) + _copy_compliance_requirement_rows( + str(row["tenant_id"]), str(row["scan_id"]), [row], 2000 + ) # Verify commit was called and rollback was not connection.commit.assert_called_once() @@ -2966,9 +3123,10 @@ class TestComplianceRequirementCopy: @patch("tasks.jobs.scan._copy_compliance_requirement_rows") def test_persist_compliance_requirement_rows_success(self, mock_copy): """Test successful COPY path without fallback to ORM.""" - mock_copy.return_value = None # Success, no exception + mock_copy.return_value = 1 # Success, no exception tenant_id = str(uuid.uuid4()) + scan_id = str(uuid.uuid4()) rows = [ { "id": uuid.uuid4(), @@ -2984,16 +3142,21 @@ class TestComplianceRequirementCopy: "passed_checks": 1, "failed_checks": 0, "total_checks": 1, - "scan_id": uuid.uuid4(), + "scan_id": scan_id, } ] - _persist_compliance_requirement_rows(tenant_id, rows) + total = _persist_compliance_requirement_rows(tenant_id, scan_id, lambda: rows) - # Verify COPY was called - mock_copy.assert_called_once_with(tenant_id, rows) + assert total == 1 + mock_copy.assert_called_once() + copy_args = mock_copy.call_args[0] + assert copy_args[0] == tenant_id + assert copy_args[1] == scan_id + assert list(copy_args[2]) == rows @patch("tasks.jobs.scan.logger") + @patch("tasks.jobs.scan.ComplianceRequirementOverview.objects.filter") @patch("tasks.jobs.scan.ComplianceRequirementOverview.objects.bulk_create") @patch("tasks.jobs.scan.rls_transaction") @patch( @@ -3001,7 +3164,12 @@ class TestComplianceRequirementCopy: side_effect=Exception("COPY failed"), ) def test_persist_compliance_requirement_rows_fallback_logging( - self, mock_copy, mock_rls_transaction, mock_bulk_create, mock_logger + self, + mock_copy, + mock_rls_transaction, + mock_bulk_create, + mock_filter, + mock_logger, ): """Test logger.exception is called when COPY fails and fallback occurs.""" tenant_id = str(uuid.uuid4()) @@ -3027,7 +3195,9 @@ class TestComplianceRequirementCopy: ctx.__exit__.return_value = False mock_rls_transaction.return_value = ctx - _persist_compliance_requirement_rows(tenant_id, [row]) + _persist_compliance_requirement_rows( + tenant_id, str(row["scan_id"]), lambda: [row] + ) # Verify logger.exception was called mock_logger.exception.assert_called_once() @@ -3036,6 +3206,7 @@ class TestComplianceRequirementCopy: assert "falling back to ORM" in args[0] assert kwargs.get("exc_info") is not None + @patch("tasks.jobs.scan.ComplianceRequirementOverview.objects.filter") @patch("tasks.jobs.scan.ComplianceRequirementOverview.objects.bulk_create") @patch("tasks.jobs.scan.rls_transaction") @patch( @@ -3043,7 +3214,7 @@ class TestComplianceRequirementCopy: side_effect=Exception("copy failed"), ) def test_persist_compliance_requirement_rows_fallback_multiple_rows( - self, mock_copy, mock_rls_transaction, mock_bulk_create + self, mock_copy, mock_rls_transaction, mock_bulk_create, mock_filter ): """Test ORM fallback with multiple rows.""" tenant_id = str(uuid.uuid4()) @@ -3090,10 +3261,14 @@ class TestComplianceRequirementCopy: ctx.__exit__.return_value = False mock_rls_transaction.return_value = ctx - _persist_compliance_requirement_rows(tenant_id, rows) + total = _persist_compliance_requirement_rows( + tenant_id, str(scan_id), lambda: rows + ) - mock_copy.assert_called_once_with(tenant_id, rows) + assert total == 2 + mock_copy.assert_called_once() mock_rls_transaction.assert_called_once_with(tenant_id) + mock_filter.assert_called_once_with(scan_id=str(scan_id)) mock_bulk_create.assert_called_once() args, kwargs = mock_bulk_create.call_args @@ -3117,6 +3292,7 @@ class TestComplianceRequirementCopy: assert objects[1].passed_checks == 2 assert objects[1].failed_checks == 3 + @patch("tasks.jobs.scan.ComplianceRequirementOverview.objects.filter") @patch("tasks.jobs.scan.ComplianceRequirementOverview.objects.bulk_create") @patch("tasks.jobs.scan.rls_transaction") @patch( @@ -3124,7 +3300,7 @@ class TestComplianceRequirementCopy: side_effect=Exception("copy failed"), ) def test_persist_compliance_requirement_rows_fallback_all_fields( - self, mock_copy, mock_rls_transaction, mock_bulk_create + self, mock_copy, mock_rls_transaction, mock_bulk_create, mock_filter ): """Test ORM fallback correctly maps all fields from row dict to model.""" tenant_id = str(uuid.uuid4()) @@ -3154,7 +3330,7 @@ class TestComplianceRequirementCopy: ctx.__exit__.return_value = False mock_rls_transaction.return_value = ctx - _persist_compliance_requirement_rows(tenant_id, [row]) + _persist_compliance_requirement_rows(tenant_id, str(scan_id), lambda: [row]) args, kwargs = mock_bulk_create.call_args objects = args[0] diff --git a/docs/user-guide/cli/tutorials/parallel-execution.mdx b/docs/user-guide/cli/tutorials/parallel-execution.mdx index 93b8ef381b..d659116b50 100644 --- a/docs/user-guide/cli/tutorials/parallel-execution.mdx +++ b/docs/user-guide/cli/tutorials/parallel-execution.mdx @@ -184,7 +184,7 @@ $combinedCsv | Export-Csv -Path "CombinedCSV.csv" -NoTypeInformation ## TODO: Additional Improvements -Some services need to instantiate another service to perform a check. For instance, `cloudwatch` will instantiate Prowler's `iam` service to perform the `cloudwatch_cross_account_sharing_disabled` check. When the `iam` service is instantiated, it will perform the `__init__` function, and pull all the information required for that service. This provides an opportunity for an improvement in the above script to group related services together so that the `iam` services (or any other cross-service references) isn't repeatedily instantiated by grouping dependant services together. A complete mapping between these services still needs to be further investigated, but these are the cross-references that have been noted: +Some services need to instantiate another service to perform a check. For instance, `cloudwatch` will instantiate Prowler's `iam` service to perform the `cloudwatch_cross_account_sharing_disabled` check. When the `iam` service is instantiated, it will perform the `__init__` function, and pull all the information required for that service. This provides an opportunity for an improvement in the above script to group related services together so that the `iam` services (or any other cross-service references) aren't repeatedly instantiated by grouping dependent services together. A complete mapping between these services still needs to be further investigated, but these are the cross-references that have been noted: * inspector2 needs lambda and ec2 * cloudwatch needs iam diff --git a/docs/user-guide/cli/tutorials/reporting.mdx b/docs/user-guide/cli/tutorials/reporting.mdx index 19a14c9ae2..47e46dc2aa 100644 --- a/docs/user-guide/cli/tutorials/reporting.mdx +++ b/docs/user-guide/cli/tutorials/reporting.mdx @@ -114,7 +114,7 @@ The CSV format follows a standardized structure across all providers. The follow #### CSV Headers Mapping -The following table shows the mapping between the CSV headers and the the providers fields: +The following table shows the mapping between the CSV headers and the providers fields: | Open Source Consolidated| AWS| GCP| AZURE| KUBERNETES |----------|----------|----------|----------|---------- diff --git a/docs/user-guide/providers/oci/authentication.mdx b/docs/user-guide/providers/oci/authentication.mdx index 9627e88cd4..cc0e75cded 100644 --- a/docs/user-guide/providers/oci/authentication.mdx +++ b/docs/user-guide/providers/oci/authentication.mdx @@ -434,7 +434,7 @@ prowler oci --oci-config-file /path/to/config **Cause**: Insufficient IAM permissions -**Solution**: Add required policies (see [Required Permissions](./getting-started-oci.md#required-permissions)) +**Solution**: Add required policies (see [Required Permissions](/user-guide/providers/oci/getting-started-oci#required-permissions)) ### Configuration Validation diff --git a/docs/user-guide/providers/oci/getting-started-oci.mdx b/docs/user-guide/providers/oci/getting-started-oci.mdx index 2f610cd09c..6a8c8de3e9 100644 --- a/docs/user-guide/providers/oci/getting-started-oci.mdx +++ b/docs/user-guide/providers/oci/getting-started-oci.mdx @@ -58,7 +58,7 @@ Before you begin, ensure you have: ### Authentication -Prowler supports multiple authentication methods for OCI. For detailed authentication setup, see the [OCI Authentication Guide](./authentication). +Prowler supports multiple authentication methods for OCI. For detailed authentication setup, see the [OCI Authentication Guide](/user-guide/providers/oci/authentication). **Note:** OCI Session Authentication and Config File Authentication both use the same `~/.oci/config` file. The difference is how the config file is generated - automatically via browser (session auth) or manually with API keys. @@ -107,7 +107,7 @@ The easiest and most secure method is using OCI session authentication, which au #### Alternative: Manual API Key Setup -If you prefer to manually generate API keys instead of using browser-based session authentication, see the detailed instructions in the [Authentication Guide](./authentication#config-file-authentication-manual-api-key-setup). +If you prefer to manually generate API keys instead of using browser-based session authentication, see the detailed instructions in the [Authentication Guide](/user-guide/providers/oci/authentication#config-file-authentication-manual-api-key-setup). **Note:** Both methods use the same `~/.oci/config` file - the difference is that manual setup uses static API keys while session authentication uses temporary session tokens. diff --git a/prowler/changelog.d/alibabacloud-security-group-policy-case.fixed.md b/prowler/changelog.d/alibabacloud-security-group-policy-case.fixed.md new file mode 100644 index 0000000000..cef4ff076c --- /dev/null +++ b/prowler/changelog.d/alibabacloud-security-group-policy-case.fixed.md @@ -0,0 +1 @@ +Alibaba Cloud SSH and RDP security group checks no longer produce false negatives when allowed rules use capitalized `Policy="Accept"` values diff --git a/prowler/changelog.d/bucket-validation-syntaxwarning.fixed.md b/prowler/changelog.d/bucket-validation-syntaxwarning.fixed.md new file mode 100644 index 0000000000..ceba09541c --- /dev/null +++ b/prowler/changelog.d/bucket-validation-syntaxwarning.fixed.md @@ -0,0 +1 @@ +Fix invalid escape sequence `SyntaxWarning` raised on startup by the S3 bucket name validation regex diff --git a/prowler/changelog.d/sagemaker-notebook-no-secrets.added.md b/prowler/changelog.d/sagemaker-notebook-no-secrets.added.md new file mode 100644 index 0000000000..4789aa6aaf --- /dev/null +++ b/prowler/changelog.d/sagemaker-notebook-no-secrets.added.md @@ -0,0 +1 @@ +`sagemaker_notebook_instance_no_secrets` check for AWS provider, scanning SageMaker notebook instance lifecycle configuration scripts (`OnCreate` and `OnStart`) for hardcoded secrets such as API keys, passwords, tokens, and connection strings diff --git a/prowler/providers/alibabacloud/services/ecs/ecs_securitygroup_restrict_rdp_internet/ecs_securitygroup_restrict_rdp_internet.py b/prowler/providers/alibabacloud/services/ecs/ecs_securitygroup_restrict_rdp_internet/ecs_securitygroup_restrict_rdp_internet.py index 72e579bbc6..45cfa81bda 100644 --- a/prowler/providers/alibabacloud/services/ecs/ecs_securitygroup_restrict_rdp_internet/ecs_securitygroup_restrict_rdp_internet.py +++ b/prowler/providers/alibabacloud/services/ecs/ecs_securitygroup_restrict_rdp_internet/ecs_securitygroup_restrict_rdp_internet.py @@ -26,7 +26,7 @@ class ecs_securitygroup_restrict_rdp_internet(Check): for ingress_rule in security_group.ingress_rules: # Check if rule allows traffic (policy == "accept") - if ingress_rule.get("policy", "accept") != "accept": + if str(ingress_rule.get("policy", "accept")).lower() != "accept": continue # Check protocol (tcp for RDP) diff --git a/prowler/providers/alibabacloud/services/ecs/ecs_securitygroup_restrict_ssh_internet/ecs_securitygroup_restrict_ssh_internet.py b/prowler/providers/alibabacloud/services/ecs/ecs_securitygroup_restrict_ssh_internet/ecs_securitygroup_restrict_ssh_internet.py index 9dfdd182e1..0315a69207 100644 --- a/prowler/providers/alibabacloud/services/ecs/ecs_securitygroup_restrict_ssh_internet/ecs_securitygroup_restrict_ssh_internet.py +++ b/prowler/providers/alibabacloud/services/ecs/ecs_securitygroup_restrict_ssh_internet/ecs_securitygroup_restrict_ssh_internet.py @@ -26,7 +26,7 @@ class ecs_securitygroup_restrict_ssh_internet(Check): for ingress_rule in security_group.ingress_rules: # Check if rule allows traffic (policy == "accept") - if ingress_rule.get("policy", "accept") != "accept": + if str(ingress_rule.get("policy", "accept")).lower() != "accept": continue # Check protocol (tcp for SSH) diff --git a/prowler/providers/aws/lib/arguments/arguments.py b/prowler/providers/aws/lib/arguments/arguments.py index 50f4665b2d..2d1632422b 100644 --- a/prowler/providers/aws/lib/arguments/arguments.py +++ b/prowler/providers/aws/lib/arguments/arguments.py @@ -235,7 +235,7 @@ def validate_arguments(arguments: Namespace) -> tuple[bool, str]: def validate_bucket(bucket_name: str) -> str: """validate_bucket validates that the input bucket_name is valid""" if search( - "^(?!^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$)(?!.*\.{2})(?!.*\.-)(?!.*-\.)(?!^xn--)(?!^sthree-)(?!^amzn-s3-demo-)(?!.*--table-s3$)[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$", + r"^(?!^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$)(?!.*\.{2})(?!.*\.-)(?!.*-\.)(?!^xn--)(?!^sthree-)(?!^amzn-s3-demo-)(?!.*--table-s3$)[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$", bucket_name, ): return bucket_name diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_notebook_instance_no_secrets/__init__.py b/prowler/providers/aws/services/sagemaker/sagemaker_notebook_instance_no_secrets/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_notebook_instance_no_secrets/sagemaker_notebook_instance_no_secrets.metadata.json b/prowler/providers/aws/services/sagemaker/sagemaker_notebook_instance_no_secrets/sagemaker_notebook_instance_no_secrets.metadata.json new file mode 100644 index 0000000000..161abe5d30 --- /dev/null +++ b/prowler/providers/aws/services/sagemaker/sagemaker_notebook_instance_no_secrets/sagemaker_notebook_instance_no_secrets.metadata.json @@ -0,0 +1,41 @@ +{ + "Provider": "aws", + "CheckID": "sagemaker_notebook_instance_no_secrets", + "CheckTitle": "SageMaker notebook instance lifecycle configuration contains no hardcoded secrets", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Sensitive Data Identifications/Passwords", + "Effects/Data Exposure" + ], + "ServiceName": "sagemaker", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "AwsSageMakerNotebookInstance", + "ResourceGroup": "ai_ml", + "Description": "**SageMaker notebook instance lifecycle configuration scripts** (`OnCreate` and `OnStart`) are analyzed for **embedded secrets**, detecting patterns like API keys, passwords, tokens, and connection strings. Findings reference the lifecycle hook and line numbers where potential secrets appear.", + "Risk": "**Hardcoded secrets** in lifecycle configuration scripts can be read by anyone with SageMaker access to the notebook instance, letting attackers reuse the credentials to access databases, APIs, or cloud resources, enabling data exfiltration and unauthorized changes.\n\nRotation is harder, increasing dwell time and blast radius of compromises.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/sagemaker/latest/dg/notebook-lifecycle-config.html" + ], + "Remediation": { + "Code": { + "CLI": "aws sagemaker update-notebook-instance-lifecycle-config --notebook-instance-lifecycle-config-name --on-start Content=", + "NativeIaC": "", + "Other": "1. Create a secret in AWS Secrets Manager for the hardcoded value.\n2. Update the notebook instance IAM role to allow secretsmanager:GetSecretValue on that secret.\n3. Edit the lifecycle script to fetch the secret at runtime instead of hardcoding it.\n4. Update the notebook instance lifecycle configuration.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Use AWS Secrets Manager or Parameter Store to store secrets and retrieve them at runtime in lifecycle scripts; never hardcode them.", + "Url": "https://hub.prowler.com/check/sagemaker_notebook_instance_no_secrets" + } + }, + "Categories": [ + "secrets", + "gen-ai" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_notebook_instance_no_secrets/sagemaker_notebook_instance_no_secrets.py b/prowler/providers/aws/services/sagemaker/sagemaker_notebook_instance_no_secrets/sagemaker_notebook_instance_no_secrets.py new file mode 100644 index 0000000000..9b975596e4 --- /dev/null +++ b/prowler/providers/aws/services/sagemaker/sagemaker_notebook_instance_no_secrets/sagemaker_notebook_instance_no_secrets.py @@ -0,0 +1,131 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.lib.utils.utils import ( + SecretsScanError, + annotate_verified_secrets, + detect_secrets_scan_batch, +) +from prowler.providers.aws.services.sagemaker.sagemaker_client import ( + sagemaker_client, +) + + +class sagemaker_notebook_instance_no_secrets(Check): + """Check for hardcoded secrets in SageMaker notebook instance lifecycle scripts. + + Scans the OnCreate and OnStart lifecycle configuration scripts of each + SageMaker notebook instance for hardcoded secrets such as API keys, + passwords, tokens, and connection strings. The scripts are fetched and + decoded by the SageMaker service; this check only consumes that data. + """ + + def execute(self): + """Execute the sagemaker_notebook_instance_no_secrets check. + + Returns: + list[Check_Report_AWS]: One report per SageMaker notebook + instance, with status PASS, FAIL, or MANUAL. + """ + findings = [] + notebook_instances = sagemaker_client.sagemaker_notebook_instances + if not notebook_instances: + return findings + + secrets_ignore_patterns = sagemaker_client.audit_config.get( + "secrets_ignore_patterns", [] + ) + validate = sagemaker_client.audit_config.get("secrets_validate", False) + + # Instances that actually contribute a script to the batch. Only these + # (plus instances whose describe/decode failed) may be marked MANUAL on + # a batch scan failure; instances with nothing to scan must PASS. + scanned_resources = { + notebook_instance.arn + for notebook_instance in notebook_instances + if notebook_instance.lifecycle_scripts + } + + def payloads(): + for notebook_instance in notebook_instances: + for fragment, script in notebook_instance.lifecycle_scripts.items(): + yield (notebook_instance.arn, fragment), script + + scan_error = None + try: + batch_results = detect_secrets_scan_batch( + payloads(), + excluded_secrets=secrets_ignore_patterns, + validate=validate, + ) + except SecretsScanError as error: + batch_results = {} + scan_error = error + + findings_by_instance = {} + for ( + resource_id, + fragment, + ), fragment_findings in batch_results.items(): + findings_by_instance.setdefault(resource_id, {})[ + fragment + ] = fragment_findings + + for notebook_instance in notebook_instances: + report = Check_Report_AWS( + metadata=self.metadata(), resource=notebook_instance + ) + + # MANUAL when the instance could not be fully scanned: either the + # lifecycle config describe/decode failed, or the batch scan failed + # for an instance that actually had scripts queued for scanning. + batch_failed = ( + scan_error is not None and notebook_instance.arn in scanned_resources + ) + if notebook_instance.lifecycle_scan_failed or batch_failed: + report.status = "MANUAL" + report.status_extended = ( + f"Could not fully scan SageMaker notebook instance " + f"{notebook_instance.name} lifecycle configuration for " + f"secrets; manual review is required." + ) + findings.append(report) + continue + + report.status = "PASS" + if not notebook_instance.lifecycle_config_name: + report.status_extended = ( + f"SageMaker notebook instance {notebook_instance.name} " + f"does not have a lifecycle configuration." + ) + else: + report.status_extended = ( + f"No secrets found in SageMaker notebook instance " + f"{notebook_instance.name} lifecycle configuration." + ) + + fragments_with_secrets = findings_by_instance.get(notebook_instance.arn) + + if fragments_with_secrets: + all_secrets = [] + secrets_findings = [] + + for fragment, fragment_findings in fragments_with_secrets.items(): + all_secrets.extend(fragment_findings) + secrets_string = ", ".join( + f"{secret['type']} on line {secret['line_number']}" + for secret in fragment_findings + ) + secrets_findings.append(f"{fragment}: {secrets_string}") + + final_output_string = "; ".join(secrets_findings) + report.status = "FAIL" + report.status_extended = ( + f"Potential {'secrets' if len(secrets_findings) > 1 else 'secret'} " + f"found in SageMaker notebook instance " + f"{notebook_instance.name} lifecycle configuration -> " + f"{final_output_string}." + ) + annotate_verified_secrets(report, all_secrets) + + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_service.py b/prowler/providers/aws/services/sagemaker/sagemaker_service.py index 20ea4c0280..6303ebbbf2 100644 --- a/prowler/providers/aws/services/sagemaker/sagemaker_service.py +++ b/prowler/providers/aws/services/sagemaker/sagemaker_service.py @@ -1,3 +1,4 @@ +import base64 from typing import Optional from botocore.client import ClientError @@ -37,6 +38,11 @@ class SageMaker(AWSService): self.__threading_call__( self._describe_notebook_instance, self.sagemaker_notebook_instances ) + # Runs after _describe_notebook_instance so lifecycle_config_name is set. + self.__threading_call__( + self._describe_notebook_instance_lifecycle_config, + self.sagemaker_notebook_instances, + ) self.__threading_call__( self._describe_training_job, self.sagemaker_training_jobs ) @@ -224,11 +230,61 @@ class SageMaker(AWSService): notebook_instance.direct_internet_access = True if "KmsKeyId" in describe_notebook_instance: notebook_instance.kms_key_id = describe_notebook_instance["KmsKeyId"] + if "NotebookInstanceLifecycleConfigName" in describe_notebook_instance: + notebook_instance.lifecycle_config_name = describe_notebook_instance[ + "NotebookInstanceLifecycleConfigName" + ] except Exception as error: logger.error( f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _describe_notebook_instance_lifecycle_config(self, notebook_instance): + """Fetch and decode a notebook instance's lifecycle scripts. + + Reads the ``OnCreate`` and ``OnStart`` scripts from + ``DescribeNotebookInstanceLifecycleConfig`` and stores the base64-decoded + content on ``notebook_instance.lifecycle_scripts`` keyed by + ``"[]"``. Instances without a lifecycle configuration are + skipped. Any describe or decode failure sets + ``notebook_instance.lifecycle_scan_failed`` to True so the consuming + check can report ``MANUAL`` instead of a false ``PASS``. + + Args: + notebook_instance: NotebookInstance model to enrich in-place. + """ + if not notebook_instance.lifecycle_config_name: + return + logger.info("SageMaker - describing notebook instance lifecycle config...") + try: + regional_client = self.regional_clients[notebook_instance.region] + lifecycle_config = regional_client.describe_notebook_instance_lifecycle_config( + NotebookInstanceLifecycleConfigName=notebook_instance.lifecycle_config_name + ) + except Exception as error: + notebook_instance.lifecycle_scan_failed = True + logger.error( + f"{notebook_instance.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return + + scripts = {} + for hook_name in ("OnCreate", "OnStart"): + for script_index, script in enumerate(lifecycle_config.get(hook_name, [])): + content_b64 = script.get("Content") + if not content_b64: + continue + try: + scripts[f"{hook_name}[{script_index}]"] = base64.b64decode( + content_b64 + ).decode("utf-8", errors="ignore") + except Exception as error: + notebook_instance.lifecycle_scan_failed = True + logger.error( + f"{notebook_instance.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + notebook_instance.lifecycle_scripts = scripts + def _describe_model(self, model): logger.info("SageMaker - describing models...") try: @@ -497,6 +553,13 @@ class NotebookInstance(BaseModel): subnet_id: str = None direct_internet_access: bool = None kms_key_id: str = None + lifecycle_config_name: str = None + # Decoded lifecycle scripts keyed by "[]" (e.g. "OnStart[0]"), + # populated by _describe_notebook_instance_lifecycle_config. + lifecycle_scripts: dict = {} + # True if the lifecycle configuration could not be fully described/decoded, + # so the secrets check reports MANUAL instead of a false PASS. + lifecycle_scan_failed: bool = False tags: Optional[list] = [] diff --git a/skills/prowler-compliance/SKILL.md b/skills/prowler-compliance/SKILL.md index f119c7fa9b..747cb6ab64 100644 --- a/skills/prowler-compliance/SKILL.md +++ b/skills/prowler-compliance/SKILL.md @@ -2,23 +2,29 @@ name: prowler-compliance description: > Creates, syncs, audits and manages Prowler compliance frameworks end-to-end. - Covers the four-layer architecture (SDK models → JSON catalogs → output - formatters → API/UI), upstream sync workflows, cloud-auditor check-mapping - reviews, output formatter creation, and framework-specific attribute models. - Trigger: When working with compliance frameworks (CIS, NIST, PCI-DSS, SOC2, - GDPR, ISO27001, ENS, MITRE ATT&CK, CCC, C5, CSA CCM, KISA ISMS-P, - Prowler ThreatScore, FedRAMP, HIPAA), syncing with upstream catalogs, - auditing check-to-requirement mappings, adding output formatters, or fixing - compliance JSON bugs (duplicate IDs, empty Version, wrong Section, stale - check refs). + Covers the two supported JSON schemas (universal multi-provider and legacy + per-provider), the SDK model tree (legacy attribute classes, universal + ComplianceFramework, ConfigRequirements guardrails), output formatters + (legacy per-framework + universal data-driven), API/UI consumption, upstream + sync workflows, and cloud-auditor check-mapping reviews. + Trigger: When working with compliance frameworks (CIS, CIS Controls, NIST, + PCI-DSS, SOC2, GDPR, ISO27001, ENS, MITRE ATT&CK, CCC, C5, CSA CCM, DORA, + KISA ISMS-P, ASD Essential Eight, DISA STIG, CISA SCuBA, SecNumCloud, + FedRAMP, HIPAA, NIS2, Prowler ThreatScore), creating a universal + multi-provider framework, adding ConfigRequirements guardrails, syncing with + upstream catalogs, auditing check-to-requirement mappings, adding output + formatters, or fixing compliance JSON bugs (duplicate IDs, empty Version, + wrong Section, stale check refs). license: Apache-2.0 metadata: author: prowler-cloud - version: "1.2" + version: "2.0" scope: [root, sdk] auto_invoke: - "Creating/updating compliance frameworks" + - "Creating a universal (multi-provider) compliance framework" - "Mapping checks to compliance controls" + - "Adding ConfigRequirements guardrails to compliance requirements" - "Syncing compliance framework with upstream catalog" - "Auditing check-to-requirement mappings as a cloud auditor" - "Adding a compliance output formatter (per-provider class + table dispatcher)" @@ -29,527 +35,673 @@ allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task ## When to Use Use this skill when: -- Creating a new compliance framework for any provider + +- Creating a new compliance framework for any provider — **decide universal vs legacy first** (see below) - **Syncing an existing framework with an upstream source of truth** (CIS, FINOS CCC, CSA CCM, NIST, ENS, etc.) -- Adding requirements to existing frameworks +- Adding requirements to existing frameworks, or extending a universal framework to a new provider - Mapping checks to compliance controls -- **Auditing existing check mappings as a cloud auditor** (user asks "are these mappings correct?", "which checks apply to this requirement?", "review the mappings") -- **Adding a new output formatter** (new framework needs a table dispatcher + per-provider classes + CSV models) +- **Adding `ConfigRequirements` guardrails** so configurable checks can't silently satisfy a requirement with a loosened config +- **Auditing existing check mappings as a cloud auditor** ("are these mappings correct?", "which checks apply?", "review the mappings") +- **Adding a new legacy output formatter** (table dispatcher + per-provider classes + CSV models) - **Fixing JSON bugs**: duplicate IDs, empty Version, wrong Section, stale check refs, inconsistent FamilyName, padded tangential check mappings -- **Registering a framework in the CLI table dispatcher or API export map** - Investigating why a finding/check isn't showing under the expected compliance framework in the UI - Understanding compliance framework structures and attributes -## Four-Layer Architecture (Mental Model) +The authoritative contributor doc is `docs/developer-guide/security-compliance-framework.mdx` — +keep this skill and that doc consistent when either changes. For **reviewing** +a compliance PR, use the sister skill +[prowler-compliance-review](../prowler-compliance-review/SKILL.md) instead. -Prowler compliance is a **four-layer system** hanging off one Pydantic model tree. Bugs usually happen where one layer doesn't match another, so know all four before touching anything. +## Universal vs Legacy: The First Decision + +Prowler supports **two JSON schemas**. Choosing wrong means unnecessary Python +code, so decide this before anything else. At load time both converge: legacy +files are adapted into the universal `ComplianceFramework` model +(`adapt_legacy_to_universal()`), so the difference is about **authoring cost +and capabilities**, not about what the rest of Prowler sees. + +### Side-by-side comparison + +| | Universal (recommended for new frameworks) | Legacy provider-specific | +|---|---|---| +| File location | `prowler/compliance/.json` (top level) | `prowler/compliance//__.json` | +| Providers | Any number, one file (`checks` dict keyed by provider) | Exactly one provider per file (one file per provider to multi-cover) | +| Key style | lowercase (`framework`, `requirements`, `checks`) | Capitalized (`Framework`, `Requirements`, `Checks`) | +| Attribute schema | Declared **in the JSON itself** via `attributes_metadata`, validated at load | Pydantic class per framework family in `compliance_models.py` (code change for new shapes) | +| Attributes per requirement | One flat dict (`attributes: {...}`) | List of objects (`Attributes: [{...}]`) — only `Attributes[0]` is used downstream | +| Table/CSV/OCSF output | Data-driven from `outputs.table_config` — **zero Python changes** | Formatter package + registrations in `compliance.py`, `__main__.py`, `export.py` | +| Guardrails field | `config_requirements` (+ mandatory `Provider` per constraint) | `ConfigRequirements` (`Provider` omitted) | +| Loader behavior on error | Lenient: logs + skips file (`load_compliance_framework_universal`) | Fail-fast: `sys.exit(1)` (`load_compliance_framework`) | +| Loaded by | Only `get_bulk_compliance_frameworks_universal()` | Both loaders (`Compliance.get_bulk()` + universal, via adapter) | +| Shipped examples | `cis_controls_8.1.json`, `csa_ccm_4.0.json`, `dora_2022_2554.json` | Everything else (~105 files across 11 providers) | + +### When to use which + +**Use universal when** (any of these): + +- The framework is **new to Prowler** — no existing attribute class, no + existing formatter. This is the default: zero Python changes needed. +- The framework spans (or will span) **more than one provider** — DORA, CSA + CCM, CIS Controls. One file covers all providers; extending to a new + provider is a one-line `checks` edit. +- The attribute shape is **unique to this framework** — declare it in + `attributes_metadata` instead of adding a Pydantic class to the Union. + +**Use legacy only when extending an existing legacy family**: + +- A new **version** of a shipped legacy framework (CIS 8.0 for AWS → new + `cis_8.0_aws.json`, same `CIS_Requirement_Attribute`, same `cis/` formatter). +- An existing legacy framework for a **new provider** (ENS for m365 → new + `ens_rd2022_m365.json` + `ens_m365.py` transformer). +- Consistency with the family matters more than the universal benefits — a + lone `cis_8.0_aws` in universal format while 20+ CIS files stay legacy + would fragment the family. + +**Never**: start a brand-new single-provider framework as legacy "because it's +only AWS today". Universal handles single-provider fine (the `checks` dict +just has one key) and you skip 3 output files + 3 registrations. + +### The same requirement in both schemas + +Universal (`prowler/compliance/my_framework_1.0.json`): + +```json +{ + "framework": "My-Framework", + "name": "My Framework 1.0", + "version": "1.0", + "description": "...", + "attributes_metadata": [ + {"key": "Section", "type": "str", "required": true}, + {"key": "Service", "type": "str"} + ], + "outputs": {"table_config": {"group_by": "Section"}}, + "requirements": [ + { + "id": "MF-1.1", + "name": "Root MFA", + "description": "Root account must have MFA enabled.", + "attributes": {"Section": "IAM", "Service": "iam"}, + "checks": { + "aws": ["iam_root_mfa_enabled"], + "azure": [] + } + } + ] +} +``` + +Legacy (`prowler/compliance/aws/my_framework_1.0_aws.json` — plus a second +file per extra provider, plus formatter + registrations): + +```json +{ + "Framework": "My-Framework", + "Name": "My Framework 1.0 for AWS", + "Version": "1.0", + "Provider": "AWS", + "Description": "...", + "Requirements": [ + { + "Id": "MF-1.1", + "Name": "Root MFA", + "Description": "Root account must have MFA enabled.", + "Attributes": [ + {"ItemId": "MF-1.1", "Section": "IAM", "Service": "iam"} + ], + "Checks": ["iam_root_mfa_enabled"] + } + ] +} +``` + +Same control, but the universal file already covers Azure, validates its own +attribute schema, and renders table/CSV/OCSF with no code. Field-by-field +references for each schema follow below. + +## Architecture (Mental Model) + +Prowler compliance is a four-layer system. Bugs usually happen where one layer +doesn't match another, so know all four before touching anything. ### Layer 1: SDK / Core Models — `prowler/lib/check/` -- **`compliance_models.py`** — Pydantic **v1** model tree (`from pydantic.v1 import`). One `*_Requirement_Attribute` class per framework type + `Generic_Compliance_Requirement_Attribute` as fallback. -- `Compliance_Requirement.Attributes: list[Union[...]]` — **`Generic_Compliance_Requirement_Attribute` MUST be LAST** in the Union or every framework-specific attribute falls through to Generic (Pydantic v1 tries union members in order). -- **`compliance.py`** — runtime linker. `get_check_compliance()` builds the key as `f"{Framework}-{Version}"` **only if `Version` is non-empty**. An empty Version makes the key just `"{Framework}"` — this breaks downstream filters and tests that expect the versioned key. -- `Compliance.get_bulk(provider)` walks `prowler/compliance/{provider}/` and parses every `.json` file. No central index — just directory scan. +All in **Pydantic v1** (`from pydantic.v1 import ...`). Three model groups live +in `compliance_models.py`: -### Layer 2: JSON Frameworks — `prowler/compliance/{provider}/` +**Legacy tree** — `Compliance` → `Compliance_Requirement` / `Mitre_Requirement`: -See "Compliance Framework Location" and "Framework-Specific Attribute Structures" sections below. +- One `*_Requirement_Attribute` class per framework family. Registered today (Union order matters): + `ASDEssentialEight`, `CIS`, `ENS`, `ISO27001_2013`, `AWS_Well_Architected`, + `KISA_ISMSP`, `Prowler_ThreatScore`, `CCC`, `C5Germany`, `CSA_CCM`, `STIG` + (Okta IDaaS), and `Generic_Compliance_Requirement_Attribute` as fallback. +- **Generic MUST stay LAST** in `Compliance_Requirement.Attributes: list[Union[...]]` — + Pydantic v1 tries union members in order; Generic first would swallow every + framework-specific attribute. NIST 800-53/CSF, PCI DSS, GDPR, HIPAA, SOC2, + FedRAMP, SecNumCloud etc. intentionally use Generic. +- A `root_validator` rejects empty `Framework`, `Provider` or `Name`. +- MITRE uses the separate `Mitre_Requirement` model (`Tactics`, `SubTechniques`, + `Platforms`, `TechniqueURL` at requirement top level, per-provider + `Mitre_Requirement_Attribute_{AWS,Azure,GCP}`). -### Layer 3: Output Formatters — `prowler/lib/outputs/compliance/{framework}/` +**Universal tree** — `ComplianceFramework` → `UniversalComplianceRequirement`: -**Every framework directory follows this exact convention** — do not deviate: +- Flat `attributes: dict` per requirement, schema declared in + `attributes_metadata` (key, label, type, enum, required, `enum_display`, + `enum_order`, `output_formats`). A `root_validator` rejects missing required + keys, unknown keys (drift guard), enum violations, and int/float/bool type + mismatches. If `attributes_metadata` is omitted, **no validation runs**. +- `checks: dict[provider, list[check_id]]` — the provider list of the framework + is **derived** from these keys (`get_providers()` / `supports_provider()`); + the top-level `provider` field is only a fallback. +- `outputs.table_config` (group_by, split_by, scoring, labels) drives the CLI + table; `outputs.pdf_config` exists in the model but **is not consumed by the + API PDF pipeline yet** (see Layer 4). + +**Guardrails** — `Compliance_Requirement_ConfigConstraint`: + +- Fields `Check`, `ConfigKey`, `Operator` (`lte|gte|eq|in|subset|superset`), + `Value`, optional `Provider` (required in universal multi-provider files). +- A `root_validator` rejects Value/Operator type mismatches at load time. +- Evaluation is centralized in `prowler/lib/check/compliance_config_eval.py` + (`evaluate_config_constraints`, `apply_config_status`, `get_effective_status`, + `CONFIG_NOT_VALID_PREFIX = "Configuration not valid for this requirement."`), + shared by CSV/OCSF/table outputs **and** the API backend. A violated + constraint forces the requirement to FAIL and prepends the reason to + `status_extended`. Constraints whose `ConfigKey` is absent from + `audit_config` are skipped (defaults assumed compliant). + +**Loaders**: + +- `Compliance.get_bulk(provider)` — legacy: scans only + `prowler/compliance/{provider}/` (+ external JSONs via the + `prowler.compliance` entry-point group). Does NOT see top-level universal files. +- `get_bulk_compliance_frameworks_universal(provider)` — scans **both** the + top-level `prowler/compliance/` and every provider subdirectory, adapting + legacy files via `adapt_legacy_to_universal()` (flattens `Attributes[0]` to a + dict, wraps `Checks` as `{provider: [...]}`, infers `attributes_metadata`). + Also loads external universal frameworks via the + `prowler.compliance.universal` entry-point group (built-ins win collisions). +- `get_check_compliance(finding, provider_type, bulk_checks_metadata)` lives in + **`prowler/lib/outputs/compliance/compliance_check.py`** (not in + `lib/check/compliance.py`). It builds the per-finding dict keyed + `f"{Framework}-{Version}"` **only when Version is non-empty** — an empty + Version silently produces the key `"{Framework}"` and breaks downstream + filters and tests. +- `prowler/lib/check/compliance.py` now contains only + `update_checks_metadata_with_compliance()`. + +### Layer 2: JSON Catalogs — `prowler/compliance/` + +See "Compliance Catalog Coverage" below. + +### Layer 3: Output Formatters — `prowler/lib/outputs/compliance/` + +**Universal path** (no Python needed per framework): + +- `universal/universal_table.py` — `get_universal_table()`, renders the CLI + table from `outputs.table_config` + `attributes_metadata`. +- `universal/universal_output.py` — `UniversalComplianceOutput`, builds the CSV + Pydantic model **dynamically** from `attributes_metadata`. +- `universal/ocsf_compliance.py` — `OCSFComplianceOutput`; OCSF output is + **always generated** for universal frameworks regardless of `--output-formats`. +- Orchestrated by `process_universal_compliance_frameworks()` in + `compliance.py`, which runs **before** any legacy dispatch and removes the + processed frameworks from the set. + +**Legacy path** — per-framework directory, usually: ```text {framework}/ ├── __init__.py -├── {framework}.py # ONLY get_{framework}_table() — NO function docstring -├── {framework}_{provider}.py # One class per provider (e.g., CCC_AWS, CCC_Azure, CCC_GCP) -└── models.py # One Pydantic v2 BaseModel per provider (CSV columns) +├── {framework}.py # get_{framework}_table() summary-table function +├── {framework}_{provider}.py # One ComplianceOutput subclass per provider +└── models.py # One Pydantic CSV row model per provider ``` -- **`{framework}.py`** holds the **table dispatcher function** `get_{framework}_table()`. It prints the pass/fail/muted summary table. **Must NOT import `Finding` or `ComplianceOutput`** — doing so creates a circular import with `prowler/lib/outputs/compliance/compliance.py`. Only imports: `colorama`, `tabulate`, `prowler.config.config.orange_color`. -- **`{framework}_{provider}.py`** holds a per-provider class like `CCC_AWS(ComplianceOutput)` with a `transform()` method that walks findings and emits rows. This file IS allowed to import `Finding` because it's not on the dispatcher import chain. -- **`models.py`** holds one Pydantic v2 `BaseModel` per provider. Field names become CSV column headers (**public API** — renaming breaks downstream consumers). -- **Never collapse per-provider files into a unified parameterized class**, even when DRY-tempting. Every framework in Prowler follows the per-provider file pattern and reviewers will reject the refactor. CSV columns differ per provider (`AccountId`/`Region` vs `SubscriptionId`/`Location` vs `ProjectId`/`Location`) — three classes is the convention. -- **No function docstring on `get_{framework}_table()`** — no other framework has one; stay consistent. -- Register in `prowler/lib/outputs/compliance/compliance.py` → `display_compliance_table()` with an `elif compliance_framework.startswith("{framework}_"):` branch. Import the table function at the top of the file. +Directories today: `asd_essential_eight`, `aws_well_architected`, `c5`, `ccc`, +`cis`, `cisa_scuba`, `ens`, `generic`, `iso27001`, `kisa_ismsp`, +`mitre_attack`, `okta_idaas_stig`, `prowler_threatscore`, `universal`. +Known deviations (don't "fix" them without a reason): `iso27001/` has no table +file (falls to the generic table), `aws_well_architected/` has no per-provider +files, `cisa_scuba/` only ships googleworkspace. + +- CSV writers emit `;`-delimited files with UPPERCASE headers + (`ComplianceOutput.batch_write_data_to_file`). Field names in `models.py` + are **public API** — renaming breaks downstream consumers. +- **Circular import rule**: the table file (`{framework}.py`) must not import + `Finding` directly or transitively (`compliance.compliance` → table module → + `ComplianceOutput` → `Finding` → `get_check_compliance` → cycle). Keep table + files bare (`colorama`, `tabulate`, `prowler.config.config`); when a module + genuinely needs both, use `if TYPE_CHECKING:` or function-local imports (see + `universal_output.py` / `process_universal_compliance_frameworks`). +- Legacy table functions have no docstrings; the universal ones do. Match the + style of the file family you're touching. +- Dispatcher `display_compliance_table()` in `compliance.py` order: + universal (`table_config`) first → `cis_` → `ens_` → `mitre_attack` → + `kisa` → `prowler_threatscore_` → `c5_` → `ccc_` → `asd_essential_eight` + (substring) → `okta_idaas_stig` → else provider hook + (`provider.display_compliance_table()`, may raise `NotImplementedError`) → + `get_generic_compliance_table()`. iso27001, aws_well_architected and + cisa_scuba ride the fallback on purpose. ### Layer 4: API / UI -- **API table dispatcher**: `api/src/backend/tasks/jobs/export.py` → `COMPLIANCE_CLASS_MAP` keyed by provider. Uses `startswith` predicates: `(lambda name: name.startswith("ccc_"), CCC_AWS)`. **Never use exact match** (`name == "ccc_aws"`) — it's inconsistent and breaks versioning. -- **API lazy loader**: `api/src/backend/api/compliance.py` — `LazyComplianceTemplate` and `LazyChecksMapping` load compliance per provider on first access. -- **UI mapper routing**: `ui/lib/compliance/compliance-mapper.ts` routes framework names → per-framework mapper. -- **UI per-framework mapper**: `ui/lib/compliance/{framework}.tsx` flattens `Requirements` into a 3-level tree (Framework → Category → Control → Requirement) for the accordion view. Groups by `Attributes[0].FamilyName` and `Attributes[0].Section`. -- **UI detail panel**: `ui/components/compliance/compliance-custom-details/{framework}-details.tsx`. -- **UI types**: `ui/types/compliance.ts` — TypeScript mirrors of the attribute metadata. +- **API lazy loaders**: `api/src/backend/api/compliance.py` — + `LazyComplianceTemplate` / `LazyChecksMapping` (per-provider lazy caches over + `get_bulk_compliance_frameworks_universal`, with Gunicorn background warm-up). +- **API CSV export dispatch**: `COMPLIANCE_CLASS_MAP` in + `api/src/backend/tasks/jobs/export.py`, consumed from `tasks/tasks.py`. It is + a dict `provider → [(predicate, exporter_class)]` with `GenericCompliance` as + fallback. Predicates mix **`startswith` for multi-version families** + (`cis_`, `ens_`, `iso27001_`, `ccc_`, `cisa_scuba_`, ...) and **exact + `name == ...` for true singletons** (`mitre_attack_aws`, + `prowler_threatscore_*`, `asd_essential_eight_aws` — and inconsistently + `c5_azure`/`c5_gcp`, while aws uses `startswith("c5_")`). Rule of thumb: if + the framework can ever grow versions or variants, use `startswith`. +- **API overview ingestion**: `create_compliance_requirements()` in + `api/src/backend/tasks/jobs/scan.py` builds per-region rows from the lazy + template and persists `ComplianceRequirementOverview` (COPY with bulk-create + fallback) plus `ComplianceOverviewSummary`. +- **API PDF reports**: `api/src/backend/tasks/jobs/reports/` — hardcoded + `FRAMEWORK_REGISTRY` (own `FrameworkConfig` dataclass, NOT the SDK + `PDFConfig`) with one generator class per framework. Only + `prowler_threatscore`, `ens`, `nis2`, `csa_ccm` and `cis` have PDFs today; + adding one means a generator class + registry entry + wiring in `report.py`. +- **UI mapper routing**: `ui/lib/compliance/compliance-mapper.ts` — + `getComplianceMappers()` keyed by the JSON's `framework` value + (e.g. `"CIS"`, `"CIS-Controls"`, `"DORA"`, `"Okta-IDaaS-STIG"`). Unregistered + frameworks **fall back to the generic mapper + `GenericCustomDetails` + automatically** — a dedicated mapper/detail panel is a first-class upgrade, + not a requirement to render. +- **UI grouping varies per mapper**: generic/cis group by + `Section`/`SubSection`, iso by `Category`, ccc by `FamilyName`. All read + `attributes[0]` — inconsistent values within one JSON become separate tree + branches, so normalize before shipping. +- **UI types**: `ui/types/compliance.ts` — one `*AttributesMetadata` interface + per framework, added to the `AttributesItemData` metadata union. +- **UI icons**: `ui/components/icons/compliance/` + `IconCompliance.tsx`. + Registration is an ordered substring match (`COMPLIANCE_LOGOS`): put + framework-specific keywords **before** generic ones (`nist` before `nis2`, + `cisa` before `cis`; `aws` deliberately last). ### The CLI Pipeline (end-to-end) ```text -prowler aws --compliance ccc_aws +prowler aws --compliance cis_7.0_aws # framework key = JSON basename ↓ -Compliance.get_bulk("aws") → parses prowler/compliance/aws/*.json +Compliance.get_bulk("aws") # legacy frameworks +get_bulk_compliance_frameworks_universal("aws") # legacy (adapted) + universal ↓ -update_checks_metadata_with_compliance() → attaches compliance info to CheckMetadata +update_checks_metadata_with_compliance() # attaches compliance to CheckMetadata ↓ -execute_checks() → runs checks, produces Finding objects +execute_checks() → Finding objects ↓ -get_check_compliance(finding, "aws", bulk_checks_metadata) - → dict "{Framework}-{Version}" → [requirement_ids] +get_check_compliance(finding, "aws", bulk) # dict "{Framework}-{Version}" → [req_ids] ↓ -CCC_AWS(findings, compliance).transform() → per-provider class builds CSV rows +process_universal_compliance_frameworks() # universal: CSV + OCSF, then removed from set +per-provider elif branches in __main__.py # legacy: AWSCIS(...).batch_write_data_to_file() ↓ -batch_write_data_to_file() → writes {output_filename}_ccc_aws.csv - ↓ -display_compliance_table() → get_ccc_table() → prints stdout summary +display_compliance_table() # universal table first, then legacy elifs, + # then generic fallback ``` --- -## Compliance Framework Location +## Compliance Catalog Coverage -Frameworks are JSON files located in: `prowler/compliance/{provider}/{framework_name}_{provider}.json` +Counts as of 2026-07 (109 JSON files). Regenerate before trusting them: -**Supported Providers:** -- `aws` - Amazon Web Services -- `azure` - Microsoft Azure -- `gcp` - Google Cloud Platform -- `kubernetes` - Kubernetes -- `github` - GitHub -- `m365` - Microsoft 365 -- `alibabacloud` - Alibaba Cloud -- `cloudflare` - Cloudflare -- `oraclecloud` - Oracle Cloud -- `oci` - Oracle Cloud Infrastructure -- `nhn` - NHN Cloud -- `mongodbatlas` - MongoDB Atlas -- `iac` - Infrastructure as Code -- `llm` - Large Language Models +```bash +for d in prowler/compliance/*/; do printf "%s: %s\n" "$(basename $d)" "$(ls $d*.json 2>/dev/null | wc -l)"; done +ls prowler/compliance/*.json # universal, top-level +``` -## Base Framework Structure +**Universal (top-level, multi-provider)**: `cis_controls_8.1.json` (18 +providers), `csa_ccm_4.0.json` (aws/azure/gcp/alibabacloud/oraclecloud), +`dora_2022_2554.json` (aws/azure/gcp/alibabacloud/cloudflare). -All compliance frameworks share this base structure: +**Legacy per-provider** (families, not exhaustive versions): + +| Provider | # | Framework families | +|---|---|---| +| aws | 45 | CIS 1.4–7.0, NIST 800-53 r4/r5, NIST 800-171 r2, NIST CSF 1.1/2.0, PCI 3.2.1/4.0, ISO 27001 2013/2022, HIPAA, GDPR, SOC2, FedRAMP low/moderate r4 + 20x KSI low, ENS RD2022, MITRE ATT&CK, C5, CCC, CISA, FFIEC, RBI, Well-Architected (security/reliability), FTR, FSBP, AWS AI Security Framework, AWS Account Security Onboarding, Audit Manager Control Tower, GxP 21 CFR 11 / EU Annex 11, KISA ISMS-P 2023 (en+ko), NIS2, ASD Essential Eight, SecNumCloud 3.2, Prowler ThreatScore | +| azure | 19 | CIS 2.0–6.0, ISO 27001 2022, ENS RD2022, MITRE ATT&CK, PCI 4.0, HIPAA, SOC2, NIS2, RBI, C5, CCC, FedRAMP 20x KSI low, SecNumCloud 3.2, Prowler ThreatScore | +| gcp | 17 | CIS 2.0–5.0, ISO 27001 2022, ENS RD2022, MITRE ATT&CK, PCI 4.0, HIPAA, SOC2, NIS2, RBI, C5, CCC, FedRAMP 20x KSI low, SecNumCloud 3.2, Prowler ThreatScore | +| kubernetes | 8 | CIS 1.8–2.0.1, ISO 27001 2022, PCI 4.0, Prowler ThreatScore | +| m365 | 5 | CIS 4.0/6.0/7.0, ISO 27001 2022, Prowler ThreatScore | +| alibabacloud | 3 | CIS 2.0, SecNumCloud 3.2, Prowler ThreatScore | +| oraclecloud | 3 | CIS 3.0/3.1, SecNumCloud 3.2 | +| github | 2 | CIS 1.0/1.2.0 | +| googleworkspace | 2 | CIS 1.3, CISA SCuBA 0.6 | +| okta | 1 | Okta IDaaS STIG V1R2 | +| nhn | 1 | ISO 27001 2022 | + +Providers with a compliance directory but no frameworks yet: cloudflare, iac, +linode, llm, mongodbatlas, openstack, stackit. Provider keys inside universal +`checks` dicts must match directory names under `prowler/providers/` (lowercase). + +--- + +## Universal Schema Reference + +Full spec in `docs/developer-guide/security-compliance-framework.mdx`. Skeleton: + +```json +{ + "framework": "DORA", + "name": "Digital Operational Resilience Act (DORA) 2022/2554", + "version": "2022/2554", + "description": "Shown in --list-compliance and PDF reports.", + "icon": "dora", + "attributes_metadata": [ + {"key": "Pillar", "label": "Pillar", "type": "str", "required": true, + "enum": ["ICT Risk Management", "..."], + "output_formats": {"csv": true, "ocsf": true}}, + {"key": "Article", "type": "str", "required": true} + ], + "outputs": { + "table_config": {"group_by": "Pillar"}, + "pdf_config": {"group_by_field": "Pillar", "charts": ["..."]} + }, + "requirements": [ + { + "id": "DORA-Art5", + "name": "Governance and organisation", + "description": "Requirement text verbatim from the source.", + "attributes": {"Pillar": "ICT Risk Management", "Article": "Article 5"}, + "checks": { + "aws": ["iam_no_root_access_key"], + "azure": [], + "gcp": [] + }, + "config_requirements": [ + {"Check": "iam_user_accesskey_unused", "Provider": "aws", + "ConfigKey": "max_unused_access_keys_days", "Operator": "lte", "Value": 45} + ] + } + ] +} +``` + +### Universal fields, top level (`ComplianceFramework`) + +| Field | Type | Required | Notes | +|---|---|---|---| +| `framework` | string | Yes | Short identifier (`DORA`, `CSA-CCM`, `CIS-Controls`). This is the key the UI mapper routes on. | +| `name` | string | Yes | Human-readable full name. | +| `version` | string | No (never leave empty) | Framework version/edition (`8.1`, `2022/2554`). | +| `description` | string | Yes | Shown in `--list-compliance` and PDF reports. | +| `provider` | string | No | Fallback only — the effective provider list is derived from `checks` keys across requirements (`get_providers()`). | +| `icon` | string | No | Short icon slug. | +| `attributes_metadata` | array | No (strongly recommended) | Declares the schema of every `attributes` key. **If omitted, no attribute validation runs at all.** | +| `outputs` | object | No | `table_config` (CLI table) + `pdf_config` (modeled, not yet consumed by the API). | +| `requirements` | array | Yes | List of requirement objects (below). | + +### Universal fields, per requirement (`UniversalComplianceRequirement`) + +| Field | Type | Required | Notes | +|---|---|---|---| +| `id` | string | Yes | Unique within the framework. | +| `description` | string | Yes | Requirement text verbatim from the source. | +| `name` | string | No | Short title. | +| `attributes` | dict | No (default `{}`) | Flat dict; every key must be declared in `attributes_metadata` (unknown keys are rejected at load when metadata exists). | +| `checks` | dict | No (default `{}`) | `{provider: [check_ids]}`, lowercase keys matching `prowler/providers/` dirs. Empty list = manual requirement for that provider. | +| `config_requirements` | array | No | Guardrails; each constraint **must** carry `Provider`. | +| `tactics`, `sub_techniques`, `platforms`, `technique_url` | — | No | MITRE-style extras (auto-populated when adapting legacy MITRE files). | + +### `attributes_metadata` entry fields (`AttributeMetadata`) + +| Field | Type | Notes | +|---|---|---| +| `key` | string (required) | Attribute name as used in `requirement.attributes`. | +| `label` | string | Human-readable label for CSV headers / PDF. | +| `type` | string | `str` (default), `int`, `float`, `bool`, `list_str`, `list_dict`. Only int/float/bool are enforced at load; the rest are documentation. | +| `enum` | list | Allowed values — enforced at load. Use it whenever the value set is closed. | +| `required` | bool | Enforced at load: every requirement must carry the key non-null. | +| `enum_display` / `enum_order` | dict / list | Per-enum-value visual metadata (label, abbreviation, color, icon) and ordering for PDF rendering. | +| `chart_label` | string | Axis label when the attribute is used in charts. | +| `output_formats` | object | `{"csv": bool, "ocsf": bool}`, both default `true` — toggles inclusion per output. | + +Key rules: + +- `--compliance` key = JSON basename without `.json` (`dora_2022_2554`). +- Auto-discovered: no `__init__.py`, no formatter, no dispatcher registration. +- `table_config.group_by`, `pdf_config.group_by_field` and every + `charts[].group_by` must reference a key declared in `attributes_metadata`. +- Runtime type validation only covers `int`/`float`/`bool`; `str`/`list_str`/ + `list_dict` are documentation-only. +- Extending to a new provider = adding a key to `requirement.checks`. Nothing else. +- **No automatic check-existence validation at load time** — a typo'd check id + silently produces a requirement with no findings. Always run the + check-existence cross-check (see Validation). +- In universal files, always set `Provider` on every config constraint so a + guardrail authored for an AWS check never affects Azure/GCP scans of the + same requirement. + +## Legacy Schema Reference + +Base legacy file structure: ```json { "Framework": "FRAMEWORK_NAME", "Name": "Full Framework Name with Version", "Version": "X.X", - "Provider": "PROVIDER", + "Provider": "AWS", "Description": "Framework description...", "Requirements": [ { "Id": "requirement_id", - "Description": "Requirement description", "Name": "Optional requirement name", - "Attributes": [...], - "Checks": ["check_name_1", "check_name_2"] + "Description": "Requirement description", + "Attributes": [ ... ], + "Checks": ["check_name_1"], + "ConfigRequirements": [ ... ] } ] } ``` -## Framework-Specific Attribute Structures +### Legacy fields, top level (`Compliance`) -Each framework type has its own attribute model. Below are the exact structures used by Prowler: +| Field | Type | Required | Notes | +|---|---|---|---| +| `Framework` | string | Yes (non-empty, validated) | Canonical identifier (`CIS`, `ENS`, `NIST-800-53-Revision-5`). | +| `Name` | string | Yes (non-empty, validated) | Human-readable name with version. | +| `Version` | string | Optional in the model — **never leave it empty in practice** | Empty Version silently degrades the `get_check_compliance()` key to `"{Framework}"` (gotcha #4). Must match the version substring in the filename. | +| `Provider` | string | Yes (non-empty, validated) | Upper-cased single provider (`AWS`, `AZURE`, `GCP`, `M365`, ...). One file = one provider. | +| `Description` | string | Yes | Framework scope and purpose. | +| `Requirements` | array | Yes | Requirement objects (below), or `Mitre_Requirement` objects for MITRE files. | -### CIS (Center for Internet Security) +### Legacy fields, per requirement (`Compliance_Requirement`) -**Framework ID format:** `cis_{version}_{provider}` (e.g., `cis_5.0_aws`) +| Field | Type | Required | Notes | +|---|---|---|---| +| `Id` | string | Yes | Unique within the framework; follow the source numbering exactly (`1.1`, `A.5.1`, `CCC.Core.CN01.AR01`). | +| `Description` | string | Yes | Verbatim from the source catalog. | +| `Name` | string | No | Optional short title (NIST-style catalogs use it). | +| `Attributes` | array of objects | Yes | Parsed against the Union of attribute classes below; only `Attributes[0]` survives the universal adaptation and drives UI grouping. | +| `Checks` | array of strings | Yes | Check ids automating the requirement; `[]` = manual. | +| `ConfigRequirements` | array | No | Guardrails; `Provider` is omitted (the file is single-provider). | + +MITRE files use `Mitre_Requirement` instead, which adds `Tactics`, +`SubTechniques`, `Platforms`, `TechniqueURL` at the requirement top level. + +### Attribute shapes per framework family + +Unlike universal (schema in-file), a legacy requirement's `Attributes` must +match one of the Pydantic classes registered in +`Compliance_Requirement.Attributes` — a shape matching no class **silently +falls through to Generic**, dropping its specific fields. The most common +shapes (full field sets in `compliance_models.py`): + +### CIS — `cis_{version}_{provider}` ```json { - "Id": "1.1", - "Description": "Maintain current contact details", - "Checks": ["account_maintain_current_contact_details"], - "Attributes": [ - { - "Section": "1 Identity and Access Management", - "SubSection": "Optional subsection", - "Profile": "Level 1", - "AssessmentStatus": "Automated", - "Description": "Detailed attribute description", - "RationaleStatement": "Why this control matters", - "ImpactStatement": "Impact of implementing this control", - "RemediationProcedure": "Steps to fix the issue", - "AuditProcedure": "Steps to verify compliance", - "AdditionalInformation": "Extra notes", - "DefaultValue": "Default configuration value", - "References": "https://docs.example.com/reference" - } - ] + "Section": "1 Identity and Access Management", + "SubSection": "Optional subsection", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "...", "RationaleStatement": "...", "ImpactStatement": "...", + "RemediationProcedure": "...", "AuditProcedure": "...", + "AdditionalInformation": "...", "DefaultValue": "...", "References": "https://..." } ``` -**Profile values:** `Level 1`, `Level 2`, `E3 Level 1`, `E3 Level 2`, `E5 Level 1`, `E5 Level 2` -**AssessmentStatus values:** `Automated`, `Manual` +`Profile`: `Level 1|Level 2|E3 Level 1|E3 Level 2|E5 Level 1|E5 Level 2`. +`AssessmentStatus`: `Automated|Manual`. ---- - -### ISO 27001 - -**Framework ID format:** `iso27001_{year}_{provider}` (e.g., `iso27001_2022_aws`) +### ENS — `ens_rd2022_{provider}` ```json { - "Id": "A.5.1", - "Description": "Policies for information security should be defined...", - "Name": "Policies for information security", - "Checks": ["securityhub_enabled"], - "Attributes": [ - { - "Category": "A.5 Organizational controls", - "Objetive_ID": "A.5.1", - "Objetive_Name": "Policies for information security", - "Check_Summary": "Summary of what is being checked" - } - ] + "IdGrupoControl": "op.acc.1", "Marco": "operacional", + "Categoria": "control de acceso", "DescripcionControl": "...", + "Nivel": "alto", "Tipo": "requisito", + "Dimensiones": ["trazabilidad", "autenticidad"], + "ModoEjecucion": "automatico", "Dependencias": [] } ``` -**Note:** `Objetive_ID` and `Objetive_Name` use this exact spelling (not "Objective"). +`Nivel`: `opcional|bajo|medio|alto`. `Tipo`: `refuerzo|requisito|recomendacion|medida`. +`Dimensiones`: `confidencialidad|integridad|trazabilidad|autenticidad|disponibilidad`. ---- - -### ENS (Esquema Nacional de Seguridad - Spain) - -**Framework ID format:** `ens_rd2022_{provider}` (e.g., `ens_rd2022_aws`) +### ISO 27001 — `iso27001_{year}_{provider}` ```json { - "Id": "op.acc.1.aws.iam.2", - "Description": "Proveedor de identidad centralizado", - "Checks": ["iam_check_saml_providers_sts"], - "Attributes": [ - { - "IdGrupoControl": "op.acc.1", - "Marco": "operacional", - "Categoria": "control de acceso", - "DescripcionControl": "Detailed control description in Spanish", - "Nivel": "alto", - "Tipo": "requisito", - "Dimensiones": ["trazabilidad", "autenticidad"], - "ModoEjecucion": "automatico", - "Dependencias": [] - } - ] + "Category": "A.5 Organizational controls", + "Objetive_ID": "A.5.1", "Objetive_Name": "Policies for information security", + "Check_Summary": "Summary of what is being checked" } ``` -**Nivel values:** `opcional`, `bajo`, `medio`, `alto` -**Tipo values:** `refuerzo`, `requisito`, `recomendacion`, `medida` -**Dimensiones values:** `confidencialidad`, `integridad`, `trazabilidad`, `autenticidad`, `disponibilidad` +Note: `Objetive_ID` / `Objetive_Name` use this exact (mis)spelling. ---- - -### MITRE ATT&CK - -**Framework ID format:** `mitre_attack_{provider}` (e.g., `mitre_attack_aws`) - -MITRE uses a different requirement structure: +### MITRE ATT&CK — `mitre_attack_{provider}` (separate requirement model) ```json { - "Name": "Exploit Public-Facing Application", - "Id": "T1190", - "Tactics": ["Initial Access"], - "SubTechniques": [], - "Platforms": ["Containers", "IaaS", "Linux", "Network", "Windows", "macOS"], - "Description": "Adversaries may attempt to exploit a weakness...", + "Name": "Exploit Public-Facing Application", "Id": "T1190", + "Tactics": ["Initial Access"], "SubTechniques": [], + "Platforms": ["IaaS"], "Description": "...", "TechniqueURL": "https://attack.mitre.org/techniques/T1190/", - "Checks": ["guardduty_is_enabled", "inspector2_is_enabled"], + "Checks": ["guardduty_is_enabled"], "Attributes": [ - { - "AWSService": "Amazon GuardDuty", - "Category": "Detect", - "Value": "Minimal", - "Comment": "Explanation of how this service helps..." - } + {"AWSService": "Amazon GuardDuty", "Category": "Detect", + "Value": "Minimal", "Comment": "..."} ] } ``` -**For Azure:** Use `AzureService` instead of `AWSService` -**For GCP:** Use `GCPService` instead of `AWSService` -**Category values:** `Detect`, `Protect`, `Respond` -**Value values:** `Minimal`, `Partial`, `Significant` +`AzureService`/`GCPService` for the other providers. `Category`: +`Detect|Protect|Respond`. `Value`: `Minimal|Partial|Significant`. ---- - -### NIST 800-53 - -**Framework ID format:** `nist_800_53_revision_{version}_{provider}` (e.g., `nist_800_53_revision_5_aws`) +### CCC — `ccc_{provider}` ```json { - "Id": "ac_2_1", - "Name": "AC-2(1) Automated System Account Management", - "Description": "Support the management of system accounts...", - "Checks": ["iam_password_policy_minimum_length_14"], - "Attributes": [ - { - "ItemId": "ac_2_1", - "Section": "Access Control (AC)", - "SubSection": "Account Management (AC-2)", - "SubGroup": "AC-2(3) Disable Accounts", - "Service": "iam" - } - ] + "FamilyName": "Data", "FamilyDescription": "...", + "Section": "CCC.Core.CN01 Encrypt Data for Transmission", "SubSection": "", + "SubSectionObjective": "...", + "Applicability": ["tlp-green", "tlp-amber", "tlp-red"], + "Recommendation": "...", + "SectionThreatMappings": [{"ReferenceId": "CCC", "Identifiers": ["CCC.Core.TH02"]}], + "SectionGuidelineMappings": [{"ReferenceId": "NIST-CSF", "Identifiers": ["PR.DS-02"]}] } ``` ---- +`Applicability` holds TLP tags (`tlp-clear|tlp-green|tlp-amber|tlp-red`). -### Generic Compliance (Fallback) - -For frameworks without specific attribute models: +### ASD Essential Eight — `asd_essential_eight_aws` ```json { - "Id": "requirement_id", - "Description": "Requirement description", - "Name": "Optional name", - "Checks": ["check_name"], - "Attributes": [ - { - "ItemId": "item_id", - "Section": "Section name", - "SubSection": "Subsection name", - "SubGroup": "Subgroup name", - "Service": "service_name", - "Type": "type" - } - ] + "Section": "Patch applications", "MaturityLevel": "ML1", + "AssessmentStatus": "Automated", "CloudApplicability": "partial", + "MitigatedThreats": ["..."], "Description": "...", + "RationaleStatement": "...", "ImpactStatement": "...", + "RemediationProcedure": "...", "AuditProcedure": "...", + "AdditionalInformation": "...", "References": "..." } ``` ---- +`MaturityLevel`: `ML1|ML2|ML3`. `CloudApplicability`: `full|partial|limited|non-applicable`. -### AWS Well-Architected Framework - -**Framework ID format:** `aws_well_architected_framework_{pillar}_pillar_aws` +### DISA STIG — `okta_idaas_stig_v1r2_okta` ```json { - "Id": "SEC01-BP01", - "Description": "Establish common guardrails...", - "Name": "Establish common guardrails", - "Checks": ["account_part_of_organizations"], - "Attributes": [ - { - "Name": "Establish common guardrails", - "WellArchitectedQuestionId": "securely-operate", - "WellArchitectedPracticeId": "sec_securely_operate_multi_accounts", - "Section": "Security", - "SubSection": "Security foundations", - "LevelOfRisk": "High", - "AssessmentMethod": "Automated", - "Description": "Detailed description", - "ImplementationGuidanceUrl": "https://docs.aws.amazon.com/..." - } - ] + "Section": "...", "Severity": "high", "RuleID": "...", "StigID": "...", + "CCI": ["CCI-000015"], "CheckText": "...", "FixText": "..." } ``` ---- +`Severity`: `high|medium|low` (maps to CAT I/II/III). -### KISA ISMS-P (Korea) +### Other registered shapes -**Framework ID format:** `kisa_isms_p_{year}_{provider}` (e.g., `kisa_isms_p_2023_aws`) +- **AWS Well-Architected** (`aws_well_architected_framework_{pillar}_pillar_aws`): + `Name`, `WellArchitectedQuestionId`, `WellArchitectedPracticeId`, `Section`, + `SubSection`, `LevelOfRisk`, `AssessmentMethod`, `Description`, + `ImplementationGuidanceUrl`. +- **KISA ISMS-P** (`kisa_isms_p_2023_{provider}`): `Domain`, `Subdomain`, + `Section`, `AuditChecklist`, `RelatedRegulations`, `AuditEvidence`, + `NonComplianceCases`. +- **C5** (`c5_{provider}`): `Section`, `SubSection`, `Type`, `AboutCriteria`, + `ComplementaryCriteria`. +- **CSA CCM** (legacy shape; the shipped CSA CCM 4.0 is universal): `Section`, + `CCMLite`, `IaaS`, `PaaS`, `SaaS`, `ScopeApplicability`. +- **Prowler ThreatScore** (`prowler_threatscore_{provider}`): `Title`, + `Section`, `SubSection`, `AttributeDescription`, `AdditionalInformation`, + `LevelOfRisk` (1–5), `Weight` (1/8/10/100/1000). Pillars: 1 IAM, 2 Attack + Surface, 3 Logging and Monitoring, 4 Encryption. Available for aws, + azure, gcp, kubernetes, m365, alibabacloud. +- **Generic (fallback)**: `ItemId`, `Section`, `SubSection`, `SubGroup`, + `Service`, `Type`, `Comment` — all optional. Used by NIST, PCI, GDPR, + HIPAA, SOC2, FedRAMP, CISA, FFIEC, RBI, NIS2, GxP, SecNumCloud, etc. + +## Config Guardrails (`ConfigRequirements`) + +Requirements backed by [configurable checks](https://docs.prowler.com/developer-guide/configurable-checks) +can be silently "satisfied" by a loosened `audit_config` (e.g. CIS demands +45-day unused credentials but the scan ran with `max_unused_access_keys_days: 120`). +Guardrails force such requirements to FAIL: ```json -{ - "Id": "1.1.1", - "Description": "Requirement description", - "Name": "Requirement name", - "Checks": ["check_name"], - "Attributes": [ - { - "Domain": "1. Management System", - "Subdomain": "1.1 Management System Establishment", - "Section": "1.1.1 Section Name", - "AuditChecklist": ["Checklist item 1", "Checklist item 2"], - "RelatedRegulations": ["Regulation 1"], - "AuditEvidence": ["Evidence type 1"], - "NonComplianceCases": ["Non-compliance example"] - } - ] -} +"ConfigRequirements": [ + {"Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", "Operator": "lte", "Value": 45} +] ``` ---- - -### C5 (Germany Cloud Computing Compliance Criteria Catalogue) - -**Framework ID format:** `c5_{provider}` (e.g., `c5_aws`) - -```json -{ - "Id": "BCM-01", - "Description": "Requirement description", - "Name": "Requirement name", - "Checks": ["check_name"], - "Attributes": [ - { - "Section": "BCM Business Continuity Management", - "SubSection": "BCM-01", - "Type": "Basic Criteria", - "AboutCriteria": "Description of criteria", - "ComplementaryCriteria": "Additional criteria" - } - ] -} -``` +- Operators: `lte`/`gte` (numeric thresholds), `eq` (toggles/exact — use JSON + booleans, not 0/1), `in` (scalar in allowed set), `subset` (allowlists — + widening breaks it), `superset` (denylists — removing an entry breaks it). +- `Value` must be the **strictest** setting the control text tolerates. +- `ConfigKey` must be spelled exactly as the check reads it; unknown keys are + silently skipped (defaults assumed OK). +- Guardrails only tighten (PASS→FAIL), never relax. +- Universal files: lowercase `config_requirements` + mandatory `Provider` per + constraint. +- Tests: `tests/lib/check/compliance_config_eval_test.py`, + `compliance_config_constraint_model_test.py`, + `compliance_config_requirements_data_test.py`, plus per-output tests under + `tests/lib/outputs/compliance/`. --- -### CCC (Cloud Computing Compliance) - -**Framework ID format:** `ccc_{provider}` (e.g., `ccc_aws`) - -```json -{ - "Id": "CCC.C01", - "Description": "Requirement description", - "Name": "Requirement name", - "Checks": ["check_name"], - "Attributes": [ - { - "FamilyName": "Cryptography & Key Management", - "FamilyDescription": "Family description", - "Section": "CCC.C01", - "SubSection": "Key Management", - "SubSectionObjective": "Objective description", - "Applicability": ["IaaS", "PaaS", "SaaS"], - "Recommendation": "Recommended action", - "SectionThreatMappings": [{"threat": "T1190"}], - "SectionGuidelineMappings": [{"guideline": "NIST"}] - } - ] -} -``` - ---- - -### Prowler ThreatScore - -**Framework ID format:** `prowler_threatscore_{provider}` (e.g., `prowler_threatscore_aws`) - -Prowler ThreatScore is a custom security scoring framework developed by Prowler that evaluates AWS account security based on **four main pillars**: - -| Pillar | Description | -|--------|-------------| -| **1. IAM** | Identity and Access Management controls (authentication, authorization, credentials) | -| **2. Attack Surface** | Network exposure, public resources, security group rules | -| **3. Logging and Monitoring** | Audit logging, threat detection, forensic readiness | -| **4. Encryption** | Data at rest and in transit encryption | - -**Scoring System:** -- **LevelOfRisk** (1-5): Severity of the security issue - - `5` = Critical (e.g., root MFA, public S3 buckets) - - `4` = High (e.g., user MFA, public EC2) - - `3` = Medium (e.g., password policies, encryption) - - `2` = Low - - `1` = Informational -- **Weight**: Impact multiplier for score calculation - - `1000` = Critical controls (root security, public exposure) - - `100` = High-impact controls (user authentication, monitoring) - - `10` = Standard controls (password policies, encryption) - - `1` = Low-impact controls (best practices) - -```json -{ - "Id": "1.1.1", - "Description": "Ensure MFA is enabled for the 'root' user account", - "Checks": ["iam_root_mfa_enabled"], - "Attributes": [ - { - "Title": "MFA enabled for 'root'", - "Section": "1. IAM", - "SubSection": "1.1 Authentication", - "AttributeDescription": "The root user account holds the highest level of privileges within an AWS account. Enabling MFA enhances security by adding an additional layer of protection.", - "AdditionalInformation": "Enabling MFA enhances console security by requiring the authenticating user to both possess a time-sensitive key-generating device and have knowledge of their credentials.", - "LevelOfRisk": 5, - "Weight": 1000 - } - ] -} -``` - -**Available for providers:** AWS, Kubernetes, M365 - ---- - -## Available Compliance Frameworks - -### AWS (41 frameworks) - -| Framework | File Name | -|-----------|-----------| -| CIS 1.4, 1.5, 2.0, 3.0, 4.0, 5.0 | `cis_{version}_aws.json` | -| ISO 27001:2013, 2022 | `iso27001_{year}_aws.json` | -| NIST 800-53 Rev 4, 5 | `nist_800_53_revision_{version}_aws.json` | -| NIST 800-171 Rev 2 | `nist_800_171_revision_2_aws.json` | -| NIST CSF 1.1, 2.0 | `nist_csf_{version}_aws.json` | -| PCI DSS 3.2.1, 4.0 | `pci_{version}_aws.json` | -| HIPAA | `hipaa_aws.json` | -| GDPR | `gdpr_aws.json` | -| SOC 2 | `soc2_aws.json` | -| FedRAMP Low/Moderate | `fedramp_{level}_revision_4_aws.json` | -| ENS RD2022 | `ens_rd2022_aws.json` | -| MITRE ATT&CK | `mitre_attack_aws.json` | -| C5 Germany | `c5_aws.json` | -| CISA | `cisa_aws.json` | -| FFIEC | `ffiec_aws.json` | -| RBI Cyber Security | `rbi_cyber_security_framework_aws.json` | -| AWS Well-Architected | `aws_well_architected_framework_{pillar}_pillar_aws.json` | -| AWS FTR | `aws_foundational_technical_review_aws.json` | -| GxP 21 CFR Part 11, EU Annex 11 | `gxp_{standard}_aws.json` | -| KISA ISMS-P 2023 | `kisa_isms_p_2023_aws.json` | -| NIS2 | `nis2_aws.json` | - -### Azure (15+ frameworks) - -| Framework | File Name | -|-----------|-----------| -| CIS 2.0, 2.1, 3.0, 4.0 | `cis_{version}_azure.json` | -| ISO 27001:2022 | `iso27001_2022_azure.json` | -| ENS RD2022 | `ens_rd2022_azure.json` | -| MITRE ATT&CK | `mitre_attack_azure.json` | -| PCI DSS 4.0 | `pci_4.0_azure.json` | -| NIST CSF 2.0 | `nist_csf_2.0_azure.json` | - -### GCP (15+ frameworks) - -| Framework | File Name | -|-----------|-----------| -| CIS 2.0, 3.0, 4.0 | `cis_{version}_gcp.json` | -| ISO 27001:2022 | `iso27001_2022_gcp.json` | -| HIPAA | `hipaa_gcp.json` | -| MITRE ATT&CK | `mitre_attack_gcp.json` | -| PCI DSS 4.0 | `pci_4.0_gcp.json` | -| NIST CSF 2.0 | `nist_csf_2.0_gcp.json` | - -### Kubernetes (6 frameworks) - -| Framework | File Name | -|-----------|-----------| -| CIS 1.8, 1.10, 1.11 | `cis_{version}_kubernetes.json` | -| ISO 27001:2022 | `iso27001_2022_kubernetes.json` | -| PCI DSS 4.0 | `pci_4.0_kubernetes.json` | - -### Other Providers -- **GitHub:** `cis_1.0_github.json` -- **M365:** `cis_4.0_m365.json`, `iso27001_2022_m365.json` -- **NHN:** `iso27001_2022_nhn.json` - ## Workflow A: Sync a Framework With an Upstream Catalog -Use when the framework is maintained upstream (CIS Benchmarks, FINOS CCC, CSA CCM, NIST, ENS, etc.) and Prowler needs to catch up. +Use when the framework is maintained upstream (CIS Benchmarks, FINOS CCC, CSA +CCM, NIST, ENS, etc.) and Prowler needs to catch up. ### Step 1 — Cache the upstream source -Download every upstream file to a local cache so subsequent iterations don't hit the network. For FINOS CCC: +Download every upstream file to a local cache so iterations don't hit the +network. For FINOS CCC: ```bash mkdir -p /tmp/ccc_upstream @@ -563,492 +715,480 @@ done ### Step 2 — Run the generic sync runner against a framework config -The sync tooling is split into three layers so adding a new framework only takes a YAML config (and optionally a new parser module for an unfamiliar upstream format): +The sync tooling is three layers, so adding a framework only takes a YAML +config (plus a parser module for an unfamiliar upstream format): ```text skills/prowler-compliance/assets/ ├── sync_framework.py # generic runner — works for any framework -├── configs/ -│ └── ccc.yaml # per-framework config (canonical example) -└── parsers/ - ├── __init__.py - └── finos_ccc.py # parser module for FINOS CCC YAML +├── configs/ccc.yaml # per-framework config (canonical example) +└── parsers/finos_ccc.py # parser module for FINOS CCC YAML ``` -**For frameworks that already have a config + parser** (today: FINOS CCC), run: - ```bash python skills/prowler-compliance/assets/sync_framework.py \ skills/prowler-compliance/assets/configs/ccc.yaml ``` -The runner loads the config, validates it, dynamically imports the parser declared in `parser.module`, calls `parser.parse_upstream(config) -> list[dict]`, then applies generic post-processing (id uniqueness safety net, `FamilyName` normalization, legacy check-mapping preservation) and writes the provider JSONs. +The runner loads the config, dynamically imports `parser.module`, calls +`parse_upstream(config) -> list[dict]`, then applies generic post-processing +(id-uniqueness safety net, `FamilyName` normalization, legacy check-mapping +preservation with config-driven fallback keys) and writes the provider JSONs +with Pydantic post-validation. **To add a new framework sync**: -1. **Write a config file** at `skills/prowler-compliance/assets/configs/{framework}.yaml`. See `configs/ccc.yaml` as the canonical example. Required top-level sections: - - `framework` — `name`, `display_name`, `version` (**never empty** — empty Version silently breaks `get_check_compliance()` key construction, so the runner refuses to start), `description_template` (accepts `{provider_display}`, `{provider_key}`, `{framework_name}`, `{framework_display}`, `{version}` placeholders). - - `providers` — list of `{key, display}` pairs, one per Prowler provider the framework targets. - - `output.path_template` — supports `{provider}`, `{framework}`, `{version}` placeholders. Examples: `"prowler/compliance/{provider}/ccc_{provider}.json"` for unversioned file names, `"prowler/compliance/{provider}/cis_{version}_{provider}.json"` for versioned ones. - - `upstream.dir` — local cache directory (populate via Step 1). - - `parser.module` — name of the module under `parsers/` to load (without `.py`). Everything else under `parser.` is opaque to the runner and passed to the parser as config. - - `post_processing.check_preservation.primary_key` — top-level field name for the primary legacy-mapping lookup (almost always `Id`). - - `post_processing.check_preservation.fallback_keys` — **config-driven fallback keys** for preserving check mappings when ids change. Each entry is a list of `Attributes[0]` field names composed into a tuple. Examples: - - CCC: `- [Section, Applicability]` (because `Applicability` is a CCC-only attribute, verified in `compliance_models.py:213`). - - CIS would use `- [Section, Profile]`. - - NIST would use `- [ItemId]`. - - List-valued fields (like `Applicability`) are automatically frozen to `frozenset` so the tuple is hashable. - - `post_processing.family_name_normalization` (optional) — map of raw → canonical `FamilyName` values. The UI groups by `Attributes[0].FamilyName` exactly, so inconsistent upstream variants otherwise become separate tree branches. +1. Write `assets/configs/{framework}.yaml` (see `ccc.yaml`). Required sections: + - `framework` — `name`, `display_name`, `version` (**never empty** — the + runner refuses to start, because empty Version breaks the + `get_check_compliance()` key), `description_template`. + - `providers` — list of `{key, display}` pairs. + - `output.path_template` — e.g. + `"prowler/compliance/{provider}/cis_{version}_{provider}.json"`. + - `upstream.dir` — local cache (Step 1). + - `parser.module` — module under `parsers/`; the rest of `parser.` is + passed through opaque. + - `post_processing.check_preservation.primary_key` (almost always `Id`) and + `fallback_keys` — lists of `Attributes[0]` field names composed into + tuples for recovering mappings when ids change. CCC: + `- [Section, Applicability]`; CIS: `- [Section, Profile]`; NIST: + `- [ItemId]`. List-valued fields are frozen to `frozenset` automatically. + - `post_processing.family_name_normalization` (optional) — raw → canonical + map; the UI groups by the exact attribute value, so upstream variants + otherwise become separate tree branches. +2. Reuse an existing parser or write `parsers/{name}.py` implementing + `parse_upstream(config) -> list[dict]` returning Prowler-format + requirements with **guaranteed-unique ids**. The runner raises on + duplicates — it never silently renumbers, because mutating a canonical + upstream id (CIS `1.1.1`, NIST `AC-2(1)`) would be catastrophic. The parser + owns all upstream quirks: foreign-prefix rewriting, genuine collision + renumbering, multi-shape handling. -2. **Reuse an existing parser** if the upstream format matches one (currently only `finos_ccc` exists). Otherwise, **write a new parser** at `parsers/{name}.py` implementing: +**Gotchas the runner already handles** (from the FINOS CCC v2025.10 sync): - ```python - def parse_upstream(config: dict) -> list[dict]: - """Return Prowler-format requirements {Id, Description, Attributes: [...], Checks: []}. - - Ids MUST be unique in the returned list. The runner raises ValueError - on duplicates — it does NOT silently renumber, because mutating a - canonical upstream id (e.g. CIS '1.1.1' or NIST 'AC-2(1)') would be - catastrophic. The parser owns all upstream-format quirks: foreign-prefix - rewriting, genuine collision renumbering, shape handling. - """ - ``` - - The parser reads its own settings from `config['upstream']` and `config['parser']`. It does NOT load existing Prowler JSONs (the runner does that for check preservation) and does NOT write output (the runner does that too). - -**Gotchas the runner already handles for you** (learned from the FINOS CCC v2025.10 sync — they're documented here so you don't re-discover them): - -- **Multiple upstream YAML shapes**. Most FINOS CCC catalogs use `control-families: [...]`, but `storage/object` uses a top-level `controls: [...]` with a `family: "CCC.X.Y"` reference id and no human-readable family name. A parser that only handles shape 1 silently drops the shape-2 catalog — this exact bug dropped ObjStor from Prowler for a full iteration. `parsers/finos_ccc.py` handles both shapes; if you write a new parser for a similar format, test with at least one file of each shape. -- **Whitespace collapse**. Upstream YAML multi-line block scalars (`|`) preserve newlines. Prowler stores descriptions single-line. Collapse with `" ".join(value.split())` before emitting (see `parsers/finos_ccc.py::clean()`). -- **Foreign-prefix AR id rewriting**. Upstream sometimes aliases requirements across catalogs by keeping the original prefix (e.g., `CCC.AuditLog.CN08.AR01` appears nested under `CCC.Logging.CN03`). Rewrite the foreign id to fit its parent control: `CCC.Logging.CN03.AR01`. This logic is parser-specific because the id structure varies per framework (CCC uses 3-dot depth; CIS uses numeric dots; NIST uses `AC-2(1)`). -- **Genuine upstream collision renumbering**. Sometimes upstream has a real typo where two different requirements share the same id (e.g., `CCC.Core.CN14.AR02` defined twice for 30-day and 14-day backup variants). Renumber the second copy to the next free AR number (`.AR03`). The parser handles this; the runner asserts the final list has unique ids as a safety net. -- **Existing check mapping preservation**. The runner uses the `primary_key` + `fallback_keys` declared in config to look up the old `Checks` list for each requirement. For CCC this means primary index by `Id` plus fallback index by `(Section, frozenset(Applicability))` — the fallback recovers mappings for requirements whose ids were rewritten or renumbered by the parser. -- **FamilyName normalization**. Configured via `post_processing.family_name_normalization` — no code changes needed to collapse upstream variants like `"Logging & Monitoring"` → `"Logging and Monitoring"`. -- **Populate `Version`**. The runner refuses to start on empty `framework.version` — fail-fast replaces the silent bug where `get_check_compliance()` would build the key as just `"{Framework}"`. +- **Multiple upstream YAML shapes.** Most FINOS CCC catalogs use + `control-families: [...]` but `storage/object` uses top-level + `controls: [...]`. A single-shape parser silently drops entire catalogs — + this exact bug dropped ObjStor for a full iteration. Test with one file of + each shape. +- **Whitespace collapse.** Upstream `|` block scalars keep newlines; Prowler + stores single-line. Collapse with `" ".join(value.split())`. +- **Foreign-prefix id rewriting.** Upstream aliases requirements across + catalogs keeping the original prefix (`CCC.AuditLog.CN08.AR01` nested under + `CCC.Logging.CN03`) — rewrite to fit the parent (`CCC.Logging.CN03.AR01`). +- **Genuine upstream collisions.** Two different requirements sharing one id + (upstream typo): renumber the second to the next free number; check-mapping + preservation recovers by the fallback keys. +- **Populate `Version`** — fail-fast beats the silent broken-key bug. ### Step 3 — Validate before committing -```python -from prowler.lib.check.compliance_models import Compliance -for prov in ['aws', 'azure', 'gcp']: - c = Compliance.parse_file(f"prowler/compliance/{prov}/ccc_{prov}.json") - print(f"{prov}: {len(c.Requirements)} reqs, version={c.Version}") -``` +Run the full Validation section below (universal loader + check existence + +CLI smoke + pytest). -Any `ValidationError` means the Attribute fields don't match the `*_Requirement_Attribute` model. Either fix the JSON or extend the model in `compliance_models.py` (remember: Generic stays last). +### Step 4 — Add an attribute model if needed -### Step 4 — Verify every check id exists - -```python -import json -from pathlib import Path -for prov in ['aws', 'azure', 'gcp']: - existing = {p.stem.replace('.metadata','') - for p in Path(f'prowler/providers/{prov}/services').rglob('*.metadata.json')} - with open(f'prowler/compliance/{prov}/ccc_{prov}.json') as f: - data = json.load(f) - refs = {c for r in data['Requirements'] for c in r['Checks']} - missing = refs - existing - assert not missing, f"{prov} missing: {missing}" -``` - -A stale check id silently becomes dead weight — no finding will ever map to it. This pre-validation **must run on every write**; bake it into the generator script. - -### Step 5 — Add an attribute model if needed - -Only if the framework has fields beyond `Generic_Compliance_Requirement_Attribute`. Add the class to `prowler/lib/check/compliance_models.py` and register it in `Compliance_Requirement.Attributes: list[Union[...]]`. **Generic stays last.** +Only if the framework has fields beyond +`Generic_Compliance_Requirement_Attribute` and must stay legacy. Add the class +to `compliance_models.py` and register it in the +`Compliance_Requirement.Attributes` Union **before Generic** (Generic stays +last). For new frameworks, prefer universal `attributes_metadata` instead. --- ## Workflow B: Audit Check Mappings as a Cloud Auditor -Use when the user asks to review existing mappings ("are these correct?", "verify that the checks apply", "audit the CCC mappings"). This is the highest-value compliance task — it surfaces padded mappings with zero actual coverage and missing mappings for legitimate coverage. +Use when the user asks to review existing mappings. This is the +highest-value compliance task — it surfaces padded mappings with zero actual +coverage and missing mappings for legitimate coverage. ### The golden rule -> A Prowler check's title/risk MUST **literally describe what the requirement text says**. "Related" is not enough. If no check actually addresses the requirement, leave `Checks: []` (MANUAL) — **honest MANUAL is worth more than padded coverage**. +> A Prowler check's title/risk MUST **literally describe what the requirement +> text says**. "Related" is not enough. If no check actually addresses the +> requirement, leave the checks list empty (MANUAL) — **honest MANUAL is worth +> more than padded coverage**. ### Audit process -**Step 1 — Build a per-provider check inventory** (cache in `/tmp/`): +1. **Build a per-provider check inventory** — `assets/build_inventory.py` + (writes `/tmp/checks_{provider}.json` for every provider discovered under + `prowler/providers/`). +2. **Query it** — `assets/query_checks.py` (run from the repository root): -```python -import json -from pathlib import Path -for provider in ['aws', 'azure', 'gcp']: - inv = {} - for meta in Path(f'prowler/providers/{provider}/services').rglob('*.metadata.json'): - with open(meta) as f: - d = json.load(f) - cid = d.get('CheckID') or meta.stem.replace('.metadata','') - inv[cid] = { - 'service': d.get('ServiceName', ''), - 'title': d.get('CheckTitle', ''), - 'risk': d.get('Risk', ''), - 'description': d.get('Description', ''), - } - with open(f'/tmp/checks_{provider}.json', 'w') as f: - json.dump(inv, f, indent=2) -``` + ```bash + python skills/prowler-compliance/assets/query_checks.py aws encryption transit # keyword AND-search + python skills/prowler-compliance/assets/query_checks.py aws --service iam # all iam checks + python skills/prowler-compliance/assets/query_checks.py aws --id kms_cmk_rotation_enabled + ``` -**Step 2 — Keyword/service query helper** — see [assets/query_checks.py](assets/query_checks.py): +3. **Dump a framework section with current mappings** — `assets/dump_section.py`: -```bash -python assets/query_checks.py aws encryption transit # keyword AND-search -python assets/query_checks.py aws --service iam # all iam checks -python assets/query_checks.py aws --id kms_cmk_rotation_enabled # full metadata -``` + ```bash + python skills/prowler-compliance/assets/dump_section.py ccc "CCC.Core." + python skills/prowler-compliance/assets/dump_section.py cis_5.0_aws "1." + ``` -**Step 3 — Dump a framework section with current mappings** — see [assets/dump_section.py](assets/dump_section.py): +4. **Encode explicit REPLACE decisions** — `assets/audit_framework_template.py`: -```bash -python assets/dump_section.py ccc "CCC.Core." # all Core ARs across 3 providers -python assets/dump_section.py ccc "CCC.AuditLog." # all AuditLog ARs -``` + ```python + DECISIONS = {} + DECISIONS["CCC.Core.CN01.AR01"] = { + "aws": ["cloudfront_distributions_https_enabled", ...], + "azure": ["storage_secure_transfer_required_is_enabled", ...], + "gcp": ["cloudsql_instance_ssl_connections"], + # Missing provider key = leave the legacy mapping untouched + } + # Empty list = EXPLICITLY MANUAL (overwrites legacy) + DECISIONS["CCC.Core.CN01.AR07"] = {"aws": [], "azure": [], "gcp": []} + ``` -**Step 4 — Encode explicit REPLACE decisions** — see [assets/audit_framework_template.py](assets/audit_framework_template.py). Structure: + **REPLACE, not PATCH.** Full lists make the audit reproducible and surface + hidden assumptions in the legacy data. +5. **Pre-validate** every check id against the inventory; the script MUST + abort with stderr listing typos (real audits caught + `storage_secure_transfer_required_enabled` → + `storage_secure_transfer_required_is_enabled`, + `sqlserver_minimum_tls_version_12` → + `sqlserver_recommended_minimal_tls_version`, and several checks that + simply don't exist). +6. **Apply + validate + test**: -```python -DECISIONS = {} + ```bash + python /path/to/audit_script.py + uv run pytest -n auto tests/lib/outputs/compliance/ tests/lib/check/ -q + ``` -DECISIONS["CCC.Core.CN01.AR01"] = { - "aws": [ - "cloudfront_distributions_https_enabled", - "cloudfront_distributions_origin_traffic_encrypted", - # ... - ], - "azure": [ - "storage_secure_transfer_required_is_enabled", - "app_minimum_tls_version_12", - # ... - ], - "gcp": [ - "cloudsql_instance_ssl_connections", - ], - # Missing provider key = leave the legacy mapping untouched -} - -# Empty list = EXPLICITLY MANUAL (overwrites legacy) -DECISIONS["CCC.Core.CN01.AR07"] = { - "aws": [], # Prowler has no IANA port/protocol check - "azure": [], - "gcp": [], -} -``` - -**REPLACE, not PATCH.** Encoding every mapping as a full list (not add/remove delta) makes the audit reproducible and surfaces hidden assumptions from the legacy data. - -**Step 5 — Pre-validation**. The audit script MUST validate every check id against the inventory and **abort with stderr listing typos**. Common typos caught during a real audit: - -- `fsx_file_system_encryption_at_rest_using_kms` (doesn't exist) -- `cosmosdb_account_encryption_at_rest_with_cmk` (doesn't exist) -- `sqlserver_geo_replication` (doesn't exist) -- `redshift_cluster_audit_logging` (should be `redshift_cluster_encrypted_at_rest`) -- `postgresql_flexible_server_require_secure_transport` (should be `postgresql_flexible_server_enforce_ssl_enabled`) -- `storage_secure_transfer_required_enabled` (should be `storage_secure_transfer_required_is_enabled`) -- `sqlserver_minimum_tls_version_12` (should be `sqlserver_recommended_minimal_tls_version`) - -**Step 6 — Apply + validate + test**: - -```bash -python /path/to/audit_script.py # applies decisions, pre-validates -python -m pytest tests/lib/outputs/compliance/ tests/lib/check/ -q -``` - -### Audit Reference Table: Requirement Text → Prowler Checks - -Use this table to map CCC-style / NIST-style / ISO-style requirements to the checks that actually verify them. Built from a real audit of 172 CCC ARs × 3 providers. - -| Requirement text | AWS checks | Azure checks | GCP checks | -|---|---|---|---| -| **TLS in transit enforced** | `cloudfront_distributions_https_enabled`, `s3_bucket_secure_transport_policy`, `elbv2_ssl_listeners`, `elbv2_insecure_ssl_ciphers`, `elb_ssl_listeners`, `elb_insecure_ssl_ciphers`, `opensearch_service_domains_https_communications_enforced`, `rds_instance_transport_encrypted`, `redshift_cluster_in_transit_encryption_enabled`, `elasticache_redis_cluster_in_transit_encryption_enabled`, `dynamodb_accelerator_cluster_in_transit_encryption_enabled`, `dms_endpoint_ssl_enabled`, `kafka_cluster_in_transit_encryption_enabled`, `transfer_server_in_transit_encryption_enabled`, `glue_database_connections_ssl_enabled`, `sns_subscription_not_using_http_endpoints` | `storage_secure_transfer_required_is_enabled`, `storage_ensure_minimum_tls_version_12`, `postgresql_flexible_server_enforce_ssl_enabled`, `mysql_flexible_server_ssl_connection_enabled`, `mysql_flexible_server_minimum_tls_version_12`, `sqlserver_recommended_minimal_tls_version`, `app_minimum_tls_version_12`, `app_ensure_http_is_redirected_to_https`, `app_ftp_deployment_disabled` | `cloudsql_instance_ssl_connections` (almost only option) | -| **TLS 1.3 specifically** | Partial: `cloudfront_distributions_using_deprecated_ssl_protocols`, `elb*_insecure_ssl_ciphers`, `*_minimum_tls_version_12` | Partial: `*_minimum_tls_version_12` checks | None — accept as MANUAL | -| **SSH / port 22 hardening** | `ec2_instance_port_ssh_exposed_to_internet`, `ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22`, `ec2_networkacl_allow_ingress_tcp_port_22` | `network_ssh_internet_access_restricted`, `vm_linux_enforce_ssh_authentication` | `compute_firewall_ssh_access_from_the_internet_allowed`, `compute_instance_block_project_wide_ssh_keys_disabled`, `compute_project_os_login_enabled`, `compute_project_os_login_2fa_enabled` | -| **mTLS (mutual TLS)** | `kafka_cluster_mutual_tls_authentication_enabled`, `apigateway_restapi_client_certificate_enabled` | `app_client_certificates_on` | None — MANUAL | -| **Data at rest encrypted** | `s3_bucket_default_encryption`, `s3_bucket_kms_encryption`, `ec2_ebs_default_encryption`, `ec2_ebs_volume_encryption`, `rds_instance_storage_encrypted`, `rds_cluster_storage_encrypted`, `rds_snapshots_encrypted`, `dynamodb_tables_kms_cmk_encryption_enabled`, `redshift_cluster_encrypted_at_rest`, `neptune_cluster_storage_encrypted`, `documentdb_cluster_storage_encrypted`, `opensearch_service_domains_encryption_at_rest_enabled`, `kinesis_stream_encrypted_at_rest`, `firehose_stream_encrypted_at_rest`, `sns_topics_kms_encryption_at_rest_enabled`, `sqs_queues_server_side_encryption_enabled`, `efs_encryption_at_rest_enabled`, `athena_workgroup_encryption`, `glue_data_catalogs_metadata_encryption_enabled`, `backup_vaults_encrypted`, `backup_recovery_point_encrypted`, `cloudtrail_kms_encryption_enabled`, `cloudwatch_log_group_kms_encryption_enabled`, `eks_cluster_kms_cmk_encryption_in_secrets_enabled`, `sagemaker_notebook_instance_encryption_enabled`, `apigateway_restapi_cache_encrypted`, `kafka_cluster_encryption_at_rest_uses_cmk`, `dynamodb_accelerator_cluster_encryption_enabled`, `storagegateway_fileshare_encryption_enabled` | `storage_infrastructure_encryption_is_enabled`, `storage_ensure_encryption_with_customer_managed_keys`, `vm_ensure_attached_disks_encrypted_with_cmk`, `vm_ensure_unattached_disks_encrypted_with_cmk`, `sqlserver_tde_encryption_enabled`, `sqlserver_tde_encrypted_with_cmk`, `databricks_workspace_cmk_encryption_enabled`, `monitor_storage_account_with_activity_logs_cmk_encrypted` | `compute_instance_encryption_with_csek_enabled`, `dataproc_encrypted_with_cmks_disabled`, `bigquery_dataset_cmk_encryption`, `bigquery_table_cmk_encryption` | -| **CMEK required (customer-managed keys)** | `kms_cmk_are_used` | `storage_ensure_encryption_with_customer_managed_keys`, `vm_ensure_attached_disks_encrypted_with_cmk`, `vm_ensure_unattached_disks_encrypted_with_cmk`, `sqlserver_tde_encrypted_with_cmk`, `databricks_workspace_cmk_encryption_enabled` | `bigquery_dataset_cmk_encryption`, `bigquery_table_cmk_encryption`, `dataproc_encrypted_with_cmks_disabled`, `compute_instance_encryption_with_csek_enabled` | -| **Key rotation enabled** | `kms_cmk_rotation_enabled` | `keyvault_key_rotation_enabled`, `storage_key_rotation_90_days` | `kms_key_rotation_enabled` | -| **MFA for UI access** | `iam_root_mfa_enabled`, `iam_root_hardware_mfa_enabled`, `iam_user_mfa_enabled_console_access`, `iam_user_hardware_mfa_enabled`, `iam_administrator_access_with_mfa`, `cognito_user_pool_mfa_enabled` | `entra_privileged_user_has_mfa`, `entra_non_privileged_user_has_mfa`, `entra_user_with_vm_access_has_mfa`, `entra_security_defaults_enabled` | `compute_project_os_login_2fa_enabled` | -| **API access / credentials** | `iam_no_root_access_key`, `iam_user_no_setup_initial_access_key`, `apigateway_restapi_authorizers_enabled`, `apigateway_restapi_public_with_authorizer`, `apigatewayv2_api_authorizers_enabled` | `entra_conditional_access_policy_require_mfa_for_management_api`, `app_function_access_keys_configured`, `app_function_identity_is_configured` | `apikeys_api_restrictions_configured`, `apikeys_key_exists`, `apikeys_key_rotated_in_90_days` | -| **Log all admin/config changes** | `cloudtrail_multi_region_enabled`, `cloudtrail_multi_region_enabled_logging_management_events`, `cloudtrail_cloudwatch_logging_enabled`, `cloudtrail_log_file_validation_enabled`, `cloudwatch_log_metric_filter_*`, `cloudwatch_changes_to_*_alarm_configured`, `config_recorder_all_regions_enabled` | `monitor_diagnostic_settings_exists`, `monitor_diagnostic_setting_with_appropriate_categories`, `monitor_alert_*` | `iam_audit_logs_enabled`, `logging_log_metric_filter_and_alert_for_*`, `logging_sink_created` | -| **Log integrity (digital signatures)** | `cloudtrail_log_file_validation_enabled` (exact) | None | None | -| **Public access denied** | `s3_bucket_public_access`, `s3_bucket_public_list_acl`, `s3_bucket_public_write_acl`, `s3_account_level_public_access_blocks`, `apigateway_restapi_public`, `awslambda_function_url_public`, `awslambda_function_not_publicly_accessible`, `rds_instance_no_public_access`, `rds_snapshots_public_access`, `ec2_securitygroup_allow_ingress_from_internet_to_all_ports`, `sns_topics_not_publicly_accessible`, `sqs_queues_not_publicly_accessible` | `storage_blob_public_access_level_is_disabled`, `storage_ensure_private_endpoints_in_storage_accounts`, `containerregistry_not_publicly_accessible`, `keyvault_private_endpoints`, `app_function_not_publicly_accessible`, `aks_clusters_public_access_disabled`, `network_http_internet_access_restricted` | `cloudstorage_bucket_public_access`, `compute_instance_public_ip`, `cloudsql_instance_public_ip`, `compute_firewall_*_access_from_the_internet_allowed` | -| **IAM least privilege** | `iam_*_no_administrative_privileges`, `iam_policy_allows_privilege_escalation`, `iam_inline_policy_allows_privilege_escalation`, `iam_role_administratoraccess_policy`, `iam_group_administrator_access_policy`, `iam_user_administrator_access_policy`, `iam_policy_attached_only_to_group_or_roles`, `iam_role_cross_service_confused_deputy_prevention` | `iam_role_user_access_admin_restricted`, `iam_subscription_roles_owner_custom_not_created`, `iam_custom_role_has_permissions_to_administer_resource_locks` | `iam_sa_no_administrative_privileges`, `iam_no_service_roles_at_project_level`, `iam_role_kms_enforce_separation_of_duties`, `iam_role_sa_enforce_separation_of_duties` | -| **Password policy** | `iam_password_policy_minimum_length_14`, `iam_password_policy_uppercase`, `iam_password_policy_lowercase`, `iam_password_policy_symbol`, `iam_password_policy_number`, `iam_password_policy_expires_passwords_within_90_days_or_less`, `iam_password_policy_reuse_24` | None | None | -| **Credential rotation / unused** | `iam_rotate_access_key_90_days`, `iam_user_accesskey_unused`, `iam_user_console_access_unused` | None | `iam_sa_user_managed_key_rotate_90_days`, `iam_sa_user_managed_key_unused`, `iam_service_account_unused` | -| **VPC / flow logs** | `vpc_flow_logs_enabled` | `network_flow_log_captured_sent`, `network_watcher_enabled`, `network_flow_log_more_than_90_days` | `compute_subnet_flow_logs_enabled` | -| **Backup / DR / Multi-AZ** | `backup_vaults_exist`, `backup_plans_exist`, `backup_reportplans_exist`, `rds_instance_backup_enabled`, `rds_*_protected_by_backup_plan`, `rds_cluster_multi_az`, `neptune_cluster_backup_enabled`, `documentdb_cluster_backup_enabled`, `efs_have_backup_enabled`, `s3_bucket_cross_region_replication`, `dynamodb_table_protected_by_backup_plan` | `vm_backup_enabled`, `vm_sufficient_daily_backup_retention_period`, `storage_geo_redundant_enabled` | `cloudsql_instance_automated_backups`, `cloudstorage_bucket_log_retention_policy_lock`, `cloudstorage_bucket_sufficient_retention_period` | -| **Access analysis / discovery** | `accessanalyzer_enabled`, `accessanalyzer_enabled_without_findings` | None specific | `iam_account_access_approval_enabled`, `iam_cloud_asset_inventory_enabled` | -| **Object lock / retention** | `s3_bucket_object_lock`, `s3_bucket_object_versioning`, `s3_bucket_lifecycle_enabled`, `cloudtrail_bucket_requires_mfa_delete`, `s3_bucket_no_mfa_delete` | `storage_ensure_soft_delete_is_enabled`, `storage_blob_versioning_is_enabled`, `storage_ensure_file_shares_soft_delete_is_enabled` | `cloudstorage_bucket_log_retention_policy_lock`, `cloudstorage_bucket_soft_delete_enabled`, `cloudstorage_bucket_versioning_enabled`, `cloudstorage_bucket_sufficient_retention_period` | -| **Uniform bucket-level access** | `s3_bucket_acl_prohibited` | `storage_account_key_access_disabled`, `storage_default_to_entra_authorization_enabled` | `cloudstorage_bucket_uniform_bucket_level_access` | -| **Container vulnerability scanning** | `ecr_registry_scan_images_on_push_enabled`, `ecr_repositories_scan_vulnerabilities_in_latest_image` | `defender_container_images_scan_enabled`, `defender_container_images_resolved_vulnerabilities` | `artifacts_container_analysis_enabled`, `gcr_container_scanning_enabled` | -| **WAF / rate limiting** | `wafv2_webacl_with_rules`, `waf_*_webacl_with_rules`, `wafv2_webacl_logging_enabled`, `waf_global_webacl_logging_enabled` | None | None | -| **Deployment region restriction** | `organizations_scp_check_deny_regions` | None | None | -| **Secrets automatic rotation** | `secretsmanager_automatic_rotation_enabled`, `secretsmanager_secret_rotated_periodically` | `keyvault_rbac_secret_expiration_set`, `keyvault_non_rbac_secret_expiration_set` | None | -| **Certificate management** | `acm_certificates_expiration_check`, `acm_certificates_with_secure_key_algorithms`, `acm_certificates_transparency_logs_enabled` | `keyvault_key_expiration_set_in_non_rbac`, `keyvault_rbac_key_expiration_set`, `keyvault_non_rbac_secret_expiration_set` | None | -| **GenAI guardrails / input/output filtering** | `bedrock_guardrail_prompt_attack_filter_enabled`, `bedrock_guardrail_sensitive_information_filter_enabled`, `bedrock_agent_guardrail_enabled`, `bedrock_model_invocation_logging_enabled`, `bedrock_api_key_no_administrative_privileges`, `bedrock_api_key_no_long_term_credentials` | None | None | -| **ML dev environment security** | `sagemaker_notebook_instance_root_access_disabled`, `sagemaker_notebook_instance_without_direct_internet_access_configured`, `sagemaker_notebook_instance_vpc_settings_configured`, `sagemaker_models_vpc_settings_configured`, `sagemaker_training_jobs_vpc_settings_configured`, `sagemaker_training_jobs_network_isolation_enabled`, `sagemaker_training_jobs_volume_and_output_encryption_enabled` | None | None | -| **Threat detection / anomalous behavior** | `cloudtrail_threat_detection_enumeration`, `cloudtrail_threat_detection_privilege_escalation`, `cloudtrail_threat_detection_llm_jacking`, `guardduty_is_enabled`, `guardduty_no_high_severity_findings` | None | None | -| **Serverless private access** | `awslambda_function_inside_vpc`, `awslambda_function_not_publicly_accessible`, `awslambda_function_url_public` | `app_function_not_publicly_accessible` | None | - -### What Prowler Does NOT Cover (accept MANUAL honestly) - -Don't pad mappings for these — mark `Checks: []` and move on: - -- **TLS 1.3 version specifically** — Prowler verifies TLS is enforced, not always the exact version -- **IANA port-protocol consistency** — no check for "protocol running on its assigned port" -- **mTLS on most Azure/GCP services** — limited to App Service client certs on Azure, nothing on GCP -- **Rate limiting** on monitoring endpoints, load balancers, serverless invocations, vector ingestion -- **Session cookie expiry** (LB stickiness) -- **HTTP header scrubbing** (Server, X-Powered-By) -- **Certificate transparency verification for imports** -- **Model version pinning, red teaming, AI quality review** -- **Vector embedding validation, dimensional constraints, ANN vs exact search** -- **Secret region replication** (cross-region residency) -- **Lifecycle cleanup policies on container registries** -- **Row-level / column-level security in data warehouses** -- **Deployment region restriction on Azure/GCP** (AWS has `organizations_scp_check_deny_regions`, others don't) -- **Cross-tenant alert silencing permissions** -- **Field-level masking in logs** -- **Managed view enforcement for database access** -- **Automatic MFA delete on all S3 buckets** (only CloudTrail bucket variant exists for some frameworks — AWS has the generic `s3_bucket_no_mfa_delete` though) +For the curated mapping table (requirement text → AWS/Azure/GCP checks) and +the list of controls Prowler genuinely cannot verify, see +[references/check-mapping-reference.md](references/check-mapping-reference.md). --- -## Workflow C: Add a New Output Formatter +## Workflow C: Add a New Universal Framework -Use when a new framework needs its own CSV columns or terminal table. Follow the c5/csa/ens layout exactly: +1. Author `prowler/compliance/{framework}_{version}.json` following the + Universal Schema Reference above (use `dora_2022_2554.json` or + `csa_ccm_4.0.json` as template). +2. Declare every attribute in `attributes_metadata` (with `required`/`enum` + where possible — that's your load-time validation) and a + `outputs.table_config.group_by`. +3. Map checks per provider; add `config_requirements` (with `Provider`) for + configurable checks; leave empty lists for manual requirements — **include + every requirement of the source catalog** (coverage percentages depend on + the full denominator). +4. Validate (section below). No Python registration of any kind is needed for + CLI table/CSV/OCSF. +5. Optional first-class UI: mapper in `ui/lib/compliance/{framework}.tsx`, + registration in `getComplianceMappers()` under the JSON's `framework` value, + detail panel, `*AttributesMetadata` type, and icon (ordered keyword!). Until + then the generic mapper renders it. +6. Optional API extras: CSV exporter entry in `COMPLIANCE_CLASS_MAP`; PDF + generator + `FRAMEWORK_REGISTRY` entry if a PDF is required. +7. Tests: extend `tests/lib/check/universal_compliance_models_test.py` with a + case loading the new JSON. The parametrized `test_loads_as_universal` + already picks the file up automatically. +8. Changelog fragment `prowler/changelog.d/.added.md` + user-guide + tutorial under `docs/user-guide/compliance/tutorials/` for high-profile + frameworks. -```bash -mkdir -p prowler/lib/outputs/compliance/{framework} -touch prowler/lib/outputs/compliance/{framework}/__init__.py -``` +## Workflow D: Add a New Legacy Output Formatter -### Step 1 — Create `{framework}.py` (table dispatcher ONLY) +Only for new members of an existing legacy family. Follow the `c5/` or `ccc/` +layout exactly: -Copy from `prowler/lib/outputs/compliance/c5/c5.py` and change the function name + framework string. The `diff` between your file and `c5.py` should be just those two lines. **No function docstring** — other frameworks don't have one, stay consistent. +1. `mkdir prowler/lib/outputs/compliance/{framework}` with `__init__.py`. +2. `{framework}.py` — copy `c5/c5.py`, change function name + framework + string; the diff should be just those lines. No docstring (legacy style). +3. `models.py` — one Pydantic CSV row model per provider. Column sets differ + per provider (`AccountId`/`Region` vs `SubscriptionId`/`Location` vs + `ProjectId`/`Location`); per-provider files are the convention — don't + collapse them into a parameterized class, reviewers will reject it. +4. `{framework}_{provider}.py` — `{Framework}_{Provider}(ComplianceOutput)` + with `transform()`; this file may import `Finding`. +5. Register: + - `compliance.py` → `display_compliance_table()` `elif` branch (+ top import). + - `prowler/__main__.py` → per-provider `elif compliance_name.startswith(...)` + branches instantiating the writer classes. + - `api/src/backend/tasks/jobs/export.py` → `COMPLIANCE_CLASS_MAP` entries + (`startswith` for families, exact match only for true singletons). +6. Tests under `tests/lib/outputs/compliance/{framework}/` + fixtures in + `tests/lib/outputs/compliance/fixtures.py` (1 evaluated + 1 manual + requirement to exercise both `transform()` paths). -### Step 2 — Create `models.py` +**Circular import warning**: the table file must not import `Finding` directly +or transitively (cycle: `compliance.compliance` → table → `ComplianceOutput` → +`Finding` → `get_check_compliance` → `compliance.compliance`). Keep it bare; +use `TYPE_CHECKING`/function-local imports where both are genuinely needed. -One Pydantic v2 `BaseModel` per provider. Field names become CSV column headers (public API — don't rename later without a migration). +--- -```python -from typing import Optional -from pydantic import BaseModel +## Validation (run before every commit) -class {Framework}_AWSModel(BaseModel): - Provider: str - Description: str - AccountId: str - Region: str - AssessmentDate: str - Requirements_Id: str - Requirements_Description: str - # ... provider-specific columns - Status: str - StatusExtended: str - ResourceId: str - ResourceName: str - CheckId: str - Muted: bool -``` +1. **Schema load (both formats)**: -### Step 3 — Create `{framework}_{provider}.py` for each provider + ```python + from prowler.lib.check.compliance_models import ( + load_compliance_framework_universal, + get_bulk_compliance_frameworks_universal, + ) + fw = load_compliance_framework_universal("prowler/compliance/.json") + assert fw is not None, "check logs for the ValidationError" + print(fw.framework, len(fw.requirements), fw.get_providers()) + assert "" in get_bulk_compliance_frameworks_universal("aws") + ``` -Copy from `prowler/lib/outputs/compliance/c5/c5_aws.py` etc. Contains the `{Framework}_AWS(ComplianceOutput)` class with `transform()` that walks findings and emits model rows. This file IS allowed to import `Finding`. + Remember: the universal loader is lenient (skips broken files with a log + line) — an `assert fw is not None` is mandatory, a green scan is not proof. -### Step 4 — Register everywhere +2. **Check existence** — no loader validates this; a stale id is silent dead + weight: -**`prowler/lib/outputs/compliance/compliance.py`** (CLI table dispatcher): -```python -from prowler.lib.outputs.compliance.{framework}.{framework} import get_{framework}_table + ```python + import json + from pathlib import Path + for prov in ["aws", "azure", "gcp"]: + real = {p.stem.replace(".metadata", "") + for p in Path(f"prowler/providers/{prov}/services").rglob("*.metadata.json")} + data = json.load(open(f"prowler/compliance/{prov}/.json")) + refs = {c for r in data["Requirements"] for c in r["Checks"]} + missing = refs - real + assert not missing, f"{prov} missing: {missing}" + ``` -def display_compliance_table(...): - ... - elif compliance_framework.startswith("{framework}_"): - get_{framework}_table(findings, bulk_checks_metadata, - compliance_framework, output_filename, - output_directory, compliance_overview) -``` + (For universal files use `r.get("checks", {}).get(prov, [])` instead — + requirements may legitimately omit a provider key.) -**`prowler/__main__.py`** (CLI output writer per provider): -Add imports at the top: -```python -from prowler.lib.outputs.compliance.{framework}.{framework}_aws import {Framework}_AWS -from prowler.lib.outputs.compliance.{framework}.{framework}_azure import {Framework}_Azure -from prowler.lib.outputs.compliance.{framework}.{framework}_gcp import {Framework}_GCP -``` -Add provider-specific `elif compliance_name.startswith("{framework}_"):` branches that instantiate the class and call `batch_write_data_to_file()`. +3. **CLI smoke test**: -**`api/src/backend/tasks/jobs/export.py`** (API export dispatcher): -```python -from prowler.lib.outputs.compliance.{framework}.{framework}_aws import {Framework}_AWS -# ... azure, gcp + ```bash + uv run python prowler-cli.py --list-compliance # appears? + uv run python prowler-cli.py --compliance --log-level ERROR + ``` -COMPLIANCE_CLASS_MAP = { - "aws": [ - # ... - (lambda name: name.startswith("{framework}_"), {Framework}_AWS), - ], - # ... azure, gcp -} -``` + Verify the CSV under `output/compliance/`, the summary table sections, and + the findings roll-up. -**Always use `startswith`**, never `name == "framework_aws"`. Exact match is a regression. +4. **Tests**: -### Step 5 — Add tests + ```bash + uv run pytest -n auto tests/lib/check/universal_compliance_models_test.py \ + tests/lib/outputs/compliance/ + ``` -Create `tests/lib/outputs/compliance/{framework}/` with `{framework}_aws_test.py`, `{framework}_azure_test.py`, `{framework}_gcp_test.py`. See the test template in [references/test_template.md](references/test_template.md). + `test_loads_as_universal` is parametrized over **every** JSON in + `prowler/compliance/` (top-level + subdirectories) — a malformed file fails + CI here even if you never wrote a dedicated test. -Add fixtures to `tests/lib/outputs/compliance/fixtures.py`: one `Compliance` object per provider with 1 evaluated + 1 manual requirement to exercise both code paths in `transform()`. +5. **What CI/pre-commit do and don't cover**: pre-commit only guarantees + well-formed/pretty JSON (`check-json`, `pretty-format-json`) — no semantic + validation. The workflow `.github/workflows/pr-check-compliance-mapping.yml` + flags PRs adding new checks without mapping them to any framework (label + `needs-compliance-review`; skip with label `no-compliance-check`). Semantic + validation happens in the pytest suite above and manually via + `skills/prowler-compliance-review/assets/validate_compliance.py` (note: + that validator assumes the **legacy** schema). -### Circular import warning - -**The table dispatcher file (`{framework}.py`) MUST NOT import `Finding`** (directly or transitively). The cycle is: - -```text -compliance.compliance imports get_{framework}_table - → {framework}.py imports ComplianceOutput - → compliance_output imports Finding - → finding imports get_check_compliance from compliance.compliance - → CIRCULAR -``` - -Keep `{framework}.py` bare — only `colorama`, `tabulate`, `prowler.config.config`. Put anything that imports `Finding` in the per-provider `{framework}_{provider}.py` files. +6. **Prowler Local Server**: `docker compose up` and confirm the compliance + page renders requirements, sections and widgets. --- ## Conventions and Hard-Won Gotchas -These are lessons from the FINOS CCC v2025.10 sync + 172-AR audit pass (April 2026). Learn them once; save days of debugging. - -1. **Per-provider files are non-negotiable.** Never collapse `{framework}_aws.py`, `{framework}_azure.py`, `{framework}_gcp.py` into a single parameterized class, no matter how DRY-tempting. Every other framework in the codebase follows the per-provider pattern and reviewers will reject the refactor. The CSV column names differ per provider — three classes is the convention. -2. **`{framework}.py` has NO function docstring.** Other frameworks don't have them. Don't add one to be "helpful". -3. **Circular import protection**: the table dispatcher file MUST NOT import `Finding` (directly or transitively). Split the code so `{framework}.py` only has `get_{framework}_table()` with bare imports, and `{framework}_{provider}.py` holds the class that needs `Finding`. -4. **`Generic_Compliance_Requirement_Attribute` is the fallback** — in the `Compliance_Requirement.Attributes` Union in `compliance_models.py`, Generic MUST be LAST because Pydantic v1 tries union members in order. Putting Generic first means every framework-specific attribute falls through to Generic and the specific model is never used. -5. **Pydantic v1 imports.** `from pydantic.v1 import BaseModel` in `compliance_models.py` — not v2. Mixing causes validation errors. Pydantic v2 is used in the CSV models (`models.py`) — that's fine because they're separate trees. -6. **`get_check_compliance()` key format** is `f"{Framework}-{Version}"` ONLY if Version is set. Empty Version → key is `"{Framework}"` (no version suffix). Tests that mock compliance dicts must match this exact format — when a framework ships with `Version: ""`, downstream code and tests break silently. -7. **CSV column names from `models.py` are public API.** Don't rename a field without migrating downstream consumers — CSV headers change. -8. **Upstream YAML multi-line scalars** (`|` block scalars) preserve newlines. Collapse to single-line with `" ".join(value.split())` before writing to JSON. -9. **Upstream catalogs can use multiple shapes.** FINOS CCC uses `control-families: [...]` in most catalogs but `controls: [...]` at the top level in `storage/object`. Any sync script must handle both or silently drop entire catalogs. -10. **Foreign-prefix AR ids.** Upstream sometimes "imports" requirements from one catalog into another by keeping the original id prefix (e.g., `CCC.AuditLog.CN08.AR01` appearing under `CCC.Logging.CN03`). Prowler's compliance model requires unique ids within a catalog — rewrite the foreign id to fit the parent control: `CCC.AuditLog.CN08.AR01` (inside `CCC.Logging.CN03`) → `CCC.Logging.CN03.AR01`. -11. **Genuine upstream id collisions.** Sometimes upstream has a real typo where two different requirements share the same id (e.g., `CCC.Core.CN14.AR02` defined twice for 30-day and 14-day backup variants). Renumber the second copy to the next free AR number. Preserve check mappings by matching on `(Section, frozenset(Applicability))` since the renumbered id won't match by id. -12. **`COMPLIANCE_CLASS_MAP` in `export.py` uses `startswith` predicates** for all modern frameworks. Exact match (`name == "ccc_aws"`) is an anti-pattern — it was present for CCC until April 2026 and was the reason CCC couldn't have versioned variants. -13. **Pre-validate every check id** against the per-provider inventory before writing the JSON. A typo silently creates an unreferenced check that will fail when findings try to map to it. The audit script MUST abort with stderr listing typos, not swallow them. -14. **REPLACE is better than PATCH** for audit decisions. Encoding every mapping explicitly makes the audit reproducible and surfaces hidden assumptions from the legacy data. A PATCH system that adds/removes is too easy to forget. -15. **When no check applies, MANUAL is correct.** Do not pad mappings with tangential checks "just in case". Prowler's compliance reports are meant to be actionable — padding them with noise breaks that. Honest manual reqs can be mapped later when new checks land. -16. **UI groups by `Attributes[0].FamilyName` and `Attributes[0].Section`.** If FamilyName has inconsistent variants within the same JSON (e.g., "Logging & Monitoring" vs "Logging and Monitoring"), the UI renders them as separate categories. Section empty → the requirement falls into an orphan control with label "". Normalize before shipping. -17. **Provider coverage is asymmetric.** AWS has dense coverage (~586 checks across 80+ services): in-transit encryption, IAM, database encryption, backup. Azure (~167 checks) and GCP (~102 checks) are thinner especially for in-transit encryption, mTLS, and ML/AI. Accept the asymmetry in mappings — don't force GCP parity where Prowler genuinely can't verify. +1. **Universal first.** A new framework that starts as legacy needs 3 output + files + 3 registrations; the same framework as universal needs zero. Only + extend legacy families. +2. **`Generic_Compliance_Requirement_Attribute` stays LAST** in the legacy + Attributes Union — Pydantic v1 tries members in order; Generic first + silently swallows every specific shape. +3. **Pydantic v1 everywhere in `compliance_models.py`** + (`from pydantic.v1 import ...`). Don't mix in v2. +4. **`get_check_compliance()` lives in + `prowler/lib/outputs/compliance/compliance_check.py`** and keys the dict + `f"{Framework}-{Version}"` only when Version is non-empty. Never ship + `Version: ""` — the key silently degrades to `"{Framework}"` and breaks + filters, tests and `--compliance`. For legacy files the filename version + substring must match `Version` (the CLI reads + `compliance_framework.split("_")[1]`). +5. **`Compliance.get_bulk()` does not see top-level universal files** — only + `get_bulk_compliance_frameworks_universal()` does. Wire new code paths + against the universal loader. +6. **Loader leniency differs**: legacy loader exits the process on a broken + JSON; universal loader logs and skips. A missing framework after your edit + usually means the universal loader dropped it — check the logs. +7. **Circular import protection**: legacy table dispatcher files must not + import `Finding` (directly or transitively). Use `TYPE_CHECKING` or + function-local imports when a module needs both sides (that's how the + universal formatter does it). +8. **Per-provider formatter files are the legacy convention** — but know the + exceptions before flagging them (iso27001 has no table file, + aws_well_architected has no per-provider files, cisa_scuba is + googleworkspace-only). CSV model field names are public API. +9. **CSV output**: `;` delimiter, UPPERCASE headers. OCSF compliance output is + always generated for universal frameworks regardless of `--output-formats`. +10. **`COMPLIANCE_CLASS_MAP` mixes predicate styles**: `startswith` for + multi-version families, exact `==` for singletons. When in doubt use + `startswith` — exact match blocked versioned CCC variants until 2026. +11. **UI grouping is per-mapper, always on `attributes[0]`**: generic/cis → + `Section`/`SubSection`, iso → `Category`, ccc → `FamilyName`. Inconsistent + values (or empty Section) create orphan/duplicate tree branches — normalize + before shipping. +12. **UI has a generic fallback** — an unregistered framework still renders. + A dedicated mapper/panel/icon is an upgrade, not a prerequisite. +13. **Icon registration is ordered substring matching** in + `IconCompliance.tsx` — specific keywords before generic (`nist` before + `nis2`, `cisa` before `cis`, `aws` last). +14. **API PDF pipeline is not `PDFConfig`-driven yet** — it has its own + `FRAMEWORK_REGISTRY` (5 frameworks). Don't assume adding `pdf_config` to a + JSON produces a PDF in Prowler App. +15. **Pre-validate every check id** against the per-provider inventory before + writing JSON. No loader will catch a typo; the requirement just never + matches a finding. +16. **REPLACE beats PATCH** for audit decisions — full explicit lists are + reproducible and surface legacy assumptions. +17. **When no check applies, MANUAL is correct.** Don't pad mappings with + tangential checks; compliance reports must stay actionable. +18. **Include every requirement of the source catalog**, automated or not — + compliance percentages use the full requirement count as denominator. +19. **Provider coverage is asymmetric** (AWS dense; Azure/GCP thinner; new + providers minimal). Accept it — don't force parity Prowler can't verify. +20. **Guardrail authoring**: strictest tolerated `Value`, exact `ConfigKey` + spelling, `Provider` mandatory in universal files, booleans as JSON + booleans. Malformed constraints are treated as satisfied — validate with + the config tests, don't trust silence. --- ## Useful One-Liners ```bash -# Count requirements per service prefix (CCC, CIS sections, etc.) -jq -r '.Requirements[].Id | split(".")[1]' prowler/compliance/aws/ccc_aws.json | sort | uniq -c - -# Find duplicate requirement IDs +# Find duplicate requirement IDs (legacy | universal) jq -r '.Requirements[].Id' file.json | sort | uniq -d +jq -r '.requirements[].id' file.json | sort | uniq -d -# Count manual requirements (no checks) +# Count manual requirements (legacy | universal, per provider) jq '[.Requirements[] | select((.Checks | length) == 0)] | length' file.json +jq '[.requirements[] | select((.checks.aws // [] | length) == 0)] | length' file.json -# List all unique check references in a framework +# List unique check references (legacy | universal) jq -r '.Requirements[].Checks[]' file.json | sort -u +jq -r '.requirements[].checks[]? | .[]' file.json | sort -u -# List all unique Sections (to spot inconsistency) +# Providers covered by a universal framework +jq '[.requirements[].checks | keys[]] | unique' file.json + +# Spot inconsistent grouping values (UI tree branches) jq '[.Requirements[].Attributes[0].Section] | unique' file.json - -# List all unique FamilyNames (to spot inconsistency) jq '[.Requirements[].Attributes[0].FamilyName] | unique' file.json -# Diff requirement ids between two versions of the same framework +# Requirements with config guardrails (empty arrays are truthy in jq — check length) +jq '[.Requirements[] | select((.ConfigRequirements // []) | length > 0)] | length' file.json + +# Diff requirement ids between two versions diff <(jq -r '.Requirements[].Id' a.json | sort) <(jq -r '.Requirements[].Id' b.json | sort) -# Find where a check id is used across all frameworks +# Where is a check mapped across all frameworks? grep -rl "my_check_name" prowler/compliance/ -# Check if a Prowler check exists +# Does a check exist? find prowler/providers/aws/services -name "{check_id}.metadata.json" -# Validate a JSON with Pydantic -python -c "from prowler.lib.check.compliance_models import Compliance; print(Compliance.parse_file('prowler/compliance/aws/ccc_aws.json').Framework)" +# Validate one file with the universal loader +python -c "from prowler.lib.check.compliance_models import load_compliance_framework_universal as l; fw=l('prowler/compliance/aws/cis_7.0_aws.json'); print(fw.framework, len(fw.requirements))" ``` ---- - -## Best Practices - -1. **Requirement IDs**: Follow the original framework numbering exactly (e.g., "1.1", "A.5.1", "T1190", "ac_2_1") -2. **Check Mapping**: Map to existing checks when possible. Use `Checks: []` for manual-only requirements — honest MANUAL beats padded coverage -3. **Completeness**: Include all framework requirements, even those without automated checks -4. **Version Control**: Include framework version in `Name` and `Version` fields. **Never leave `Version: ""`** — it breaks `get_check_compliance()` key format -5. **File Naming**: Use format `{framework}_{version}_{provider}.json` -6. **Validation**: Prowler validates JSON against Pydantic models at startup — invalid JSON will cause errors -7. **Pre-validate check ids** against the provider's `*.metadata.json` inventory before every commit -8. **Normalize FamilyName and Section** to avoid inconsistent UI tree branches -9. **Register everywhere**: SDK model (if needed) → `compliance.py` dispatcher → `__main__.py` CLI writer → `export.py` API map → UI mapper. Skipping any layer results in silent failures -10. **Audit, don't pad**: when reviewing mappings, apply the golden rule — the check's title/risk MUST literally describe what the requirement text says. Tangential relation doesn't count - ## Commands ```bash -# List available frameworks for a provider prowler {provider} --list-compliance - -# Run scan with specific compliance framework -prowler aws --compliance cis_5.0_aws - -# Run scan with multiple frameworks -prowler aws --compliance cis_5.0_aws pci_4.0_aws - -# Output compliance report in multiple formats -prowler aws --compliance cis_5.0_aws -M csv json html +prowler {provider} --compliance cis_7.0_aws +prowler aws --compliance cis_7.0_aws pci_4.0_aws +prowler aws --compliance dora_2022_2554 # universal key = file basename +prowler aws --list-compliance-requirements cis_7.0_aws +prowler aws --compliance cis_7.0_aws -M csv json html ``` ## Code References ### Layer 1 — SDK / Core -- **Compliance Models:** `prowler/lib/check/compliance_models.py` (Pydantic v1 model tree) -- **Compliance Processing / Linker:** `prowler/lib/check/compliance.py` (`get_check_compliance`, `update_checks_metadata_with_compliance`) -- **Check Utils:** `prowler/lib/check/utils.py` (`list_compliance_modules`) + +- `prowler/lib/check/compliance_models.py` — legacy + universal model trees, + `Compliance_Requirement_ConfigConstraint`, all loaders and the + legacy→universal adapter +- `prowler/lib/check/compliance.py` — `update_checks_metadata_with_compliance` +- `prowler/lib/check/compliance_config_eval.py` — guardrail evaluation + (shared with the API) +- `prowler/lib/outputs/compliance/compliance_check.py` — `get_check_compliance` +- `prowler/lib/check/utils.py` — `list_compliance_modules` ### Layer 2 — JSON Catalogs -- **Framework JSONs:** `prowler/compliance/{provider}/` (auto-discovered via directory walk) + +- `prowler/compliance/*.json` — universal, multi-provider (auto-discovered) +- `prowler/compliance/{provider}/` — legacy, per-provider (auto-discovered) ### Layer 3 — Output Formatters -- **Per-framework folders:** `prowler/lib/outputs/compliance/{framework}/` -- **Shared base class:** `prowler/lib/outputs/compliance/compliance_output.py` (`ComplianceOutput` + `batch_write_data_to_file`) -- **CLI table dispatcher:** `prowler/lib/outputs/compliance/compliance.py` (`display_compliance_table`) -- **Finding model:** `prowler/lib/outputs/finding.py` (**do not import transitively from table dispatcher files — circular import**) -- **CLI writer:** `prowler/__main__.py` (per-provider `elif compliance_name.startswith(...)` branches that instantiate per-provider classes) + +- `prowler/lib/outputs/compliance/universal/` — `universal_table.py`, + `universal_output.py`, `ocsf_compliance.py` +- `prowler/lib/outputs/compliance/{framework}/` — legacy per-framework packages +- `prowler/lib/outputs/compliance/compliance.py` — + `process_universal_compliance_frameworks`, `display_compliance_table` +- `prowler/lib/outputs/compliance/compliance_output.py` — `ComplianceOutput` + base + CSV writer +- `prowler/__main__.py` — universal processing + per-provider legacy writer + branches ### Layer 4 — API / UI -- **API lazy loader:** `api/src/backend/api/compliance.py` (`LazyComplianceTemplate`, `LazyChecksMapping`) -- **API export dispatcher:** `api/src/backend/tasks/jobs/export.py` (`COMPLIANCE_CLASS_MAP` with `startswith` predicates) -- **UI framework router:** `ui/lib/compliance/compliance-mapper.ts` -- **UI per-framework mapper:** `ui/lib/compliance/{framework}.tsx` -- **UI detail panel:** `ui/components/compliance/compliance-custom-details/{framework}-details.tsx` -- **UI types:** `ui/types/compliance.ts` -- **UI icon:** `ui/components/icons/compliance/{framework}.svg` + registration in `IconCompliance.tsx` + +- `api/src/backend/api/compliance.py` — `LazyComplianceTemplate`, + `LazyChecksMapping`, cache warm-up +- `api/src/backend/tasks/jobs/export.py` — `COMPLIANCE_CLASS_MAP` +- `api/src/backend/tasks/jobs/scan.py` — `create_compliance_requirements` + (overview ingestion) +- `api/src/backend/tasks/jobs/reports/` — PDF generators + `FRAMEWORK_REGISTRY` +- `ui/lib/compliance/compliance-mapper.ts` — mapper routing + generic fallback +- `ui/lib/compliance/{framework}.tsx` — per-framework mappers +- `ui/components/compliance/compliance-custom-details/` — detail panels +- `ui/types/compliance.ts` — attribute metadata types +- `ui/components/icons/compliance/` + `IconCompliance.tsx` — icons (ordered) ### Tests -- **Output formatter tests:** `tests/lib/outputs/compliance/{framework}/{framework}_{provider}_test.py` -- **Shared fixtures:** `tests/lib/outputs/compliance/fixtures.py` + +- `tests/lib/check/universal_compliance_models_test.py` — includes the + parametrized `test_loads_as_universal` over every shipped JSON +- `tests/lib/check/compliance_check_test.py`, + `compliance_config_eval_test.py`, `compliance_config_constraint_model_test.py`, + `compliance_config_requirements_data_test.py`, `mitre_config_requirements_test.py` +- `tests/lib/outputs/compliance/` — per-framework + universal + dispatcher + + config-status coverage tests; shared `fixtures.py` ## Resources -- **JSON Templates:** See [assets/](assets/) for framework JSON templates (cis, ens, iso27001, mitre_attack, prowler_threatscore, generic) -- **Config-driven compliance sync** (any upstream-backed framework): - - [assets/sync_framework.py](assets/sync_framework.py) — generic runner. Loads a YAML config, dynamically imports the declared parser, applies generic post-processing (id uniqueness safety net, `FamilyName` normalization, legacy check-mapping preservation with config-driven fallback keys), and writes the provider JSONs with Pydantic post-validation. Framework-agnostic — works for any compliance framework. - - [assets/configs/ccc.yaml](assets/configs/ccc.yaml) — canonical config example (FINOS CCC v2025.10). Copy and adapt for new frameworks. - - [assets/parsers/finos_ccc.py](assets/parsers/finos_ccc.py) — FINOS CCC YAML parser. Handles both upstream shapes (`control-families` and top-level `controls`), foreign-prefix AR rewriting, and genuine collision renumbering. Exposes `parse_upstream(config) -> list[dict]`. - - [assets/parsers/](assets/parsers/) — add new parser modules here for unfamiliar upstream formats (NIST OSCAL JSON, MITRE STIX, CIS Benchmarks, etc.). Each parser is a `{name}.py` file implementing `parse_upstream(config) -> list[dict]` with guaranteed-unique ids. -- **Reusable audit tooling** (added April 2026 after the FINOS CCC v2025.10 sync): - - [assets/audit_framework_template.py](assets/audit_framework_template.py) — explicit REPLACE decision ledger with pre-validation against the per-provider inventory. Drop-in template for auditing any framework. - - [assets/query_checks.py](assets/query_checks.py) — keyword/service/id query helper over `/tmp/checks_{provider}.json`. - - [assets/dump_section.py](assets/dump_section.py) — dumps every AR for a given id prefix across all 3 providers with current check mappings. - - [assets/build_inventory.py](assets/build_inventory.py) — generates `/tmp/checks_{provider}.json` from `*.metadata.json` files. -- **Documentation:** See [references/compliance-docs.md](references/compliance-docs.md) for additional resources -- **Related skill:** [prowler-compliance-review](../prowler-compliance-review/SKILL.md) — PR review checklist and validator script for compliance framework PRs +- **Docs (source of truth for contributors)**: + `docs/developer-guide/security-compliance-framework.mdx` (both schemas, + guardrails, validation, PR process), + `docs/user-guide/compliance/tutorials/compliance.mdx`, + `docs/user-guide/compliance/tutorials/cross-provider-compliance.mdx` +- **Repo tooling** (`util/compliance/`): CSV→JSON generators + (`generate_json_from_csv/`), `ccc/from_yaml_to_json.py`, + `compliance_mapper/`, `threatscore/` +- **Skill assets** ([assets/](assets/)): + - `sync_framework.py` + `configs/ccc.yaml` + `parsers/finos_ccc.py` — + config-driven upstream sync (Workflow A) + - `build_inventory.py`, `query_checks.py`, `dump_section.py`, + `audit_framework_template.py` — audit tooling (Workflow B) + - Legacy JSON templates: `cis_framework.json`, `ens_framework.json`, + `iso27001_framework.json`, `mitre_attack_framework.json`, + `prowler_threatscore_framework.json`, `generic_framework.json` +- **References**: + [references/compliance-docs.md](references/compliance-docs.md) — model/loader + quick reference; + [references/check-mapping-reference.md](references/check-mapping-reference.md) + — curated requirement-text → checks mapping table + honest-MANUAL list +- **Sister skill**: + [prowler-compliance-review](../prowler-compliance-review/SKILL.md) — PR + review checklist + `validate_compliance.py` (legacy-schema validator) +- After editing this skill's frontmatter, run + `./skills/skill-sync/assets/sync.sh` to regenerate the AGENTS.md auto-invoke + tables. diff --git a/skills/prowler-compliance/references/check-mapping-reference.md b/skills/prowler-compliance/references/check-mapping-reference.md new file mode 100644 index 0000000000..cae9701ae7 --- /dev/null +++ b/skills/prowler-compliance/references/check-mapping-reference.md @@ -0,0 +1,78 @@ +# Audit Reference: Requirement Text → Prowler Checks + +Built from a real audit of 172 CCC ARs × 3 providers (April 2026). Use it to map +CCC-style / NIST-style / ISO-style requirement text to the checks that actually +verify them. Always re-validate every check id against the current inventory +(`assets/build_inventory.py` + `assets/query_checks.py`) before using a row — +checks get renamed and added over time. + +**Entries containing `*` are glob patterns, NOT literal check ids** (e.g. +`iam_*_no_administrative_privileges`, `cloudwatch_log_metric_filter_*`, +`*_minimum_tls_version_12`). Copied verbatim into a compliance JSON they map +nothing — expand each pattern to the concrete check ids via +`python skills/prowler-compliance/assets/query_checks.py ` +before writing any mapping. + +| Requirement text | AWS checks | Azure checks | GCP checks | +|---|---|---|---| +| **TLS in transit enforced** | `cloudfront_distributions_https_enabled`, `s3_bucket_secure_transport_policy`, `elbv2_ssl_listeners`, `elbv2_insecure_ssl_ciphers`, `elb_ssl_listeners`, `elb_insecure_ssl_ciphers`, `opensearch_service_domains_https_communications_enforced`, `rds_instance_transport_encrypted`, `redshift_cluster_in_transit_encryption_enabled`, `elasticache_redis_cluster_in_transit_encryption_enabled`, `dynamodb_accelerator_cluster_in_transit_encryption_enabled`, `dms_endpoint_ssl_enabled`, `kafka_cluster_in_transit_encryption_enabled`, `transfer_server_in_transit_encryption_enabled`, `glue_database_connections_ssl_enabled`, `sns_subscription_not_using_http_endpoints` | `storage_secure_transfer_required_is_enabled`, `storage_ensure_minimum_tls_version_12`, `postgresql_flexible_server_enforce_ssl_enabled`, `mysql_flexible_server_ssl_connection_enabled`, `mysql_flexible_server_minimum_tls_version_12`, `sqlserver_recommended_minimal_tls_version`, `app_minimum_tls_version_12`, `app_ensure_http_is_redirected_to_https`, `app_ftp_deployment_disabled` | `cloudsql_instance_ssl_connections` (almost only option) | +| **TLS 1.3 specifically** | Partial: `cloudfront_distributions_using_deprecated_ssl_protocols`, `elb*_insecure_ssl_ciphers`, `*_minimum_tls_version_12` | Partial: `*_minimum_tls_version_12` checks | None — accept as MANUAL | +| **SSH / port 22 hardening** | `ec2_instance_port_ssh_exposed_to_internet`, `ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22`, `ec2_networkacl_allow_ingress_tcp_port_22` | `network_ssh_internet_access_restricted`, `vm_linux_enforce_ssh_authentication` | `compute_firewall_ssh_access_from_the_internet_allowed`, `compute_instance_block_project_wide_ssh_keys_disabled`, `compute_project_os_login_enabled`, `compute_project_os_login_2fa_enabled` | +| **mTLS (mutual TLS)** | `kafka_cluster_mutual_tls_authentication_enabled`, `apigateway_restapi_client_certificate_enabled` | `app_client_certificates_on` | None — MANUAL | +| **Data at rest encrypted** | `s3_bucket_default_encryption`, `s3_bucket_kms_encryption`, `ec2_ebs_default_encryption`, `ec2_ebs_volume_encryption`, `rds_instance_storage_encrypted`, `rds_cluster_storage_encrypted`, `rds_snapshots_encrypted`, `dynamodb_tables_kms_cmk_encryption_enabled`, `redshift_cluster_encrypted_at_rest`, `neptune_cluster_storage_encrypted`, `documentdb_cluster_storage_encrypted`, `opensearch_service_domains_encryption_at_rest_enabled`, `kinesis_stream_encrypted_at_rest`, `firehose_stream_encrypted_at_rest`, `sns_topics_kms_encryption_at_rest_enabled`, `sqs_queues_server_side_encryption_enabled`, `efs_encryption_at_rest_enabled`, `athena_workgroup_encryption`, `glue_data_catalogs_metadata_encryption_enabled`, `backup_vaults_encrypted`, `backup_recovery_point_encrypted`, `cloudtrail_kms_encryption_enabled`, `cloudwatch_log_group_kms_encryption_enabled`, `eks_cluster_kms_cmk_encryption_in_secrets_enabled`, `sagemaker_notebook_instance_encryption_enabled`, `apigateway_restapi_cache_encrypted`, `kafka_cluster_encryption_at_rest_uses_cmk`, `dynamodb_accelerator_cluster_encryption_enabled`, `storagegateway_fileshare_encryption_enabled` | `storage_infrastructure_encryption_is_enabled`, `storage_ensure_encryption_with_customer_managed_keys`, `vm_ensure_attached_disks_encrypted_with_cmk`, `vm_ensure_unattached_disks_encrypted_with_cmk`, `sqlserver_tde_encryption_enabled`, `sqlserver_tde_encrypted_with_cmk`, `databricks_workspace_cmk_encryption_enabled`, `monitor_storage_account_with_activity_logs_cmk_encrypted` | `compute_instance_encryption_with_csek_enabled`, `dataproc_encrypted_with_cmks_disabled`, `bigquery_dataset_cmk_encryption`, `bigquery_table_cmk_encryption` | +| **CMEK required (customer-managed keys)** | `kms_cmk_are_used` | `storage_ensure_encryption_with_customer_managed_keys`, `vm_ensure_attached_disks_encrypted_with_cmk`, `vm_ensure_unattached_disks_encrypted_with_cmk`, `sqlserver_tde_encrypted_with_cmk`, `databricks_workspace_cmk_encryption_enabled` | `bigquery_dataset_cmk_encryption`, `bigquery_table_cmk_encryption`, `dataproc_encrypted_with_cmks_disabled`, `compute_instance_encryption_with_csek_enabled` | +| **Key rotation enabled** | `kms_cmk_rotation_enabled` | `keyvault_key_rotation_enabled`, `storage_key_rotation_90_days` | `kms_key_rotation_enabled` | +| **MFA for UI access** | `iam_root_mfa_enabled`, `iam_root_hardware_mfa_enabled`, `iam_user_mfa_enabled_console_access`, `iam_user_hardware_mfa_enabled`, `iam_administrator_access_with_mfa`, `cognito_user_pool_mfa_enabled` | `entra_privileged_user_has_mfa`, `entra_non_privileged_user_has_mfa`, `entra_user_with_vm_access_has_mfa`, `entra_security_defaults_enabled` | `compute_project_os_login_2fa_enabled` | +| **API access / credentials** | `iam_no_root_access_key`, `iam_user_no_setup_initial_access_key`, `apigateway_restapi_authorizers_enabled`, `apigateway_restapi_public_with_authorizer`, `apigatewayv2_api_authorizers_enabled` | `entra_conditional_access_policy_require_mfa_for_management_api`, `app_function_access_keys_configured`, `app_function_identity_is_configured` | `apikeys_api_restrictions_configured`, `apikeys_key_exists`, `apikeys_key_rotated_in_90_days` | +| **Log all admin/config changes** | `cloudtrail_multi_region_enabled`, `cloudtrail_multi_region_enabled_logging_management_events`, `cloudtrail_cloudwatch_logging_enabled`, `cloudtrail_log_file_validation_enabled`, `cloudwatch_log_metric_filter_*`, `cloudwatch_changes_to_*_alarm_configured`, `config_recorder_all_regions_enabled` | `monitor_diagnostic_settings_exists`, `monitor_diagnostic_setting_with_appropriate_categories`, `monitor_alert_*` | `iam_audit_logs_enabled`, `logging_log_metric_filter_and_alert_for_*`, `logging_sink_created` | +| **Log integrity (digital signatures)** | `cloudtrail_log_file_validation_enabled` (exact) | None | None | +| **Public access denied** | `s3_bucket_public_access`, `s3_bucket_public_list_acl`, `s3_bucket_public_write_acl`, `s3_account_level_public_access_blocks`, `apigateway_restapi_public`, `awslambda_function_url_public`, `awslambda_function_not_publicly_accessible`, `rds_instance_no_public_access`, `rds_snapshots_public_access`, `ec2_securitygroup_allow_ingress_from_internet_to_all_ports`, `sns_topics_not_publicly_accessible`, `sqs_queues_not_publicly_accessible` | `storage_blob_public_access_level_is_disabled`, `storage_ensure_private_endpoints_in_storage_accounts`, `containerregistry_not_publicly_accessible`, `keyvault_private_endpoints`, `app_function_not_publicly_accessible`, `aks_clusters_public_access_disabled`, `network_http_internet_access_restricted` | `cloudstorage_bucket_public_access`, `compute_instance_public_ip`, `cloudsql_instance_public_ip`, `compute_firewall_*_access_from_the_internet_allowed` | +| **IAM least privilege** | `iam_*_no_administrative_privileges`, `iam_policy_allows_privilege_escalation`, `iam_inline_policy_allows_privilege_escalation`, `iam_role_administratoraccess_policy`, `iam_group_administrator_access_policy`, `iam_user_administrator_access_policy`, `iam_policy_attached_only_to_group_or_roles`, `iam_role_cross_service_confused_deputy_prevention` | `iam_role_user_access_admin_restricted`, `iam_subscription_roles_owner_custom_not_created`, `iam_custom_role_has_permissions_to_administer_resource_locks` | `iam_sa_no_administrative_privileges`, `iam_no_service_roles_at_project_level`, `iam_role_kms_enforce_separation_of_duties`, `iam_role_sa_enforce_separation_of_duties` | +| **Password policy** | `iam_password_policy_minimum_length_14`, `iam_password_policy_uppercase`, `iam_password_policy_lowercase`, `iam_password_policy_symbol`, `iam_password_policy_number`, `iam_password_policy_expires_passwords_within_90_days_or_less`, `iam_password_policy_reuse_24` | None | None | +| **Credential rotation / unused** | `iam_rotate_access_key_90_days`, `iam_user_accesskey_unused`, `iam_user_console_access_unused` | None | `iam_sa_user_managed_key_rotate_90_days`, `iam_sa_user_managed_key_unused`, `iam_service_account_unused` | +| **VPC / flow logs** | `vpc_flow_logs_enabled` | `network_flow_log_captured_sent`, `network_watcher_enabled`, `network_flow_log_more_than_90_days` | `compute_subnet_flow_logs_enabled` | +| **Backup / DR / Multi-AZ** | `backup_vaults_exist`, `backup_plans_exist`, `backup_reportplans_exist`, `rds_instance_backup_enabled`, `rds_*_protected_by_backup_plan`, `rds_cluster_multi_az`, `neptune_cluster_backup_enabled`, `documentdb_cluster_backup_enabled`, `efs_have_backup_enabled`, `s3_bucket_cross_region_replication`, `dynamodb_table_protected_by_backup_plan` | `vm_backup_enabled`, `vm_sufficient_daily_backup_retention_period`, `storage_geo_redundant_enabled` | `cloudsql_instance_automated_backups`, `cloudstorage_bucket_log_retention_policy_lock`, `cloudstorage_bucket_sufficient_retention_period` | +| **Access analysis / discovery** | `accessanalyzer_enabled`, `accessanalyzer_enabled_without_findings` | None specific | `iam_account_access_approval_enabled`, `iam_cloud_asset_inventory_enabled` | +| **Object lock / retention** | `s3_bucket_object_lock`, `s3_bucket_object_versioning`, `s3_bucket_lifecycle_enabled`, `cloudtrail_bucket_requires_mfa_delete`, `s3_bucket_no_mfa_delete` | `storage_ensure_soft_delete_is_enabled`, `storage_blob_versioning_is_enabled`, `storage_ensure_file_shares_soft_delete_is_enabled` | `cloudstorage_bucket_log_retention_policy_lock`, `cloudstorage_bucket_soft_delete_enabled`, `cloudstorage_bucket_versioning_enabled`, `cloudstorage_bucket_sufficient_retention_period` | +| **Uniform bucket-level access** | `s3_bucket_acl_prohibited` | `storage_account_key_access_disabled`, `storage_default_to_entra_authorization_enabled` | `cloudstorage_bucket_uniform_bucket_level_access` | +| **Container vulnerability scanning** | `ecr_registry_scan_images_on_push_enabled`, `ecr_repositories_scan_vulnerabilities_in_latest_image` | `defender_container_images_scan_enabled`, `defender_container_images_resolved_vulnerabilities` | `artifacts_container_analysis_enabled`, `gcr_container_scanning_enabled` | +| **WAF / rate limiting** | `wafv2_webacl_with_rules`, `waf_*_webacl_with_rules`, `wafv2_webacl_logging_enabled`, `waf_global_webacl_logging_enabled` | None | None | +| **Deployment region restriction** | `organizations_scp_check_deny_regions` | None | None | +| **Secrets automatic rotation** | `secretsmanager_automatic_rotation_enabled`, `secretsmanager_secret_rotated_periodically` | `keyvault_rbac_secret_expiration_set`, `keyvault_non_rbac_secret_expiration_set` | None | +| **Certificate management** | `acm_certificates_expiration_check`, `acm_certificates_with_secure_key_algorithms`, `acm_certificates_transparency_logs_enabled` | `keyvault_key_expiration_set_in_non_rbac`, `keyvault_rbac_key_expiration_set`, `keyvault_non_rbac_secret_expiration_set` | None | +| **GenAI guardrails / input/output filtering** | `bedrock_guardrail_prompt_attack_filter_enabled`, `bedrock_guardrail_sensitive_information_filter_enabled`, `bedrock_agent_guardrail_enabled`, `bedrock_model_invocation_logging_enabled`, `bedrock_api_key_no_administrative_privileges`, `bedrock_api_key_no_long_term_credentials` | None | None | +| **ML dev environment security** | `sagemaker_notebook_instance_root_access_disabled`, `sagemaker_notebook_instance_without_direct_internet_access_configured`, `sagemaker_notebook_instance_vpc_settings_configured`, `sagemaker_models_vpc_settings_configured`, `sagemaker_training_jobs_vpc_settings_configured`, `sagemaker_training_jobs_network_isolation_enabled`, `sagemaker_training_jobs_volume_and_output_encryption_enabled` | None | None | +| **Threat detection / anomalous behavior** | `cloudtrail_threat_detection_enumeration`, `cloudtrail_threat_detection_privilege_escalation`, `cloudtrail_threat_detection_llm_jacking`, `guardduty_is_enabled`, `guardduty_no_high_severity_findings` | None | None | +| **Serverless private access** | `awslambda_function_inside_vpc`, `awslambda_function_not_publicly_accessible`, `awslambda_function_url_public` | `app_function_not_publicly_accessible` | None | + +## What Prowler Does NOT Cover (accept MANUAL honestly) + +Don't pad mappings for these — mark the requirement's checks empty and move on: + +- **TLS 1.3 version specifically** — Prowler verifies TLS is enforced, not always the exact version +- **IANA port-protocol consistency** — no check for "protocol running on its assigned port" +- **mTLS on most Azure/GCP services** — limited to App Service client certs on Azure, nothing on GCP +- **Rate limiting** on monitoring endpoints, load balancers, serverless invocations, vector ingestion +- **Session cookie expiry** (LB stickiness) +- **HTTP header scrubbing** (Server, X-Powered-By) +- **Certificate transparency verification for imports** +- **Model version pinning, red teaming, AI quality review** +- **Vector embedding validation, dimensional constraints, ANN vs exact search** +- **Secret region replication** (cross-region residency) +- **Lifecycle cleanup policies on container registries** +- **Row-level / column-level security in data warehouses** +- **Deployment region restriction on Azure/GCP** (AWS has `organizations_scp_check_deny_regions`, others don't) +- **Cross-tenant alert silencing permissions** +- **Field-level masking in logs** +- **Managed view enforcement for database access** +- **Automatic MFA delete on all S3 buckets** (only CloudTrail bucket variant exists for some frameworks — AWS has the generic `s3_bucket_no_mfa_delete` though) + +## Provider coverage asymmetry + +AWS has dense coverage (in-transit encryption, IAM, database encryption, backup, +GenAI). Azure and GCP are thinner, especially for in-transit encryption, mTLS, +and ML/AI. Accept the asymmetry in mappings — don't force GCP parity where +Prowler genuinely can't verify. Newer providers (alibabacloud, oraclecloud, +googleworkspace, okta, cloudflare, linode...) have far smaller inventories: +always rebuild the inventory with `assets/build_inventory.py` before assuming +a mapping exists. diff --git a/skills/prowler-compliance/references/compliance-docs.md b/skills/prowler-compliance/references/compliance-docs.md index a8d11484a9..272aa10ef1 100644 --- a/skills/prowler-compliance/references/compliance-docs.md +++ b/skills/prowler-compliance/references/compliance-docs.md @@ -1,137 +1,154 @@ -# Compliance Framework Documentation +# Compliance Framework Quick Reference ## Code References -Key files for understanding and modifying compliance frameworks: - | File | Purpose | |------|---------| -| `prowler/lib/check/compliance_models.py` | Pydantic models defining attribute structures for each framework type | -| `prowler/lib/check/compliance.py` | Core compliance processing logic | -| `prowler/lib/check/utils.py` | Utility functions including `list_compliance_modules()` | -| `prowler/lib/outputs/compliance/` | Framework-specific output generators | -| `prowler/compliance/{provider}/` | JSON compliance framework definitions | +| `prowler/lib/check/compliance_models.py` | Legacy + universal Pydantic (v1) model trees, config-constraint model, loaders, legacy→universal adapter | +| `prowler/lib/check/compliance.py` | `update_checks_metadata_with_compliance()` (only) | +| `prowler/lib/check/compliance_config_eval.py` | Shared `ConfigRequirements` guardrail evaluation (SDK outputs + API) | +| `prowler/lib/outputs/compliance/compliance_check.py` | `get_check_compliance()` — per-finding `{Framework}-{Version}` → requirement ids | +| `prowler/lib/check/utils.py` | `list_compliance_modules()` | +| `prowler/lib/outputs/compliance/` | Output formatters (legacy per-framework + `universal/`) | +| `prowler/compliance/*.json` | Universal multi-provider framework definitions | +| `prowler/compliance/{provider}/` | Legacy per-provider framework definitions | -## Attribute Model Classes +## Attribute Model Classes (legacy schema) -Each framework type has a specific Pydantic model in `compliance_models.py`: +Registered in the `Compliance_Requirement.Attributes` Union, in this order +(order is load-bearing; Generic must stay last): -| Framework | Model Class | +| Framework family | Model Class | |-----------|-------------| +| ASD Essential Eight | `ASDEssentialEight_Requirement_Attribute` | | CIS | `CIS_Requirement_Attribute` | -| ISO 27001 | `ISO27001_2013_Requirement_Attribute` | | ENS | `ENS_Requirement_Attribute` | -| MITRE ATT&CK | `Mitre_Requirement` (uses different structure) | +| ISO 27001 | `ISO27001_2013_Requirement_Attribute` | | AWS Well-Architected | `AWS_Well_Architected_Requirement_Attribute` | | KISA ISMS-P | `KISA_ISMSP_Requirement_Attribute` | | Prowler ThreatScore | `Prowler_ThreatScore_Requirement_Attribute` | | CCC | `CCC_Requirement_Attribute` | | C5 Germany | `C5Germany_Requirement_Attribute` | -| Generic/Fallback | `Generic_Compliance_Requirement_Attribute` | +| CSA CCM (legacy shape) | `CSA_CCM_Requirement_Attribute` | +| DISA STIG (Okta IDaaS) | `STIG_Requirement_Attribute` | +| Generic/Fallback (NIST, PCI, GDPR, HIPAA, SOC2, FedRAMP, ...) | `Generic_Compliance_Requirement_Attribute` | -## How Compliance Frameworks are Loaded +MITRE ATT&CK uses the separate `Mitre_Requirement` model with per-provider +`Mitre_Requirement_Attribute_{AWS,Azure,GCP}` attribute classes. -1. `Compliance.get_bulk(provider)` is called at startup -2. Scans `prowler/compliance/{provider}/` for `.json` files -3. Each file is parsed using `load_compliance_framework()` -4. Pydantic validates against `Compliance` model -5. Framework is stored in dictionary with filename (without `.json`) as key +`Compliance_Requirement_ConfigConstraint` models each `ConfigRequirements` / +`config_requirements` entry (`Check`, `ConfigKey`, `Operator`, `Value`, +optional `Provider`) with load-time operator/value type validation. + +## Universal Schema Models + +| Model | Purpose | +|-------|---------| +| `ComplianceFramework` | Top-level container (`framework`, `name`, `version`, `requirements`, `attributes_metadata`, `outputs`); validates attributes against metadata at load | +| `UniversalComplianceRequirement` | Flat `attributes: dict`, `checks: dict[provider, list]`, `config_requirements`, MITRE extras | +| `AttributeMetadata` | Per-attribute schema descriptor (key/label/type/enum/required/`enum_display`/`enum_order`/`output_formats`) | +| `OutputsConfig` → `TableConfig` | CLI table rendering (`group_by`, `split_by`, `scoring`, `labels`) — consumed by `universal_table.py` | +| `OutputsConfig` → `PDFConfig` (+ `ChartConfig`, `ScoringFormula`, `I18nLabels`, ...) | Declarative PDF config — modeled but **not yet consumed** by the API PDF pipeline (it uses its own `FRAMEWORK_REGISTRY`) | + +## How Frameworks Are Loaded + +Two entry points — they see different files: + +1. **Legacy**: `Compliance.get_bulk(provider)` scans only + `prowler/compliance/{provider}/` (exact provider-segment match) plus + external JSONs from the `prowler.compliance` entry-point group. Invalid + built-in file → `logger.critical` + `sys.exit(1)` + (`load_compliance_framework`, `fatal=True`). +2. **Universal**: `get_bulk_compliance_frameworks_universal(provider)` scans + the top-level `prowler/compliance/` **and** every provider subdirectory, + plus the `prowler.compliance.universal` entry-point group (built-ins win + collisions). Legacy files are adapted via `adapt_legacy_to_universal()` + (flattens `Attributes[0]` into a dict, wraps `Checks` as + `{provider: [...]}`, infers `attributes_metadata` from the matched Pydantic + class). Invalid file → logged and **skipped** + (`load_compliance_framework_universal` returns `None`). + +The framework key in both bulk dicts is the JSON basename without `.json` — +that's also the `--compliance` CLI key. ## How Checks Map to Compliance -1. After loading, `update_checks_metadata_with_compliance()` is called -2. For each check, it finds all compliance requirements that reference it -3. Compliance info is attached to `CheckMetadata.Compliance` list -4. During output, `get_check_compliance()` retrieves mappings per finding +1. `update_checks_metadata_with_compliance()` attaches, per check, every + framework requirement that references it (`CheckMetadata.Compliance`). +2. During output, `get_check_compliance()` + (`prowler/lib/outputs/compliance/compliance_check.py`) returns the + per-finding dict `{"{Framework}-{Version}": [requirement_ids]}` — the + `-{Version}` suffix only exists when `Version` is non-empty. +3. `ConfigRequirements` guardrails are evaluated by + `evaluate_config_constraints()` (`compliance_config_eval.py`); a violated + constraint forces FAIL and prepends + `Configuration not valid for this requirement.` to `status_extended` in + every output format. -## File Naming Convention +## File Naming Conventions ```text -{framework}_{version}_{provider}.json +prowler/compliance/{framework}_{version}.json # universal +prowler/compliance/{provider}/{framework}_{version}_{provider}.json # legacy ``` -Examples: -- `cis_5.0_aws.json` -- `iso27001_2022_azure.json` -- `mitre_attack_gcp.json` -- `ens_rd2022_aws.json` -- `nist_800_53_revision_5_aws.json` +Examples: `dora_2022_2554.json`, `cis_controls_8.1.json`, `cis_7.0_aws.json`, +`iso27001_2022_azure.json`, `okta_idaas_stig_v1r2_okta.json`, +`cisa_scuba_0.6_googleworkspace.json`, `ccc_aws.json` (unversioned only when +the framework has no versioning). For legacy files the version substring in +the filename must equal `Version`. -## Validation +## Validation Summary -Prowler validates compliance JSON at startup. Invalid files cause: -- `ValidationError` logged with details -- Application exit with error code +- **Load time (universal)**: `attributes_metadata` root validator — required + keys, unknown-key drift guard, enums, int/float/bool types. Omit the + metadata and nothing is validated. +- **Load time (legacy)**: Pydantic attribute-class matching; a shape matching + no specific class silently falls through to Generic. +- **Never validated at load**: check-id existence. Cross-check manually + (see SKILL.md → Validation). +- **Test suite**: `tests/lib/check/universal_compliance_models_test.py::test_loads_as_universal` + is parametrized over every shipped JSON (top-level + per-provider). +- **CI**: `.github/workflows/pr-check-compliance-mapping.yml` flags new checks + not mapped in any framework (`needs-compliance-review` label; opt out with + `no-compliance-check`). +- **Pre-commit**: `check-json` + `pretty-format-json` only (syntax/format, no + semantics). +- **Manual**: `skills/prowler-compliance-review/assets/validate_compliance.py` + (legacy schema only). -Common validation errors: -- Missing required fields (`Id`, `Description`, `Checks`, `Attributes`) -- Invalid enum values (e.g., `Profile` must be "Level 1" or "Level 2" for CIS) -- Type mismatches (e.g., `Checks` must be array of strings) +## Repo Tooling (`util/compliance/`) -## Adding a New Framework - -1. Create JSON file in `prowler/compliance/{provider}/` -2. Use appropriate attribute model (see table above) -3. Map existing checks to requirements via `Checks` array -4. Use empty `Checks: []` for manual-only requirements -5. Test with `prowler {provider} --list-compliance` to verify loading -6. Run `prowler {provider} --compliance {framework_name}` to test execution - -## Templates - -See `assets/` directory for example templates: -- `cis_framework.json` - CIS Benchmark template -- `iso27001_framework.json` - ISO 27001 template -- `ens_framework.json` - ENS (Spain) template -- `mitre_attack_framework.json` - MITRE ATT&CK template -- `prowler_threatscore_framework.json` - Prowler ThreatScore template -- `generic_framework.json` - Generic/custom framework template +| Tool | Purpose | +|------|---------| +| `util/compliance/generate_json_from_csv/*.py` | CSV→JSON generators (CIS 1.5, CIS 2.0 GCP, CIS 1.0 GitHub, CIS 4.0 M365, ENS, ThreatScore) | +| `util/compliance/ccc/from_yaml_to_json.py` | FINOS CCC YAML→JSON converter | +| `util/compliance/compliance_mapper/` | Compliance mapper (see its README) | +| `util/compliance/threatscore/get_prowler_threatscore_from_generic_output.py` | Derive ThreatScore from generic output | ## Prowler ThreatScore Details -Prowler ThreatScore is a custom security scoring framework that calculates an overall security posture score based on: +Custom Prowler scoring framework. Pillars / ID prefixes: `1.x.x` IAM, `2.x.x` +Attack Surface, `3.x.x` Logging and Monitoring, `4.x.x` Encryption. -### Four Pillars -1. **IAM (Identity and Access Management)** - - SubSections: Authentication, Authorization, Credentials Management - -2. **Attack Surface** - - SubSections: Network Exposure, Storage Exposure, Service Exposure - -3. **Logging and Monitoring** - - SubSections: Audit Logging, Threat Detection, Alerting - -4. **Encryption** - - SubSections: Data at Rest, Data in Transit - -### Scoring Algorithm -The ThreatScore uses `LevelOfRisk` and `Weight` to calculate severity: - -| LevelOfRisk | Weight | Example Controls | -|-------------|--------|------------------| -| 5 (Critical) | 1000 | Root MFA, No root access keys, Public S3 buckets | -| 4 (High) | 100 | User MFA, Public EC2, GuardDuty enabled | -| 3 (Medium) | 10 | Password policies, EBS encryption, CloudTrail | -| 2 (Low) | 1-10 | Best practice recommendations | -| 1 (Info) | 1 | Informational controls | - -### ID Numbering Convention -- `1.x.x` - IAM controls -- `2.x.x` - Attack Surface controls -- `3.x.x` - Logging and Monitoring controls -- `4.x.x` - Encryption controls +Scoring: `LevelOfRisk` 1–5 (5=critical) × `Weight` (values in the shipped +catalogs: 1000 critical / 100 high / 8–10 standard / 1 low). Available for +aws, azure, gcp, kubernetes, m365, alibabacloud. ## External Resources -### Official Framework Documentation - [CIS Benchmarks](https://www.cisecurity.org/cis-benchmarks) -- [ISO 27001:2022](https://www.iso.org/standard/27001) +- [CIS Critical Security Controls](https://www.cisecurity.org/controls) +- [ISO 27001](https://www.iso.org/standard/27001) - [NIST 800-53](https://csrc.nist.gov/publications/detail/sp/800-53/rev-5/final) - [NIST CSF](https://www.nist.gov/cyberframework) - [PCI DSS](https://www.pcisecuritystandards.org/) - [MITRE ATT&CK](https://attack.mitre.org/) - [ENS (Spain)](https://www.ccn-cert.cni.es/es/ens.html) - -### Prowler Documentation -- [Prowler Docs - Compliance](https://docs.prowler.com/projects/prowler-open-source/en/latest/) -- [Prowler GitHub](https://github.com/prowler-cloud/prowler) +- [FINOS CCC](https://github.com/finos/common-cloud-controls) +- [CSA CCM](https://cloudsecurityalliance.org/research/cloud-controls-matrix) +- [DORA (EU 2022/2554)](https://eur-lex.europa.eu/eli/reg/2022/2554/oj) +- [ASD Essential Eight](https://www.cyber.gov.au/resources-business-and-government/essential-cybersecurity/essential-eight) +- [CISA SCuBA](https://www.cisa.gov/resources-tools/services/secure-cloud-business-applications-scuba-project) +- [DISA STIGs](https://public.cyber.mil/stigs/) +- [Prowler Docs — Compliance developer guide](https://docs.prowler.com/developer-guide/security-compliance-framework) diff --git a/tests/providers/alibabacloud/services/ecs/ecs_securitygroup_restrict_rdp_internet/ecs_securitygroup_restrict_rdp_internet_test.py b/tests/providers/alibabacloud/services/ecs/ecs_securitygroup_restrict_rdp_internet/ecs_securitygroup_restrict_rdp_internet_test.py index 17709a94a7..adbd82d11a 100644 --- a/tests/providers/alibabacloud/services/ecs/ecs_securitygroup_restrict_rdp_internet/ecs_securitygroup_restrict_rdp_internet_test.py +++ b/tests/providers/alibabacloud/services/ecs/ecs_securitygroup_restrict_rdp_internet/ecs_securitygroup_restrict_rdp_internet_test.py @@ -37,7 +37,7 @@ class TestEcsSecurityGroupRestrictRdpInternet: "ip_protocol": "tcp", "source_cidr_ip": "0.0.0.0/0", "port_range": "3389/3389", - "policy": "accept", + "policy": "Accept", } ], ) @@ -80,7 +80,7 @@ class TestEcsSecurityGroupRestrictRdpInternet: "ip_protocol": "tcp", "source_cidr_ip": "10.0.0.0/24", "port_range": "3389/3389", - "policy": "accept", + "policy": "Accept", } ], ) diff --git a/tests/providers/alibabacloud/services/ecs/ecs_securitygroup_restrict_ssh_internet/ecs_securitygroup_restrict_ssh_internet_test.py b/tests/providers/alibabacloud/services/ecs/ecs_securitygroup_restrict_ssh_internet/ecs_securitygroup_restrict_ssh_internet_test.py index 3278ce9a80..718baedce0 100644 --- a/tests/providers/alibabacloud/services/ecs/ecs_securitygroup_restrict_ssh_internet/ecs_securitygroup_restrict_ssh_internet_test.py +++ b/tests/providers/alibabacloud/services/ecs/ecs_securitygroup_restrict_ssh_internet/ecs_securitygroup_restrict_ssh_internet_test.py @@ -37,7 +37,7 @@ class TestEcsSecurityGroupRestrictSSHInternet: "ip_protocol": "tcp", "source_cidr_ip": "0.0.0.0/0", "port_range": "22/22", - "policy": "accept", + "policy": "Accept", } ], ) @@ -81,7 +81,7 @@ class TestEcsSecurityGroupRestrictSSHInternet: "ip_protocol": "tcp", "source_cidr_ip": "10.0.0.0/24", "port_range": "22/22", - "policy": "accept", + "policy": "Accept", } ], ) diff --git a/tests/providers/aws/services/sagemaker/sagemaker_notebook_instance_no_secrets/sagemaker_notebook_instance_no_secrets_test.py b/tests/providers/aws/services/sagemaker/sagemaker_notebook_instance_no_secrets/sagemaker_notebook_instance_no_secrets_test.py new file mode 100644 index 0000000000..106a3f2b1b --- /dev/null +++ b/tests/providers/aws/services/sagemaker/sagemaker_notebook_instance_no_secrets/sagemaker_notebook_instance_no_secrets_test.py @@ -0,0 +1,269 @@ +from unittest import mock + +from prowler.lib.utils.utils import SecretsScanError +from prowler.providers.aws.services.sagemaker.sagemaker_service import ( + NotebookInstance, +) +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + set_mocked_aws_provider, +) + +test_notebook_instance = "test-notebook-instance" +notebook_instance_arn = ( + f"arn:aws:sagemaker:{AWS_REGION_EU_WEST_1}:" + f"{AWS_ACCOUNT_NUMBER}:notebook-instance/{test_notebook_instance}" +) + +other_notebook_instance = "other-notebook-instance" +other_notebook_instance_arn = ( + f"arn:aws:sagemaker:{AWS_REGION_EU_WEST_1}:" + f"{AWS_ACCOUNT_NUMBER}:notebook-instance/{other_notebook_instance}" +) + +CHECK_MODULE = "prowler.providers.aws.services.sagemaker.sagemaker_notebook_instance_no_secrets.sagemaker_notebook_instance_no_secrets" + + +class Test_sagemaker_notebook_instance_no_secrets: + def test_no_instances(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_notebook_instances = [] + sagemaker_client.audit_config = {} + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.sagemaker_client", sagemaker_client), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_notebook_instance_no_secrets.sagemaker_notebook_instance_no_secrets import ( + sagemaker_notebook_instance_no_secrets, + ) + + check = sagemaker_notebook_instance_no_secrets() + result = check.execute() + + assert len(result) == 0 + + def test_pass_no_lifecycle_config(self): + sagemaker_client = mock.MagicMock + sagemaker_client.audit_config = {} + sagemaker_client.sagemaker_notebook_instances = [ + NotebookInstance( + name=test_notebook_instance, + arn=notebook_instance_arn, + region=AWS_REGION_EU_WEST_1, + lifecycle_config_name=None, + ) + ] + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.sagemaker_client", sagemaker_client), + mock.patch( + f"{CHECK_MODULE}.detect_secrets_scan_batch", + return_value={}, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_notebook_instance_no_secrets.sagemaker_notebook_instance_no_secrets import ( + sagemaker_notebook_instance_no_secrets, + ) + + check = sagemaker_notebook_instance_no_secrets() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + "does not have a lifecycle configuration" in result[0].status_extended + ) + assert result[0].resource_id == test_notebook_instance + assert result[0].resource_arn == notebook_instance_arn + + def test_pass_lifecycle_config_scanned_clean(self): + sagemaker_client = mock.MagicMock + sagemaker_client.audit_config = {} + sagemaker_client.sagemaker_notebook_instances = [ + NotebookInstance( + name=test_notebook_instance, + arn=notebook_instance_arn, + region=AWS_REGION_EU_WEST_1, + lifecycle_config_name="test-lifecycle-config", + lifecycle_scripts={"OnCreate[0]": "echo hello"}, + ) + ] + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.sagemaker_client", sagemaker_client), + mock.patch( + f"{CHECK_MODULE}.detect_secrets_scan_batch", + return_value={}, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_notebook_instance_no_secrets.sagemaker_notebook_instance_no_secrets import ( + sagemaker_notebook_instance_no_secrets, + ) + + check = sagemaker_notebook_instance_no_secrets() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "No secrets found" in result[0].status_extended + assert result[0].resource_id == test_notebook_instance + assert result[0].resource_arn == notebook_instance_arn + + def test_fail_secret_found(self): + notebook_instance = NotebookInstance( + name=test_notebook_instance, + arn=notebook_instance_arn, + region=AWS_REGION_EU_WEST_1, + lifecycle_config_name="test-lifecycle-config", + lifecycle_scripts={"OnCreate[0]": "echo API_KEY=12345"}, + ) + + sagemaker_client = mock.MagicMock + sagemaker_client.audit_config = {} + sagemaker_client.sagemaker_notebook_instances = [notebook_instance] + + fake_secret = {"type": "Secret Keyword", "line_number": 1} + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.sagemaker_client", sagemaker_client), + mock.patch( + f"{CHECK_MODULE}.detect_secrets_scan_batch", + return_value={(notebook_instance_arn, "OnCreate[0]"): [fake_secret]}, + ), + mock.patch( + f"{CHECK_MODULE}.annotate_verified_secrets", + lambda *_: None, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_notebook_instance_no_secrets.sagemaker_notebook_instance_no_secrets import ( + sagemaker_notebook_instance_no_secrets, + ) + + check = sagemaker_notebook_instance_no_secrets() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "Secret Keyword" in result[0].status_extended + assert "OnCreate[0]" in result[0].status_extended + assert result[0].resource_id == test_notebook_instance + assert result[0].resource_arn == notebook_instance_arn + + def test_manual_lifecycle_describe_failed(self): + # Service could not fully describe/decode the lifecycle config. + notebook_instance = NotebookInstance( + name=test_notebook_instance, + arn=notebook_instance_arn, + region=AWS_REGION_EU_WEST_1, + lifecycle_config_name="test-lifecycle-config", + lifecycle_scripts={}, + lifecycle_scan_failed=True, + ) + + sagemaker_client = mock.MagicMock + sagemaker_client.audit_config = {} + sagemaker_client.sagemaker_notebook_instances = [notebook_instance] + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.sagemaker_client", sagemaker_client), + mock.patch( + f"{CHECK_MODULE}.detect_secrets_scan_batch", + return_value={}, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_notebook_instance_no_secrets.sagemaker_notebook_instance_no_secrets import ( + sagemaker_notebook_instance_no_secrets, + ) + + check = sagemaker_notebook_instance_no_secrets() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert result[0].resource_id == test_notebook_instance + assert result[0].resource_arn == notebook_instance_arn + + def test_manual_scan_error_only_scanned_instances(self): + # Batch scan fails. The instance with scripts must be MANUAL; the + # instance without a lifecycle config (nothing to scan) must PASS. + scanned_instance = NotebookInstance( + name=test_notebook_instance, + arn=notebook_instance_arn, + region=AWS_REGION_EU_WEST_1, + lifecycle_config_name="test-lifecycle-config", + lifecycle_scripts={"OnStart[0]": "echo hello"}, + ) + unscanned_instance = NotebookInstance( + name=other_notebook_instance, + arn=other_notebook_instance_arn, + region=AWS_REGION_EU_WEST_1, + lifecycle_config_name=None, + lifecycle_scripts={}, + ) + + sagemaker_client = mock.MagicMock + sagemaker_client.audit_config = {} + sagemaker_client.sagemaker_notebook_instances = [ + scanned_instance, + unscanned_instance, + ] + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.sagemaker_client", sagemaker_client), + mock.patch( + f"{CHECK_MODULE}.detect_secrets_scan_batch", + side_effect=SecretsScanError("scan failed"), + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_notebook_instance_no_secrets.sagemaker_notebook_instance_no_secrets import ( + sagemaker_notebook_instance_no_secrets, + ) + + check = sagemaker_notebook_instance_no_secrets() + result = check.execute() + + assert len(result) == 2 + results_by_id = {report.resource_id: report for report in result} + + assert results_by_id[test_notebook_instance].status == "MANUAL" + assert results_by_id[other_notebook_instance].status == "PASS" + assert ( + "does not have a lifecycle configuration" + in results_by_id[other_notebook_instance].status_extended + ) diff --git a/tests/providers/aws/services/sagemaker/sagemaker_service_test.py b/tests/providers/aws/services/sagemaker/sagemaker_service_test.py index 50431c2e13..bfadd59efe 100644 --- a/tests/providers/aws/services/sagemaker/sagemaker_service_test.py +++ b/tests/providers/aws/services/sagemaker/sagemaker_service_test.py @@ -28,6 +28,10 @@ test_training_job = "test-training-job" test_arn_training_job = f"arn:aws:sagemaker:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:training-job/{test_model}" subnet_id = "subnet-" + str(uuid4()) kms_key_id = str(uuid4()) +lifecycle_config_name = "test-lifecycle-config" +# base64 of "echo OnCreate" / "echo OnStart" +lifecycle_on_create_b64 = "ZWNobyBPbkNyZWF0ZQ==" +lifecycle_on_start_b64 = "ZWNobyBPblN0YXJ0" endpoint_config_name = "endpoint-config-test" endpoint_config_arn = f"arn:aws:sagemaker:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:endpoint-config/{endpoint_config_name}" prod_variant_name = "Variant1" @@ -76,6 +80,12 @@ def mock_make_api_call(self, operation_name, kwarg): "KmsKeyId": kms_key_id, "DirectInternetAccess": "Enabled", "RootAccess": "Enabled", + "NotebookInstanceLifecycleConfigName": lifecycle_config_name, + } + if operation_name == "DescribeNotebookInstanceLifecycleConfig": + return { + "OnCreate": [{"Content": lifecycle_on_create_b64}], + "OnStart": [{"Content": lifecycle_on_start_b64}], } if operation_name == "DescribeModel": return { @@ -247,6 +257,21 @@ class Test_SageMaker_Service: assert sagemaker.sagemaker_notebook_instances[0].subnet_id == subnet_id assert sagemaker.sagemaker_notebook_instances[0].direct_internet_access assert sagemaker.sagemaker_notebook_instances[0].kms_key_id == kms_key_id + assert ( + sagemaker.sagemaker_notebook_instances[0].lifecycle_config_name + == lifecycle_config_name + ) + + # Test SageMaker describe notebook instance lifecycle config + def test_describe_notebook_instance_lifecycle_config(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + sagemaker = SageMaker(aws_provider) + notebook_instance = sagemaker.sagemaker_notebook_instances[0] + assert notebook_instance.lifecycle_scan_failed is False + assert notebook_instance.lifecycle_scripts == { + "OnCreate[0]": "echo OnCreate", + "OnStart[0]": "echo OnStart", + } # Test SageMaker describe model def test_describe_model(self): diff --git a/ui/actions/overview/resources-inventory/resources-inventory.adapter.ts b/ui/actions/overview/resources-inventory/resources-inventory.adapter.ts index 5e6f5fd1f2..508d9a46ad 100644 --- a/ui/actions/overview/resources-inventory/resources-inventory.adapter.ts +++ b/ui/actions/overview/resources-inventory/resources-inventory.adapter.ts @@ -1,5 +1,5 @@ -import { LucideIcon } from "lucide-react"; import { + LucideIcon, Activity, BarChart3, Bot, diff --git a/ui/app/(auth)/(guest-only)/sign-up/page.tsx b/ui/app/(auth)/(guest-only)/sign-up/page.tsx index 8dc1848cf9..0415c6b0f3 100644 --- a/ui/app/(auth)/(guest-only)/sign-up/page.tsx +++ b/ui/app/(auth)/(guest-only)/sign-up/page.tsx @@ -1,6 +1,9 @@ import { AuthForm } from "@/components/auth/oss"; -import { getAuthUrl, isGithubOAuthEnabled } from "@/lib/helper"; -import { isGoogleOAuthEnabled } from "@/lib/helper"; +import { + getAuthUrl, + isGithubOAuthEnabled, + isGoogleOAuthEnabled, +} from "@/lib/helper"; import { isCloud } from "@/lib/shared/env"; import { SearchParamsProps } from "@/types"; diff --git a/ui/app/(prowler)/_overview/_components/lighthouse-overview-banner.test.tsx b/ui/app/(prowler)/_overview/_components/lighthouse-overview-banner.test.tsx index 255ac50c63..b407396ce8 100644 --- a/ui/app/(prowler)/_overview/_components/lighthouse-overview-banner.test.tsx +++ b/ui/app/(prowler)/_overview/_components/lighthouse-overview-banner.test.tsx @@ -2,6 +2,7 @@ import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import { LIGHTHOUSE_OVERVIEW_BANNER_HREF } from "../_lib/lighthouse-banner"; + import { LighthouseOverviewBanner } from "./lighthouse-overview-banner"; describe("LighthouseOverviewBanner", () => { diff --git a/ui/app/(prowler)/_overview/attack-surface/attack-surface.ssr.tsx b/ui/app/(prowler)/_overview/attack-surface/attack-surface.ssr.tsx index 7f5b0b0f5f..03b89db714 100644 --- a/ui/app/(prowler)/_overview/attack-surface/attack-surface.ssr.tsx +++ b/ui/app/(prowler)/_overview/attack-surface/attack-surface.ssr.tsx @@ -5,6 +5,7 @@ import { import { pickFilterParams } from "../_lib/filter-params"; import { SSRComponentProps } from "../_types"; + import { AttackSurface } from "./_components/attack-surface"; export const AttackSurfaceSSR = async ({ searchParams }: SSRComponentProps) => { diff --git a/ui/app/(prowler)/_overview/graphs-tabs/risk-plot/risk-plot.ssr.tsx b/ui/app/(prowler)/_overview/graphs-tabs/risk-plot/risk-plot.ssr.tsx index 1f4d3625d4..940362dfe6 100644 --- a/ui/app/(prowler)/_overview/graphs-tabs/risk-plot/risk-plot.ssr.tsx +++ b/ui/app/(prowler)/_overview/graphs-tabs/risk-plot/risk-plot.ssr.tsx @@ -13,6 +13,7 @@ import { filterProvidersByScope, parseFilterIds, } from "../../_lib/provider-scope"; + import { RiskPlotClient } from "./risk-plot-client"; export async function RiskPlotSSR({ diff --git a/ui/app/(prowler)/_overview/graphs-tabs/risk-radar-view/risk-radar-view.ssr.tsx b/ui/app/(prowler)/_overview/graphs-tabs/risk-radar-view/risk-radar-view.ssr.tsx index 932251e08a..355c03c397 100644 --- a/ui/app/(prowler)/_overview/graphs-tabs/risk-radar-view/risk-radar-view.ssr.tsx +++ b/ui/app/(prowler)/_overview/graphs-tabs/risk-radar-view/risk-radar-view.ssr.tsx @@ -7,6 +7,7 @@ import { import { SearchParamsProps } from "@/types"; import { pickFilterParams } from "../../_lib/filter-params"; + import { RiskRadarViewClient } from "./risk-radar-view-client"; export async function RiskRadarViewSSR({ diff --git a/ui/app/(prowler)/_overview/resources-inventory/resources-inventory.ssr.tsx b/ui/app/(prowler)/_overview/resources-inventory/resources-inventory.ssr.tsx index a95f65f9d8..17a7df9f52 100644 --- a/ui/app/(prowler)/_overview/resources-inventory/resources-inventory.ssr.tsx +++ b/ui/app/(prowler)/_overview/resources-inventory/resources-inventory.ssr.tsx @@ -5,6 +5,7 @@ import { import { pickFilterParams } from "../_lib/filter-params"; import { SSRComponentProps } from "../_types"; + import { ResourcesInventory } from "./_components/resources-inventory"; export const ResourcesInventorySSR = async ({ diff --git a/ui/app/(prowler)/_overview/risk-severity/risk-severity-chart.ssr.tsx b/ui/app/(prowler)/_overview/risk-severity/risk-severity-chart.ssr.tsx index c825748169..d59b961c92 100644 --- a/ui/app/(prowler)/_overview/risk-severity/risk-severity-chart.ssr.tsx +++ b/ui/app/(prowler)/_overview/risk-severity/risk-severity-chart.ssr.tsx @@ -2,6 +2,7 @@ import { getFindingsBySeverity } from "@/actions/overview"; import { pickFilterParams } from "../_lib/filter-params"; import { SSRComponentProps } from "../_types"; + import { RiskSeverityChart } from "./_components/risk-severity-chart"; export const RiskSeverityChartSSR = async ({ diff --git a/ui/app/(prowler)/_overview/severity-over-time/_components/finding-severity-over-time.tsx b/ui/app/(prowler)/_overview/severity-over-time/_components/finding-severity-over-time.tsx index b65b6a21f0..31cbc42cc4 100644 --- a/ui/app/(prowler)/_overview/severity-over-time/_components/finding-severity-over-time.tsx +++ b/ui/app/(prowler)/_overview/severity-over-time/_components/finding-severity-over-time.tsx @@ -15,6 +15,7 @@ import { } from "@/types/severities"; import { DEFAULT_TIME_RANGE } from "../_constants/time-range.constants"; + import { type TimeRange, TimeRangeSelector } from "./time-range-selector"; interface FindingSeverityOverTimeProps { diff --git a/ui/app/(prowler)/_overview/severity-over-time/finding-severity-over-time.ssr.tsx b/ui/app/(prowler)/_overview/severity-over-time/finding-severity-over-time.ssr.tsx index a2d7a36d5c..e1f9c6635e 100644 --- a/ui/app/(prowler)/_overview/severity-over-time/finding-severity-over-time.ssr.tsx +++ b/ui/app/(prowler)/_overview/severity-over-time/finding-severity-over-time.ssr.tsx @@ -3,6 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn"; import { pickFilterParams } from "../_lib/filter-params"; import { SSRComponentProps } from "../_types"; + import { FindingSeverityOverTime } from "./_components/finding-severity-over-time"; import { FindingSeverityOverTimeSkeleton } from "./_components/finding-severity-over-time.skeleton"; import { DEFAULT_TIME_RANGE } from "./_constants/time-range.constants"; diff --git a/ui/app/(prowler)/_overview/threat-score/threat-score.ssr.tsx b/ui/app/(prowler)/_overview/threat-score/threat-score.ssr.tsx index 7f5364d147..244c50527e 100644 --- a/ui/app/(prowler)/_overview/threat-score/threat-score.ssr.tsx +++ b/ui/app/(prowler)/_overview/threat-score/threat-score.ssr.tsx @@ -2,6 +2,7 @@ import { getThreatScore } from "@/actions/overview"; import { pickFilterParams } from "../_lib/filter-params"; import { SSRComponentProps } from "../_types"; + import { ThreatScore } from "./_components/threat-score"; export const ThreatScoreSSR = async ({ searchParams }: SSRComponentProps) => { diff --git a/ui/app/(prowler)/_overview/watchlist/compliance-watchlist.ssr.tsx b/ui/app/(prowler)/_overview/watchlist/compliance-watchlist.ssr.tsx index d0aae42dad..e897a58ea6 100644 --- a/ui/app/(prowler)/_overview/watchlist/compliance-watchlist.ssr.tsx +++ b/ui/app/(prowler)/_overview/watchlist/compliance-watchlist.ssr.tsx @@ -5,6 +5,7 @@ import { import { pickFilterParams } from "../_lib/filter-params"; import { SSRComponentProps } from "../_types"; + import { ComplianceWatchlist } from "./_components/compliance-watchlist"; export const ComplianceWatchlistSSR = async ({ diff --git a/ui/app/(prowler)/_overview/watchlist/service-watchlist.ssr.tsx b/ui/app/(prowler)/_overview/watchlist/service-watchlist.ssr.tsx index 9ec08a6cb1..a84d9f1c56 100644 --- a/ui/app/(prowler)/_overview/watchlist/service-watchlist.ssr.tsx +++ b/ui/app/(prowler)/_overview/watchlist/service-watchlist.ssr.tsx @@ -2,6 +2,7 @@ import { getServicesOverview, ServiceOverview } from "@/actions/overview"; import { pickFilterParams } from "../_lib/filter-params"; import { SSRComponentProps } from "../_types"; + import { ServiceWatchlist } from "./_components/service-watchlist"; export const ServiceWatchlistSSR = async ({ diff --git a/ui/app/(prowler)/alerts/_actions/alerts.test.ts b/ui/app/(prowler)/alerts/_actions/alerts.test.ts index 5f19b33733..4dfe3dd563 100644 --- a/ui/app/(prowler)/alerts/_actions/alerts.test.ts +++ b/ui/app/(prowler)/alerts/_actions/alerts.test.ts @@ -25,6 +25,7 @@ vi.mock("@/lib/server-actions-helper", () => ({ })); import { ALERT_AGGREGATE_OPS, ALERT_TRIGGER_KINDS } from "../_types"; + import { createAlert, deleteAlert, diff --git a/ui/app/(prowler)/alerts/_components/alerts-manager.tsx b/ui/app/(prowler)/alerts/_components/alerts-manager.tsx index 92f11631ad..c9b499b251 100644 --- a/ui/app/(prowler)/alerts/_components/alerts-manager.tsx +++ b/ui/app/(prowler)/alerts/_components/alerts-manager.tsx @@ -15,12 +15,10 @@ import { ALERT_TRIGGER_KINDS, type AlertRule, } from "@/app/(prowler)/alerts/_types"; -import { Button } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { Button, useToast } from "@/components/shadcn"; import { Modal } from "@/components/shadcn/modal"; import { DOCS_URLS } from "@/lib/external-urls"; -import type { MetaDataProps } from "@/types"; -import type { ScanEntity } from "@/types"; +import type { MetaDataProps, ScanEntity } from "@/types"; import type { ProviderProps } from "@/types/providers"; import { toAlertPayload } from "../_lib/alert-adapter"; @@ -29,6 +27,7 @@ import type { AlertFormSubmitResult, AlertFormValues, } from "../_types/alert-form"; + import { AlertFormModal } from "./alert-form-modal"; import { AlertsEmptyState } from "./alerts-empty-state"; import { AlertsTable } from "./alerts-table"; diff --git a/ui/app/(prowler)/alerts/_components/seed-from-findings-button.tsx b/ui/app/(prowler)/alerts/_components/seed-from-findings-button.tsx index 6b94549a9e..988f7e70f8 100644 --- a/ui/app/(prowler)/alerts/_components/seed-from-findings-button.tsx +++ b/ui/app/(prowler)/alerts/_components/seed-from-findings-button.tsx @@ -28,8 +28,9 @@ import { Tooltip, TooltipContent, TooltipTrigger, + ToastAction, + useToast, } from "@/components/shadcn"; -import { ToastAction, useToast } from "@/components/shadcn"; import { useCloudUpgradeStore } from "@/store"; import type { ScanEntity } from "@/types"; import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.test.tsx index 20210a3126..f95aa99870 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.test.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.test.tsx @@ -2,6 +2,7 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { ATTACK_PATHS_VIEW_STATES } from "../_lib/get-attack-paths-view-state"; + import { AttackPathsStatusPanel } from "./attack-paths-status-panel"; describe("AttackPathsStatusPanel", () => { diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx index 6e9c608f64..a987e3a7ac 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx @@ -34,6 +34,7 @@ import { resolveHiddenFindingIds, } from "../../_lib"; import { isFindingNode, layoutWithDagre } from "../../_lib/layout"; + import { FindingNode } from "./nodes/finding-node"; import { InternetNode } from "./nodes/internet-node"; import { ResourceNode } from "./nodes/resource-node"; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.tsx index 789a379551..78583c628a 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.tsx @@ -12,6 +12,7 @@ import type { GraphNode } from "@/types/attack-paths"; import { resolveNodeColors, resolveNodeVisual } from "../../../_lib"; import { FINDING_NODE_DIMENSIONS } from "../../../_lib/node-dimensions"; import { getNodeLabelDisplay } from "../../../_lib/node-label-lines"; + import { HiddenHandles } from "./hidden-handles"; interface FindingNodeData { diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/internet-node.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/internet-node.tsx index e2009f71c9..097e2903c5 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/internet-node.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/internet-node.tsx @@ -5,6 +5,7 @@ import { type NodeProps } from "@xyflow/react"; import type { GraphNode } from "@/types/attack-paths"; import { resolveNodeColors } from "../../../_lib"; + import { HiddenHandles } from "./hidden-handles"; interface InternetNodeData { diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.tsx index 9860dbea27..3120129091 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.tsx @@ -12,6 +12,7 @@ import type { GraphNode } from "@/types/attack-paths"; import { resolveNodeColors, resolveNodeVisual } from "../../../_lib"; import { RESOURCE_NODE_DIMENSIONS } from "../../../_lib/node-dimensions"; import { getNodeLabelDisplay } from "../../../_lib/node-label-lines"; + import { HiddenHandles } from "./hidden-handles"; interface ResourceNodeData { diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx index cc87406aa8..3fc2e59f94 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx @@ -28,13 +28,13 @@ import { import { StatusAlert } from "@/components/shared/status-alert"; import { useMountEffect } from "@/hooks/use-mount-effect"; import { isCloud } from "@/lib/shared/env"; +import { attackPathsEmptyTour } from "@/lib/tours/attack-paths-empty.tour"; import { attackPathsTour, type AttackPathsTourTarget, pickDemoQuery, pickDemoScan, } from "@/lib/tours/attack-paths.tour"; -import { attackPathsEmptyTour } from "@/lib/tours/attack-paths-empty.tour"; import { advanceActiveTour, useDriverTour } from "@/lib/tours/use-driver-tour"; import type { AttackPathQuery, diff --git a/ui/app/(prowler)/compliance/_components/compliance-page-tabs.test.tsx b/ui/app/(prowler)/compliance/_components/compliance-page-tabs.test.tsx index 617d943030..531cfbe79d 100644 --- a/ui/app/(prowler)/compliance/_components/compliance-page-tabs.test.tsx +++ b/ui/app/(prowler)/compliance/_components/compliance-page-tabs.test.tsx @@ -6,6 +6,7 @@ import { useCloudUpgradeStore } from "@/store"; import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; import { COMPLIANCE_TAB } from "../_types"; + import { CompliancePageTabs } from "./compliance-page-tabs"; import { getComplianceTab } from "./compliance-page-tabs.shared"; diff --git a/ui/app/(prowler)/compliance/_components/cross-provider-detail.tsx b/ui/app/(prowler)/compliance/_components/cross-provider-detail.tsx index 7972379b1f..4901294ad4 100644 --- a/ui/app/(prowler)/compliance/_components/cross-provider-detail.tsx +++ b/ui/app/(prowler)/compliance/_components/cross-provider-detail.tsx @@ -29,6 +29,7 @@ import { parseCrossProviderFilters, } from "../_lib/cross-provider-frameworks"; import { CROSS_PROVIDER_OVERVIEW_RESULT_STATUS } from "../_types"; + import { CrossProviderErrorAlert } from "./cross-provider-error-alert"; import type { CrossProviderAccountOption, diff --git a/ui/app/(prowler)/compliance/_components/cross-provider-overview.test.tsx b/ui/app/(prowler)/compliance/_components/cross-provider-overview.test.tsx index b2fb3b66f4..1e5a71deba 100644 --- a/ui/app/(prowler)/compliance/_components/cross-provider-overview.test.tsx +++ b/ui/app/(prowler)/compliance/_components/cross-provider-overview.test.tsx @@ -11,6 +11,7 @@ import { CROSS_PROVIDER_OVERVIEW_RESULT_STATUS, CROSS_PROVIDER_OVERVIEW_TYPE, } from "../_types"; + import { CrossProviderOverview } from "./cross-provider-overview"; vi.mock("../_actions/cross-provider", () => ({ diff --git a/ui/app/(prowler)/compliance/_components/cross-provider-overview.tsx b/ui/app/(prowler)/compliance/_components/cross-provider-overview.tsx index 17af2b38bf..d115ed4e0e 100644 --- a/ui/app/(prowler)/compliance/_components/cross-provider-overview.tsx +++ b/ui/app/(prowler)/compliance/_components/cross-provider-overview.tsx @@ -15,6 +15,7 @@ import { } from "../_lib/cross-provider-frameworks"; import type { CrossProviderFrameworkSummary } from "../_types"; import { CROSS_PROVIDER_OVERVIEW_RESULT_STATUS } from "../_types"; + import { CrossProviderErrorAlert } from "./cross-provider-error-alert"; import type { CrossProviderAccountOption, diff --git a/ui/app/(prowler)/compliance/_components/cross-provider-requirement-content.test.tsx b/ui/app/(prowler)/compliance/_components/cross-provider-requirement-content.test.tsx index 98a9f11aab..5ec45475ce 100644 --- a/ui/app/(prowler)/compliance/_components/cross-provider-requirement-content.test.tsx +++ b/ui/app/(prowler)/compliance/_components/cross-provider-requirement-content.test.tsx @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest"; import type { CheckProviderTypesMap, Requirement } from "@/types/compliance"; import type { CrossProviderRequirementExtras } from "../_types"; + import { CrossProviderRequirementContent } from "./cross-provider-requirement-content"; const { clientAccordionContentMock } = vi.hoisted(() => ({ diff --git a/ui/app/(prowler)/compliance/_components/provider-coverage-card.test.tsx b/ui/app/(prowler)/compliance/_components/provider-coverage-card.test.tsx index d1f73d264a..ac30a01f53 100644 --- a/ui/app/(prowler)/compliance/_components/provider-coverage-card.test.tsx +++ b/ui/app/(prowler)/compliance/_components/provider-coverage-card.test.tsx @@ -2,6 +2,7 @@ import { render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import type { ProviderBreakdownEntry } from "../_types"; + import { ProviderCoverageCard } from "./provider-coverage-card"; vi.mock("@/components/icons/providers-badge/provider-type-icon", () => ({ diff --git a/ui/app/(prowler)/invitations/(send-invite)/new/page.tsx b/ui/app/(prowler)/invitations/(send-invite)/new/page.tsx index 520760092b..d0372c4f4b 100644 --- a/ui/app/(prowler)/invitations/(send-invite)/new/page.tsx +++ b/ui/app/(prowler)/invitations/(send-invite)/new/page.tsx @@ -1,4 +1,3 @@ -import React from "react"; import { Suspense } from "react"; import { getRoles } from "@/actions/roles"; diff --git a/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-view.tsx b/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-view.tsx index 0060dbe44e..9b687383bc 100644 --- a/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-view.tsx +++ b/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-view.tsx @@ -29,6 +29,7 @@ import { import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; import { ProviderIcon } from "../config/provider-icon"; + import { ChatComposerPanel } from "./composer"; import { ChatEmptyState } from "./empty-state"; import { useLighthouseChatStore } from "./lighthouse-chat-store-provider"; diff --git a/ui/app/(prowler)/lighthouse/_components/panel/lighthouse-panel-chat.tsx b/ui/app/(prowler)/lighthouse/_components/panel/lighthouse-panel-chat.tsx index acecf64b32..371075a088 100644 --- a/ui/app/(prowler)/lighthouse/_components/panel/lighthouse-panel-chat.tsx +++ b/ui/app/(prowler)/lighthouse/_components/panel/lighthouse-panel-chat.tsx @@ -42,6 +42,7 @@ import { LIGHTHOUSE_CHAT_SURFACE, LighthouseV2ChatView, } from "../chat/lighthouse-v2-chat-view"; + import { LighthousePanelChatSkeleton } from "./lighthouse-panel-chat-skeleton"; const PANEL_CHAT_STATUS = { diff --git a/ui/app/(prowler)/lighthouse/settings/(connect-llm)/layout.tsx b/ui/app/(prowler)/lighthouse/settings/(connect-llm)/layout.tsx index 8c8db3f7c7..9b03f5b3a2 100644 --- a/ui/app/(prowler)/lighthouse/settings/(connect-llm)/layout.tsx +++ b/ui/app/(prowler)/lighthouse/settings/(connect-llm)/layout.tsx @@ -12,8 +12,7 @@ import { } from "@/actions/lighthouse-v1/lighthouse"; import { DeleteLLMProviderForm } from "@/components/lighthouse-v1/forms/delete-llm-provider-form"; import { WorkflowConnectLLM } from "@/components/lighthouse-v1/workflow"; -import { Button } from "@/components/shadcn"; -import { NavigationHeader } from "@/components/shadcn"; +import { Button, NavigationHeader } from "@/components/shadcn"; import { Modal } from "@/components/shadcn/modal"; import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes"; import type { LighthouseProvider } from "@/types/lighthouse-v1"; diff --git a/ui/app/(prowler)/mutelist/_components/advanced-mutelist-form.tsx b/ui/app/(prowler)/mutelist/_components/advanced-mutelist-form.tsx index 5d30308ee3..b7a6885b55 100644 --- a/ui/app/(prowler)/mutelist/_components/advanced-mutelist-form.tsx +++ b/ui/app/(prowler)/mutelist/_components/advanced-mutelist-form.tsx @@ -15,8 +15,8 @@ import { FieldError, Skeleton, Textarea, + useToast, } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; import { CustomLink } from "@/components/shadcn/custom/custom-link"; import { Modal } from "@/components/shadcn/modal"; import { fontMono } from "@/config/fonts"; diff --git a/ui/app/(prowler)/mutelist/_components/simple/mute-rule-enabled-toggle.tsx b/ui/app/(prowler)/mutelist/_components/simple/mute-rule-enabled-toggle.tsx index 73af798640..40bcf5d3ff 100644 --- a/ui/app/(prowler)/mutelist/_components/simple/mute-rule-enabled-toggle.tsx +++ b/ui/app/(prowler)/mutelist/_components/simple/mute-rule-enabled-toggle.tsx @@ -4,8 +4,7 @@ import { useState } from "react"; import { toggleMuteRule } from "@/actions/mute-rules"; import { MuteRuleData } from "@/actions/mute-rules/types"; -import { Switch } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { Switch, useToast } from "@/components/shadcn"; interface MuteRuleEnabledToggleProps { muteRule: MuteRuleData; diff --git a/ui/app/(prowler)/mutelist/_components/simple/mute-rule-targets-modal.test.tsx b/ui/app/(prowler)/mutelist/_components/simple/mute-rule-targets-modal.test.tsx index eb8db616e2..fd45dc2e91 100644 --- a/ui/app/(prowler)/mutelist/_components/simple/mute-rule-targets-modal.test.tsx +++ b/ui/app/(prowler)/mutelist/_components/simple/mute-rule-targets-modal.test.tsx @@ -3,6 +3,7 @@ import { type ReactNode } from "react"; import { describe, expect, it, vi } from "vitest"; import { type MuteRuleTableData } from "./mute-rule-target-previews"; +import { MuteRuleTargetsModal } from "./mute-rule-targets-modal"; vi.mock("@/components/shadcn/modal", () => ({ Modal: ({ @@ -21,8 +22,6 @@ vi.mock("@/components/shadcn/modal", () => ({ ) : null, })); -import { MuteRuleTargetsModal } from "./mute-rule-targets-modal"; - const longMuteRule: MuteRuleTableData = { type: "mute-rules", id: "mute-rule-1", diff --git a/ui/app/(prowler)/scans/config/_components/scan-configuration-editor.tsx b/ui/app/(prowler)/scans/config/_components/scan-configuration-editor.tsx index e9086cd366..83cfe429bb 100644 --- a/ui/app/(prowler)/scans/config/_components/scan-configuration-editor.tsx +++ b/ui/app/(prowler)/scans/config/_components/scan-configuration-editor.tsx @@ -17,8 +17,8 @@ import { FieldLabel, Input, Textarea, + useToast, } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; import { CustomLink } from "@/components/shadcn/custom/custom-link"; import { Modal } from "@/components/shadcn/modal"; import { DOCS_URLS } from "@/lib/external-urls"; diff --git a/ui/app/(prowler)/scans/config/_components/scan-configurations-manager.tsx b/ui/app/(prowler)/scans/config/_components/scan-configurations-manager.tsx index 2599b7291b..618ee1b8d0 100644 --- a/ui/app/(prowler)/scans/config/_components/scan-configurations-manager.tsx +++ b/ui/app/(prowler)/scans/config/_components/scan-configurations-manager.tsx @@ -10,8 +10,7 @@ import { import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector"; import { BatchFiltersLayout } from "@/components/filters/batch-filters-layout"; import { ClearFiltersButton } from "@/components/filters/clear-filters-button"; -import { Button, Card } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { Button, Card, useToast } from "@/components/shadcn"; import { CustomLink } from "@/components/shadcn/custom/custom-link"; import { Modal } from "@/components/shadcn/modal"; import { DataTable } from "@/components/shadcn/table"; diff --git a/ui/app/(prowler)/scans/page.tsx b/ui/app/(prowler)/scans/page.tsx index f307920e52..217b94b4c6 100644 --- a/ui/app/(prowler)/scans/page.tsx +++ b/ui/app/(prowler)/scans/page.tsx @@ -11,6 +11,8 @@ import { import { getSchedules, getSchedulesPage } from "@/actions/schedules"; import { auth } from "@/auth.config"; import { PageReady } from "@/components/onboarding"; +import { ScansPageShell } from "@/components/scans/scans-page-shell"; +import { ScansProvidersEmptyState } from "@/components/scans/scans-providers-empty-state"; import { appendPendingScheduleRowsToPage, buildScheduledTabRows, @@ -20,8 +22,6 @@ import { getScanJobsUserFilters, pickScheduleProviderFilters, } from "@/components/scans/scans.utils"; -import { ScansPageShell } from "@/components/scans/scans-page-shell"; -import { ScansProvidersEmptyState } from "@/components/scans/scans-providers-empty-state"; import { SkeletonTableScans } from "@/components/scans/table"; import { ScanJobsTable } from "@/components/scans/table/scan-jobs-table"; import { ContentLayout } from "@/components/shadcn/content-layout"; diff --git a/ui/changelog.d/findings-timeline-y-axis.fixed.md b/ui/changelog.d/findings-timeline-y-axis.fixed.md new file mode 100644 index 0000000000..4dd338070f --- /dev/null +++ b/ui/changelog.d/findings-timeline-y-axis.fixed.md @@ -0,0 +1 @@ +Findings Severity Over Time chart Y-axis labels no longer overflow for large findings counts diff --git a/ui/changelog.d/ui-sentry-actionability.fixed.md b/ui/changelog.d/ui-sentry-actionability.fixed.md new file mode 100644 index 0000000000..7a4b8ce2ca --- /dev/null +++ b/ui/changelog.d/ui-sentry-actionability.fixed.md @@ -0,0 +1 @@ +UI Sentry alerts now suppress non-actionable warnings and expected API/control-flow noise while preserving actionable runtime failures diff --git a/ui/components/auth/oss/sign-in-form.tsx b/ui/components/auth/oss/sign-in-form.tsx index 8ebd8f3c0e..7c971913be 100644 --- a/ui/components/auth/oss/sign-in-form.tsx +++ b/ui/components/auth/oss/sign-in-form.tsx @@ -17,8 +17,8 @@ import { Tooltip, TooltipContent, TooltipTrigger, + useToast, } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; import { CustomInput } from "@/components/shadcn/custom"; import { Form } from "@/components/shadcn/form"; import { getSafeCallbackPath } from "@/lib/auth-callback-url"; diff --git a/ui/components/auth/oss/sign-up-form.tsx b/ui/components/auth/oss/sign-up-form.tsx index 2127151b73..68fe4e2c6a 100644 --- a/ui/components/auth/oss/sign-up-form.tsx +++ b/ui/components/auth/oss/sign-up-form.tsx @@ -15,8 +15,7 @@ import { AuthFooterLink } from "@/components/auth/oss/auth-footer-link"; import { AuthLayout } from "@/components/auth/oss/auth-layout"; import { PasswordRequirementsMessage } from "@/components/auth/oss/password-validator"; import { SocialButtons } from "@/components/auth/oss/social-buttons"; -import { Button, Checkbox } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { Button, Checkbox, useToast } from "@/components/shadcn"; import { CustomInput } from "@/components/shadcn/custom"; import { CustomLink } from "@/components/shadcn/custom/custom-link"; import { diff --git a/ui/components/compliance/compliance-accordion/client-accordion-wrapper.tsx b/ui/components/compliance/compliance-accordion/client-accordion-wrapper.tsx index e3ab821a55..256431cdfb 100644 --- a/ui/components/compliance/compliance-accordion/client-accordion-wrapper.tsx +++ b/ui/components/compliance/compliance-accordion/client-accordion-wrapper.tsx @@ -2,8 +2,7 @@ import { useRef, useState } from "react"; -import { Button } from "@/components/shadcn"; -import { Accordion, AccordionItemProps } from "@/components/shadcn"; +import { Button, Accordion, AccordionItemProps } from "@/components/shadcn"; import { Card } from "@/components/shadcn/card/card"; export const ClientAccordionWrapper = ({ diff --git a/ui/components/compliance/compliance-card.tsx b/ui/components/compliance/compliance-card.tsx index 6168784f2f..37c7892fda 100644 --- a/ui/components/compliance/compliance-card.tsx +++ b/ui/components/compliance/compliance-card.tsx @@ -19,6 +19,7 @@ import { import { ScanEntity } from "@/types/scans"; import { getComplianceIcon } from "../icons"; + import { ComplianceDownloadContainer } from "./compliance-download-container"; interface ComplianceCardProps { diff --git a/ui/components/findings/table/findings-group-drill-down.tsx b/ui/components/findings/table/findings-group-drill-down.tsx index 0b96a9657e..b71787ef37 100644 --- a/ui/components/findings/table/findings-group-drill-down.tsx +++ b/ui/components/findings/table/findings-group-drill-down.tsx @@ -20,8 +20,9 @@ import { TableHead, TableHeader, TableRow, + SeverityBadge, + StatusFindingBadge, } from "@/components/shadcn/table"; -import { SeverityBadge, StatusFindingBadge } from "@/components/shadcn/table"; import { useFindingGroupResourceState } from "@/hooks/use-finding-group-resource-state"; import { cn, hasHistoricalFindingFilter } from "@/lib"; import { @@ -32,6 +33,7 @@ import { import { FindingGroupRow } from "@/types"; import { FloatingMuteButton } from "../floating-mute-button"; + import { getColumnFindingResources } from "./column-finding-resources"; import { FindingsSelectionContext } from "./findings-selection-context"; import { ImpactedResourcesCell } from "./impacted-resources-cell"; diff --git a/ui/components/findings/table/findings-group-table.tsx b/ui/components/findings/table/findings-group-table.tsx index 10eeff0b62..986cba7f09 100644 --- a/ui/components/findings/table/findings-group-table.tsx +++ b/ui/components/findings/table/findings-group-table.tsx @@ -14,6 +14,7 @@ import { createExploreFindingsTourStepHandlers } from "@/lib/tours/explore-findi import { FindingGroupRow, MetaDataProps } from "@/types"; import { FloatingMuteButton } from "../floating-mute-button"; + import { getColumnFindingGroups } from "./column-finding-groups"; import { canMuteFindingGroup } from "./finding-group-selection"; import { FindingsSelectionContext } from "./findings-selection-context"; diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx index 1d2716985f..f18f0b671a 100644 --- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx @@ -71,8 +71,7 @@ import { type QueryEditorLanguage, } from "@/components/shared/query-code-editor"; import { ResourceMetadataPanel } from "@/components/shared/resource-metadata-panel"; -import { getFailingForLabel } from "@/lib/date-utils"; -import { formatDuration } from "@/lib/date-utils"; +import { getFailingForLabel, formatDuration } from "@/lib/date-utils"; import { shouldRefreshAfterTriageUpdate } from "@/lib/finding-triage"; import { buildFindingAnalysisPrompt } from "@/lib/lighthouse/prompts"; import { getRegionFlag } from "@/lib/region-flags"; @@ -89,6 +88,7 @@ import { FindingTriageStatusCell, } from "../finding-triage-cells"; import { DeltaValues, NotificationIndicator } from "../notification-indicator"; + import { ResourceDetailSkeleton } from "./resource-detail-skeleton"; import type { CheckMeta } from "./use-resource-detail-drawer"; diff --git a/ui/components/graphs/line-chart.test.tsx b/ui/components/graphs/line-chart.test.tsx new file mode 100644 index 0000000000..3febcd46f3 --- /dev/null +++ b/ui/components/graphs/line-chart.test.tsx @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; + +import { formatYAxisTick } from "./line-chart.utils"; + +describe("formatYAxisTick", () => { + describe("when findings counts are large", () => { + it("should compact six-digit values so Y-axis labels do not overflow", () => { + // Given + const tickValue = 150000; + + // When + const formattedValue = formatYAxisTick(tickValue); + + // Then + expect(formattedValue).toBe("150K"); + }); + + it("should compact million-scale values", () => { + // Given + const tickValue = 1200000; + + // When + const formattedValue = formatYAxisTick(tickValue); + + // Then + expect(formattedValue).toBe("1.2M"); + }); + }); + + describe("when findings counts are small", () => { + it("should keep values below 1000 readable without compact notation", () => { + // Given + const tickValue = 999; + + // When + const formattedValue = formatYAxisTick(tickValue); + + // Then + expect(formattedValue).toBe("999"); + }); + }); +}); diff --git a/ui/components/graphs/line-chart.tsx b/ui/components/graphs/line-chart.tsx index eab0551bb6..1ffdd19dc3 100644 --- a/ui/components/graphs/line-chart.tsx +++ b/ui/components/graphs/line-chart.tsx @@ -17,6 +17,7 @@ import { ChartTooltip, } from "@/components/shadcn/chart/Chart"; +import { formatYAxisTick } from "./line-chart.utils"; import { AlertPill } from "./shared/alert-pill"; import { ChartLegend } from "./shared/chart-legend"; import { CustomActiveDot, PointClickData } from "./shared/custom-active-dot"; @@ -222,6 +223,8 @@ export function LineChart({ tickLine={false} axisLine={false} tickMargin={8} + tickFormatter={formatYAxisTick} + width={56} padding={{ top: 20 }} tick={{ fill: "var(--color-text-neutral-secondary)", diff --git a/ui/components/graphs/line-chart.utils.ts b/ui/components/graphs/line-chart.utils.ts new file mode 100644 index 0000000000..b219529c24 --- /dev/null +++ b/ui/components/graphs/line-chart.utils.ts @@ -0,0 +1,8 @@ +const Y_AXIS_TICK_FORMATTER = new Intl.NumberFormat("en-US", { + notation: "compact", + maximumFractionDigits: 1, +}); + +export function formatYAxisTick(value: number) { + return Y_AXIS_TICK_FORMATTER.format(value); +} diff --git a/ui/components/integrations/jira/jira-integration-card.tsx b/ui/components/integrations/jira/jira-integration-card.tsx index 4629215f5e..b99cf7cdbe 100644 --- a/ui/components/integrations/jira/jira-integration-card.tsx +++ b/ui/components/integrations/jira/jira-integration-card.tsx @@ -4,11 +4,9 @@ import { SettingsIcon } from "lucide-react"; import Link from "next/link"; import { JiraIcon } from "@/components/icons/services/IconServices"; -import { Button } from "@/components/shadcn"; +import { Button, Card, CardContent, CardHeader } from "@/components/shadcn"; import { CustomLink } from "@/components/shadcn/custom/custom-link"; -import { Card, CardContent, CardHeader } from "../../shadcn"; - export const JiraIntegrationCard = () => { return ( diff --git a/ui/components/integrations/jira/jira-integrations-manager.tsx b/ui/components/integrations/jira/jira-integrations-manager.tsx index c3f3a1412f..e63b17dac3 100644 --- a/ui/components/integrations/jira/jira-integrations-manager.tsx +++ b/ui/components/integrations/jira/jira-integrations-manager.tsx @@ -15,15 +15,19 @@ import { IntegrationCardHeader, IntegrationSkeleton, } from "@/components/integrations/shared"; -import { Button } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { + Button, + useToast, + Card, + CardContent, + CardHeader, +} from "@/components/shadcn"; import { Modal } from "@/components/shadcn/modal"; import { DataTablePagination } from "@/components/shadcn/table/data-table-pagination"; import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper"; import { MetaDataProps } from "@/types"; import { IntegrationProps } from "@/types/integrations"; -import { Card, CardContent, CardHeader } from "../../shadcn"; import { JiraIntegrationForm } from "./jira-integration-form"; interface JiraIntegrationsManagerProps { diff --git a/ui/components/integrations/s3/s3-integration-card.tsx b/ui/components/integrations/s3/s3-integration-card.tsx index 7e2be1890d..be173c5609 100644 --- a/ui/components/integrations/s3/s3-integration-card.tsx +++ b/ui/components/integrations/s3/s3-integration-card.tsx @@ -4,11 +4,9 @@ import { SettingsIcon } from "lucide-react"; import Link from "next/link"; import { AmazonS3Icon } from "@/components/icons/services/IconServices"; -import { Button } from "@/components/shadcn"; +import { Button, Card, CardContent, CardHeader } from "@/components/shadcn"; import { CustomLink } from "@/components/shadcn/custom/custom-link"; -import { Card, CardContent, CardHeader } from "../../shadcn"; - export const S3IntegrationCard = () => { return ( diff --git a/ui/components/integrations/s3/s3-integration-form.tsx b/ui/components/integrations/s3/s3-integration-form.tsx index e189063427..30587fc3b3 100644 --- a/ui/components/integrations/s3/s3-integration-form.tsx +++ b/ui/components/integrations/s3/s3-integration-form.tsx @@ -13,8 +13,7 @@ import { ProviderTypeIcon, } from "@/components/icons/providers-badge/provider-type-icon"; import { AWSRoleCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form"; -import { Separator } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { Separator, useToast } from "@/components/shadcn"; import { CustomInput } from "@/components/shadcn/custom"; import { CustomLink } from "@/components/shadcn/custom/custom-link"; import { diff --git a/ui/components/integrations/s3/s3-integrations-manager.tsx b/ui/components/integrations/s3/s3-integrations-manager.tsx index 1e75d1b43c..03ec525abc 100644 --- a/ui/components/integrations/s3/s3-integrations-manager.tsx +++ b/ui/components/integrations/s3/s3-integrations-manager.tsx @@ -15,8 +15,13 @@ import { IntegrationCardHeader, IntegrationSkeleton, } from "@/components/integrations/shared"; -import { Button } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { + Button, + useToast, + Card, + CardContent, + CardHeader, +} from "@/components/shadcn"; import { Modal } from "@/components/shadcn/modal"; import { DataTablePagination } from "@/components/shadcn/table/data-table-pagination"; import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper"; @@ -24,7 +29,6 @@ import { MetaDataProps } from "@/types"; import { IntegrationProps } from "@/types/integrations"; import { ProviderProps } from "@/types/providers"; -import { Card, CardContent, CardHeader } from "../../shadcn"; import { S3IntegrationForm } from "./s3-integration-form"; interface S3IntegrationsManagerProps { diff --git a/ui/components/integrations/saml/saml-config-form.tsx b/ui/components/integrations/saml/saml-config-form.tsx index db713252a8..82d5fccfd5 100644 --- a/ui/components/integrations/saml/saml-config-form.tsx +++ b/ui/components/integrations/saml/saml-config-form.tsx @@ -12,8 +12,13 @@ import { z } from "zod"; import { createSamlConfig, updateSamlConfig } from "@/actions/integrations"; import { AddIcon } from "@/components/icons"; -import { Button, Card, CardContent, CardHeader } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { + Button, + Card, + CardContent, + CardHeader, + useToast, +} from "@/components/shadcn"; import { CodeSnippet } from "@/components/shadcn/code-snippet/code-snippet"; import { CustomServerInput } from "@/components/shadcn/custom"; import { CustomLink } from "@/components/shadcn/custom/custom-link"; diff --git a/ui/components/integrations/security-hub/security-hub-integration-card.tsx b/ui/components/integrations/security-hub/security-hub-integration-card.tsx index 4003f8c286..3861702fe0 100644 --- a/ui/components/integrations/security-hub/security-hub-integration-card.tsx +++ b/ui/components/integrations/security-hub/security-hub-integration-card.tsx @@ -4,11 +4,9 @@ import { SettingsIcon } from "lucide-react"; import Link from "next/link"; import { AWSSecurityHubIcon } from "@/components/icons/services/IconServices"; -import { Button } from "@/components/shadcn"; +import { Button, Card, CardContent, CardHeader } from "@/components/shadcn"; import { CustomLink } from "@/components/shadcn/custom/custom-link"; -import { Card, CardContent, CardHeader } from "../../shadcn"; - export const SecurityHubIntegrationCard = () => { return ( diff --git a/ui/components/integrations/security-hub/security-hub-integration-form.tsx b/ui/components/integrations/security-hub/security-hub-integration-form.tsx index b7fac10706..24e88b744f 100644 --- a/ui/components/integrations/security-hub/security-hub-integration-form.tsx +++ b/ui/components/integrations/security-hub/security-hub-integration-form.tsx @@ -12,8 +12,7 @@ import { ProviderTypeIcon, } from "@/components/icons/providers-badge/provider-type-icon"; import { AWSRoleCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form"; -import { Checkbox, Separator } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { Checkbox, Separator, useToast } from "@/components/shadcn"; import { CustomLink } from "@/components/shadcn/custom/custom-link"; import { Form, diff --git a/ui/components/integrations/security-hub/security-hub-integrations-manager.tsx b/ui/components/integrations/security-hub/security-hub-integrations-manager.tsx index 34c2d9e303..c0c5f9f02e 100644 --- a/ui/components/integrations/security-hub/security-hub-integrations-manager.tsx +++ b/ui/components/integrations/security-hub/security-hub-integrations-manager.tsx @@ -15,8 +15,14 @@ import { IntegrationCardHeader, IntegrationSkeleton, } from "@/components/integrations/shared"; -import { Badge, Button } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { + Badge, + Button, + useToast, + Card, + CardContent, + CardHeader, +} from "@/components/shadcn"; import { Modal } from "@/components/shadcn/modal"; import { DataTablePagination } from "@/components/shadcn/table/data-table-pagination"; import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper"; @@ -24,7 +30,6 @@ import { MetaDataProps } from "@/types"; import { IntegrationProps } from "@/types/integrations"; import { ProviderProps } from "@/types/providers"; -import { Card, CardContent, CardHeader } from "../../shadcn"; import { SecurityHubIntegrationForm } from "./security-hub-integration-form"; interface SecurityHubIntegrationsManagerProps { diff --git a/ui/components/integrations/shared/link-card.tsx b/ui/components/integrations/shared/link-card.tsx index ceb74d6a1b..212c95ed39 100644 --- a/ui/components/integrations/shared/link-card.tsx +++ b/ui/components/integrations/shared/link-card.tsx @@ -3,11 +3,9 @@ import { ExternalLinkIcon, LucideIcon } from "lucide-react"; import Link from "next/link"; -import { Button } from "@/components/shadcn"; +import { Button, Card, CardContent, CardHeader } from "@/components/shadcn"; import { CustomLink } from "@/components/shadcn/custom/custom-link"; -import { Card, CardContent, CardHeader } from "../../shadcn"; - interface LinkCardProps { icon: LucideIcon; title: string; diff --git a/ui/components/invitations/forms/delete-form.tsx b/ui/components/invitations/forms/delete-form.tsx index 58618b9dc6..e34a5e30bd 100644 --- a/ui/components/invitations/forms/delete-form.tsx +++ b/ui/components/invitations/forms/delete-form.tsx @@ -7,8 +7,7 @@ import * as z from "zod"; import { revokeInvite } from "@/actions/invitations/invitation"; import { DeleteIcon } from "@/components/icons"; -import { Button } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { Button, useToast } from "@/components/shadcn"; import { Form } from "@/components/shadcn/form"; const formSchema = z.object({ diff --git a/ui/components/invitations/forms/edit-form.tsx b/ui/components/invitations/forms/edit-form.tsx index b8bfaf6f70..bbfc57b2fb 100644 --- a/ui/components/invitations/forms/edit-form.tsx +++ b/ui/components/invitations/forms/edit-form.tsx @@ -5,7 +5,7 @@ import { Controller, useForm } from "react-hook-form"; import * as z from "zod"; import { updateInvite } from "@/actions/invitations/invitation"; -import { useToast } from "@/components/shadcn"; +import { useToast, Card, CardContent } from "@/components/shadcn"; import { CustomInput } from "@/components/shadcn/custom"; import { Form, FormButtons } from "@/components/shadcn/form"; import { @@ -17,8 +17,6 @@ import { } from "@/components/shadcn/select/select"; import { editInviteFormSchema } from "@/types"; -import { Card, CardContent } from "../../shadcn"; - export const EditForm = ({ invitationId, invitationEmail, diff --git a/ui/components/invitations/workflow/forms/send-invitation-form.tsx b/ui/components/invitations/workflow/forms/send-invitation-form.tsx index 67e6938d4a..ce98cfdc97 100644 --- a/ui/components/invitations/workflow/forms/send-invitation-form.tsx +++ b/ui/components/invitations/workflow/forms/send-invitation-form.tsx @@ -7,8 +7,7 @@ import { Controller, useForm } from "react-hook-form"; import * as z from "zod"; import { sendInvite } from "@/actions/invitations/invitation"; -import { Button } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { Button, useToast } from "@/components/shadcn"; import { CustomInput } from "@/components/shadcn/custom"; import { Form } from "@/components/shadcn/form"; import { diff --git a/ui/components/invitations/workflow/vertical-steps.tsx b/ui/components/invitations/workflow/vertical-steps.tsx index 17abec63d2..79fb77678c 100644 --- a/ui/components/invitations/workflow/vertical-steps.tsx +++ b/ui/components/invitations/workflow/vertical-steps.tsx @@ -2,19 +2,18 @@ import { useControlledState } from "@react-stately/utils"; import { domAnimation, LazyMotion, m } from "framer-motion"; -import type { ComponentProps } from "react"; -import React from "react"; +import { forwardRef, useMemo } from "react"; +import type { ComponentProps, HTMLAttributes, ReactNode } from "react"; import { cn } from "@/lib/utils"; export type VerticalStepProps = { className?: string; - description?: React.ReactNode; - title?: React.ReactNode; + description?: ReactNode; + title?: ReactNode; }; -export interface VerticalStepsProps - extends React.HTMLAttributes { +export interface VerticalStepsProps extends HTMLAttributes { /** * An array of steps. * @@ -89,10 +88,7 @@ function CheckIcon(props: ComponentProps<"svg">) { ); } -export const VerticalSteps = React.forwardRef< - HTMLButtonElement, - VerticalStepsProps ->( +export const VerticalSteps = forwardRef( ( { color = "primary", @@ -113,7 +109,7 @@ export const VerticalSteps = React.forwardRef< onStepChange, ); - const colors = React.useMemo(() => { + const colors = useMemo(() => { let userColor; let fgColor; diff --git a/ui/components/lighthouse-v1/chat.tsx b/ui/components/lighthouse-v1/chat.tsx index 851762b8f5..72cf6eac28 100644 --- a/ui/components/lighthouse-v1/chat.tsx +++ b/ui/components/lighthouse-v1/chat.tsx @@ -34,8 +34,8 @@ import { CardHeader, CardTitle, Combobox, + useToast, } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; import { CustomLink } from "@/components/shadcn/custom/custom-link"; import { useMountEffect } from "@/hooks/use-mount-effect"; import type { LighthouseProvider } from "@/types/lighthouse-v1"; diff --git a/ui/components/lighthouse-v1/lighthouse-settings.tsx b/ui/components/lighthouse-v1/lighthouse-settings.tsx index 825786d4d6..14c534a278 100644 --- a/ui/components/lighthouse-v1/lighthouse-settings.tsx +++ b/ui/components/lighthouse-v1/lighthouse-settings.tsx @@ -17,8 +17,8 @@ import { CardContent, CardHeader, CardTitle, + useToast, } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; import { CustomTextarea } from "@/components/shadcn/custom"; import { Form } from "@/components/shadcn/form"; diff --git a/ui/components/manage-groups/forms/add-group-form.tsx b/ui/components/manage-groups/forms/add-group-form.tsx index 886d462e14..251b7b11d7 100644 --- a/ui/components/manage-groups/forms/add-group-form.tsx +++ b/ui/components/manage-groups/forms/add-group-form.tsx @@ -5,8 +5,7 @@ import { Controller, useForm } from "react-hook-form"; import * as z from "zod"; import { createProviderGroup } from "@/actions/manage-groups"; -import { Button, Separator } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { Button, Separator, useToast } from "@/components/shadcn"; import { CustomInput } from "@/components/shadcn/custom"; import { Form } from "@/components/shadcn/form"; import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select"; diff --git a/ui/components/manage-groups/forms/delete-group-form.tsx b/ui/components/manage-groups/forms/delete-group-form.tsx index 6a2a035875..c3f04fed94 100644 --- a/ui/components/manage-groups/forms/delete-group-form.tsx +++ b/ui/components/manage-groups/forms/delete-group-form.tsx @@ -8,8 +8,7 @@ import * as z from "zod"; import { deleteProviderGroup } from "@/actions/manage-groups/manage-groups"; import { DeleteIcon } from "@/components/icons"; -import { Button } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { Button, useToast } from "@/components/shadcn"; import { Form } from "@/components/shadcn/form"; const formSchema = z.object({ diff --git a/ui/components/manage-groups/forms/edit-group-form.tsx b/ui/components/manage-groups/forms/edit-group-form.tsx index 7db75aef2b..997c92e370 100644 --- a/ui/components/manage-groups/forms/edit-group-form.tsx +++ b/ui/components/manage-groups/forms/edit-group-form.tsx @@ -7,8 +7,7 @@ import { Controller, useForm } from "react-hook-form"; import * as z from "zod"; import { updateProviderGroup } from "@/actions/manage-groups/manage-groups"; -import { Button, Separator } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { Button, Separator, useToast } from "@/components/shadcn"; import { CustomInput } from "@/components/shadcn/custom"; import { Form } from "@/components/shadcn/form"; import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select"; diff --git a/ui/components/providers/forms/delete-form.tsx b/ui/components/providers/forms/delete-form.tsx index 1547e9f83d..45e8abde73 100644 --- a/ui/components/providers/forms/delete-form.tsx +++ b/ui/components/providers/forms/delete-form.tsx @@ -7,8 +7,7 @@ import * as z from "zod"; import { deleteProvider } from "@/actions/providers"; import { DeleteIcon } from "@/components/icons"; -import { Button } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { Button, useToast } from "@/components/shadcn"; import { Form } from "@/components/shadcn/form"; import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields"; diff --git a/ui/components/providers/forms/delete-organization-form.tsx b/ui/components/providers/forms/delete-organization-form.tsx index 9d17e4251c..b7c90570ef 100644 --- a/ui/components/providers/forms/delete-organization-form.tsx +++ b/ui/components/providers/forms/delete-organization-form.tsx @@ -7,8 +7,7 @@ import { deleteOrganizationalUnit, } from "@/actions/organizations/organizations"; import { DeleteIcon } from "@/components/icons"; -import { Button } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { Button, useToast } from "@/components/shadcn"; import { PROVIDERS_GROUP_KIND, ProvidersGroupKind, diff --git a/ui/components/providers/forms/edit-name-form.tsx b/ui/components/providers/forms/edit-name-form.tsx index b6d937baf6..511744527c 100644 --- a/ui/components/providers/forms/edit-name-form.tsx +++ b/ui/components/providers/forms/edit-name-form.tsx @@ -4,8 +4,7 @@ import type { Dispatch, FormEvent, SetStateAction } from "react"; import { useState } from "react"; import { SaveIcon } from "@/components/icons"; -import { Button } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { Button, useToast } from "@/components/shadcn"; import { Input } from "@/components/shadcn/input/input"; interface EditNameFormProps { diff --git a/ui/components/providers/organizations/hooks/use-org-account-selection-flow.ts b/ui/components/providers/organizations/hooks/use-org-account-selection-flow.ts index a5332fdfd5..ffe3b0d089 100644 --- a/ui/components/providers/organizations/hooks/use-org-account-selection-flow.ts +++ b/ui/components/providers/organizations/hooks/use-org-account-selection-flow.ts @@ -26,6 +26,7 @@ import { pollConnectionTask, runWithConcurrencyLimit, } from "../org-account-selection.utils"; + import { extractErrorMessage } from "./error-utils"; interface SelectionState { diff --git a/ui/components/providers/scan-config/manage-scan-config-modal.tsx b/ui/components/providers/scan-config/manage-scan-config-modal.tsx index d1d4d6471c..34da9cb056 100644 --- a/ui/components/providers/scan-config/manage-scan-config-modal.tsx +++ b/ui/components/providers/scan-config/manage-scan-config-modal.tsx @@ -3,8 +3,7 @@ import { useState } from "react"; import { setScanConfigurationProviders } from "@/actions/scan-configurations"; -import { Button } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { Button, useToast } from "@/components/shadcn"; import { CustomLink } from "@/components/shadcn/custom/custom-link"; import { Modal } from "@/components/shadcn/modal"; import { diff --git a/ui/components/providers/table/column-providers.tsx b/ui/components/providers/table/column-providers.tsx index 2590b5669e..49ea8c265f 100644 --- a/ui/components/providers/table/column-providers.tsx +++ b/ui/components/providers/table/column-providers.tsx @@ -31,6 +31,7 @@ import type { } from "@/types/schedules"; import { LinkToScans } from "../link-to-scans"; + import { DataTableRowActions } from "./data-table-row-actions"; interface GroupNameChipsProps { diff --git a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx index 938a311216..b12a541697 100644 --- a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx +++ b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx @@ -10,6 +10,7 @@ import { } from "@/types/provider-wizard"; import type { ProviderWizardInitialData } from "../types"; + import { useProviderWizardController } from "./use-provider-wizard-controller"; const { refreshMock, requestOpenOnWizardCloseMock } = vi.hoisted(() => ({ diff --git a/ui/components/providers/wizard/steps/credentials-step.tsx b/ui/components/providers/wizard/steps/credentials-step.tsx index 48c6418d46..371f356d30 100644 --- a/ui/components/providers/wizard/steps/credentials-step.tsx +++ b/ui/components/providers/wizard/steps/credentials-step.tsx @@ -22,6 +22,7 @@ import { import { SelectViaGitHub } from "../../workflow/forms/select-credentials-type/github"; import { SelectViaM365 } from "../../workflow/forms/select-credentials-type/m365"; import { UpdateViaServiceAccountForm } from "../../workflow/forms/update-via-service-account-key-form"; + import { WIZARD_FOOTER_ACTION_TYPE, WizardFooterConfig, diff --git a/ui/components/providers/wizard/steps/launch-step.tsx b/ui/components/providers/wizard/steps/launch-step.tsx index 1c24f2b893..0474821a71 100644 --- a/ui/components/providers/wizard/steps/launch-step.tsx +++ b/ui/components/providers/wizard/steps/launch-step.tsx @@ -11,8 +11,7 @@ import { saveScheduleWithInitialScan, } from "@/components/scans/schedule/save-schedule"; import { ScanScheduleFields } from "@/components/scans/schedule/scan-schedule-fields"; -import { Field, FieldLabel } from "@/components/shadcn"; -import { ToastAction, useToast } from "@/components/shadcn"; +import { Field, FieldLabel, ToastAction, useToast } from "@/components/shadcn"; import { Badge } from "@/components/shadcn/badge/badge"; import { EntityInfo } from "@/components/shadcn/entities"; import { diff --git a/ui/components/providers/wizard/steps/test-connection-step.tsx b/ui/components/providers/wizard/steps/test-connection-step.tsx index 0032771848..cc211c4f47 100644 --- a/ui/components/providers/wizard/steps/test-connection-step.tsx +++ b/ui/components/providers/wizard/steps/test-connection-step.tsx @@ -11,6 +11,7 @@ import { TestConnectionForm, TestConnectionProviderData, } from "../../workflow/forms/test-connection-form"; + import { WIZARD_FOOTER_ACTION_TYPE, WizardFooterConfig, diff --git a/ui/components/providers/workflow/forms/base-credentials-form.tsx b/ui/components/providers/workflow/forms/base-credentials-form.tsx index 8bedaa2514..b90c9095c4 100644 --- a/ui/components/providers/workflow/forms/base-credentials-form.tsx +++ b/ui/components/providers/workflow/forms/base-credentials-form.tsx @@ -37,6 +37,7 @@ import { } from "@/types"; import { ProviderTitleDocs } from "../provider-title-docs"; + import { AlibabaCloudRoleCredentialsForm, AlibabaCloudStaticCredentialsForm, diff --git a/ui/components/providers/workflow/forms/connect-account-form.tsx b/ui/components/providers/workflow/forms/connect-account-form.tsx index 303a5c918a..d8dd37edde 100644 --- a/ui/components/providers/workflow/forms/connect-account-form.tsx +++ b/ui/components/providers/workflow/forms/connect-account-form.tsx @@ -11,8 +11,7 @@ import { addProvider } from "@/actions/providers/providers"; import { AwsMethodSelector } from "@/components/providers/organizations/aws-method-selector"; import { WizardInputField } from "@/components/providers/workflow/forms/fields"; import { ProviderTitleDocs } from "@/components/providers/workflow/provider-title-docs"; -import { Button } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { Button, useToast } from "@/components/shadcn"; import { Form } from "@/components/shadcn/form"; import { addProviderFormSchema, diff --git a/ui/components/providers/workflow/forms/test-connection-form.tsx b/ui/components/providers/workflow/forms/test-connection-form.tsx index 503c74e1b0..687b036cce 100644 --- a/ui/components/providers/workflow/forms/test-connection-form.tsx +++ b/ui/components/providers/workflow/forms/test-connection-form.tsx @@ -14,8 +14,7 @@ import { CheckIcon } from "@/components/icons"; import { Button } from "@/components/shadcn"; import { Form } from "@/components/shadcn/form"; import { testProviderConnection } from "@/lib/provider-helpers"; -import { ProviderType } from "@/types"; -import { testConnectionFormSchema } from "@/types"; +import { ProviderType, testConnectionFormSchema } from "@/types"; import { ProviderConnectionInfo } from "./provider-connection-info"; diff --git a/ui/components/providers/workflow/forms/via-credentials/k8s-credentials-form.tsx b/ui/components/providers/workflow/forms/via-credentials/k8s-credentials-form.tsx index a0f687e037..c442b3fbab 100644 --- a/ui/components/providers/workflow/forms/via-credentials/k8s-credentials-form.tsx +++ b/ui/components/providers/workflow/forms/via-credentials/k8s-credentials-form.tsx @@ -1,7 +1,6 @@ "use client"; -import { Control } from "react-hook-form"; -import { useWatch } from "react-hook-form"; +import { Control, useWatch } from "react-hook-form"; import { WizardTextareaField } from "@/components/providers/workflow/forms/fields"; import { KubernetesCredentials } from "@/types"; diff --git a/ui/components/providers/workflow/provider-title-docs.tsx b/ui/components/providers/workflow/provider-title-docs.tsx index 3f06dd5f88..3556d96bc8 100644 --- a/ui/components/providers/workflow/provider-title-docs.tsx +++ b/ui/components/providers/workflow/provider-title-docs.tsx @@ -1,7 +1,9 @@ "use client"; -import { getProviderName } from "@/components/shadcn/entities/get-provider-logo"; -import { getProviderLogo } from "@/components/shadcn/entities/get-provider-logo"; +import { + getProviderName, + getProviderLogo, +} from "@/components/shadcn/entities/get-provider-logo"; import { ProviderType } from "@/types"; export const ProviderTitleDocs = ({ diff --git a/ui/components/resources/table/resource-detail-content.tsx b/ui/components/resources/table/resource-detail-content.tsx index fba3949bc8..3bb7566d11 100644 --- a/ui/components/resources/table/resource-detail-content.tsx +++ b/ui/components/resources/table/resource-detail-content.tsx @@ -18,8 +18,6 @@ import { Tooltip, TooltipContent, TooltipTrigger, -} from "@/components/shadcn"; -import { BreadcrumbNavigation, CustomBreadcrumbItem, } from "@/components/shadcn"; diff --git a/ui/components/roles/workflow/forms/delete-role-form.tsx b/ui/components/roles/workflow/forms/delete-role-form.tsx index 0c4aa127f1..d6b9cabdff 100644 --- a/ui/components/roles/workflow/forms/delete-role-form.tsx +++ b/ui/components/roles/workflow/forms/delete-role-form.tsx @@ -7,8 +7,7 @@ import * as z from "zod"; import { deleteRole } from "@/actions/roles"; import { DeleteIcon } from "@/components/icons"; -import { Button } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { Button, useToast } from "@/components/shadcn"; import { Form } from "@/components/shadcn/form"; const formSchema = z.object({ diff --git a/ui/components/roles/workflow/vertical-steps.tsx b/ui/components/roles/workflow/vertical-steps.tsx index 17abec63d2..79fb77678c 100644 --- a/ui/components/roles/workflow/vertical-steps.tsx +++ b/ui/components/roles/workflow/vertical-steps.tsx @@ -2,19 +2,18 @@ import { useControlledState } from "@react-stately/utils"; import { domAnimation, LazyMotion, m } from "framer-motion"; -import type { ComponentProps } from "react"; -import React from "react"; +import { forwardRef, useMemo } from "react"; +import type { ComponentProps, HTMLAttributes, ReactNode } from "react"; import { cn } from "@/lib/utils"; export type VerticalStepProps = { className?: string; - description?: React.ReactNode; - title?: React.ReactNode; + description?: ReactNode; + title?: ReactNode; }; -export interface VerticalStepsProps - extends React.HTMLAttributes { +export interface VerticalStepsProps extends HTMLAttributes { /** * An array of steps. * @@ -89,10 +88,7 @@ function CheckIcon(props: ComponentProps<"svg">) { ); } -export const VerticalSteps = React.forwardRef< - HTMLButtonElement, - VerticalStepsProps ->( +export const VerticalSteps = forwardRef( ( { color = "primary", @@ -113,7 +109,7 @@ export const VerticalSteps = React.forwardRef< onStepChange, ); - const colors = React.useMemo(() => { + const colors = useMemo(() => { let userColor; let fgColor; diff --git a/ui/components/scans/table/scan-jobs-columns.test.tsx b/ui/components/scans/table/scan-jobs-columns.test.tsx index 3a30d74800..496fd11e14 100644 --- a/ui/components/scans/table/scan-jobs-columns.test.tsx +++ b/ui/components/scans/table/scan-jobs-columns.test.tsx @@ -3,7 +3,11 @@ import { render, screen } from "@testing-library/react"; import type { ReactNode } from "react"; import { describe, expect, it, vi } from "vitest"; -import type { ScanProps } from "@/types"; +import { type ScanProps, SCAN_JOBS_TAB, type ScanJobsTab } from "@/types"; +import { + SCAN_SCHEDULE_CAPABILITY, + type ScanScheduleCapability, +} from "@/types/schedules"; vi.mock("@/components/shadcn", async (importOriginal) => ({ ...(await importOriginal>()), @@ -65,12 +69,6 @@ vi.mock("./scan-jobs-row-actions", () => ({ ), })); -import { SCAN_JOBS_TAB, type ScanJobsTab } from "@/types"; -import { - SCAN_SCHEDULE_CAPABILITY, - type ScanScheduleCapability, -} from "@/types/schedules"; - import { getScanJobsColumns } from "./scan-jobs-columns"; const getColumnIds = (tab: ScanJobsTab) => diff --git a/ui/components/scans/table/scan-jobs-columns.tsx b/ui/components/scans/table/scan-jobs-columns.tsx index 553310ec0d..dc040368fa 100644 --- a/ui/components/scans/table/scan-jobs-columns.tsx +++ b/ui/components/scans/table/scan-jobs-columns.tsx @@ -10,6 +10,7 @@ import { SCAN_JOBS_TAB, type ScanJobsTab, type ScanProps } from "@/types"; import type { ScanScheduleCapability } from "@/types/schedules"; import { formatScanDuration } from "../scans.utils"; + import { AccountCell, ProgressCell, diff --git a/ui/components/scans/table/scan-jobs-table.tsx b/ui/components/scans/table/scan-jobs-table.tsx index 35c53b8e3e..5e0792b891 100644 --- a/ui/components/scans/table/scan-jobs-table.tsx +++ b/ui/components/scans/table/scan-jobs-table.tsx @@ -7,6 +7,7 @@ import type { ScanScheduleCapability } from "@/types/schedules"; import { AutoRefresh } from "../auto-refresh"; import { NoScansEmptyState } from "../no-scans-empty-state"; + import { getScanJobsColumns } from "./scan-jobs-columns"; interface ScanJobsTableProps { diff --git a/ui/components/users/forms/delete-form.tsx b/ui/components/users/forms/delete-form.tsx index 9a86efed8e..ad4c3baf75 100644 --- a/ui/components/users/forms/delete-form.tsx +++ b/ui/components/users/forms/delete-form.tsx @@ -7,8 +7,7 @@ import * as z from "zod"; import { deleteUser } from "@/actions/users/users"; import { DeleteIcon } from "@/components/icons"; -import { Button } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { Button, useToast } from "@/components/shadcn"; import { Form } from "@/components/shadcn/form"; const formSchema = z.object({ diff --git a/ui/components/users/forms/edit-form.tsx b/ui/components/users/forms/edit-form.tsx index 362ec3088e..5bcfe51545 100644 --- a/ui/components/users/forms/edit-form.tsx +++ b/ui/components/users/forms/edit-form.tsx @@ -7,8 +7,7 @@ import { Controller, useForm } from "react-hook-form"; import * as z from "zod"; import { updateUser, updateUserRole } from "@/actions/users/users"; -import { Card } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { Card, useToast } from "@/components/shadcn"; import { CustomInput } from "@/components/shadcn/custom"; import { Form, FormButtons } from "@/components/shadcn/form"; import { diff --git a/ui/components/users/forms/expel-user-form.tsx b/ui/components/users/forms/expel-user-form.tsx index d333b1ab49..a9a98b5a15 100644 --- a/ui/components/users/forms/expel-user-form.tsx +++ b/ui/components/users/forms/expel-user-form.tsx @@ -4,8 +4,7 @@ import { Dispatch, SetStateAction, useTransition } from "react"; import { removeUserFromTenant } from "@/actions/users/users"; import { DeleteIcon } from "@/components/icons"; -import { Button } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { Button, useToast } from "@/components/shadcn"; interface ExpelUserFormProps { userId: string; diff --git a/ui/dependency-log.json b/ui/dependency-log.json index acb4622141..16b7f627fe 100644 --- a/ui/dependency-log.json +++ b/ui/dependency-log.json @@ -759,22 +759,6 @@ "strategy": "installed", "generatedAt": "2025-10-30T10:22:21.335Z" }, - { - "section": "devDependencies", - "name": "@typescript-eslint/eslint-plugin", - "from": "7.18.0", - "to": "8.53.0", - "strategy": "installed", - "generatedAt": "2026-01-19T13:54:24.770Z" - }, - { - "section": "devDependencies", - "name": "@typescript-eslint/parser", - "from": "7.18.0", - "to": "8.53.0", - "strategy": "installed", - "generatedAt": "2026-01-19T13:54:24.770Z" - }, { "section": "devDependencies", "name": "@vitejs/plugin-react", @@ -849,17 +833,25 @@ }, { "section": "devDependencies", - "name": "eslint-plugin-jsx-a11y", - "from": "6.10.2", - "to": "6.10.2", + "name": "eslint-import-resolver-typescript", + "from": "4.4.4", + "to": "4.4.4", "strategy": "installed", - "generatedAt": "2025-10-22T12:36:37.962Z" + "generatedAt": "2026-05-13T15:04:07.559Z" }, { "section": "devDependencies", - "name": "eslint-plugin-prettier", - "from": "5.5.1", - "to": "5.5.1", + "name": "eslint-plugin-import-x", + "from": "4.16.2", + "to": "4.16.2", + "strategy": "installed", + "generatedAt": "2026-05-13T15:02:04.867Z" + }, + { + "section": "devDependencies", + "name": "eslint-plugin-jsx-a11y", + "from": "6.10.2", + "to": "6.10.2", "strategy": "installed", "generatedAt": "2025-10-22T12:36:37.962Z" }, @@ -887,22 +879,6 @@ "strategy": "installed", "generatedAt": "2025-10-22T12:36:37.962Z" }, - { - "section": "devDependencies", - "name": "eslint-plugin-simple-import-sort", - "from": "12.1.1", - "to": "12.1.1", - "strategy": "installed", - "generatedAt": "2025-10-22T12:36:37.962Z" - }, - { - "section": "devDependencies", - "name": "eslint-plugin-unused-imports", - "from": "3.2.0", - "to": "4.3.0", - "strategy": "installed", - "generatedAt": "2026-01-19T13:54:24.770Z" - }, { "section": "devDependencies", "name": "globals", @@ -911,6 +887,14 @@ "strategy": "installed", "generatedAt": "2026-01-19T13:54:24.770Z" }, + { + "section": "devDependencies", + "name": "jiti", + "from": "2.7.0", + "to": "2.7.0", + "strategy": "installed", + "generatedAt": "2026-05-13T15:02:04.867Z" + }, { "section": "devDependencies", "name": "jsdom", @@ -983,6 +967,14 @@ "strategy": "installed", "generatedAt": "2025-10-22T12:36:37.962Z" }, + { + "section": "devDependencies", + "name": "typescript-eslint", + "from": "8.59.3", + "to": "8.59.3", + "strategy": "installed", + "generatedAt": "2026-05-13T15:02:04.867Z" + }, { "section": "devDependencies", "name": "vitest", diff --git a/ui/eslint.config.mjs b/ui/eslint.config.ts similarity index 53% rename from ui/eslint.config.mjs rename to ui/eslint.config.ts index b62359296f..dc0658649f 100644 --- a/ui/eslint.config.mjs +++ b/ui/eslint.config.ts @@ -1,22 +1,15 @@ -import { dirname } from "path"; -import { fileURLToPath } from "url"; -import tsPlugin from "@typescript-eslint/eslint-plugin"; -import tsParser from "@typescript-eslint/parser"; -import prettierPlugin from "eslint-plugin-prettier"; -import simpleImportSort from "eslint-plugin-simple-import-sort"; -import jsxA11y from "eslint-plugin-jsx-a11y"; -import security from "eslint-plugin-security"; -import unusedImports from "eslint-plugin-unused-imports"; import nextPlugin from "@next/eslint-plugin-next"; +import prettierConfig from "eslint-config-prettier/flat"; +import { createTypeScriptImportResolver } from "eslint-import-resolver-typescript"; +import importX, { createNodeResolver } from "eslint-plugin-import-x"; +import jsxA11y from "eslint-plugin-jsx-a11y"; import reactPlugin from "eslint-plugin-react"; import reactHooksPlugin from "eslint-plugin-react-hooks"; +import security from "eslint-plugin-security"; import globals from "globals"; +import tseslint from "typescript-eslint"; -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -export default [ - // Global ignores (replaces .eslintignore) +export default tseslint.config( { ignores: [ ".now/**", @@ -29,6 +22,11 @@ export default [ "scripts/**", "*.config.js", "*.config.mjs", + "auth.config.ts", + "eslint.config.ts", + "knip.config.ts", + "playwright.config.ts", + "vitest.config.ts", ".DS_Store", "node_modules/**", "coverage/**", @@ -37,27 +35,33 @@ export default [ "next-env.d.ts", ], }, - - // TypeScript and React files configuration + importX.flatConfigs.recommended, + importX.flatConfigs.typescript, { files: ["**/*.{ts,tsx,js,jsx}"], linterOptions: { reportUnusedDisableDirectives: "error", }, plugins: { - "@typescript-eslint": tsPlugin, + "@typescript-eslint": tseslint.plugin, "@next/next": nextPlugin, react: reactPlugin, "react-hooks": reactHooksPlugin, - prettier: prettierPlugin, - "simple-import-sort": simpleImportSort, "jsx-a11y": jsxA11y, - security: security, - "unused-imports": unusedImports, + security, }, languageOptions: { - parser: tsParser, + parser: tseslint.parser, parserOptions: { + projectService: { + allowDefaultProject: [ + // Duplicate of events-timeline.test.ts in the same folder; + // TypeScript only picks the .ts sibling, so this .tsx file is + // outside the project graph. Tracked for follow-up cleanup. + "components/shared/events-timeline/events-timeline.test.tsx", + ], + }, + tsconfigRootDir: import.meta.dirname, ecmaVersion: "latest", sourceType: "module", ecmaFeatures: { @@ -75,46 +79,54 @@ export default [ react: { version: "detect", }, + "import-x/resolver-next": [ + createTypeScriptImportResolver({ + alwaysTryTypes: true, + project: "./tsconfig.json", + }), + createNodeResolver(), + ], }, rules: { - // Console rules - allow console.error but no console.log "no-console": ["error", { allow: ["error"] }], - eqeqeq: 2, - quotes: ["error", "double", "avoid-escape"], + eqeqeq: "error", + quotes: ["error", "double", { avoidEscape: true }], - // TypeScript rules "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-unused-vars": [ "error", { + enableAutofixRemoval: { imports: true }, argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_", }, ], - // Security "security/detect-object-injection": "off", - // Prettier integration - "prettier/prettier": [ - "error", - { - endOfLine: "auto", - tabWidth: 2, - useTabs: false, - }, - ], "eol-last": ["error", "always"], - // Import sorting - "simple-import-sort/imports": "error", - "simple-import-sort/exports": "error", + "import-x/order": [ + "error", + { + groups: [ + "builtin", + "external", + "internal", + "parent", + "sibling", + "index", + ], + "newlines-between": "always", + alphabetize: { order: "asc", caseInsensitive: true }, + }, + ], + // Pre-existing duplicate exports and re-export shape mismatches are + // tracked separately; the migration keeps behavior parity with the + // legacy config until the rule is enforced in the canonical Base layer. + "import-x/export": "off", - // Unused imports - "unused-imports/no-unused-imports": "error", - - // Accessibility "jsx-a11y/anchor-is-valid": [ "error", { @@ -125,14 +137,13 @@ export default [ ], "jsx-a11y/alt-text": "error", - // React Hooks "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "warn", - // Next.js specific rules "@next/next/no-html-link-for-pages": "error", "@next/next/no-img-element": "warn", "@next/next/no-sync-scripts": "error", }, }, -]; + prettierConfig, +); diff --git a/ui/instrumentation-client.ts b/ui/instrumentation-client.ts index 910af90010..34dcde2f64 100644 --- a/ui/instrumentation-client.ts +++ b/ui/instrumentation-client.ts @@ -21,6 +21,10 @@ import { startProgress, } from "@/components/shadcn/navigation-progress/use-navigation-progress"; import { getRuntimeConfigClient } from "@/lib/get-runtime-config.client"; +import { + applySentryEventPolicy, + SENTRY_EVENT_SOURCE, +} from "@/sentry/event-policy"; export const NAVIGATION_TYPE = { PUSH: "push", @@ -96,25 +100,9 @@ if (typeof window !== "undefined" && sentryDsn) { ], beforeSend(event, hint) { - // Filter out noise: ResizeObserver errors (common browser quirk, not real bugs) - if (event.message?.includes("ResizeObserver")) { - return null; // Don't send to Sentry - } - - // Filter out non-actionable errors if (event.exception) { const error = hint.originalException; - // Don't send cancelled requests - if ( - error && - typeof error === "object" && - "name" in error && - error.name === "AbortError" - ) { - return null; - } - // Add additional context for API errors if ( error && @@ -130,7 +118,9 @@ if (typeof window !== "undefined" && sentryDsn) { } } - return event; // Send to Sentry + return applySentryEventPolicy(event, hint, { + source: SENTRY_EVENT_SOURCE.CLIENT, + }); }, }); diff --git a/ui/lib/server-actions-helper.test.ts b/ui/lib/server-actions-helper.test.ts index bfb9fb5ced..a3b1c96154 100644 --- a/ui/lib/server-actions-helper.test.ts +++ b/ui/lib/server-actions-helper.test.ts @@ -1,22 +1,23 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as Sentry from "@sentry/nextjs"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const { captureExceptionMock, captureMessageMock, revalidatePathMock } = - vi.hoisted(() => ({ - captureExceptionMock: vi.fn(), - captureMessageMock: vi.fn(), - revalidatePathMock: vi.fn(), - })); +import { + isErrorAlreadyReported, + markErrorAsReported, +} from "@/sentry/event-policy"; + +import { handleApiError, handleApiResponse } from "./server-actions-helper"; vi.mock("@sentry/nextjs", () => ({ - captureException: captureExceptionMock, - captureMessage: captureMessageMock, + captureException: vi.fn(), + captureMessage: vi.fn(), })); vi.mock("next/cache", () => ({ - revalidatePath: revalidatePathMock, + revalidatePath: vi.fn(), })); -vi.mock("./helper", () => ({ +vi.mock("@/lib/helper", () => ({ GENERIC_SERVER_ERROR_MESSAGE: "Server is temporarily unavailable. Please try again in a few minutes.", getErrorMessage: (error: unknown) => @@ -26,29 +27,230 @@ vi.mock("./helper", () => ({ / { +describe("server-actions-helper", () => { beforeEach(() => { vi.clearAllMocks(); - vi.spyOn(console, "error").mockImplementation(() => {}); }); - it("throws a generic server error instead of raw HTML for 5xx responses", async () => { - // Given - const response = new Response( - "502 Bad Gateway

502 Bad Gateway

", - { - status: 502, - statusText: "Bad Gateway", - headers: { "content-type": "text/html" }, + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("handleApiResponse", () => { + it("should throw a generic server error instead of raw HTML for 5xx responses", async () => { + // Given + const response = new Response( + "502 Bad Gateway

502 Bad Gateway

", + { + status: 502, + statusText: "Bad Gateway", + headers: { "content-type": "text/html" }, + }, + ); + + // When / Then + await expect(handleApiResponse(response)).rejects.toThrow( + "Server is temporarily unavailable. Please try again in a few minutes.", + ); + }); + + it("should not capture controlled 4xx API validation responses", async () => { + // Given + const response = new Response( + JSON.stringify({ errors: [{ detail: "Provider already exists" }] }), + { status: 409, statusText: "Conflict" }, + ); + + // When + const result = await handleApiResponse(response); + + // Then + expect(Sentry.captureException).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + error: "Provider already exists", + status: 409, + }); + }); + + it.each([418, 429])( + "should capture unexpected %s API client failures before returning them", + async (status) => { + // Given + const response = new Response( + JSON.stringify({ message: "Unexpected API contract failure" }), + { status, statusText: "Unexpected Client Failure" }, + ); + + // When + const result = await handleApiResponse(response); + + // Then + expect(Sentry.captureException).toHaveBeenCalledTimes(1); + expect(Sentry.captureException).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + tags: expect.objectContaining({ + api_error: true, + error_source: "handleApiResponse", + error_type: "client_error", + status_code: status.toString(), + }), + }), + ); + expect(result).toMatchObject({ + error: "Unexpected API contract failure", + status, + }); }, ); - // When / Then - const result = handleApiResponse(response); - await expect(result).rejects.toThrow( - "Server is temporarily unavailable. Please try again in a few minutes.", - ); + it("should not mark server errors before Sentry processes the event", async () => { + // Given + const response = new Response( + JSON.stringify({ message: "backend down" }), + { + status: 500, + statusText: "Internal Server Error", + }, + ); + + // When + await expect(handleApiResponse(response)).rejects.toThrow("backend down"); + + // Then + expect(Sentry.captureException).toHaveBeenCalledTimes(1); + const capturedError = vi.mocked(Sentry.captureException).mock + .calls[0]?.[0]; + expect(isErrorAlreadyReported(capturedError)).toBe(false); + }); + + it("should fingerprint server errors by a normalized pathname", async () => { + // Given + const response = new Response( + JSON.stringify({ message: "backend down" }), + { + status: 500, + statusText: "Internal Server Error", + }, + ); + Object.defineProperty(response, "url", { + value: + "https://api.prowler.test/api/v1/providers/550e8400-e29b-41d4-a716-446655440000?tenant=123", + }); + + // When + await expect(handleApiResponse(response)).rejects.toThrow("backend down"); + + // Then + expect(Sentry.captureException).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + fingerprint: ["api-server-error", "500", "/api/v1/providers/:id"], + }), + ); + }); + + it("should fingerprint unexpected client failures by a normalized pathname", async () => { + // Given + const response = new Response( + JSON.stringify({ message: "Unexpected API contract failure" }), + { status: 429, statusText: "Too Many Requests" }, + ); + Object.defineProperty(response, "url", { + value: "https://api.prowler.test/api/v1/scans/12345?page=2", + }); + + // When + await handleApiResponse(response); + + // Then + expect(Sentry.captureException).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + fingerprint: [ + "api-client-contract-error", + "429", + "/api/v1/scans/:id", + ], + }), + ); + }); + }); + + describe("handleApiError", () => { + it("should not recapture errors that were already reported", () => { + // Given + const error = new Error("Already reported failure"); + vi.spyOn(console, "error").mockImplementation(() => undefined); + markErrorAsReported(error); + + // When + const result = handleApiError(error); + + // Then + expect(Sentry.captureException).not.toHaveBeenCalled(); + expect(result).toEqual({ error: "Already reported failure" }); + }); + + it("should not recapture errors already marked by Sentry", () => { + // Given + const error = new Error("Already captured failure"); + Object.defineProperty(error, "__sentry_captured__", { value: true }); + vi.spyOn(console, "error").mockImplementation(() => undefined); + + // When + const result = handleApiError(error); + + // Then + expect(Sentry.captureException).not.toHaveBeenCalled(); + expect(result).toEqual({ error: "Already captured failure" }); + }); + + it("should capture unmarked request failure errors", () => { + // Given + const error = new Error("Request failed unexpectedly"); + vi.spyOn(console, "error").mockImplementation(() => undefined); + + // When + const result = handleApiError(error); + + // Then + expect(Sentry.captureException).toHaveBeenCalledTimes(1); + expect(Sentry.captureException).toHaveBeenCalledWith( + error, + expect.objectContaining({ + tags: expect.objectContaining({ + error_source: "handleApiError", + error_type: "unexpected_error", + }), + }), + ); + expect(isErrorAlreadyReported(error)).toBe(false); + expect(result).toEqual({ error: "Request failed unexpectedly" }); + }); + + it("should capture unmarked runtime errors that include expected HTTP status numbers", () => { + // Given + const error = new Error("Runtime worker 401 crashed unexpectedly"); + vi.spyOn(console, "error").mockImplementation(() => undefined); + + // When + const result = handleApiError(error); + + // Then + expect(Sentry.captureException).toHaveBeenCalledTimes(1); + expect(Sentry.captureException).toHaveBeenCalledWith( + error, + expect.objectContaining({ + tags: expect.objectContaining({ + error_source: "handleApiError", + error_type: "unexpected_error", + }), + }), + ); + expect(result).toEqual({ + error: "Runtime worker 401 crashed unexpectedly", + }); + }); }); }); diff --git a/ui/lib/server-actions-helper.ts b/ui/lib/server-actions-helper.ts index ae677f2ed2..dd9d5c291e 100644 --- a/ui/lib/server-actions-helper.ts +++ b/ui/lib/server-actions-helper.ts @@ -2,6 +2,10 @@ import * as Sentry from "@sentry/nextjs"; import { revalidatePath } from "next/cache"; import { SentryErrorSource, SentryErrorType } from "@/sentry"; +import { + isErrorAlreadyReported, + isErrorCapturedBySentry, +} from "@/sentry/event-policy"; import { GENERIC_SERVER_ERROR_MESSAGE, @@ -10,6 +14,14 @@ import { sanitizeErrorMessage, } from "./helper"; +const EXPECTED_HTTP_CLIENT_STATUS_CODES = new Set([401, 403, 404]); +const CONTROLLED_CLIENT_STATUS_CODES = new Set([400, 409, 422]); +const KNOWN_NON_ACTIONABLE_CLIENT_ERROR_MESSAGES = [ + "already exists", + "duplicate", +] as const; +const UNKNOWN_URL_PATH_FINGERPRINT = "unknown-url-path"; + /** * Helper function to handle API responses consistently * Includes Sentry error tracking for debugging @@ -78,37 +90,17 @@ export const handleApiResponse = async ( fingerprint: [ "api-server-error", response.status.toString(), - response.url, + getSentryFingerprintUrlPath(response.url), ], }); throw serverError; } - // Client errors (4xx) - Only capture unexpected ones - if (![401, 403, 404].includes(response.status)) { - const clientError = new Error( - errorDetail || - `Request failed (${response.status}): ${response.statusText}`, - ); - - Sentry.captureException(clientError, { - tags: { - api_error: true, - status_code: response.status.toString(), - error_type: SentryErrorType.CLIENT_ERROR, - error_source: SentryErrorSource.HANDLE_API_RESPONSE, - }, - level: "warning", - contexts: { - api_response: errorContext, - }, - fingerprint: [ - "api-client-error", - response.status.toString(), - response.url, - ], - }); + if ( + !shouldSuppressApiClientFailure(response.status, errorDetail, errorsArray) + ) { + captureUnexpectedApiClientFailure(response, errorDetail, errorContext); } return errorsArray @@ -159,33 +151,26 @@ export const handleApiError = (error: unknown): { error: string } => { // Check if this error was already captured by handleApiResponse const isAlreadyCaptured = - error instanceof Error && - (error.message.includes("Server error") || - error.message.includes("Request failed")); + isErrorAlreadyReported(error) || isErrorCapturedBySentry(error); - // Only capture if not already captured by handleApiResponse + // Only capture if not already captured by handleApiResponse. + // HTTP status-based suppression belongs in the structured Sentry event policy, + // not in string-only Error message matching. if (!isAlreadyCaptured) { if (error instanceof Error) { - // Don't capture expected errors - if ( - !error.message.includes("401") && - !error.message.includes("403") && - !error.message.includes("404") - ) { - Sentry.captureException(error, { - tags: { - error_source: SentryErrorSource.HANDLE_API_ERROR, - error_type: SentryErrorType.UNEXPECTED_ERROR, + Sentry.captureException(error, { + tags: { + error_source: SentryErrorSource.HANDLE_API_ERROR, + error_type: SentryErrorType.UNEXPECTED_ERROR, + }, + level: "error", + contexts: { + error_details: { + message: error.message, + stack: error.stack, }, - level: "error", - contexts: { - error_details: { - message: error.message, - stack: error.stack, - }, - }, - }); - } + }, + }); } else { // Capture non-Error objects Sentry.captureMessage( @@ -208,3 +193,90 @@ export const handleApiError = (error: unknown): { error: string } => { error: getErrorMessage(error), }; }; + +function shouldSuppressApiClientFailure( + status: number, + errorDetail: unknown, + errorsArray: unknown[] | undefined, +) { + if (status < 400 || status >= 500) { + return true; + } + + if (EXPECTED_HTTP_CLIENT_STATUS_CODES.has(status)) { + return true; + } + + return ( + CONTROLLED_CLIENT_STATUS_CODES.has(status) && + (hasStructuredApiErrors(errorsArray) || + hasKnownNonActionableClientErrorMessage(errorDetail)) + ); +} + +function captureUnexpectedApiClientFailure( + response: Response, + errorDetail: unknown, + errorContext: Record, +) { + const clientError = new Error( + typeof errorDetail === "string" + ? errorDetail + : `Unexpected API client failure (${response.status})`, + ); + + Sentry.captureException(clientError, { + tags: { + api_error: true, + status_code: response.status.toString(), + error_type: SentryErrorType.CLIENT_ERROR, + error_source: SentryErrorSource.HANDLE_API_RESPONSE, + }, + level: "error", + contexts: { + api_response: errorContext, + }, + fingerprint: [ + "api-client-contract-error", + response.status.toString(), + getSentryFingerprintUrlPath(response.url), + ], + }); +} + +function getSentryFingerprintUrlPath(url: string) { + if (url.trim() === "") { + return UNKNOWN_URL_PATH_FINGERPRINT; + } + + try { + const pathname = new URL(url).pathname; + + return ( + pathname + .replace( + /\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?=\/|$)/gi, + "/:id", + ) + .replace(/\/\d+(?=\/|$)/g, "/:id") || UNKNOWN_URL_PATH_FINGERPRINT + ); + } catch { + return UNKNOWN_URL_PATH_FINGERPRINT; + } +} + +function hasStructuredApiErrors(errorsArray: unknown[] | undefined) { + return Array.isArray(errorsArray) && errorsArray.length > 0; +} + +function hasKnownNonActionableClientErrorMessage(errorDetail: unknown) { + if (typeof errorDetail !== "string") { + return false; + } + + const normalizedErrorDetail = errorDetail.toLowerCase(); + + return KNOWN_NON_ACTIONABLE_CLIENT_ERROR_MESSAGES.some((message) => + normalizedErrorDetail.includes(message), + ); +} diff --git a/ui/lib/tours/store/local-storage-adapter.test.ts b/ui/lib/tours/store/local-storage-adapter.test.ts index a071b4c169..d58ef9a0f1 100644 --- a/ui/lib/tours/store/local-storage-adapter.test.ts +++ b/ui/lib/tours/store/local-storage-adapter.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { TOUR_COMPLETION_STATES } from "../tour-types"; + import { buildStorageKey, localStorageAdapter } from "./local-storage-adapter"; const TOUR_ID = { id: "attack-paths", version: 1 }; diff --git a/ui/lib/tours/store/local-storage-adapter.ts b/ui/lib/tours/store/local-storage-adapter.ts index 9ace12fd69..80154685a8 100644 --- a/ui/lib/tours/store/local-storage-adapter.ts +++ b/ui/lib/tours/store/local-storage-adapter.ts @@ -1,4 +1,5 @@ import type { TourCompletionRecord, TourId } from "../tour-types"; + import type { TourCompletionStore } from "./tour-completion-store"; // All records share ONE localStorage key, keyed by `.v`. diff --git a/ui/package.json b/ui/package.json index d6fcf89c72..1a042dd39d 100644 --- a/ui/package.json +++ b/ui/package.json @@ -132,8 +132,6 @@ "@types/react-dom": "19.2.3", "@types/topojson-client": "3.1.5", "@types/topojson-specification": "1.0.5", - "@typescript-eslint/eslint-plugin": "8.53.0", - "@typescript-eslint/parser": "8.53.0", "@vitejs/plugin-react": "5.1.2", "@vitest/browser": "4.1.8", "@vitest/browser-playwright": "4.1.8", @@ -143,14 +141,14 @@ "dotenv-expand": "12.0.3", "eslint": "9.39.2", "eslint-config-prettier": "10.1.5", + "eslint-import-resolver-typescript": "4.4.4", + "eslint-plugin-import-x": "4.16.2", "eslint-plugin-jsx-a11y": "6.10.2", - "eslint-plugin-prettier": "5.5.1", "eslint-plugin-react": "7.37.5", "eslint-plugin-react-hooks": "7.0.1", "eslint-plugin-security": "3.0.1", - "eslint-plugin-simple-import-sort": "12.1.1", - "eslint-plugin-unused-imports": "4.3.0", "globals": "17.0.0", + "jiti": "2.7.0", "jsdom": "27.4.0", "knip": "6.3.1", "msw": "2.13.4", @@ -160,6 +158,7 @@ "prettier-plugin-tailwindcss": "0.6.14", "tailwindcss": "4.1.18", "typescript": "5.5.4", + "typescript-eslint": "8.59.3", "vitest": "4.1.8", "vitest-browser-react": "2.0.4" }, diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index f15c1cc800..bee8abde4c 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -328,21 +328,15 @@ importers: '@types/topojson-specification': specifier: 1.0.5 version: 1.0.5 - '@typescript-eslint/eslint-plugin': - specifier: 8.53.0 - version: 8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.5.4))(eslint@9.39.2(jiti@2.6.1))(typescript@5.5.4) - '@typescript-eslint/parser': - specifier: 8.53.0 - version: 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.5.4) '@vitejs/plugin-react': specifier: 5.1.2 - version: 5.1.2(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0)) + version: 5.1.2(vite@7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0)) '@vitest/browser': specifier: 4.1.8 - version: 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))(vitest@4.1.8) + version: 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))(vitest@4.1.8) '@vitest/browser-playwright': specifier: 4.1.8 - version: 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))(vitest@4.1.8) + version: 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))(vitest@4.1.8) '@vitest/coverage-v8': specifier: 4.1.8 version: 4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8) @@ -357,40 +351,40 @@ importers: version: 12.0.3 eslint: specifier: 9.39.2 - version: 9.39.2(jiti@2.6.1) + version: 9.39.2(jiti@2.7.0) eslint-config-prettier: specifier: 10.1.5 - version: 10.1.5(eslint@9.39.2(jiti@2.6.1)) + version: 10.1.5(eslint@9.39.2(jiti@2.7.0)) + eslint-import-resolver-typescript: + specifier: 4.4.4 + version: 4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.59.3(eslint@9.39.2(jiti@2.7.0))(typescript@5.5.4))(eslint@9.39.2(jiti@2.7.0)))(eslint@9.39.2(jiti@2.7.0)) + eslint-plugin-import-x: + specifier: 4.16.2 + version: 4.16.2(@typescript-eslint/utils@8.59.3(eslint@9.39.2(jiti@2.7.0))(typescript@5.5.4))(eslint@9.39.2(jiti@2.7.0)) eslint-plugin-jsx-a11y: specifier: 6.10.2 - version: 6.10.2(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-prettier: - specifier: 5.5.1 - version: 5.5.1(@types/eslint@9.6.1)(eslint-config-prettier@10.1.5(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.6.2) + version: 6.10.2(eslint@9.39.2(jiti@2.7.0)) eslint-plugin-react: specifier: 7.37.5 - version: 7.37.5(eslint@9.39.2(jiti@2.6.1)) + version: 7.37.5(eslint@9.39.2(jiti@2.7.0)) eslint-plugin-react-hooks: specifier: 7.0.1 - version: 7.0.1(eslint@9.39.2(jiti@2.6.1)) + version: 7.0.1(eslint@9.39.2(jiti@2.7.0)) eslint-plugin-security: specifier: 3.0.1 version: 3.0.1 - eslint-plugin-simple-import-sort: - specifier: 12.1.1 - version: 12.1.1(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-unused-imports: - specifier: 4.3.0 - version: 4.3.0(@typescript-eslint/eslint-plugin@8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.5.4))(eslint@9.39.2(jiti@2.6.1))(typescript@5.5.4))(eslint@9.39.2(jiti@2.6.1)) globals: specifier: 17.0.0 version: 17.0.0 + jiti: + specifier: 2.7.0 + version: 2.7.0 jsdom: specifier: 27.4.0 version: 27.4.0 knip: specifier: 6.3.1 - version: 6.3.1(@emnapi/core@1.8.1)(@emnapi/runtime@1.10.0) + version: 6.3.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) msw: specifier: 2.13.4 version: 2.13.4(@types/node@24.10.8)(typescript@5.5.4) @@ -412,9 +406,12 @@ importers: typescript: specifier: 5.5.4 version: 5.5.4 + typescript-eslint: + specifier: 8.59.3 + version: 8.59.3(eslint@9.39.2(jiti@2.7.0))(typescript@5.5.4) vitest: specifier: 4.1.8 - version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0)) + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0)) vitest-browser-react: specifier: 2.0.4 version: 2.0.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.8) @@ -820,14 +817,14 @@ packages: '@date-fns/tz@1.4.1': resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} - '@emnapi/core@1.8.1': - resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} - '@emnapi/wasi-threads@1.1.0': - resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} @@ -1885,13 +1882,12 @@ packages: cpu: [x64] os: [win32] + '@package-json/types@0.0.12': + resolution: {integrity: sha512-uu43FGU34B5VM9mCNjXCwLaGHYjXdNincqKLaraaCW+7S2+SmiBg1Nv8bPnmschrIfZmfKNY9f3fC376MRrObw==} + '@panva/hkdf@1.2.1': resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} - '@pkgr/core@0.2.9': - resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} - engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@playwright/test@1.56.1': resolution: {integrity: sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==} engines: {node: '>=18'} @@ -3480,63 +3476,67 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} - '@typescript-eslint/eslint-plugin@8.53.0': - resolution: {integrity: sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==} + '@typescript-eslint/eslint-plugin@8.59.3': + resolution: {integrity: sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.53.0 - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/parser': ^8.59.3 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.53.0': - resolution: {integrity: sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==} + '@typescript-eslint/parser@8.59.3': + resolution: {integrity: sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.53.0': - resolution: {integrity: sha512-Bl6Gdr7NqkqIP5yP9z1JU///Nmes4Eose6L1HwpuVHwScgDPPuEWbUVhvlZmb8hy0vX9syLk5EGNL700WcBlbg==} + '@typescript-eslint/project-service@8.59.3': + resolution: {integrity: sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.53.0': - resolution: {integrity: sha512-kWNj3l01eOGSdVBnfAF2K1BTh06WS0Yet6JUgb9Cmkqaz3Jlu0fdVUjj9UI8gPidBWSMqDIglmEXifSgDT/D0g==} + '@typescript-eslint/scope-manager@8.59.3': + resolution: {integrity: sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.53.0': - resolution: {integrity: sha512-K6Sc0R5GIG6dNoPdOooQ+KtvT5KCKAvTcY8h2rIuul19vxH5OTQk7ArKkd4yTzkw66WnNY0kPPzzcmWA+XRmiA==} + '@typescript-eslint/tsconfig-utils@8.59.3': + resolution: {integrity: sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.53.0': - resolution: {integrity: sha512-BBAUhlx7g4SmcLhn8cnbxoxtmS7hcq39xKCgiutL3oNx1TaIp+cny51s8ewnKMpVUKQUGb41RAUWZ9kxYdovuw==} + '@typescript-eslint/type-utils@8.59.3': + resolution: {integrity: sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.53.0': - resolution: {integrity: sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ==} + '@typescript-eslint/types@8.59.3': + resolution: {integrity: sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.53.0': - resolution: {integrity: sha512-pw0c0Gdo7Z4xOG987u3nJ8akL9093yEEKv8QTJ+Bhkghj1xyj8cgPaavlr9rq8h7+s6plUJ4QJYw2gCZodqmGw==} + '@typescript-eslint/types@8.64.0': + resolution: {integrity: sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.59.3': + resolution: {integrity: sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.53.0': - resolution: {integrity: sha512-XDY4mXTez3Z1iRDI5mbRhH4DFSt46oaIFsLg+Zn97+sYrXACziXSQcSelMybnVZ5pa1P6xYkPr5cMJyunM1ZDA==} + '@typescript-eslint/utils@8.59.3': + resolution: {integrity: sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.53.0': - resolution: {integrity: sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw==} + '@typescript-eslint/visitor-keys@8.59.3': + resolution: {integrity: sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@uiw/codemirror-extensions-basic-setup@4.25.8': @@ -3565,6 +3565,126 @@ packages: resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} deprecated: Potential CWE-502 - Update to 1.3.1 or higher + '@unrs/resolver-binding-android-arm-eabi@1.12.2': + resolution: {integrity: sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==} + cpu: [arm] + os: [android] + + '@unrs/resolver-binding-android-arm64@1.12.2': + resolution: {integrity: sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==} + cpu: [arm64] + os: [android] + + '@unrs/resolver-binding-darwin-arm64@1.12.2': + resolution: {integrity: sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==} + cpu: [arm64] + os: [darwin] + + '@unrs/resolver-binding-darwin-x64@1.12.2': + resolution: {integrity: sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==} + cpu: [x64] + os: [darwin] + + '@unrs/resolver-binding-freebsd-x64@1.12.2': + resolution: {integrity: sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==} + cpu: [x64] + os: [freebsd] + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': + resolution: {integrity: sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': + resolution: {integrity: sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': + resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-arm64-musl@1.12.2': + resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': + resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-loong64-musl@1.12.2': + resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': + resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': + resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': + resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': + resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-x64-gnu@1.12.2': + resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-x64-musl@1.12.2': + resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-openharmony-arm64@1.12.2': + resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} + cpu: [arm64] + os: [openharmony] + + '@unrs/resolver-binding-wasm32-wasi@1.12.2': + resolution: {integrity: sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': + resolution: {integrity: sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==} + cpu: [arm64] + os: [win32] + + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': + resolution: {integrity: sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==} + cpu: [ia32] + os: [win32] + + '@unrs/resolver-binding-win32-x64-msvc@1.12.2': + resolution: {integrity: sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==} + cpu: [x64] + os: [win32] + '@upsetjs/venn.js@2.0.0': resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} @@ -4002,6 +4122,10 @@ packages: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} + comment-parser@1.4.7: + resolution: {integrity: sha512-0h+uSNtQGW3D98eQt3jJ8L06Fves8hncB4V/PKdw/Qb8Hnk19VaKuTr55UNRYiSoVa7WwrFls+rh3ux9agmkeQ==} + engines: {node: '>= 12.0.0'} + commondir@1.0.1: resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} @@ -4449,26 +4573,47 @@ packages: peerDependencies: eslint: '>=7.0.0' + eslint-import-context@0.1.9: + resolution: {integrity: sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + peerDependencies: + unrs-resolver: ^1.0.0 + peerDependenciesMeta: + unrs-resolver: + optional: true + + eslint-import-resolver-typescript@4.4.4: + resolution: {integrity: sha512-1iM2zeBvrYmUNTj2vSC/90JTHDth+dfOfiNKkxApWRsTJYNrc8rOdxxIf5vazX+BiAXTeOT0UvWpGI/7qIWQOw==} + engines: {node: ^16.17.0 || >=18.6.0} + peerDependencies: + eslint: '*' + eslint-plugin-import: '*' + eslint-plugin-import-x: '*' + peerDependenciesMeta: + eslint-plugin-import: + optional: true + eslint-plugin-import-x: + optional: true + + eslint-plugin-import-x@4.16.2: + resolution: {integrity: sha512-rM9K8UBHcWKpzQzStn1YRN2T5NvdeIfSVoKu/lKF41znQXHAUcBbYXe5wd6GNjZjTrP7viQ49n1D83x/2gYgIw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/utils': ^8.56.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + eslint-import-resolver-node: '*' + peerDependenciesMeta: + '@typescript-eslint/utils': + optional: true + eslint-import-resolver-node: + optional: true + eslint-plugin-jsx-a11y@6.10.2: resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==} engines: {node: '>=4.0'} peerDependencies: eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 - eslint-plugin-prettier@5.5.1: - resolution: {integrity: sha512-dobTkHT6XaEVOo8IO90Q4DOSxnm3Y151QxPJlM/vKC0bVy+d6cVWQZLlFiuZPP0wS6vZwSKeJgKkcS+KfMBlRw==} - engines: {node: ^14.18.0 || >=16.0.0} - peerDependencies: - '@types/eslint': '>=8.0.0' - eslint: '>=8.0.0' - eslint-config-prettier: '>= 7.0.0 <10.0.0 || >=10.1.0' - prettier: '>=3.0.0' - peerDependenciesMeta: - '@types/eslint': - optional: true - eslint-config-prettier: - optional: true - eslint-plugin-react-hooks@7.0.1: resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==} engines: {node: '>=18'} @@ -4485,20 +4630,6 @@ packages: resolution: {integrity: sha512-XjVGBhtDZJfyuhIxnQ/WMm385RbX3DBu7H1J7HNNhmB2tnGxMeqVSnYv79oAj992ayvIBZghsymwkYFS6cGH4Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint-plugin-simple-import-sort@12.1.1: - resolution: {integrity: sha512-6nuzu4xwQtE3332Uz0to+TxDQYRLTKRESSc2hefVT48Zc8JthmN23Gx9lnYhu0FtkRSL1oxny3kJ2aveVhmOVA==} - peerDependencies: - eslint: '>=5.0.0' - - eslint-plugin-unused-imports@4.3.0: - resolution: {integrity: sha512-ZFBmXMGBYfHttdRtOG9nFFpmUvMtbHSjsKrS20vdWdbfiVYsO3yA2SGYy9i9XmZJDfMGBflZGBCm70SEnFQtOA==} - peerDependencies: - '@typescript-eslint/eslint-plugin': ^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0 - eslint: ^9.0.0 || ^8.0.0 - peerDependenciesMeta: - '@typescript-eslint/eslint-plugin': - optional: true - eslint-scope@5.1.1: resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} engines: {node: '>=8.0.0'} @@ -4515,6 +4646,10 @@ packages: resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + eslint@9.39.2: resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4603,9 +4738,6 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-diff@1.3.0: - resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} - fast-equals@5.4.0: resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} engines: {node: '>=6.0.0'} @@ -5008,6 +5140,9 @@ packages: resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} engines: {node: '>= 0.4'} + is-bun-module@2.0.0: + resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} + is-callable@1.2.7: resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} engines: {node: '>= 0.4'} @@ -5152,6 +5287,10 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + jose@6.1.3: resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} @@ -5707,6 +5846,11 @@ packages: engines: {node: ^18 || >=20} hasBin: true + napi-postinstall@0.3.4: + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + hasBin: true + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -5999,10 +6143,6 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - prettier-linter-helpers@1.0.1: - resolution: {integrity: sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==} - engines: {node: '>=6.0.0'} - prettier-plugin-packagejson@2.5.22: resolution: {integrity: sha512-G6WalmoUssKF8ZXkni0+n4324K+gG143KPysSQNW+FrR0XyNb3BdRxchGC/Q1FE/F702p7/6KU7r4mv0WSWbzA==} peerDependencies: @@ -6478,6 +6618,10 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + stable-hash-x@0.2.0: + resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==} + engines: {node: '>=12.0.0'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -6598,10 +6742,6 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} - synckit@0.11.12: - resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} - engines: {node: ^14.18.0 || >=16.0.0} - tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} @@ -6738,8 +6878,8 @@ packages: trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} - ts-api-utils@2.4.0: - resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} peerDependencies: typescript: '>=4.8.4' @@ -6783,6 +6923,13 @@ packages: resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} engines: {node: '>= 0.4'} + typescript-eslint@8.59.3: + resolution: {integrity: sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + typescript@5.5.4: resolution: {integrity: sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==} engines: {node: '>=14.17'} @@ -6830,6 +6977,9 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + unrs-resolver@1.12.2: + resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} + until-async@3.0.2: resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} @@ -8002,9 +8152,9 @@ snapshots: '@date-fns/tz@1.4.1': {} - '@emnapi/core@1.8.1': + '@emnapi/core@1.10.0': dependencies: - '@emnapi/wasi-threads': 1.1.0 + '@emnapi/wasi-threads': 1.2.1 tslib: 2.8.1 optional: true @@ -8013,7 +8163,7 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/wasi-threads@1.1.0': + '@emnapi/wasi-threads@1.2.1': dependencies: tslib: 2.8.1 optional: true @@ -8096,9 +8246,9 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.7.0))': dependencies: - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2(jiti@2.7.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -8575,9 +8725,9 @@ snapshots: outvariant: 1.4.3 strict-event-emitter: 0.5.1 - '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.8.1)(@emnapi/runtime@1.10.0)': + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: - '@emnapi/core': 1.8.1 + '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 '@tybys/wasm-util': 0.10.1 optional: true @@ -8736,9 +8886,9 @@ snapshots: '@oxc-parser/binding-openharmony-arm64@0.121.0': optional: true - '@oxc-parser/binding-wasm32-wasi@0.121.0(@emnapi/core@1.8.1)(@emnapi/runtime@1.10.0)': + '@oxc-parser/binding-wasm32-wasi@0.121.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: - '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.8.1)(@emnapi/runtime@1.10.0) + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -8803,9 +8953,9 @@ snapshots: '@oxc-resolver/binding-openharmony-arm64@11.19.1': optional: true - '@oxc-resolver/binding-wasm32-wasi@11.19.1(@emnapi/core@1.8.1)(@emnapi/runtime@1.10.0)': + '@oxc-resolver/binding-wasm32-wasi@11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: - '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.8.1)(@emnapi/runtime@1.10.0) + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -8820,9 +8970,9 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.19.1': optional: true - '@panva/hkdf@1.2.1': {} + '@package-json/types@0.0.12': {} - '@pkgr/core@0.2.9': {} + '@panva/hkdf@1.2.1': {} '@playwright/test@1.56.1': dependencies: @@ -9594,7 +9744,7 @@ snapshots: dependencies: '@types/estree': 1.0.9 estree-walker: 2.0.2 - picomatch: 4.0.4 + picomatch: 4.0.5 optionalDependencies: rollup: 4.59.0 @@ -10469,96 +10619,98 @@ snapshots: '@types/unist@3.0.3': {} - '@typescript-eslint/eslint-plugin@8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.5.4))(eslint@9.39.2(jiti@2.6.1))(typescript@5.5.4)': + '@typescript-eslint/eslint-plugin@8.59.3(@typescript-eslint/parser@8.59.3(eslint@9.39.2(jiti@2.7.0))(typescript@5.5.4))(eslint@9.39.2(jiti@2.7.0))(typescript@5.5.4)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.5.4) - '@typescript-eslint/scope-manager': 8.53.0 - '@typescript-eslint/type-utils': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.5.4) - '@typescript-eslint/utils': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.5.4) - '@typescript-eslint/visitor-keys': 8.53.0 - eslint: 9.39.2(jiti@2.6.1) + '@typescript-eslint/parser': 8.59.3(eslint@9.39.2(jiti@2.7.0))(typescript@5.5.4) + '@typescript-eslint/scope-manager': 8.59.3 + '@typescript-eslint/type-utils': 8.59.3(eslint@9.39.2(jiti@2.7.0))(typescript@5.5.4) + '@typescript-eslint/utils': 8.59.3(eslint@9.39.2(jiti@2.7.0))(typescript@5.5.4) + '@typescript-eslint/visitor-keys': 8.59.3 + eslint: 9.39.2(jiti@2.7.0) ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.4.0(typescript@5.5.4) + ts-api-utils: 2.5.0(typescript@5.5.4) typescript: 5.5.4 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.5.4)': + '@typescript-eslint/parser@8.59.3(eslint@9.39.2(jiti@2.7.0))(typescript@5.5.4)': dependencies: - '@typescript-eslint/scope-manager': 8.53.0 - '@typescript-eslint/types': 8.53.0 - '@typescript-eslint/typescript-estree': 8.53.0(typescript@5.5.4) - '@typescript-eslint/visitor-keys': 8.53.0 + '@typescript-eslint/scope-manager': 8.59.3 + '@typescript-eslint/types': 8.59.3 + '@typescript-eslint/typescript-estree': 8.59.3(typescript@5.5.4) + '@typescript-eslint/visitor-keys': 8.59.3 debug: 4.4.3 - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2(jiti@2.7.0) typescript: 5.5.4 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.53.0(typescript@5.5.4)': + '@typescript-eslint/project-service@8.59.3(typescript@5.5.4)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.53.0(typescript@5.5.4) - '@typescript-eslint/types': 8.53.0 + '@typescript-eslint/tsconfig-utils': 8.59.3(typescript@5.5.4) + '@typescript-eslint/types': 8.59.3 debug: 4.4.3 typescript: 5.5.4 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.53.0': + '@typescript-eslint/scope-manager@8.59.3': dependencies: - '@typescript-eslint/types': 8.53.0 - '@typescript-eslint/visitor-keys': 8.53.0 + '@typescript-eslint/types': 8.59.3 + '@typescript-eslint/visitor-keys': 8.59.3 - '@typescript-eslint/tsconfig-utils@8.53.0(typescript@5.5.4)': + '@typescript-eslint/tsconfig-utils@8.59.3(typescript@5.5.4)': dependencies: typescript: 5.5.4 - '@typescript-eslint/type-utils@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.5.4)': + '@typescript-eslint/type-utils@8.59.3(eslint@9.39.2(jiti@2.7.0))(typescript@5.5.4)': dependencies: - '@typescript-eslint/types': 8.53.0 - '@typescript-eslint/typescript-estree': 8.53.0(typescript@5.5.4) - '@typescript-eslint/utils': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.5.4) + '@typescript-eslint/types': 8.59.3 + '@typescript-eslint/typescript-estree': 8.59.3(typescript@5.5.4) + '@typescript-eslint/utils': 8.59.3(eslint@9.39.2(jiti@2.7.0))(typescript@5.5.4) debug: 4.4.3 - eslint: 9.39.2(jiti@2.6.1) - ts-api-utils: 2.4.0(typescript@5.5.4) + eslint: 9.39.2(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@5.5.4) typescript: 5.5.4 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.53.0': {} + '@typescript-eslint/types@8.59.3': {} - '@typescript-eslint/typescript-estree@8.53.0(typescript@5.5.4)': + '@typescript-eslint/types@8.64.0': {} + + '@typescript-eslint/typescript-estree@8.59.3(typescript@5.5.4)': dependencies: - '@typescript-eslint/project-service': 8.53.0(typescript@5.5.4) - '@typescript-eslint/tsconfig-utils': 8.53.0(typescript@5.5.4) - '@typescript-eslint/types': 8.53.0 - '@typescript-eslint/visitor-keys': 8.53.0 + '@typescript-eslint/project-service': 8.59.3(typescript@5.5.4) + '@typescript-eslint/tsconfig-utils': 8.59.3(typescript@5.5.4) + '@typescript-eslint/types': 8.59.3 + '@typescript-eslint/visitor-keys': 8.59.3 debug: 4.4.3 - minimatch: 9.0.7 + minimatch: 10.2.3 semver: 7.8.0 - tinyglobby: 0.2.16 - ts-api-utils: 2.4.0(typescript@5.5.4) + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.5.4) typescript: 5.5.4 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.5.4)': + '@typescript-eslint/utils@8.59.3(eslint@9.39.2(jiti@2.7.0))(typescript@5.5.4)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.53.0 - '@typescript-eslint/types': 8.53.0 - '@typescript-eslint/typescript-estree': 8.53.0(typescript@5.5.4) - eslint: 9.39.2(jiti@2.6.1) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.59.3 + '@typescript-eslint/types': 8.59.3 + '@typescript-eslint/typescript-estree': 8.59.3(typescript@5.5.4) + eslint: 9.39.2(jiti@2.7.0) typescript: 5.5.4 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.53.0': + '@typescript-eslint/visitor-keys@8.59.3': dependencies: - '@typescript-eslint/types': 8.53.0 - eslint-visitor-keys: 4.2.1 + '@typescript-eslint/types': 8.59.3 + eslint-visitor-keys: 5.0.1 '@uiw/codemirror-extensions-basic-setup@4.25.8(@codemirror/autocomplete@6.20.1)(@codemirror/commands@6.10.3)(@codemirror/language@6.12.2)(@codemirror/lint@6.9.5)(@codemirror/search@6.6.0)(@codemirror/state@6.6.0)(@codemirror/view@6.40.0)': dependencies: @@ -10589,6 +10741,76 @@ snapshots: '@ungap/structured-clone@1.3.0': {} + '@unrs/resolver-binding-android-arm-eabi@1.12.2': + optional: true + + '@unrs/resolver-binding-android-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-darwin-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-darwin-x64@1.12.2': + optional: true + + '@unrs/resolver-binding-freebsd-x64@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-loong64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-x64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-x64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-openharmony-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-wasm32-wasi@1.12.2': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': + optional: true + + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': + optional: true + + '@unrs/resolver-binding-win32-x64-msvc@1.12.2': + optional: true + '@upsetjs/venn.js@2.0.0': optionalDependencies: d3-selection: 3.0.0 @@ -10596,7 +10818,7 @@ snapshots: '@vercel/oidc@3.2.0': {} - '@vitejs/plugin-react@5.1.2(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))': + '@vitejs/plugin-react@5.1.2(vite@7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.7) @@ -10604,33 +10826,33 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.53 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0) + vite: 7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0) transitivePeerDependencies: - supports-color - '@vitest/browser-playwright@4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))(vitest@4.1.8)': + '@vitest/browser-playwright@4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))(vitest@4.1.8)': dependencies: - '@vitest/browser': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))(vitest@4.1.8) - '@vitest/mocker': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0)) + '@vitest/browser': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))(vitest@4.1.8) + '@vitest/mocker': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0)) playwright: 1.56.1 tinyrainbow: 3.1.0 - vitest: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0)) + vitest: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser@4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))(vitest@4.1.8)': + '@vitest/browser@4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))(vitest@4.1.8)': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0)) + '@vitest/mocker': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0)) '@vitest/utils': 4.1.8 magic-string: 0.30.21 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0)) + vitest: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0)) ws: 8.21.0 transitivePeerDependencies: - bufferutil @@ -10650,9 +10872,9 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0)) + vitest: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0)) optionalDependencies: - '@vitest/browser': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))(vitest@4.1.8) + '@vitest/browser': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))(vitest@4.1.8) '@vitest/expect@4.1.8': dependencies: @@ -10663,14 +10885,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))': + '@vitest/mocker@4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.8 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.13.4(@types/node@24.10.8)(typescript@5.5.4) - vite: 7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0) + vite: 7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0) '@vitest/pretty-format@4.1.8': dependencies: @@ -11125,6 +11347,8 @@ snapshots: commander@8.3.0: {} + comment-parser@1.4.7: {} + commondir@1.0.1: {} concat-map@0.0.1: {} @@ -11650,11 +11874,51 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-prettier@10.1.5(eslint@9.39.2(jiti@2.6.1)): + eslint-config-prettier@10.1.5(eslint@9.39.2(jiti@2.7.0)): dependencies: - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2(jiti@2.7.0) - eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.2(jiti@2.6.1)): + eslint-import-context@0.1.9(unrs-resolver@1.12.2): + dependencies: + get-tsconfig: 4.13.7 + stable-hash-x: 0.2.0 + optionalDependencies: + unrs-resolver: 1.12.2 + + eslint-import-resolver-typescript@4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.59.3(eslint@9.39.2(jiti@2.7.0))(typescript@5.5.4))(eslint@9.39.2(jiti@2.7.0)))(eslint@9.39.2(jiti@2.7.0)): + dependencies: + debug: 4.4.3 + eslint: 9.39.2(jiti@2.7.0) + eslint-import-context: 0.1.9(unrs-resolver@1.12.2) + get-tsconfig: 4.13.7 + is-bun-module: 2.0.0 + stable-hash-x: 0.2.0 + tinyglobby: 0.2.17 + unrs-resolver: 1.12.2 + optionalDependencies: + eslint-plugin-import-x: 4.16.2(@typescript-eslint/utils@8.59.3(eslint@9.39.2(jiti@2.7.0))(typescript@5.5.4))(eslint@9.39.2(jiti@2.7.0)) + transitivePeerDependencies: + - supports-color + + eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.59.3(eslint@9.39.2(jiti@2.7.0))(typescript@5.5.4))(eslint@9.39.2(jiti@2.7.0)): + dependencies: + '@package-json/types': 0.0.12 + '@typescript-eslint/types': 8.64.0 + comment-parser: 1.4.7 + debug: 4.4.3 + eslint: 9.39.2(jiti@2.7.0) + eslint-import-context: 0.1.9(unrs-resolver@1.12.2) + is-glob: 4.0.3 + minimatch: 9.0.7 + semver: 7.8.0 + stable-hash-x: 0.2.0 + unrs-resolver: 1.12.2 + optionalDependencies: + '@typescript-eslint/utils': 8.59.3(eslint@9.39.2(jiti@2.7.0))(typescript@5.5.4) + transitivePeerDependencies: + - supports-color + + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.2(jiti@2.7.0)): dependencies: aria-query: 5.3.2 array-includes: 3.1.9 @@ -11664,7 +11928,7 @@ snapshots: axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2(jiti@2.7.0) hasown: 2.0.2 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 @@ -11673,28 +11937,18 @@ snapshots: safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 - eslint-plugin-prettier@5.5.1(@types/eslint@9.6.1)(eslint-config-prettier@10.1.5(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.6.2): - dependencies: - eslint: 9.39.2(jiti@2.6.1) - prettier: 3.6.2 - prettier-linter-helpers: 1.0.1 - synckit: 0.11.12 - optionalDependencies: - '@types/eslint': 9.6.1 - eslint-config-prettier: 10.1.5(eslint@9.39.2(jiti@2.6.1)) - - eslint-plugin-react-hooks@7.0.1(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-react-hooks@7.0.1(eslint@9.39.2(jiti@2.7.0)): dependencies: '@babel/core': 7.29.7 '@babel/parser': 7.29.7 - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2(jiti@2.7.0) hermes-parser: 0.25.1 zod: 4.4.3 zod-validation-error: 4.0.2(zod@4.4.3) transitivePeerDependencies: - supports-color - eslint-plugin-react@7.37.5(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-react@7.37.5(eslint@9.39.2(jiti@2.7.0)): dependencies: array-includes: 3.1.9 array.prototype.findlast: 1.2.5 @@ -11702,7 +11956,7 @@ snapshots: array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 es-iterator-helpers: 1.2.2 - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2(jiti@2.7.0) estraverse: 5.3.0 hasown: 2.0.2 jsx-ast-utils: 3.3.5 @@ -11720,16 +11974,6 @@ snapshots: dependencies: safe-regex: 2.1.1 - eslint-plugin-simple-import-sort@12.1.1(eslint@9.39.2(jiti@2.6.1)): - dependencies: - eslint: 9.39.2(jiti@2.6.1) - - eslint-plugin-unused-imports@4.3.0(@typescript-eslint/eslint-plugin@8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.5.4))(eslint@9.39.2(jiti@2.6.1))(typescript@5.5.4))(eslint@9.39.2(jiti@2.6.1)): - dependencies: - eslint: 9.39.2(jiti@2.6.1) - optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.5.4))(eslint@9.39.2(jiti@2.6.1))(typescript@5.5.4) - eslint-scope@5.1.1: dependencies: esrecurse: 4.3.0 @@ -11744,9 +11988,11 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint@9.39.2(jiti@2.6.1): + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.2(jiti@2.7.0): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.7.0)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.21.1 '@eslint/config-helpers': 0.4.2 @@ -11781,7 +12027,7 @@ snapshots: natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: - jiti: 2.6.1 + jiti: 2.7.0 transitivePeerDependencies: - supports-color @@ -11874,8 +12120,6 @@ snapshots: fast-deep-equal@3.1.3: {} - fast-diff@1.3.0: {} - fast-equals@5.4.0: {} fast-glob@3.3.1: @@ -12360,6 +12604,10 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-bun-module@2.0.0: + dependencies: + semver: 7.8.0 + is-callable@1.2.7: {} is-core-module@2.16.1: @@ -12499,6 +12747,8 @@ snapshots: jiti@2.6.1: {} + jiti@2.7.0: {} + jose@6.1.3: {} js-tiktoken@1.0.21: @@ -12578,7 +12828,7 @@ snapshots: khroma@2.1.0: {} - knip@6.3.1(@emnapi/core@1.8.1)(@emnapi/runtime@1.10.0): + knip@6.3.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): dependencies: '@nodelib/fs.walk': 1.2.8 fast-glob: 3.3.3 @@ -12586,8 +12836,8 @@ snapshots: get-tsconfig: 4.13.7 jiti: 2.6.1 minimist: 1.2.8 - oxc-parser: 0.121.0(@emnapi/core@1.8.1)(@emnapi/runtime@1.10.0) - oxc-resolver: 11.19.1(@emnapi/core@1.8.1)(@emnapi/runtime@1.10.0) + oxc-parser: 0.121.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + oxc-resolver: 11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) picocolors: 1.1.1 picomatch: 4.0.4 smol-toml: 1.6.1 @@ -13274,6 +13524,8 @@ snapshots: nanoid@5.1.6: {} + napi-postinstall@0.3.4: {} + natural-compare@1.4.0: {} negotiator@1.0.0: {} @@ -13403,7 +13655,7 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 - oxc-parser@0.121.0(@emnapi/core@1.8.1)(@emnapi/runtime@1.10.0): + oxc-parser@0.121.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): dependencies: '@oxc-project/types': 0.121.0 optionalDependencies: @@ -13423,7 +13675,7 @@ snapshots: '@oxc-parser/binding-linux-x64-gnu': 0.121.0 '@oxc-parser/binding-linux-x64-musl': 0.121.0 '@oxc-parser/binding-openharmony-arm64': 0.121.0 - '@oxc-parser/binding-wasm32-wasi': 0.121.0(@emnapi/core@1.8.1)(@emnapi/runtime@1.10.0) + '@oxc-parser/binding-wasm32-wasi': 0.121.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) '@oxc-parser/binding-win32-arm64-msvc': 0.121.0 '@oxc-parser/binding-win32-ia32-msvc': 0.121.0 '@oxc-parser/binding-win32-x64-msvc': 0.121.0 @@ -13431,7 +13683,7 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' - oxc-resolver@11.19.1(@emnapi/core@1.8.1)(@emnapi/runtime@1.10.0): + oxc-resolver@11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): optionalDependencies: '@oxc-resolver/binding-android-arm-eabi': 11.19.1 '@oxc-resolver/binding-android-arm64': 11.19.1 @@ -13449,7 +13701,7 @@ snapshots: '@oxc-resolver/binding-linux-x64-gnu': 11.19.1 '@oxc-resolver/binding-linux-x64-musl': 11.19.1 '@oxc-resolver/binding-openharmony-arm64': 11.19.1 - '@oxc-resolver/binding-wasm32-wasi': 11.19.1(@emnapi/core@1.8.1)(@emnapi/runtime@1.10.0) + '@oxc-resolver/binding-wasm32-wasi': 11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) '@oxc-resolver/binding-win32-arm64-msvc': 11.19.1 '@oxc-resolver/binding-win32-ia32-msvc': 11.19.1 '@oxc-resolver/binding-win32-x64-msvc': 11.19.1 @@ -13588,10 +13840,6 @@ snapshots: prelude-ls@1.2.1: {} - prettier-linter-helpers@1.0.1: - dependencies: - fast-diff: 1.3.0 - prettier-plugin-packagejson@2.5.22(prettier@3.6.2): dependencies: sort-package-json: 3.6.0 @@ -14178,7 +14426,7 @@ snapshots: is-plain-obj: 4.1.0 semver: 7.8.0 sort-object-keys: 2.1.0 - tinyglobby: 0.2.16 + tinyglobby: 0.2.17 source-map-js@1.2.1: {} @@ -14191,6 +14439,8 @@ snapshots: space-separated-tokens@2.0.2: {} + stable-hash-x@0.2.0: {} + stackback@0.0.2: {} stacktrace-parser@0.1.11: @@ -14351,10 +14601,6 @@ snapshots: symbol-tree@3.2.4: {} - synckit@0.11.12: - dependencies: - '@pkgr/core': 0.2.9 - tagged-tag@1.0.0: {} tailwind-merge@3.3.1: {} @@ -14439,7 +14685,7 @@ snapshots: trough@2.2.0: {} - ts-api-utils@2.4.0(typescript@5.5.4): + ts-api-utils@2.5.0(typescript@5.5.4): dependencies: typescript: 5.5.4 @@ -14496,6 +14742,17 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 + typescript-eslint@8.59.3(eslint@9.39.2(jiti@2.7.0))(typescript@5.5.4): + dependencies: + '@typescript-eslint/eslint-plugin': 8.59.3(@typescript-eslint/parser@8.59.3(eslint@9.39.2(jiti@2.7.0))(typescript@5.5.4))(eslint@9.39.2(jiti@2.7.0))(typescript@5.5.4) + '@typescript-eslint/parser': 8.59.3(eslint@9.39.2(jiti@2.7.0))(typescript@5.5.4) + '@typescript-eslint/typescript-estree': 8.59.3(typescript@5.5.4) + '@typescript-eslint/utils': 8.59.3(eslint@9.39.2(jiti@2.7.0))(typescript@5.5.4) + eslint: 9.39.2(jiti@2.7.0) + typescript: 5.5.4 + transitivePeerDependencies: + - supports-color + typescript@5.5.4: {} ufo@1.6.3: {} @@ -14556,6 +14813,33 @@ snapshots: unpipe@1.0.0: {} + unrs-resolver@1.12.2: + dependencies: + napi-postinstall: 0.3.4 + optionalDependencies: + '@unrs/resolver-binding-android-arm-eabi': 1.12.2 + '@unrs/resolver-binding-android-arm64': 1.12.2 + '@unrs/resolver-binding-darwin-arm64': 1.12.2 + '@unrs/resolver-binding-darwin-x64': 1.12.2 + '@unrs/resolver-binding-freebsd-x64': 1.12.2 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.12.2 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.12.2 + '@unrs/resolver-binding-linux-arm64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-arm64-musl': 1.12.2 + '@unrs/resolver-binding-linux-loong64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-loong64-musl': 1.12.2 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-riscv64-musl': 1.12.2 + '@unrs/resolver-binding-linux-s390x-gnu': 1.12.2 + '@unrs/resolver-binding-linux-x64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-x64-musl': 1.12.2 + '@unrs/resolver-binding-openharmony-arm64': 1.12.2 + '@unrs/resolver-binding-wasm32-wasi': 1.12.2 + '@unrs/resolver-binding-win32-arm64-msvc': 1.12.2 + '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 + '@unrs/resolver-binding-win32-x64-msvc': 1.12.2 + until-async@3.0.2: {} update-browserslist-db@1.2.3(browserslist@4.28.2): @@ -14629,7 +14913,7 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0): + vite@7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0): dependencies: esbuild: 0.28.1 fdir: 6.5.0(picomatch@4.0.5) @@ -14640,7 +14924,7 @@ snapshots: optionalDependencies: '@types/node': 24.10.8 fsevents: 2.3.3 - jiti: 2.6.1 + jiti: 2.7.0 lightningcss: 1.30.2 terser: 5.49.0 yaml: 2.9.0 @@ -14649,15 +14933,15 @@ snapshots: dependencies: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - vitest: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0)) + vitest: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0)) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - vitest@4.1.8(@opentelemetry/api@1.9.1)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0)): + vitest@4.1.8(@opentelemetry/api@1.9.1)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.8 - '@vitest/mocker': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0)) + '@vitest/mocker': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.8 '@vitest/runner': 4.1.8 '@vitest/snapshot': 4.1.8 @@ -14674,12 +14958,12 @@ snapshots: tinyexec: 1.1.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0) + vite: 7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 24.10.8 - '@vitest/browser-playwright': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))(vitest@4.1.8) + '@vitest/browser-playwright': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.5(@types/node@24.10.8)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))(vitest@4.1.8) '@vitest/coverage-v8': 4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8) jsdom: 27.4.0 transitivePeerDependencies: diff --git a/ui/sentry/event-policy.test.ts b/ui/sentry/event-policy.test.ts new file mode 100644 index 0000000000..3f3c13d7fa --- /dev/null +++ b/ui/sentry/event-policy.test.ts @@ -0,0 +1,308 @@ +import { describe, expect, it } from "vitest"; + +import type { + SentryEventHint, + SentryEventPolicyOptions, + SentryPolicyEvent, +} from "./event-policy"; +import { + applySentryEventPolicy, + isErrorAlreadyReported, + isErrorCapturedBySentry, + markErrorAsReported, +} from "./event-policy"; + +describe("applySentryEventPolicy", () => { + describe("when events are actionable", () => { + it("should keep error events", () => { + // Given + const event = { + level: "error", + message: "Unexpected failure", + } satisfies SentryPolicyEvent & { level: string; message: string }; + const options: SentryEventPolicyOptions = { source: "client" }; + + // When + const result = applySentryEventPolicy(event, undefined, options); + + // Then + expect(result).toBe(event); + expect(result).toMatchObject({ + tags: { + actionability: "actionable", + kind: "runtime", + source: "client", + }, + }); + }); + + it("should keep fatal events", () => { + // Given + const event = { level: "fatal", message: "Runtime crashed" }; + + // When + const result = applySentryEventPolicy(event); + + // Then + expect(result).toBe(event); + }); + + it("should keep events without a level", () => { + // Given + const event = { message: "Default Sentry event" }; + + // When + const result = applySentryEventPolicy(event); + + // Then + expect(result).toBe(event); + }); + + it("should keep fatal runtime messages that contain an expected HTTP status number", () => { + // Given + const event = { + level: "fatal", + message: "Runtime worker 401 crashed unexpectedly", + }; + + // When + const result = applySentryEventPolicy(event); + + // Then + expect(result).toBe(event); + expect(result).toMatchObject({ + tags: { + actionability: "actionable", + kind: "runtime", + }, + }); + }); + + it("should keep runtime error messages that contain an expected HTTP status number", () => { + // Given + const event = { + level: "error", + message: "Background import 404 failed after a null dereference", + }; + + // When + const result = applySentryEventPolicy(event); + + // Then + expect(result).toBe(event); + expect(result).toMatchObject({ + tags: { + actionability: "actionable", + kind: "runtime", + }, + }); + }); + + it("should keep fatal runtime messages that mention status without HTTP context", () => { + // Given + const event = { + level: "fatal", + message: "State transition status 403 caused worker crash", + }; + + // When + const result = applySentryEventPolicy(event); + + // Then + expect(result).toBe(event); + expect(result).toMatchObject({ + tags: { + actionability: "actionable", + kind: "runtime", + }, + }); + }); + + it("should keep fatal runtime messages that mention status code without HTTP context", () => { + // Given + const event = { + level: "fatal", + message: "State transition status code 403 caused worker crash", + }; + + // When + const result = applySentryEventPolicy(event); + + // Then + expect(result).toBe(event); + expect(result).toMatchObject({ + tags: { + actionability: "actionable", + kind: "runtime", + }, + }); + }); + + it("should keep fatal runtime messages that mention response without HTTP context", () => { + // Given + const event = { + level: "fatal", + message: "Worker response 404 triggered fatal cache corruption", + }; + + // When + const result = applySentryEventPolicy(event); + + // Then + expect(result).toBe(event); + expect(result).toMatchObject({ + tags: { + actionability: "actionable", + kind: "runtime", + }, + }); + }); + + it("should preserve existing tags on actionable API events", () => { + // Given + const event = { + level: "error", + message: "API response failed with status 500", + tags: { + feature: "providers", + status_code: "500", + }, + }; + + // When + const result = applySentryEventPolicy(event, undefined, { + source: "server_action", + }); + + // Then + expect(result).toBe(event); + expect(result).toMatchObject({ + tags: { + actionability: "actionable", + feature: "providers", + kind: "api", + source: "server_action", + status_code: "500", + }, + }); + }); + }); + + describe("when events are non-actionable", () => { + it("should drop warning events", () => { + // Given + const event = { level: "warning", message: "Provider already exists" }; + + // When + const result = applySentryEventPolicy(event); + + // Then + expect(result).toBeNull(); + }); + + it("should drop Next.js redirect control-flow events", () => { + // Given + const event = { level: "error", message: "NEXT_REDIRECT" }; + + // When + const result = applySentryEventPolicy(event); + + // Then + expect(result).toBeNull(); + }); + + it("should drop AbortError events", () => { + // Given + const event = { level: "error", message: "The operation was aborted" }; + const hint: SentryEventHint = { + originalException: new DOMException("Aborted", "AbortError"), + }; + + // When + const result = applySentryEventPolicy(event, hint); + + // Then + expect(result).toBeNull(); + }); + + it("should drop expected HTTP 401 events", () => { + // Given + const event = { level: "error", tags: { status_code: "401" } }; + + // When + const result = applySentryEventPolicy(event); + + // Then + expect(result).toBeNull(); + }); + + it("should drop expected HTTP 403 events", () => { + // Given + const event = { level: "error", message: "Request failed with 403" }; + + // When + const result = applySentryEventPolicy(event); + + // Then + expect(result).toBeNull(); + }); + + it("should drop expected HTTP 404 events", () => { + // Given + const event = { level: "error" }; + const hint = { originalException: { status: 404 } }; + + // When + const result = applySentryEventPolicy(event, hint); + + // Then + expect(result).toBeNull(); + }); + + it("should drop clear HTTP messages with expected status codes", () => { + // Given + const event = { + level: "error", + message: "HTTP response status code 404", + }; + + // When + const result = applySentryEventPolicy(event); + + // Then + expect(result).toBeNull(); + }); + + it("should drop already-reported errors", () => { + // Given + const error = new Error("Already reported failure"); + const event = { level: "error", message: error.message }; + markErrorAsReported(error); + + // When + const result = applySentryEventPolicy(event, { + originalException: error, + }); + + // Then + expect(isErrorAlreadyReported(error)).toBe(true); + expect(result).toBeNull(); + }); + + it("should keep the first event for an error marked by Sentry", () => { + // Given + const error = new Error("First capture"); + Object.defineProperty(error, "__sentry_captured__", { value: true }); + const event = { level: "error", message: error.message }; + + // When + const result = applySentryEventPolicy(event, { + originalException: error, + }); + + // Then + expect(isErrorCapturedBySentry(error)).toBe(true); + expect(result).toBe(event); + }); + }); +}); diff --git a/ui/sentry/event-policy.ts b/ui/sentry/event-policy.ts new file mode 100644 index 0000000000..c8312abfb6 --- /dev/null +++ b/ui/sentry/event-policy.ts @@ -0,0 +1,290 @@ +const SENTRY_EVENT_LEVEL = { + WARNING: "warning", +} as const; + +export const SENTRY_EVENT_SOURCE = { + CLIENT: "client", + EDGE: "edge", + SERVER: "server", + SERVER_ACTION: "server_action", +} as const; + +const SENTRY_EVENT_KIND = { + API: "api", + RUNTIME: "runtime", +} as const; + +const SENTRY_ACTIONABILITY = { + ACTIONABLE: "actionable", +} as const; + +const EXPECTED_CONTROL_FLOW_MESSAGES = [ + "NEXT_REDIRECT", + "NEXT_NOT_FOUND", + "AbortError", + "ResizeObserver", +] as const; + +const HTTP_CONTEXT_MESSAGES = [ + /\bapi\b/i, + /\bfetch\b/i, + /\bhttp\b/i, + /\brequest failed\b/i, +] as const; + +const EXPECTED_HTTP_STATUS_CODES = new Set([401, 403, 404]); +const EXPECTED_HTTP_STATUS_PATTERN = /(^|\D)(401|403|404)(\D|$)/; +const REPORTED_ERROR_MARKER = Symbol.for("prowler.sentry.reported_error"); +const SENTRY_CAPTURED_MARKER = "__sentry_captured__"; +const reportedErrors = new WeakSet(); + +type SentryEventSource = + (typeof SENTRY_EVENT_SOURCE)[keyof typeof SENTRY_EVENT_SOURCE]; +type SentryPolicyTagValue = string | number | boolean | null | undefined; + +export interface SentryEventPolicyOptions { + source?: SentryEventSource; +} + +export interface SentryEventHint { + originalException?: unknown; +} + +export interface SentryPolicyEvent { + tags?: Record; +} + +type ObjectRecord = Record; +type ReportedErrorRecord = Record; + +export function applySentryEventPolicy( + event: TEvent, + hint?: SentryEventHint, + options: SentryEventPolicyOptions = {}, +) { + if (shouldDropSentryEvent(event, hint)) { + return null; + } + + tagActionableEvent(event, options); + + return event; +} + +export function markErrorAsReported(error: unknown) { + if (!isObjectLike(error)) { + return; + } + + reportedErrors.add(error); + + try { + Object.defineProperty(error, REPORTED_ERROR_MARKER, { + configurable: false, + enumerable: false, + value: true, + }); + } catch { + // WeakSet fallback still suppresses duplicates for non-extensible objects. + } +} + +export function isErrorAlreadyReported(error: unknown) { + if (!isObjectLike(error)) { + return false; + } + + return ( + reportedErrors.has(error) || + (error as ReportedErrorRecord)[REPORTED_ERROR_MARKER] === true + ); +} + +export function isErrorCapturedBySentry(error: unknown) { + return ( + isObjectLike(error) && getProperty(error, SENTRY_CAPTURED_MARKER) === true + ); +} + +function shouldDropSentryEvent(event: object, hint?: SentryEventHint) { + if (getStringProperty(event, "level") === SENTRY_EVENT_LEVEL.WARNING) { + return true; + } + + if (isErrorAlreadyReported(hint?.originalException)) { + return true; + } + + const messages = getEventMessages(event, hint); + + if (hasExpectedControlFlowMessage(messages)) { + return true; + } + + return hasExpectedHttpStatus(event, hint?.originalException, messages); +} + +function tagActionableEvent(event: object, options: SentryEventPolicyOptions) { + const mutableEvent = event as SentryPolicyEvent; + + mutableEvent.tags = { + ...mutableEvent.tags, + actionability: SENTRY_ACTIONABILITY.ACTIONABLE, + kind: inferEventKind(event), + ...(options.source ? { source: options.source } : {}), + }; +} + +function inferEventKind(event: object) { + const tags = getRecordProperty(event, "tags"); + const errorType = getStringProperty(tags, "error_type"); + const apiError = getProperty(tags, "api_error"); + + if ( + errorType === "api_error" || + apiError === true || + getStatusFromTags(tags) + ) { + return SENTRY_EVENT_KIND.API; + } + + return SENTRY_EVENT_KIND.RUNTIME; +} + +function hasExpectedControlFlowMessage(messages: string[]) { + return EXPECTED_CONTROL_FLOW_MESSAGES.some((expectedMessage) => + messages.some((message) => message.includes(expectedMessage)), + ); +} + +function hasExpectedHttpStatus( + event: object, + originalException: unknown, + messages: string[], +) { + const tags = getRecordProperty(event, "tags"); + const statusFromTags = getStatusFromTags(tags); + const statusFromError = getStatusFromError(originalException); + + if ( + (statusFromTags && EXPECTED_HTTP_STATUS_CODES.has(statusFromTags)) || + (statusFromError && EXPECTED_HTTP_STATUS_CODES.has(statusFromError)) + ) { + return true; + } + + return ( + hasExpectedHttpStatusMessage(messages) && + hasApiOrHttpContext(event, messages) + ); +} + +function hasExpectedHttpStatusMessage(messages: string[]) { + return messages.some((message) => EXPECTED_HTTP_STATUS_PATTERN.test(message)); +} + +function hasApiOrHttpContext(event: object, messages: string[]) { + const tags = getRecordProperty(event, "tags"); + + return hasApiTags(tags) || hasHttpContextMessage(messages); +} + +function hasApiTags(tags: unknown) { + return ( + getStringProperty(tags, "error_type") === "api_error" || + getProperty(tags, "api_error") === true + ); +} + +function hasHttpContextMessage(messages: string[]) { + return messages.some((message) => + HTTP_CONTEXT_MESSAGES.some((pattern) => pattern.test(message)), + ); +} + +function getStatusFromTags(tags: unknown) { + return ( + normalizeStatusCode(getProperty(tags, "status_code")) ?? + normalizeStatusCode(getProperty(tags, "status")) ?? + normalizeStatusCode(getProperty(tags, "http.status_code")) + ); +} + +function getStatusFromError(error: unknown): number | undefined { + const response = getRecordProperty(error, "response"); + + return ( + normalizeStatusCode(getProperty(error, "status")) ?? + normalizeStatusCode(getProperty(error, "statusCode")) ?? + normalizeStatusCode(getProperty(error, "status_code")) ?? + normalizeStatusCode(getProperty(response, "status")) + ); +} + +function getEventMessages(event: object, hint?: SentryEventHint) { + return [ + getStringProperty(event, "message"), + ...getExceptionMessages(event), + getStringProperty(hint?.originalException, "name"), + getStringProperty(hint?.originalException, "message"), + typeof hint?.originalException === "string" + ? hint.originalException + : undefined, + ].filter(isString); +} + +function getExceptionMessages(event: object) { + const exception = getRecordProperty(event, "exception"); + const values = getProperty(exception, "values"); + + if (!Array.isArray(values)) { + return []; + } + + return values.flatMap((value) => [ + getStringProperty(value, "type"), + getStringProperty(value, "value"), + ]); +} + +function normalizeStatusCode(value: unknown) { + if (typeof value === "number" && Number.isInteger(value)) { + return value; + } + + if (typeof value === "string" && /^\d{3}$/.test(value)) { + return Number(value); + } + + return undefined; +} + +function getStringProperty(value: unknown, property: string) { + const propertyValue = getProperty(value, property); + + return typeof propertyValue === "string" ? propertyValue : undefined; +} + +function getRecordProperty(value: unknown, property: string) { + const propertyValue = getProperty(value, property); + + return isRecord(propertyValue) ? propertyValue : undefined; +} + +function getProperty(value: unknown, property: string) { + return isRecord(value) ? value[property] : undefined; +} + +function isRecord(value: unknown): value is ObjectRecord { + return typeof value === "object" && value !== null; +} + +function isObjectLike(value: unknown): value is object { + return ( + (typeof value === "object" && value !== null) || typeof value === "function" + ); +} + +function isString(value: unknown): value is string { + return typeof value === "string"; +} diff --git a/ui/sentry/index.ts b/ui/sentry/index.ts index dca76ca0e0..9666b7002e 100644 --- a/ui/sentry/index.ts +++ b/ui/sentry/index.ts @@ -1,2 +1,3 @@ // Re-export all Sentry utilities +export * from "./event-policy"; export * from "./utils"; diff --git a/ui/sentry/sentry-event-filter.test.ts b/ui/sentry/sentry-event-filter.test.ts new file mode 100644 index 0000000000..370af5884e --- /dev/null +++ b/ui/sentry/sentry-event-filter.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; + +import { filterWarningSentryEvent } from "./sentry-event-filter"; + +describe("filterWarningSentryEvent", () => { + describe("when the event level is warning", () => { + it("should drop the event", () => { + // Given + const event = { level: "warning", message: "Provider already exists" }; + + // When + const result = filterWarningSentryEvent(event); + + // Then + expect(result).toBeNull(); + }); + }); + + describe("when the event level is actionable", () => { + it("should keep error events", () => { + // Given + const event = { level: "error", message: "Unexpected failure" }; + + // When + const result = filterWarningSentryEvent(event); + + // Then + expect(result).toBe(event); + }); + + it("should keep fatal events", () => { + // Given + const event = { level: "fatal", message: "Runtime crashed" }; + + // When + const result = filterWarningSentryEvent(event); + + // Then + expect(result).toBe(event); + }); + + it("should keep events without a level", () => { + // Given + const event = { message: "Default Sentry event" }; + + // When + const result = filterWarningSentryEvent(event); + + // Then + expect(result).toBe(event); + }); + }); +}); diff --git a/ui/sentry/sentry-event-filter.ts b/ui/sentry/sentry-event-filter.ts new file mode 100644 index 0000000000..6396d50e66 --- /dev/null +++ b/ui/sentry/sentry-event-filter.ts @@ -0,0 +1,7 @@ +import { applySentryEventPolicy } from "./event-policy"; + +// Backward-compatible export name: the implementation now applies the full +// actionability policy, including warning drops and expected API noise filters. +export function filterWarningSentryEvent(event: TEvent) { + return applySentryEventPolicy(event); +} diff --git a/ui/sentry/sentry.edge.config.ts b/ui/sentry/sentry.edge.config.ts index f4f10d0566..f8198902b5 100644 --- a/ui/sentry/sentry.edge.config.ts +++ b/ui/sentry/sentry.edge.config.ts @@ -2,6 +2,8 @@ import * as Sentry from "@sentry/nextjs"; import { readGatedEnv } from "@/lib/integrations"; +import { applySentryEventPolicy, SENTRY_EVENT_SOURCE } from "./event-policy"; + const sentryDsn = readGatedEnv( "UI_SENTRY_ENABLED", "UI_SENTRY_DSN", @@ -47,15 +49,13 @@ if (sentryDsn) { // 🔌 Integrations - Edge runtime doesn't support all integrations integrations: [], - // 🎣 Filter expected errors - Don't send noise to Sentry + // 🎣 Filter expected framework control-flow - Don't send noise to Sentry. + // HTTP status-based suppression belongs in applySentryEventPolicy, where + // structured event context prevents broad numeric matches from hiding crashes. ignoreErrors: [ // NextAuth redirect errors - Expected behavior in auth flow "NEXT_REDIRECT", "NEXT_NOT_FOUND", - // Expected HTTP errors - Expected when users lack permissions - "401", // Unauthorized - expected when token expires - "403", // Forbidden - expected when no permissions - "404", // Not Found - expected for missing resources ], beforeSend(event, hint) { @@ -65,20 +65,9 @@ if (sentryDsn) { runtime: "edge", }; - const error = hint.originalException; - - // Don't send NextAuth expected errors - if ( - error && - typeof error === "object" && - "message" in error && - typeof error.message === "string" && - error.message.includes("NEXT_REDIRECT") - ) { - return null; - } - - return event; + return applySentryEventPolicy(event, hint, { + source: SENTRY_EVENT_SOURCE.EDGE, + }); }, }); } diff --git a/ui/sentry/sentry.server.config.ts b/ui/sentry/sentry.server.config.ts index 3a3ac491da..d1c7fef906 100644 --- a/ui/sentry/sentry.server.config.ts +++ b/ui/sentry/sentry.server.config.ts @@ -2,6 +2,8 @@ import * as Sentry from "@sentry/nextjs"; import { readGatedEnv } from "@/lib/integrations"; +import { applySentryEventPolicy, SENTRY_EVENT_SOURCE } from "./event-policy"; + const sentryDsn = readGatedEnv( "UI_SENTRY_ENABLED", "UI_SENTRY_DSN", @@ -52,15 +54,13 @@ if (sentryDsn) { }), ], - // 🎣 Filter expected errors - Don't send noise to Sentry + // 🎣 Filter expected framework control-flow - Don't send noise to Sentry. + // HTTP status-based suppression belongs in applySentryEventPolicy, where + // structured event context prevents broad numeric matches from hiding crashes. ignoreErrors: [ // NextAuth redirect errors - Expected behavior "NEXT_REDIRECT", "NEXT_NOT_FOUND", - // Expected HTTP errors - Expected when users lack permissions - "401", // Unauthorized - "403", // Forbidden - "404", // Not Found ], beforeSend(event, hint) { @@ -87,15 +87,12 @@ if (sentryDsn) { error_type: "api_error", }; } - - // Don't send NextAuth expected errors - if (error.message.includes("NEXT_REDIRECT")) { - return null; - } } } - return event; + return applySentryEventPolicy(event, hint, { + source: SENTRY_EVENT_SOURCE.SERVER, + }); }, }); } diff --git a/ui/tsconfig.json b/ui/tsconfig.json index 40977d892c..b95dc8c77a 100644 --- a/ui/tsconfig.json +++ b/ui/tsconfig.json @@ -24,7 +24,7 @@ "strict": true, "target": "es5" }, - "exclude": ["node_modules", "vitest.config.ts"], + "exclude": ["node_modules", "vitest.config.ts", "eslint.config.ts"], "include": [ "next-env.d.ts", "**/*.ts",