mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 12:31:54 +00:00
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:
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+41
@@ -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": ""
|
||||
}
|
||||
+131
@@ -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] = []
|
||||
|
||||
|
||||
|
||||
+923
-783
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` 1–5 (5=critical) × `Weight` (values in the shipped
|
||||
catalogs: 1000 critical / 100 high / 8–10 standard / 1 low). Available for
|
||||
aws, azure, gcp, kubernetes, m365, alibabacloud.
|
||||
|
||||
## External Resources
|
||||
|
||||
### Official Framework Documentation
|
||||
- [CIS Benchmarks](https://www.cisecurity.org/cis-benchmarks)
|
||||
- [ISO 27001:2022](https://www.iso.org/standard/27001)
|
||||
- [CIS Critical Security Controls](https://www.cisecurity.org/controls)
|
||||
- [ISO 27001](https://www.iso.org/standard/27001)
|
||||
- [NIST 800-53](https://csrc.nist.gov/publications/detail/sp/800-53/rev-5/final)
|
||||
- [NIST CSF](https://www.nist.gov/cyberframework)
|
||||
- [PCI DSS](https://www.pcisecuritystandards.org/)
|
||||
- [MITRE ATT&CK](https://attack.mitre.org/)
|
||||
- [ENS (Spain)](https://www.ccn-cert.cni.es/es/ens.html)
|
||||
|
||||
### Prowler Documentation
|
||||
- [Prowler Docs - Compliance](https://docs.prowler.com/projects/prowler-open-source/en/latest/)
|
||||
- [Prowler GitHub](https://github.com/prowler-cloud/prowler)
|
||||
- [FINOS CCC](https://github.com/finos/common-cloud-controls)
|
||||
- [CSA CCM](https://cloudsecurityalliance.org/research/cloud-controls-matrix)
|
||||
- [DORA (EU 2022/2554)](https://eur-lex.europa.eu/eli/reg/2022/2554/oj)
|
||||
- [ASD Essential Eight](https://www.cyber.gov.au/resources-business-and-government/essential-cybersecurity/essential-eight)
|
||||
- [CISA SCuBA](https://www.cisa.gov/resources-tools/services/secure-cloud-business-applications-scuba-project)
|
||||
- [DISA STIGs](https://public.cyber.mil/stigs/)
|
||||
- [Prowler Docs — Compliance developer guide](https://docs.prowler.com/developer-guide/security-compliance-framework)
|
||||
|
||||
+2
-2
@@ -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",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
+2
-2
@@ -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",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
+269
@@ -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,
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
+1
@@ -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";
|
||||
|
||||
+1
@@ -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", () => {
|
||||
|
||||
+1
@@ -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";
|
||||
|
||||
+1
@@ -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 {
|
||||
|
||||
+1
@@ -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 {
|
||||
|
||||
+1
@@ -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";
|
||||
|
||||
@@ -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
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
+2
-2
@@ -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";
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user