Merge remote-tracking branch 'origin/master' into feature/cloud-enabled-runtime-var

# Conflicts:
#	ui/app/(auth)/(guest-only)/sign-up/page.tsx
This commit is contained in:
Pablo F.G
2026-07-21 11:57:49 +02:00
136 changed files with 3888 additions and 1584 deletions
+2
View File
@@ -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` |
+4 -4
View File
@@ -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 |
@@ -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
+177 -83
View File
@@ -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
+206 -30
View File
@@ -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]
@@ -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
+1 -1
View File
@@ -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
|----------|----------|----------|----------|----------
@@ -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
@@ -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.
@@ -0,0 +1 @@
Alibaba Cloud SSH and RDP security group checks no longer produce false negatives when allowed rules use capitalized `Policy="Accept"` values
@@ -0,0 +1 @@
Fix invalid escape sequence `SyntaxWarning` raised on startup by the S3 bucket name validation regex
@@ -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
@@ -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)
@@ -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)
@@ -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
@@ -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 <lifecycle-config-name> --on-start Content=<base64-script-without-secrets>",
"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": ""
}
@@ -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
@@ -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
``"<hook>[<index>]"``. 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 "<hook>[<index>]" (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] = []
File diff suppressed because it is too large Load Diff
@@ -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 <provider> <keywords>`
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.
@@ -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` 15 (5=critical) × `Weight` (values in the shipped
catalogs: 1000 critical / 100 high / 810 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)
@@ -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",
}
],
)
@@ -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",
}
],
)
@@ -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
)
@@ -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):
@@ -1,5 +1,5 @@
import { LucideIcon } from "lucide-react";
import {
LucideIcon,
Activity,
BarChart3,
Bot,
+5 -2
View File
@@ -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";
@@ -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", () => {
@@ -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) => {
@@ -13,6 +13,7 @@ import {
filterProvidersByScope,
parseFilterIds,
} from "../../_lib/provider-scope";
import { RiskPlotClient } from "./risk-plot-client";
export async function RiskPlotSSR({
@@ -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({
@@ -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 ({
@@ -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 ({
@@ -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 {
@@ -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";
@@ -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) => {
@@ -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 ({
@@ -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 ({
@@ -25,6 +25,7 @@ vi.mock("@/lib/server-actions-helper", () => ({
}));
import { ALERT_AGGREGATE_OPS, ALERT_TRIGGER_KINDS } from "../_types";
import {
createAlert,
deleteAlert,
@@ -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";
@@ -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";
@@ -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", () => {
@@ -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";
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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,
@@ -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";
@@ -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,
@@ -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", () => ({
@@ -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,
@@ -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(() => ({
@@ -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", () => ({
@@ -1,4 +1,3 @@
import React from "react";
import { Suspense } from "react";
import { getRoles } from "@/actions/roles";
@@ -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";
@@ -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 = {
@@ -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";
@@ -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";
@@ -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;
@@ -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",
@@ -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";
@@ -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";
+2 -2
View File
@@ -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";
@@ -0,0 +1 @@
Findings Severity Over Time chart Y-axis labels no longer overflow for large findings counts
@@ -0,0 +1 @@
UI Sentry alerts now suppress non-actionable warnings and expected API/control-flow noise while preserving actionable runtime failures
+1 -1
View File
@@ -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";
+1 -2
View File
@@ -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 {
@@ -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 = ({
@@ -19,6 +19,7 @@ import {
import { ScanEntity } from "@/types/scans";
import { getComplianceIcon } from "../icons";
import { ComplianceDownloadContainer } from "./compliance-download-container";
interface ComplianceCardProps {
@@ -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";
@@ -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";
@@ -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";
+42
View File
@@ -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");
});
});
});
+3
View File
@@ -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)",
+8
View File
@@ -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);
}
@@ -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 (
<Card variant="base" padding="lg">
@@ -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 {
@@ -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 (
<Card variant="base" padding="lg">
@@ -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 {
@@ -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 {
@@ -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";
@@ -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 (
<Card variant="base" padding="lg">
@@ -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,
@@ -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 {
@@ -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;
@@ -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({
@@ -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,
@@ -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 {
@@ -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<HTMLButtonElement> {
export interface VerticalStepsProps extends HTMLAttributes<HTMLButtonElement> {
/**
* An array of steps.
*
@@ -89,10 +88,7 @@ function CheckIcon(props: ComponentProps<"svg">) {
);
}
export const VerticalSteps = React.forwardRef<
HTMLButtonElement,
VerticalStepsProps
>(
export const VerticalSteps = forwardRef<HTMLButtonElement, VerticalStepsProps>(
(
{
color = "primary",
@@ -113,7 +109,7 @@ export const VerticalSteps = React.forwardRef<
onStepChange,
);
const colors = React.useMemo(() => {
const colors = useMemo(() => {
let userColor;
let fgColor;
+1 -1
View File
@@ -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";
@@ -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";
@@ -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";
@@ -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({
@@ -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";
@@ -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";
@@ -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,
@@ -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 {
@@ -26,6 +26,7 @@ import {
pollConnectionTask,
runWithConcurrencyLimit,
} from "../org-account-selection.utils";
import { extractErrorMessage } from "./error-utils";
interface SelectionState {
@@ -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 {

Some files were not shown because too many files have changed in this diff Show More