mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 18:44:24 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e5c3961d1 | ||
|
|
8da79bb47e | ||
|
|
793cc32a1d | ||
|
|
590b6360a6 |
@@ -110,20 +110,11 @@ DJANGO_OUTPUT_S3_AWS_SECRET_ACCESS_KEY=""
|
||||
DJANGO_OUTPUT_S3_AWS_SESSION_TOKEN=""
|
||||
|
||||
# The AWS region where your S3 bucket is located (e.g., "us-east-1")
|
||||
# Required if the bucket uses SSE-KMS: download URLs are then signed with SigV4, which
|
||||
# is scoped to this region, so it must match the bucket's
|
||||
DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION=""
|
||||
|
||||
# The name of the S3 bucket where scan output should be stored
|
||||
DJANGO_OUTPUT_S3_AWS_OUTPUT_BUCKET=""
|
||||
|
||||
# The storage endpoint the API and Celery workers use to upload and list scan output
|
||||
# (e.g. "http://minio:9000"). Leave empty on AWS S3. Set it when scan output is stored on
|
||||
# S3-compatible object storage such as MinIO instead of real S3.
|
||||
# If set without DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL below, report download URLs are
|
||||
# signed against this internal host, and a browser outside the container network cannot open them.
|
||||
DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL=""
|
||||
|
||||
# The storage address the browser can reach, used only to sign report download URLs
|
||||
# (e.g. "https://storage.example.com"). Leave empty on AWS S3. Set it when storage is
|
||||
# only reachable inside the container network, such as MinIO on "http://minio:9000".
|
||||
@@ -174,7 +165,7 @@ SENTRY_RELEASE=local
|
||||
# REO_DEV_CLIENT_ID=
|
||||
|
||||
#### Prowler release version ####
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.44.0
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.43.0
|
||||
|
||||
# Social login credentials
|
||||
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
|
||||
|
||||
@@ -11,7 +11,6 @@ on:
|
||||
- "v5.*"
|
||||
paths:
|
||||
- ".github/workflows/ui-e2e-tests-v2.yml"
|
||||
- ".github/workflows/test-impact-analysis.yml"
|
||||
- ".github/test-impact.yml"
|
||||
- "ui/**"
|
||||
- "api/**" # API changes can affect UI E2E
|
||||
@@ -238,8 +237,8 @@ jobs:
|
||||
|
||||
- name: Add AWS credentials for testing
|
||||
run: |
|
||||
echo "AWS_ACCESS_KEY_ID=${E2E_AWS_PROVIDER_ACCESS_KEY}" >> .env
|
||||
echo "AWS_SECRET_ACCESS_KEY=${E2E_AWS_PROVIDER_SECRET_KEY}" >> .env
|
||||
echo "AWS_ACCESS_KEY_ID=${{ secrets.E2E_AWS_PROVIDER_ACCESS_KEY }}" >> .env
|
||||
echo "AWS_SECRET_ACCESS_KEY=${{ secrets.E2E_AWS_PROVIDER_SECRET_KEY }}" >> .env
|
||||
|
||||
- name: Build API image from current code
|
||||
# docker-compose.yml references prowlercloud/prowler-api:latest from the registry,
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
Adds a periodic sweep that drops orphaned Attack Paths temp Neo4j scan databases left behind when a worker or Neo4j crashes mid-scan, before they accumulate unbounded
|
||||
@@ -1 +0,0 @@
|
||||
Resources no longer keep a stale failed findings count forever when a scoped or imported scan for the same provider completes after a full scan, which used to make the full scan skip its own cleanup
|
||||
@@ -1 +0,0 @@
|
||||
Providers whose most recent completed scan has no `completed_at` timestamp are no longer missing from every endpoint that reports a provider's latest scan, which now falls back to scan creation order instead of skipping the provider
|
||||
@@ -1 +0,0 @@
|
||||
Unify how every endpoint resolves a provider latest completed scan, so overlapping scans no longer make findings, compliance and mute rules read from different scans
|
||||
@@ -1 +0,0 @@
|
||||
Scan output uploads and downloads can now target S3-compatible object storage such as MinIO directly via `DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL`, instead of relying on process-wide AWS environment variables that also hijacked unrelated AWS API calls
|
||||
@@ -1 +0,0 @@
|
||||
Scan report downloads from an S3 bucket with default SSE-KMS encryption no longer fail with an `InvalidArgument` error: when `DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION` is set, presigned download URLs are signed with AWS Signature Version 4 for that region
|
||||
@@ -1 +0,0 @@
|
||||
`POST /api/v1/scans` again returns the new scan id in the response `task_args`, which had been empty since the scan broker publish moved to transaction commit
|
||||
@@ -1 +0,0 @@
|
||||
Celery loggers are now declared explicitly in `custom_logging.py` so fatal worker errors are no longer silenced by `disable_existing_loggers=True`. All long-running services in `docker-compose.yml` now have `restart: unless-stopped` so containers recover automatically after unexpected crashes.
|
||||
+1
-1
@@ -71,7 +71,7 @@ name = "prowler-api"
|
||||
package-mode = false
|
||||
# Needed for the SDK compatibility
|
||||
requires-python = ">=3.11,<3.13"
|
||||
version = "1.45.0"
|
||||
version = "1.44.0"
|
||||
|
||||
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
|
||||
# target-version tracks this project's lowest supported Python.
|
||||
|
||||
@@ -207,11 +207,6 @@ def drop_database(database: str) -> None:
|
||||
sink_module.get_backend().drop_database(database)
|
||||
|
||||
|
||||
def list_databases() -> list[str]:
|
||||
"""List database names on the ingest cluster. Temp scan DBs always live here."""
|
||||
return ingest.list_databases()
|
||||
|
||||
|
||||
def drop_subgraph(database: str, provider_id: str) -> int:
|
||||
return sink_module.get_backend().drop_subgraph(database, provider_id)
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ from api.attack_paths.ingest.driver import (
|
||||
get_session,
|
||||
get_uri,
|
||||
init_driver,
|
||||
list_databases,
|
||||
run_cypher,
|
||||
)
|
||||
|
||||
@@ -26,6 +25,5 @@ __all__ = [
|
||||
"get_session",
|
||||
"get_uri",
|
||||
"init_driver",
|
||||
"list_databases",
|
||||
"run_cypher",
|
||||
]
|
||||
|
||||
@@ -165,14 +165,6 @@ def drop_database(database: str) -> None:
|
||||
session.run(f"DROP DATABASE `{database}` IF EXISTS DESTROY DATA")
|
||||
|
||||
|
||||
def list_databases() -> list[str]:
|
||||
"""List every database name on the Neo4j temp-database cluster."""
|
||||
# A cluster returns one row per hosting server, so dedupe on name
|
||||
with get_session() as session:
|
||||
result = session.run("SHOW DATABASES YIELD name RETURN DISTINCT name")
|
||||
return [record["name"] for record in result]
|
||||
|
||||
|
||||
def clear_cache(database: str) -> None:
|
||||
"""Best-effort cache clear for a Neo4j database."""
|
||||
from api.attack_paths.database import GraphDatabaseQueryException
|
||||
|
||||
@@ -409,7 +409,7 @@ def batch_delete(tenant_id, queryset, batch_size=settings.DJANGO_DELETION_BATCH_
|
||||
|
||||
Args:
|
||||
tenant_id (str): Tenant ID the queryset belongs to.
|
||||
queryset: The queryset of objects to delete.
|
||||
queryset (QuerySet): The queryset of objects to delete.
|
||||
batch_size (int): The number of objects to delete in each batch.
|
||||
|
||||
Returns:
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
from django.db import migrations
|
||||
|
||||
TASK_NAME = "attack-paths-reap-orphaned-tmp-databases"
|
||||
INTERVAL_HOURS = 6
|
||||
|
||||
|
||||
def create_periodic_task(apps, schema_editor):
|
||||
IntervalSchedule = apps.get_model("django_celery_beat", "IntervalSchedule")
|
||||
PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask")
|
||||
|
||||
schedule, _ = IntervalSchedule.objects.get_or_create(
|
||||
every=INTERVAL_HOURS,
|
||||
period="hours",
|
||||
)
|
||||
|
||||
PeriodicTask.objects.update_or_create(
|
||||
name=TASK_NAME,
|
||||
defaults={
|
||||
"task": TASK_NAME,
|
||||
"interval": schedule,
|
||||
"enabled": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def delete_periodic_task(apps, schema_editor):
|
||||
IntervalSchedule = apps.get_model("django_celery_beat", "IntervalSchedule")
|
||||
PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask")
|
||||
|
||||
PeriodicTask.objects.filter(name=TASK_NAME).delete()
|
||||
|
||||
# Clean up the schedule if no other task references it
|
||||
IntervalSchedule.objects.filter(
|
||||
every=INTERVAL_HOURS,
|
||||
period="hours",
|
||||
periodictask__isnull=True,
|
||||
).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("api", "0099_delete_tenant_onboarding_profile"),
|
||||
("django_celery_beat", "0019_alter_periodictasks_options"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(create_periodic_task, delete_periodic_task),
|
||||
]
|
||||
@@ -617,66 +617,9 @@ class Task(RowLevelSecurityProtectedModel):
|
||||
resource_name = "tasks"
|
||||
|
||||
|
||||
class ScanQuerySet(models.QuerySet):
|
||||
"""Shared selectors for "the latest scan of a provider".
|
||||
|
||||
The queryset must already be scoped by the caller: manager, tenant, RBAC,
|
||||
providers and database alias.
|
||||
"""
|
||||
|
||||
# How "which completed scan is the provider's current one" is ordered.
|
||||
LATEST_ORDER_BY = (
|
||||
models.F("completed_at").desc(nulls_last=True),
|
||||
models.F("inserted_at").desc(),
|
||||
models.F("id").desc(),
|
||||
)
|
||||
|
||||
def _eligible_for_latest(self) -> "ScanQuerySet":
|
||||
"""Restrict to the scans that may be a provider's latest.
|
||||
|
||||
Returns:
|
||||
ScanQuerySet: The completed scans.
|
||||
"""
|
||||
return self.filter(state=StateChoices.COMPLETED)
|
||||
|
||||
def latest_per_provider(self) -> "ScanQuerySet":
|
||||
"""Pick each provider's latest scan with `DISTINCT ON (provider_id)`.
|
||||
|
||||
Returns:
|
||||
ScanQuerySet: One scan per provider, the latest one.
|
||||
"""
|
||||
return (
|
||||
self._eligible_for_latest()
|
||||
.order_by("provider_id", *self.LATEST_ORDER_BY)
|
||||
.distinct("provider_id")
|
||||
)
|
||||
|
||||
def latest_ids_per_provider(self) -> list[UUID]:
|
||||
"""Evaluate `latest_per_provider` and return the scan ids.
|
||||
|
||||
The ids are materialised so callers can pass them as a literal `IN`
|
||||
list; as a subquery Postgres misestimates the row count and picks a
|
||||
slow nested loop.
|
||||
|
||||
Returns:
|
||||
list[UUID]: The id of each provider's latest scan.
|
||||
"""
|
||||
return list(self.latest_per_provider().values_list("id", flat=True))
|
||||
|
||||
def latest_first(self) -> "ScanQuerySet":
|
||||
"""Order eligible scans newest first, without deduplicating per provider.
|
||||
|
||||
Expects the queryset to be already filtered to a single provider.
|
||||
|
||||
Returns:
|
||||
ScanQuerySet: The eligible scans, latest first.
|
||||
"""
|
||||
return self._eligible_for_latest().order_by(*self.LATEST_ORDER_BY)
|
||||
|
||||
|
||||
class Scan(RowLevelSecurityProtectedModel):
|
||||
objects = ActiveProviderManager.from_queryset(ScanQuerySet)()
|
||||
all_objects = ScanQuerySet.as_manager()
|
||||
objects = ActiveProviderManager()
|
||||
all_objects = models.Manager()
|
||||
|
||||
_SCOPING_SCANNER_ARG_KEYS_CACHE: tuple[str, ...] | None = None
|
||||
|
||||
@@ -783,12 +726,6 @@ class Scan(RowLevelSecurityProtectedModel):
|
||||
name="scans_prov_state_ins_desc_idx",
|
||||
),
|
||||
# TODO This might replace `scans_prov_state_ins_desc_idx` completely. Review usage
|
||||
# Since `ScanQuerySet`, no code path reads a provider's
|
||||
# completed scans by `-inserted_at`. The only query left that
|
||||
# matches this index (and `scans_prov_state_ins_desc_idx` above)
|
||||
# is `GET /scans?filter[provider]=…&filter[state]=completed` with
|
||||
# the default sort. Both are candidates to drop in a follow-up
|
||||
# once production `pg_stat_user_indexes.idx_scan` confirms it.
|
||||
models.Index(
|
||||
fields=["tenant_id", "provider_id", "-inserted_at"],
|
||||
condition=Q(state=StateChoices.COMPLETED),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Prowler API
|
||||
version: 1.45.0
|
||||
version: 1.44.0
|
||||
description: |-
|
||||
Prowler API specification.
|
||||
|
||||
|
||||
@@ -187,27 +187,6 @@ class TestRoutingByDatabasePrefix:
|
||||
sink_backend_stub.drop_database.assert_called_once_with("db-tenant-abc")
|
||||
mock_ingest.drop_database.assert_not_called()
|
||||
|
||||
def test_list_databases_always_routes_to_ingest(self, sink_backend_stub):
|
||||
with patch("api.attack_paths.database.ingest") as mock_ingest:
|
||||
mock_ingest.list_databases.return_value = ["db-tmp-scan-uuid-1"]
|
||||
|
||||
assert db_module.list_databases() == ["db-tmp-scan-uuid-1"]
|
||||
|
||||
mock_ingest.list_databases.assert_called_once_with()
|
||||
|
||||
def test_ingest_list_databases_dedupes_cluster_rows(self):
|
||||
from api.attack_paths.ingest import driver as ingest_driver
|
||||
|
||||
with patch.object(ingest_driver, "get_session") as mock_get_session:
|
||||
session = mock_get_session.return_value.__enter__.return_value
|
||||
session.run.return_value = [{"name": "db-tmp-scan-uuid-1"}]
|
||||
|
||||
assert ingest_driver.list_databases() == ["db-tmp-scan-uuid-1"]
|
||||
|
||||
session.run.assert_called_once_with(
|
||||
"SHOW DATABASES YIELD name RETURN DISTINCT name"
|
||||
)
|
||||
|
||||
def test_clear_cache_routes_temp_to_ingest(self, sink_backend_stub):
|
||||
with patch("api.attack_paths.database.ingest") as mock_ingest:
|
||||
db_module.clear_cache("db-tmp-scan-uuid-1")
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from api.db_router import MainRouter
|
||||
from api.models import (
|
||||
Provider,
|
||||
ProviderComplianceScore,
|
||||
Resource,
|
||||
ResourceTag,
|
||||
SAMLConfiguration,
|
||||
SAMLDomainIndex,
|
||||
Scan,
|
||||
StateChoices,
|
||||
StatusChoices,
|
||||
TenantComplianceSummary,
|
||||
@@ -526,226 +524,3 @@ class TestTenantComplianceSummaryModel:
|
||||
|
||||
assert summary1.id != summary2.id
|
||||
assert summary1.requirements_passed != summary2.requirements_passed
|
||||
|
||||
|
||||
def _latest_scan_fixture(tenant, provider, *, completed_at, inserted_at=None, **kwargs):
|
||||
scan = Scan.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
provider=provider,
|
||||
trigger=kwargs.pop("trigger", Scan.TriggerChoices.MANUAL),
|
||||
state=kwargs.pop("state", StateChoices.COMPLETED),
|
||||
completed_at=completed_at,
|
||||
**kwargs,
|
||||
)
|
||||
if inserted_at is not None:
|
||||
# `inserted_at` is auto_now_add, so it has to be forced after the fact.
|
||||
Scan.all_objects.filter(pk=scan.pk).update(inserted_at=inserted_at)
|
||||
scan.refresh_from_db()
|
||||
return scan
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestScanQuerySetOrdering:
|
||||
def test_completed_later_wins_over_inserted_later(
|
||||
self, tenants_fixture, aws_provider
|
||||
):
|
||||
"""The scan that FINISHED last is current, not the one that started last."""
|
||||
tenant, *_ = tenants_fixture
|
||||
now = datetime.now(UTC)
|
||||
|
||||
finished_last = _latest_scan_fixture(
|
||||
tenant,
|
||||
aws_provider,
|
||||
inserted_at=now - timedelta(hours=3),
|
||||
completed_at=now,
|
||||
)
|
||||
_latest_scan_fixture(
|
||||
tenant,
|
||||
aws_provider,
|
||||
inserted_at=now - timedelta(hours=1),
|
||||
completed_at=now - timedelta(hours=1),
|
||||
)
|
||||
|
||||
assert Scan.all_objects.filter(
|
||||
tenant_id=tenant.id
|
||||
).latest_ids_per_provider() == [finished_last.id]
|
||||
|
||||
def test_null_completed_at_provider_is_still_returned(
|
||||
self, tenants_fixture, aws_provider
|
||||
):
|
||||
"""NULLS LAST, not `completed_at__isnull=False`.
|
||||
|
||||
Excluding NULL `completed_at` would drop the provider from every
|
||||
"latest" endpoint instead of falling back to `inserted_at`.
|
||||
"""
|
||||
tenant, *_ = tenants_fixture
|
||||
only_scan = _latest_scan_fixture(tenant, aws_provider, completed_at=None)
|
||||
|
||||
assert Scan.all_objects.filter(
|
||||
tenant_id=tenant.id
|
||||
).latest_ids_per_provider() == [only_scan.id]
|
||||
|
||||
def test_null_completed_at_never_outranks_a_finished_scan(
|
||||
self, tenants_fixture, aws_provider
|
||||
):
|
||||
"""Postgres sorts NULLs first under DESC; NULLS LAST is what fixes it."""
|
||||
tenant, *_ = tenants_fixture
|
||||
now = datetime.now(UTC)
|
||||
|
||||
finished = _latest_scan_fixture(
|
||||
tenant,
|
||||
aws_provider,
|
||||
inserted_at=now - timedelta(hours=2),
|
||||
completed_at=now - timedelta(hours=2),
|
||||
)
|
||||
_latest_scan_fixture(
|
||||
tenant,
|
||||
aws_provider,
|
||||
inserted_at=now,
|
||||
completed_at=None,
|
||||
)
|
||||
|
||||
assert Scan.all_objects.filter(
|
||||
tenant_id=tenant.id
|
||||
).latest_ids_per_provider() == [finished.id]
|
||||
|
||||
def test_id_breaks_an_exact_timestamp_tie_deterministically(
|
||||
self, tenants_fixture, aws_provider
|
||||
):
|
||||
tenant, *_ = tenants_fixture
|
||||
now = datetime.now(UTC)
|
||||
|
||||
scans = [
|
||||
_latest_scan_fixture(
|
||||
tenant, aws_provider, inserted_at=now, completed_at=now
|
||||
)
|
||||
for _ in range(3)
|
||||
]
|
||||
expected = max(scan.id for scan in scans)
|
||||
|
||||
picks = {
|
||||
Scan.all_objects.filter(tenant_id=tenant.id).latest_ids_per_provider()[0]
|
||||
for _ in range(5)
|
||||
}
|
||||
assert picks == {expected}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestScanQuerySetEligibility:
|
||||
def test_unfinished_scans_are_excluded(self, tenants_fixture, aws_provider):
|
||||
tenant, *_ = tenants_fixture
|
||||
_latest_scan_fixture(
|
||||
tenant,
|
||||
aws_provider,
|
||||
completed_at=None,
|
||||
state=StateChoices.EXECUTING,
|
||||
)
|
||||
assert (
|
||||
Scan.all_objects.filter(tenant_id=tenant.id).latest_ids_per_provider() == []
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestScanQuerySetManagerChoice:
|
||||
def test_active_manager_hides_soft_deleted_providers(
|
||||
self, tenants_fixture, aws_provider
|
||||
):
|
||||
"""`Scan.objects` drops soft-deleted providers, `all_objects` keeps them.
|
||||
|
||||
The queryset must not decide this for the caller.
|
||||
"""
|
||||
tenant, *_ = tenants_fixture
|
||||
scan = _latest_scan_fixture(
|
||||
tenant, aws_provider, completed_at=datetime.now(UTC)
|
||||
)
|
||||
|
||||
Provider.all_objects.filter(pk=aws_provider.pk).update(is_deleted=True)
|
||||
|
||||
assert Scan.all_objects.filter(
|
||||
tenant_id=tenant.id
|
||||
).latest_ids_per_provider() == [scan.id]
|
||||
assert Scan.objects.filter(tenant_id=tenant.id).latest_ids_per_provider() == []
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestScanQuerySetPerProviderScoping:
|
||||
def test_one_scan_per_provider(self, tenants_fixture, aws_provider_pair):
|
||||
tenant, *_ = tenants_fixture
|
||||
provider_one, provider_two = aws_provider_pair
|
||||
now = datetime.now(UTC)
|
||||
|
||||
newest_one = _latest_scan_fixture(tenant, provider_one, completed_at=now)
|
||||
_latest_scan_fixture(tenant, provider_one, completed_at=now - timedelta(days=1))
|
||||
newest_two = _latest_scan_fixture(tenant, provider_two, completed_at=now)
|
||||
|
||||
assert set(
|
||||
Scan.all_objects.filter(tenant_id=tenant.id).latest_ids_per_provider()
|
||||
) == {newest_one.id, newest_two.id}
|
||||
|
||||
def test_caller_filters_are_preserved(self, tenants_fixture, aws_provider_pair):
|
||||
tenant, *_ = tenants_fixture
|
||||
provider_one, provider_two = aws_provider_pair
|
||||
now = datetime.now(UTC)
|
||||
|
||||
scan_one = _latest_scan_fixture(tenant, provider_one, completed_at=now)
|
||||
_latest_scan_fixture(tenant, provider_two, completed_at=now)
|
||||
|
||||
assert Scan.all_objects.filter(
|
||||
tenant_id=tenant.id, provider__in=[provider_one]
|
||||
).latest_ids_per_provider() == [scan_one.id]
|
||||
|
||||
def test_latest_first_is_ordered_not_deduplicated(
|
||||
self, tenants_fixture, aws_provider
|
||||
):
|
||||
tenant, *_ = tenants_fixture
|
||||
now = datetime.now(UTC)
|
||||
|
||||
newest = _latest_scan_fixture(tenant, aws_provider, completed_at=now)
|
||||
older = _latest_scan_fixture(
|
||||
tenant, aws_provider, completed_at=now - timedelta(days=1)
|
||||
)
|
||||
|
||||
ordered = list(
|
||||
Scan.all_objects.filter(
|
||||
tenant_id=tenant.id, provider_id=aws_provider.id
|
||||
).latest_first()
|
||||
)
|
||||
assert [scan.id for scan in ordered] == [newest.id, older.id]
|
||||
|
||||
def test_tenant_isolation(self, tenants_fixture, aws_provider):
|
||||
tenant, other_tenant, *_ = tenants_fixture
|
||||
_latest_scan_fixture(tenant, aws_provider, completed_at=datetime.now(UTC))
|
||||
|
||||
assert (
|
||||
Scan.all_objects.filter(tenant_id=other_tenant.id).latest_ids_per_provider()
|
||||
== []
|
||||
)
|
||||
|
||||
def test_empty_queryset_returns_empty_list(self, tenants_fixture):
|
||||
tenant, *_ = tenants_fixture
|
||||
assert (
|
||||
Scan.all_objects.filter(tenant_id=tenant.id).latest_ids_per_provider() == []
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestScanQuerySetPerProviderQuerysetShape:
|
||||
def test_returns_a_queryset_not_a_list(self, tenants_fixture, aws_provider):
|
||||
tenant, *_ = tenants_fixture
|
||||
_latest_scan_fixture(tenant, aws_provider, completed_at=datetime.now(UTC))
|
||||
|
||||
qs = Scan.all_objects.filter(tenant_id=tenant.id).latest_per_provider()
|
||||
# Callers chain .values(...) / .values_list(...) onto this.
|
||||
assert qs.values_list("provider_id", flat=True).count() == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestScanQuerySetRelatedManager:
|
||||
def test_reverse_relation_exposes_the_methods(self, tenants_fixture, aws_provider):
|
||||
tenant, *_ = tenants_fixture
|
||||
now = datetime.now(UTC)
|
||||
|
||||
newest = _latest_scan_fixture(tenant, aws_provider, completed_at=now)
|
||||
_latest_scan_fixture(tenant, aws_provider, completed_at=now - timedelta(days=1))
|
||||
|
||||
assert aws_provider.scans.latest_first().first().id == newest.id
|
||||
|
||||
@@ -3951,43 +3951,6 @@ class TestScanViewSet:
|
||||
mock_enqueue_scan_execution.assert_called_once()
|
||||
# assert scan.scanner_args == expected_scanner_args
|
||||
|
||||
@patch("api.v1.views.enqueue_scan_execution_on_commit")
|
||||
def test_scans_create_returns_the_scan_id_in_task_args(
|
||||
self,
|
||||
mock_enqueue_scan_execution,
|
||||
authenticated_client,
|
||||
okta_provider,
|
||||
):
|
||||
"""The 202 is a task, so `task_args` is the only place the scan id is.
|
||||
|
||||
It is serialized before the on_commit publish that would otherwise fill
|
||||
the kwargs, so the record has to carry them from the start.
|
||||
"""
|
||||
payload = {
|
||||
"data": {
|
||||
"type": "scans",
|
||||
"attributes": {"name": "New Scan"},
|
||||
"relationships": {
|
||||
"provider": {
|
||||
"data": {"type": "providers", "id": str(okta_provider.id)}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
response = authenticated_client.post(
|
||||
reverse("scan-list"),
|
||||
data=payload,
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_202_ACCEPTED
|
||||
scan = Scan.objects.get()
|
||||
assert response.json()["data"]["attributes"]["task_args"] == {
|
||||
"scan_id": str(scan.id),
|
||||
"provider_id": str(okta_provider.id),
|
||||
}
|
||||
|
||||
@patch("tasks.tasks.perform_scan_task.apply_async")
|
||||
def test_scans_create_queues_scan_when_provider_has_active_scan(
|
||||
self,
|
||||
|
||||
@@ -2822,15 +2822,6 @@ class ScanViewSet(ProviderVisibilityMixin, BaseRLSViewSet):
|
||||
tenant_id=self.request.tenant_id,
|
||||
task_id=pre_task_id,
|
||||
task_status=(QUEUED_SCAN_TASK_STATE if active_scan else None),
|
||||
# This response is serialized before the on_commit publish,
|
||||
# so without these the caller gets a task id and no scan id.
|
||||
# Kept in step with what `enqueue_scan_execution_on_commit`
|
||||
# publishes below.
|
||||
task_kwargs={
|
||||
"tenant_id": str(self.request.tenant_id),
|
||||
"scan_id": str(scan.id),
|
||||
"provider_id": str(scan.provider_id),
|
||||
},
|
||||
)
|
||||
|
||||
if not active_scan:
|
||||
@@ -3487,8 +3478,12 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet):
|
||||
filtered_queryset = self.filter_queryset(self.get_queryset())
|
||||
|
||||
latest_scans = (
|
||||
Scan.all_objects.filter(tenant_id=tenant_id)
|
||||
.latest_per_provider()
|
||||
Scan.all_objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
state=StateChoices.COMPLETED,
|
||||
)
|
||||
.order_by("provider_id", "-inserted_at")
|
||||
.distinct("provider_id")
|
||||
.values("provider_id")
|
||||
)
|
||||
|
||||
@@ -3620,9 +3615,11 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet):
|
||||
tenant_id = request.tenant_id
|
||||
query_params = request.query_params
|
||||
|
||||
latest_scans_queryset = Scan.all_objects.filter(
|
||||
tenant_id=tenant_id
|
||||
).latest_per_provider()
|
||||
latest_scans_queryset = (
|
||||
Scan.all_objects.filter(tenant_id=tenant_id, state=StateChoices.COMPLETED)
|
||||
.order_by("provider_id", "-inserted_at")
|
||||
.distinct("provider_id")
|
||||
)
|
||||
|
||||
queryset = ResourceScanSummary.objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
@@ -4209,9 +4206,12 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet):
|
||||
tenant_id = request.tenant_id
|
||||
filtered_queryset = self.filter_queryset(self.get_queryset())
|
||||
|
||||
latest_scan_ids = Scan.all_objects.filter(
|
||||
tenant_id=tenant_id
|
||||
).latest_ids_per_provider()
|
||||
latest_scan_ids = list(
|
||||
Scan.all_objects.filter(tenant_id=tenant_id, state=StateChoices.COMPLETED)
|
||||
.order_by("provider_id", "-inserted_at")
|
||||
.distinct("provider_id")
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
filtered_queryset = filtered_queryset.filter(
|
||||
tenant_id=tenant_id, scan_id__in=latest_scan_ids
|
||||
)
|
||||
@@ -4234,9 +4234,11 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet):
|
||||
tenant_id = request.tenant_id
|
||||
query_params = request.query_params
|
||||
|
||||
latest_scans_queryset = Scan.all_objects.filter(
|
||||
tenant_id=tenant_id
|
||||
).latest_per_provider()
|
||||
latest_scans_queryset = (
|
||||
Scan.all_objects.filter(tenant_id=tenant_id, state=StateChoices.COMPLETED)
|
||||
.order_by("provider_id", "-inserted_at")
|
||||
.distinct("provider_id")
|
||||
)
|
||||
raw_latest_scans_ids = list(
|
||||
latest_scans_queryset.values_list("id", "unique_resource_count")
|
||||
)
|
||||
@@ -4977,7 +4979,11 @@ class ComplianceOverviewViewSet(
|
||||
if provider_filters:
|
||||
scans = scans.filter(**provider_filters)
|
||||
|
||||
return scans.latest_ids_per_provider()
|
||||
return list(
|
||||
scans.order_by("provider_id", "-inserted_at")
|
||||
.distinct("provider_id")
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
|
||||
def _filtered_queryset_for_latest_provider_scans(self, latest_scan_ids=None):
|
||||
if latest_scan_ids is None:
|
||||
@@ -5719,9 +5725,14 @@ class OverviewViewSet(ProviderFilterParamsMixin, BaseRLSViewSet):
|
||||
else {}
|
||||
)
|
||||
|
||||
latest_scan_ids = Scan.all_objects.filter(
|
||||
tenant_id=tenant_id, **provider_filter
|
||||
).latest_ids_per_provider()
|
||||
latest_scan_ids = (
|
||||
Scan.all_objects.filter(
|
||||
tenant_id=tenant_id, state=StateChoices.COMPLETED, **provider_filter
|
||||
)
|
||||
.order_by("provider_id", "-inserted_at")
|
||||
.distinct("provider_id")
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
|
||||
return filtered_queryset.filter(
|
||||
tenant_id=tenant_id, scan_id__in=latest_scan_ids
|
||||
@@ -5748,10 +5759,16 @@ class OverviewViewSet(ProviderFilterParamsMixin, BaseRLSViewSet):
|
||||
|
||||
def _latest_scan_ids_for_allowed_providers(self, tenant_id, provider_filters=None):
|
||||
provider_filter = self._get_provider_filter()
|
||||
queryset = Scan.all_objects.filter(tenant_id=tenant_id, **provider_filter)
|
||||
queryset = Scan.all_objects.filter(
|
||||
tenant_id=tenant_id, state=StateChoices.COMPLETED, **provider_filter
|
||||
)
|
||||
if provider_filters:
|
||||
queryset = queryset.filter(**provider_filters)
|
||||
return queryset.latest_ids_per_provider()
|
||||
return (
|
||||
queryset.order_by("provider_id", "-inserted_at")
|
||||
.distinct("provider_id")
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
|
||||
@action(detail=False, methods=["get"], url_name="providers")
|
||||
def providers(self, request):
|
||||
@@ -5763,9 +5780,14 @@ class OverviewViewSet(ProviderFilterParamsMixin, BaseRLSViewSet):
|
||||
else {}
|
||||
)
|
||||
|
||||
latest_scan_ids = Scan.all_objects.filter(
|
||||
tenant_id=tenant_id, **provider_filter
|
||||
).latest_ids_per_provider()
|
||||
latest_scan_ids = (
|
||||
Scan.all_objects.filter(
|
||||
tenant_id=tenant_id, state=StateChoices.COMPLETED, **provider_filter
|
||||
)
|
||||
.order_by("provider_id", "-inserted_at")
|
||||
.distinct("provider_id")
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
|
||||
findings_aggregated = (
|
||||
queryset.filter(scan_id__in=latest_scan_ids)
|
||||
@@ -7912,13 +7934,18 @@ class FindingGroupViewSet(JsonApiFilterMixin, BaseRLSViewSet):
|
||||
|
||||
def _get_latest_findings_per_provider(self, filtered_queryset):
|
||||
"""Keep only findings from each provider's most recent completed scan."""
|
||||
# `latest_ids_per_provider` materializes to a literal IN list.
|
||||
# Left as a subquery, Postgres can't estimate the match count and picks
|
||||
# a serial nested loop on resource_finding_mappings when one scan
|
||||
# dominates findings
|
||||
latest_scan_ids = Scan.objects.filter(
|
||||
tenant_id=self.request.tenant_id
|
||||
).latest_ids_per_provider()
|
||||
# Materialize to a literal IN list. Left as a subquery, Postgres can't
|
||||
# estimate the match count and picks a serial nested loop on
|
||||
# resource_finding_mappings when one scan dominates findings
|
||||
latest_scan_ids = list(
|
||||
Scan.objects.filter(
|
||||
tenant_id=self.request.tenant_id,
|
||||
state=StateChoices.COMPLETED,
|
||||
)
|
||||
.order_by("provider_id", "-completed_at", "-inserted_at")
|
||||
.distinct("provider_id")
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
return filtered_queryset.filter(scan_id__in=latest_scan_ids)
|
||||
|
||||
def _post_process_aggregation(self, aggregated_data):
|
||||
@@ -8864,14 +8891,16 @@ class FindingGroupViewSet(JsonApiFilterMixin, BaseRLSViewSet):
|
||||
tenant_id = request.tenant_id
|
||||
queryset = self._get_finding_queryset()
|
||||
|
||||
# The shared selector orders by -completed_at (matching the /latest
|
||||
# summary path and the daily summary upsert keyed on
|
||||
# midnight(completed_at)) so that overlapping scans do not make
|
||||
# /resources and /latest read from different scans and report
|
||||
# diverging counts.
|
||||
latest_scan_ids = Scan.objects.filter(
|
||||
tenant_id=tenant_id
|
||||
).latest_ids_per_provider()
|
||||
# Order by -completed_at (matching the /latest summary path and the
|
||||
# daily summary upsert keyed on midnight(completed_at)) so that
|
||||
# overlapping scans do not make /resources and /latest read from
|
||||
# different scans and report diverging counts.
|
||||
latest_scan_ids = (
|
||||
Scan.objects.filter(tenant_id=tenant_id, state=StateChoices.COMPLETED)
|
||||
.order_by("provider_id", "-completed_at", "-inserted_at")
|
||||
.distinct("provider_id")
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
|
||||
normalized_params = self._normalize_jsonapi_params(request.query_params)
|
||||
# Remove date filters since we're using latest
|
||||
|
||||
@@ -231,62 +231,6 @@ LOGGING = {
|
||||
"level": LEVEL,
|
||||
"propagate": False,
|
||||
},
|
||||
# Celery loggers must be declared explicitly because
|
||||
# disable_existing_loggers=True silences any logger that exists at
|
||||
# dictConfig time but is not named here. Without these, fatal worker
|
||||
# errors (e.g. celery.worker CRITICAL) produce no output.
|
||||
# "celery" must keep propagating: get_task_logger() parents task
|
||||
# loggers under celery.task, so blocking here hides them from root.
|
||||
"celery": {
|
||||
"level": LEVEL,
|
||||
"propagate": True,
|
||||
},
|
||||
"celery.worker": {
|
||||
"handlers": ["tasks_console"],
|
||||
"level": LEVEL,
|
||||
"propagate": False,
|
||||
},
|
||||
"celery.worker.consumer": {
|
||||
"handlers": ["tasks_console"],
|
||||
"level": LEVEL,
|
||||
"propagate": False,
|
||||
},
|
||||
"celery.worker.consumer.consumer": {
|
||||
"handlers": ["tasks_console"],
|
||||
"level": LEVEL,
|
||||
"propagate": False,
|
||||
},
|
||||
"kombu": {
|
||||
"handlers": ["tasks_console"],
|
||||
"level": LEVEL,
|
||||
"propagate": False,
|
||||
},
|
||||
"kombu.transport.redis": {
|
||||
"handlers": ["tasks_console"],
|
||||
"level": LEVEL,
|
||||
"propagate": False,
|
||||
},
|
||||
"billiard": {
|
||||
"handlers": ["tasks_console"],
|
||||
"level": LEVEL,
|
||||
"propagate": False,
|
||||
},
|
||||
"amqp": {
|
||||
"handlers": ["tasks_console"],
|
||||
"level": LEVEL,
|
||||
"propagate": False,
|
||||
},
|
||||
# WARNING keeps task failures but skips one "succeeded" line per task.
|
||||
"celery.app.trace": {
|
||||
"handlers": ["tasks_console"],
|
||||
"level": "WARNING",
|
||||
"propagate": False,
|
||||
},
|
||||
"celery.beat": {
|
||||
"handlers": ["tasks_console"],
|
||||
"level": LEVEL,
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
# Gunicorn required configuration
|
||||
"root": {
|
||||
|
||||
@@ -7,7 +7,6 @@ from config.settings.eventstream import * # noqa
|
||||
from config.settings.partitions import * # noqa
|
||||
from config.settings.sentry import * # noqa
|
||||
from config.settings.social_login import * # noqa
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
SECRET_KEY = env("SECRET_KEY", default="secret")
|
||||
DEBUG = env.bool("DJANGO_DEBUG", default=False)
|
||||
@@ -296,9 +295,6 @@ DJANGO_OUTPUT_S3_AWS_SECRET_ACCESS_KEY = env.str(
|
||||
)
|
||||
DJANGO_OUTPUT_S3_AWS_SESSION_TOKEN = env.str("DJANGO_OUTPUT_S3_AWS_SESSION_TOKEN", "")
|
||||
DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION = env.str("DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION", "")
|
||||
# Storage endpoint the API and Celery workers use to talk to S3-compatible object storage
|
||||
# such as MinIO. Empty means the real AWS S3 endpoint, which is unaffected.
|
||||
DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL = env.str("DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL", "")
|
||||
# Browser-reachable storage host used to sign download URLs. Empty means sign against the
|
||||
# same endpoint the API talks to, which is what Prowler Cloud on S3 does.
|
||||
DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL = env.str(
|
||||
@@ -329,17 +325,6 @@ ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES = env.int(
|
||||
"ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES", 960
|
||||
) # 16h
|
||||
|
||||
# Minimum age (of the scan row, or of the scan id itself when the row is gone) before
|
||||
# the periodic reaper will drop an orphaned temp Neo4j database. Keeps a scan that is
|
||||
# still legitimately in flight from ever losing its staging database mid-run.
|
||||
ATTACK_PATHS_TMP_DB_REAP_SAFETY_MARGIN_HOURS = env.int(
|
||||
"ATTACK_PATHS_TMP_DB_REAP_SAFETY_MARGIN_HOURS", 6
|
||||
)
|
||||
if ATTACK_PATHS_TMP_DB_REAP_SAFETY_MARGIN_HOURS <= 0:
|
||||
raise ImproperlyConfigured(
|
||||
"ATTACK_PATHS_TMP_DB_REAP_SAFETY_MARGIN_HOURS must be a positive number of hours"
|
||||
)
|
||||
|
||||
# Selects where the persistent attack-paths graph is stored. The scan
|
||||
# temporary database is always Neo4j; only the sink is configurable.
|
||||
# Valid values: "neo4j" (default, OSS and local dev), "neptune" (hosted).
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
"""Periodic reaper for orphaned temp Neo4j scan databases.
|
||||
|
||||
`scan.py` creates a throw-away `db-tmp-scan-<attack_paths_scan_id>` database per
|
||||
scan and drops it once the scan finishes, success or failure. When the worker
|
||||
or Neo4j itself dies mid-scan, that drop never runs and nothing else ever
|
||||
revisits the database - it sits there forever. This sweep lists every temp
|
||||
database on the ingest cluster and drops the ones whose scan is gone or has
|
||||
been finished for longer than the configured safety margin.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from api.attack_paths import database as graph_database
|
||||
from api.db_router import MainRouter
|
||||
from api.models import AttackPathsScan, StateChoices
|
||||
from api.uuid_utils import datetime_from_uuid7
|
||||
from celery.utils.log import get_task_logger
|
||||
from config.django.base import ATTACK_PATHS_TMP_DB_REAP_SAFETY_MARGIN_HOURS
|
||||
from uuid6 import UUID as UUID7
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
TERMINAL_STATES = (
|
||||
StateChoices.COMPLETED,
|
||||
StateChoices.FAILED,
|
||||
StateChoices.CANCELLED,
|
||||
)
|
||||
|
||||
|
||||
def reap_orphaned_tmp_databases() -> dict:
|
||||
"""Drop temp Neo4j scan databases whose scan is gone or long finished.
|
||||
|
||||
A failure listing databases aborts the whole sweep (nothing to iterate).
|
||||
A failure reaping one database is logged and skipped so the rest of the
|
||||
sweep still runs.
|
||||
"""
|
||||
now = datetime.now(tz=UTC)
|
||||
safety_margin = timedelta(hours=ATTACK_PATHS_TMP_DB_REAP_SAFETY_MARGIN_HOURS)
|
||||
|
||||
try:
|
||||
databases = graph_database.list_databases()
|
||||
except Exception:
|
||||
logger.exception("Failed to list ingest Neo4j databases for temp-db reap")
|
||||
return {"dropped_count": 0, "databases": []}
|
||||
|
||||
tmp_databases = [
|
||||
name for name in databases if name.startswith(graph_database.TEMP_DB_PREFIX)
|
||||
]
|
||||
|
||||
dropped: list[str] = []
|
||||
for database in tmp_databases:
|
||||
try:
|
||||
if _is_orphaned(database, now, safety_margin):
|
||||
graph_database.drop_database(database)
|
||||
dropped.append(database)
|
||||
logger.info(f"Dropped orphaned temp Neo4j database `{database}`")
|
||||
except Exception:
|
||||
logger.exception(f"Failed to reap temp Neo4j database `{database}`")
|
||||
|
||||
logger.info(f"Temp Neo4j database reap: {len(dropped)} dropped")
|
||||
return {"dropped_count": len(dropped), "databases": dropped}
|
||||
|
||||
|
||||
def _is_orphaned(database: str, now: datetime, safety_margin: timedelta) -> bool:
|
||||
"""Decide whether a temp database is safe to drop.
|
||||
|
||||
No scan row: the row was hard-deleted (tenant/provider cleanup) or was
|
||||
never created. Falls back to the scan id's own UUIDv7 timestamp so a
|
||||
database created moments ago is never touched even without a row to check.
|
||||
|
||||
Scan row present: only reapable once it reached a terminal state and has
|
||||
been finished for longer than the safety margin, so a scan still
|
||||
legitimately executing is never touched.
|
||||
"""
|
||||
scan_id = database[len(graph_database.TEMP_DB_PREFIX) :]
|
||||
|
||||
try:
|
||||
scan_uuid = UUID7(scan_id)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
f"Temp database `{database}` has an unparseable scan id, skipping"
|
||||
)
|
||||
return False
|
||||
|
||||
# Global sweep with no tenant context: admin_db bypasses RLS on purpose, the same
|
||||
# way cleanup_stale_attack_paths_scans finds stale scans across every tenant.
|
||||
scan = (
|
||||
AttackPathsScan.all_objects.using(MainRouter.admin_db)
|
||||
.filter(id=scan_uuid)
|
||||
.first()
|
||||
)
|
||||
|
||||
if scan is None:
|
||||
if scan_uuid.version != 7:
|
||||
logger.warning(
|
||||
f"Temp database `{database}` has no scan row and a non-UUIDv7 id, "
|
||||
"skipping"
|
||||
)
|
||||
return False
|
||||
return now - datetime_from_uuid7(scan_uuid) >= safety_margin
|
||||
|
||||
if scan.state not in TERMINAL_STATES:
|
||||
return False
|
||||
|
||||
# `mark_scan_finished` does not touch `updated_at`, so prefer `completed_at`
|
||||
finished_at = scan.completed_at or scan.updated_at
|
||||
return now - finished_at >= safety_margin
|
||||
@@ -499,13 +499,10 @@ def backfill_provider_compliance_scores(tenant_id: str) -> dict:
|
||||
provider_id__in=existing_providers
|
||||
)
|
||||
|
||||
# `completed_scans` keeps its own `completed_at__isnull=False`: this
|
||||
# task writes a *dated* ProviderComplianceScore row, so unlike the read
|
||||
# paths it genuinely cannot use a scan without a `completed_at`.
|
||||
scan_info = list(
|
||||
completed_scans.latest_per_provider().values(
|
||||
"id", "provider_id", "completed_at"
|
||||
)
|
||||
completed_scans.order_by("provider_id", "-completed_at")
|
||||
.distinct("provider_id")
|
||||
.values("id", "provider_id", "completed_at")
|
||||
)
|
||||
|
||||
if not scan_info:
|
||||
|
||||
@@ -207,21 +207,15 @@ def get_s3_client():
|
||||
This function attempts to initialize an S3 client by reading the AWS access key, secret key,
|
||||
session token, and region from environment variables. It then validates the client by listing
|
||||
available S3 buckets. If an error occurs during this process (for example, due to missing or
|
||||
invalid credentials), it falls back to creating an S3 client without explicitly provided
|
||||
credentials, which may rely on other configuration sources (e.g., IAM roles).
|
||||
|
||||
That fallback is only safe when no explicit endpoint is configured: with an endpoint set, the
|
||||
explicit client already targets the intended S3-compatible storage, and the fallback client
|
||||
would go to the AWS default provider chain instead, an unrelated real-AWS account reachable
|
||||
from the host. So when an endpoint is configured, the original error propagates instead.
|
||||
invalid credentials), it falls back to creating an S3 client without explicitly provided credentials,
|
||||
which may rely on other configuration sources (e.g., IAM roles).
|
||||
|
||||
Returns:
|
||||
boto3.client: A configured S3 client instance.
|
||||
|
||||
Raises:
|
||||
ClientError, NoCredentialsError, or ParamValidationError if the client cannot be created.
|
||||
ClientError, NoCredentialsError, or ParamValidationError if both attempts to create a client fail.
|
||||
"""
|
||||
endpoint = settings.DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL
|
||||
s3_client = None
|
||||
try:
|
||||
s3_client = boto3.client(
|
||||
@@ -232,12 +226,9 @@ def get_s3_client():
|
||||
# Storage that has no meaningful region, MinIO among it, is usually configured
|
||||
# without one, and botocore rejects an empty region before any request is made.
|
||||
region_name=settings.DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION or "us-east-1",
|
||||
endpoint_url=endpoint or None,
|
||||
)
|
||||
s3_client.list_buckets()
|
||||
except (ClientError, NoCredentialsError, ParamValidationError, ValueError):
|
||||
if endpoint:
|
||||
raise
|
||||
s3_client = boto3.client("s3")
|
||||
s3_client.list_buckets()
|
||||
|
||||
@@ -245,21 +236,13 @@ def get_s3_client():
|
||||
|
||||
|
||||
def get_s3_presign_client():
|
||||
"""Return a client that signs download URLs with SigV4.
|
||||
"""Return a client that signs URLs against the public storage host.
|
||||
|
||||
It is used when a public or internal storage host is configured, or when the bucket's
|
||||
region is: boto3 otherwise presigns S3 URLs with SigV2, which S3 rejects for SSE-KMS
|
||||
objects. None means none of those is set and the caller should presign with its own
|
||||
client, which leaves those deployments with the URL they get today.
|
||||
|
||||
The public endpoint wins when both are set: the internal endpoint may only be reachable
|
||||
from inside the cluster, and a URL signed against it would not open in a browser.
|
||||
None means no public host is configured and the caller should presign with its own
|
||||
client, which leaves deployments on real S3 with the URL they get today.
|
||||
"""
|
||||
endpoint = (
|
||||
settings.DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL
|
||||
or settings.DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL
|
||||
)
|
||||
if not endpoint and not settings.DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION:
|
||||
public_endpoint = settings.DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL
|
||||
if not public_endpoint:
|
||||
return None
|
||||
|
||||
# Blank keys are signed as-is (empty credential scope) instead of deferring to the
|
||||
@@ -283,10 +266,9 @@ def get_s3_presign_client():
|
||||
# SigV4 puts the region in the credential scope, and MinIO answers to us-east-1
|
||||
# unless it was told otherwise, so an empty region would sign an unusable URL.
|
||||
region_name=settings.DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION or "us-east-1",
|
||||
endpoint_url=endpoint or None,
|
||||
endpoint_url=public_endpoint,
|
||||
# The signature covers the host, so the addressing style has to be pinned rather
|
||||
# than guessed from the endpoint: MinIO serves path-style, and on AWS it keeps the
|
||||
# regional host instead of the global one, which redirects for new buckets.
|
||||
# than guessed from the endpoint: MinIO serves path-style.
|
||||
config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from collections.abc import Iterable
|
||||
|
||||
from api.db_utils import rls_transaction
|
||||
from api.models import Finding, MuteRule, Scan
|
||||
from api.models import Finding, MuteRule, Scan, StateChoices
|
||||
from celery.utils.log import get_task_logger
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
@@ -39,9 +39,17 @@ def mute_findings_in_latest_scans(
|
||||
|
||||
with rls_transaction(tenant_id):
|
||||
mute_rule = MuteRule.objects.get(id=mute_rule_id, tenant_id=tenant_id)
|
||||
latest_scans = Scan.objects.filter(
|
||||
tenant_id=tenant_id, provider_id__in=provider_ids
|
||||
).latest_ids_per_provider()
|
||||
latest_scans = list(
|
||||
Scan.objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
provider_id__in=provider_ids,
|
||||
state=StateChoices.COMPLETED,
|
||||
completed_at__isnull=False,
|
||||
)
|
||||
.order_by("provider_id", "-completed_at", "-inserted_at", "-id")
|
||||
.distinct("provider_id")
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
|
||||
changed_scan_ids = []
|
||||
findings_muted = 0
|
||||
|
||||
@@ -84,7 +84,6 @@ _SKIP_RECOVERY = {
|
||||
"scan-perform-scheduled",
|
||||
"attack-paths-scan-perform",
|
||||
"attack-paths-cleanup-stale-scans",
|
||||
"attack-paths-reap-orphaned-tmp-databases",
|
||||
"reconcile-orphan-tasks",
|
||||
}
|
||||
|
||||
|
||||
@@ -2700,37 +2700,20 @@ def reset_ephemeral_resource_findings_count(tenant_id: str, scan_id: str) -> dic
|
||||
# refreshed). Wiping based on the older scan would zero counts the newer
|
||||
# scan just set. Skip and let the newer scan's reset task do the work; if
|
||||
# this task was delayed in the queue, that's the correct outcome.
|
||||
#
|
||||
# The comparison must be against the newest *full-scope* scan, which is
|
||||
# what this variable has always been named after but did not use to be:
|
||||
# the query filtered nothing about scope, so any newer scan that is not
|
||||
# full-scope (an imported one, for instance) made the full-scope scan
|
||||
# skip its own cleanup and leave ephemeral resources with a stale
|
||||
# failed_findings_count permanently.
|
||||
#
|
||||
# `is_full_scope()` reads `trigger` plus the scoping keys inside
|
||||
# `scanner_args`, which is not expressible as a WHERE clause, so the
|
||||
# candidates are walked newest-first in Python until the first full-scope
|
||||
# one. The walk needs no cap: `scan` is itself a full-scope candidate, so
|
||||
# it stops at `scan` at the latest, after reading only the scans newer than
|
||||
# it. A fixed window would return None once more newer scoped scans had
|
||||
# landed than it inspected, and skip the cleanup exactly like the bug above.
|
||||
#
|
||||
# NULL `completed_at` no longer needs an explicit filter here: the shared
|
||||
# ordering in `ScanQuerySet.LATEST_ORDER_BY` sorts NULLs last
|
||||
# rather than excluding them, which also fixes the case where a provider
|
||||
# whose completed scans all have a NULL `completed_at` resolved to None and
|
||||
# therefore never ran the reset at all.
|
||||
# `completed_at__isnull=False` is required: Postgres orders NULL first in
|
||||
# DESC, so a sibling COMPLETED scan with a missing completed_at would sort
|
||||
# as "newest" and incorrectly cause us to skip.
|
||||
with rls_transaction(tenant_id):
|
||||
candidates = (
|
||||
Scan.objects.filter(tenant_id=tenant_id, provider_id=scan.provider_id)
|
||||
.latest_first()
|
||||
.only("id", "trigger", "scanner_args")
|
||||
.iterator(chunk_size=100)
|
||||
)
|
||||
latest_full_scope_scan_id = next(
|
||||
(candidate.id for candidate in candidates if candidate.is_full_scope()),
|
||||
None,
|
||||
latest_full_scope_scan_id = (
|
||||
Scan.objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
provider_id=scan.provider_id,
|
||||
state=StateChoices.COMPLETED,
|
||||
completed_at__isnull=False,
|
||||
)
|
||||
.order_by("-completed_at", "-inserted_at")
|
||||
.values_list("id", flat=True)
|
||||
.first()
|
||||
)
|
||||
if latest_full_scope_scan_id != scan.id:
|
||||
logger.info(
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import json
|
||||
import os
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
@@ -43,7 +42,6 @@ from tasks.jobs.attack_paths import (
|
||||
)
|
||||
from tasks.jobs.attack_paths import db_utils as attack_paths_db_utils
|
||||
from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans
|
||||
from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases
|
||||
from tasks.jobs.backfill import (
|
||||
aggregate_scan_category_summaries,
|
||||
aggregate_scan_resource_group_summaries,
|
||||
@@ -165,28 +163,13 @@ def create_scan_task_record(
|
||||
task_id: str,
|
||||
task_name: str = "scan-perform",
|
||||
task_status: str | None = states.PENDING,
|
||||
task_kwargs: dict | None = None,
|
||||
) -> Task:
|
||||
"""Pre-create the TaskResult + Task rows for a pre-generated task id.
|
||||
|
||||
Pass ``task_kwargs`` when the response built from this record is serialized
|
||||
before the broker publish. ``task_kwargs`` is otherwise only written by the
|
||||
``before_task_publish`` signal (``api/signals.py``), and the scan publish is
|
||||
deferred to ``on_commit``, so the 202 would carry an empty ``task_args`` and
|
||||
the caller would have no way to learn the scan id it was just handed a task
|
||||
for. The publish later overwrites the field with the same kwargs as a Python
|
||||
repr; both forms decode to the same dict (``decode_celery_field``).
|
||||
"""
|
||||
if task_status is None:
|
||||
task_status = states.PENDING
|
||||
|
||||
defaults = {"status": task_status, "task_name": task_name}
|
||||
if task_kwargs is not None:
|
||||
defaults["task_kwargs"] = json.dumps(task_kwargs)
|
||||
|
||||
task_result, _ = TaskResult.objects.update_or_create(
|
||||
task_id=str(task_id),
|
||||
defaults=defaults,
|
||||
defaults={"status": task_status, "task_name": task_name},
|
||||
)
|
||||
prowler_task, _ = Task.objects.update_or_create(
|
||||
id=str(task_id),
|
||||
@@ -728,13 +711,6 @@ def cleanup_stale_attack_paths_scans_task():
|
||||
return cleanup_stale_attack_paths_scans()
|
||||
|
||||
|
||||
@shared_task(
|
||||
name="attack-paths-reap-orphaned-tmp-databases", queue="attack-paths-scans"
|
||||
)
|
||||
def reap_orphaned_attack_paths_tmp_databases_task():
|
||||
return reap_orphaned_tmp_databases()
|
||||
|
||||
|
||||
@shared_task(name="reconcile-orphan-tasks", queue="celery")
|
||||
def reconcile_orphan_tasks_task():
|
||||
"""Periodic watchdog: recover tasks whose worker is gone (deploys, crashes)."""
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from api.attack_paths.database import TEMP_DB_PREFIX
|
||||
from api.models import AttackPathsScan, StateChoices
|
||||
from api.uuid_utils import datetime_to_uuid7
|
||||
from config.django.base import ATTACK_PATHS_TMP_DB_REAP_SAFETY_MARGIN_HOURS
|
||||
|
||||
MARGIN = timedelta(hours=ATTACK_PATHS_TMP_DB_REAP_SAFETY_MARGIN_HOURS)
|
||||
|
||||
|
||||
def _tmp_db_name(scan_uuid) -> str:
|
||||
return f"{TEMP_DB_PREFIX}{scan_uuid}"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestReapOrphanedTmpDatabases:
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database")
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases")
|
||||
def test_ignores_databases_without_the_temp_prefix(self, mock_list, mock_drop):
|
||||
from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases
|
||||
|
||||
mock_list.return_value = ["db-tenant-abc123", "system", "neo4j"]
|
||||
|
||||
result = reap_orphaned_tmp_databases()
|
||||
|
||||
assert result == {"dropped_count": 0, "databases": []}
|
||||
mock_drop.assert_not_called()
|
||||
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database")
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases")
|
||||
def test_drops_temp_db_with_no_scan_row_past_safety_margin(
|
||||
self, mock_list, mock_drop
|
||||
):
|
||||
from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases
|
||||
|
||||
old_scan_id = datetime_to_uuid7(
|
||||
datetime.now(tz=UTC) - MARGIN - timedelta(hours=1)
|
||||
)
|
||||
database = _tmp_db_name(old_scan_id)
|
||||
mock_list.return_value = [database]
|
||||
|
||||
result = reap_orphaned_tmp_databases()
|
||||
|
||||
assert result == {"dropped_count": 1, "databases": [database]}
|
||||
mock_drop.assert_called_once_with(database)
|
||||
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database")
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases")
|
||||
def test_preserves_temp_db_with_no_scan_row_inside_safety_margin(
|
||||
self, mock_list, mock_drop
|
||||
):
|
||||
from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases
|
||||
|
||||
recent_scan_id = datetime_to_uuid7(datetime.now(tz=UTC) - timedelta(minutes=5))
|
||||
database = _tmp_db_name(recent_scan_id)
|
||||
mock_list.return_value = [database]
|
||||
|
||||
result = reap_orphaned_tmp_databases()
|
||||
|
||||
assert result == {"dropped_count": 0, "databases": []}
|
||||
mock_drop.assert_not_called()
|
||||
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database")
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases")
|
||||
def test_preserves_temp_db_with_unparseable_scan_id(self, mock_list, mock_drop):
|
||||
from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases
|
||||
|
||||
database = f"{TEMP_DB_PREFIX}not-a-uuid"
|
||||
mock_list.return_value = [database]
|
||||
|
||||
result = reap_orphaned_tmp_databases()
|
||||
|
||||
assert result == {"dropped_count": 0, "databases": []}
|
||||
mock_drop.assert_not_called()
|
||||
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database")
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases")
|
||||
def test_drops_terminal_scan_past_safety_margin(
|
||||
self, mock_list, mock_drop, tenants_fixture, aws_provider
|
||||
):
|
||||
from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases
|
||||
|
||||
tenant = tenants_fixture[0]
|
||||
old_updated_at = datetime.now(tz=UTC) - MARGIN - timedelta(hours=1)
|
||||
scan = AttackPathsScan.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
provider=aws_provider,
|
||||
state=StateChoices.COMPLETED,
|
||||
)
|
||||
AttackPathsScan.objects.filter(id=scan.id).update(updated_at=old_updated_at)
|
||||
|
||||
database = _tmp_db_name(scan.id)
|
||||
mock_list.return_value = [database]
|
||||
|
||||
result = reap_orphaned_tmp_databases()
|
||||
|
||||
assert result == {"dropped_count": 1, "databases": [database]}
|
||||
mock_drop.assert_called_once_with(database)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"state",
|
||||
[StateChoices.FAILED, StateChoices.CANCELLED],
|
||||
)
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database")
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases")
|
||||
def test_drops_other_terminal_states_past_safety_margin(
|
||||
self, mock_list, mock_drop, tenants_fixture, aws_provider, state
|
||||
):
|
||||
from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases
|
||||
|
||||
tenant = tenants_fixture[0]
|
||||
old_updated_at = datetime.now(tz=UTC) - MARGIN - timedelta(hours=1)
|
||||
scan = AttackPathsScan.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
provider=aws_provider,
|
||||
state=state,
|
||||
)
|
||||
AttackPathsScan.objects.filter(id=scan.id).update(updated_at=old_updated_at)
|
||||
|
||||
database = _tmp_db_name(scan.id)
|
||||
mock_list.return_value = [database]
|
||||
|
||||
result = reap_orphaned_tmp_databases()
|
||||
|
||||
assert result["dropped_count"] == 1
|
||||
mock_drop.assert_called_once_with(database)
|
||||
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database")
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases")
|
||||
def test_preserves_terminal_scan_inside_safety_margin(
|
||||
self, mock_list, mock_drop, tenants_fixture, aws_provider
|
||||
):
|
||||
from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases
|
||||
|
||||
tenant = tenants_fixture[0]
|
||||
scan = AttackPathsScan.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
provider=aws_provider,
|
||||
state=StateChoices.COMPLETED,
|
||||
)
|
||||
|
||||
database = _tmp_db_name(scan.id)
|
||||
mock_list.return_value = [database]
|
||||
|
||||
result = reap_orphaned_tmp_databases()
|
||||
|
||||
assert result == {"dropped_count": 0, "databases": []}
|
||||
mock_drop.assert_not_called()
|
||||
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database")
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases")
|
||||
def test_margin_counts_from_completed_at_not_updated_at(
|
||||
self, mock_list, mock_drop, tenants_fixture, aws_provider
|
||||
):
|
||||
from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases
|
||||
|
||||
tenant = tenants_fixture[0]
|
||||
old = datetime.now(tz=UTC) - MARGIN - timedelta(hours=1)
|
||||
scan = AttackPathsScan.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
provider=aws_provider,
|
||||
state=StateChoices.COMPLETED,
|
||||
)
|
||||
AttackPathsScan.objects.filter(id=scan.id).update(
|
||||
updated_at=old, completed_at=datetime.now(tz=UTC)
|
||||
)
|
||||
|
||||
database = _tmp_db_name(scan.id)
|
||||
mock_list.return_value = [database]
|
||||
|
||||
result = reap_orphaned_tmp_databases()
|
||||
|
||||
assert result == {"dropped_count": 0, "databases": []}
|
||||
mock_drop.assert_not_called()
|
||||
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database")
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases")
|
||||
def test_drops_scan_completed_past_safety_margin(
|
||||
self, mock_list, mock_drop, tenants_fixture, aws_provider
|
||||
):
|
||||
from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases
|
||||
|
||||
tenant = tenants_fixture[0]
|
||||
old = datetime.now(tz=UTC) - MARGIN - timedelta(hours=1)
|
||||
scan = AttackPathsScan.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
provider=aws_provider,
|
||||
state=StateChoices.FAILED,
|
||||
)
|
||||
AttackPathsScan.objects.filter(id=scan.id).update(
|
||||
updated_at=old, completed_at=old
|
||||
)
|
||||
|
||||
database = _tmp_db_name(scan.id)
|
||||
mock_list.return_value = [database]
|
||||
|
||||
result = reap_orphaned_tmp_databases()
|
||||
|
||||
assert result == {"dropped_count": 1, "databases": [database]}
|
||||
mock_drop.assert_called_once_with(database)
|
||||
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database")
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases")
|
||||
def test_never_drops_an_executing_scan_regardless_of_age(
|
||||
self, mock_list, mock_drop, tenants_fixture, aws_provider
|
||||
):
|
||||
from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases
|
||||
|
||||
tenant = tenants_fixture[0]
|
||||
very_old = datetime.now(tz=UTC) - timedelta(days=30)
|
||||
scan = AttackPathsScan.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
provider=aws_provider,
|
||||
state=StateChoices.EXECUTING,
|
||||
)
|
||||
AttackPathsScan.objects.filter(id=scan.id).update(updated_at=very_old)
|
||||
|
||||
database = _tmp_db_name(scan.id)
|
||||
mock_list.return_value = [database]
|
||||
|
||||
result = reap_orphaned_tmp_databases()
|
||||
|
||||
assert result == {"dropped_count": 0, "databases": []}
|
||||
mock_drop.assert_not_called()
|
||||
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database")
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases")
|
||||
def test_one_failed_drop_does_not_stop_the_rest_of_the_sweep(
|
||||
self, mock_list, mock_drop
|
||||
):
|
||||
from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases
|
||||
|
||||
old_time = datetime.now(tz=UTC) - MARGIN - timedelta(hours=1)
|
||||
failing_scan_id = datetime_to_uuid7(old_time)
|
||||
succeeding_scan_id = datetime_to_uuid7(old_time)
|
||||
failing_db = _tmp_db_name(failing_scan_id)
|
||||
succeeding_db = _tmp_db_name(succeeding_scan_id)
|
||||
mock_list.return_value = [failing_db, succeeding_db]
|
||||
mock_drop.side_effect = [Exception("boom"), None]
|
||||
|
||||
result = reap_orphaned_tmp_databases()
|
||||
|
||||
assert result == {"dropped_count": 1, "databases": [succeeding_db]}
|
||||
assert mock_drop.call_count == 2
|
||||
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases")
|
||||
def test_returns_empty_result_when_listing_databases_fails(self, mock_list):
|
||||
from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases
|
||||
|
||||
mock_list.side_effect = Exception("neo4j unreachable")
|
||||
|
||||
result = reap_orphaned_tmp_databases()
|
||||
|
||||
assert result == {"dropped_count": 0, "databases": []}
|
||||
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database")
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases")
|
||||
def test_preserves_temp_db_with_random_uuid_and_no_row(self, mock_list, mock_drop):
|
||||
"""A non-UUIDv7 id with no matching row has no reliable timestamp, so it
|
||||
must be left alone rather than guessed at."""
|
||||
from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases
|
||||
|
||||
database = _tmp_db_name(uuid4())
|
||||
mock_list.return_value = [database]
|
||||
|
||||
result = reap_orphaned_tmp_databases()
|
||||
|
||||
assert result == {"dropped_count": 0, "databases": []}
|
||||
mock_drop.assert_not_called()
|
||||
|
||||
|
||||
class TestReapOrphanedTmpDatabasesTask:
|
||||
@patch(
|
||||
"tasks.tasks.reap_orphaned_tmp_databases",
|
||||
return_value={"dropped_count": 2, "databases": ["db-tmp-scan-a"]},
|
||||
)
|
||||
def test_task_invokes_the_reaper(self, mock_reap):
|
||||
from tasks.tasks import reap_orphaned_attack_paths_tmp_databases_task
|
||||
|
||||
result = reap_orphaned_attack_paths_tmp_databases_task.run()
|
||||
|
||||
assert result == {"dropped_count": 2, "databases": ["db-tmp-scan-a"]}
|
||||
mock_reap.assert_called_once_with()
|
||||
@@ -3,10 +3,9 @@ import uuid
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import boto3
|
||||
import pytest
|
||||
from botocore.exceptions import ClientError
|
||||
from django.test import override_settings
|
||||
@@ -64,46 +63,15 @@ class TestOutputs:
|
||||
|
||||
assert mock_boto_client.call_args.kwargs["region_name"] == "us-east-1"
|
||||
|
||||
@patch("tasks.jobs.export.boto3.client")
|
||||
@override_settings(DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL="http://minio:9000")
|
||||
def test_get_s3_client_passes_the_endpoint_when_set(self, mock_boto_client):
|
||||
get_s3_client()
|
||||
|
||||
assert mock_boto_client.call_args.kwargs["endpoint_url"] == "http://minio:9000"
|
||||
|
||||
@patch("tasks.jobs.export.boto3.client")
|
||||
@override_settings(DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL="")
|
||||
def test_get_s3_client_endpoint_empty_by_default(self, mock_boto_client):
|
||||
"""Empty keeps today's behavior: no endpoint override, real S3 is used."""
|
||||
get_s3_client()
|
||||
|
||||
assert mock_boto_client.call_args.kwargs["endpoint_url"] is None
|
||||
|
||||
@patch("tasks.jobs.export.boto3.client")
|
||||
@override_settings(DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL="http://minio:9000")
|
||||
def test_get_s3_client_does_not_fall_back_when_endpoint_set(self, mock_boto_client):
|
||||
"""A configured endpoint means the explicit client failed talking to it. The fallback
|
||||
goes to the default provider chain (e.g. an EC2 instance role) against real AWS, so it
|
||||
must not be used: the original error propagates instead."""
|
||||
error = ClientError({"Error": {"Code": "403"}}, "ListBuckets")
|
||||
mock_boto_client.side_effect = error
|
||||
|
||||
with pytest.raises(ClientError):
|
||||
get_s3_client()
|
||||
|
||||
mock_boto_client.assert_called_once()
|
||||
|
||||
@patch("tasks.jobs.export.boto3.client")
|
||||
@patch("tasks.jobs.export.settings")
|
||||
def test_get_s3_client_fallback(self, mock_settings, mock_boto_client):
|
||||
mock_settings.DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL = ""
|
||||
mock_boto_client.side_effect = [
|
||||
ClientError({"Error": {"Code": "403"}}, "ListBuckets"),
|
||||
MagicMock(),
|
||||
]
|
||||
client = get_s3_client()
|
||||
assert client is not None
|
||||
assert mock_boto_client.call_args_list[1] == call("s3")
|
||||
|
||||
@patch("tasks.jobs.export.get_s3_client")
|
||||
@patch("tasks.jobs.export.base")
|
||||
@@ -310,69 +278,9 @@ def _presign(client):
|
||||
|
||||
|
||||
class TestS3PresignClient:
|
||||
@override_settings(
|
||||
**{**PRESIGN_SETTINGS, "DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION": ""},
|
||||
DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="",
|
||||
)
|
||||
def test_no_public_endpoint_and_no_region_returns_none(self):
|
||||
# Without a region, SigV4 would have to guess one and break other regions.
|
||||
assert get_s3_presign_client() is None
|
||||
|
||||
@override_settings(**PRESIGN_SETTINGS, DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="")
|
||||
def test_region_without_public_endpoint_signs_sigv4_on_the_regional_host(self):
|
||||
# SSE-KMS objects reject the SigV2 URLs boto3 presigns by default, and the
|
||||
# global host redirects for new buckets, which breaks a SigV4 signature.
|
||||
url = urlparse(_presign(get_s3_presign_client()))
|
||||
query = parse_qs(url.query)
|
||||
|
||||
assert url.netloc == "s3.eu-west-1.amazonaws.com"
|
||||
assert url.path == "/output-bucket/tenant/scan/report.zip"
|
||||
assert query["X-Amz-Algorithm"] == ["AWS4-HMAC-SHA256"]
|
||||
assert "/eu-west-1/s3/aws4_request" in query["X-Amz-Credential"][0]
|
||||
|
||||
@override_settings(
|
||||
**{
|
||||
**PRESIGN_SETTINGS,
|
||||
"DJANGO_OUTPUT_S3_AWS_ACCESS_KEY_ID": "",
|
||||
"DJANGO_OUTPUT_S3_AWS_SECRET_ACCESS_KEY": "",
|
||||
},
|
||||
DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="",
|
||||
)
|
||||
def test_region_without_static_keys_signs_with_the_default_chain(self, monkeypatch):
|
||||
# An ECS task role reaches boto3 through the default chain, like the env here.
|
||||
# A fresh default session keeps these keys from being cached for later tests.
|
||||
monkeypatch.setattr(boto3, "DEFAULT_SESSION", None)
|
||||
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "role-access-key")
|
||||
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "role-secret-key")
|
||||
monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1")
|
||||
|
||||
query = parse_qs(urlparse(_presign(get_s3_presign_client())).query)
|
||||
|
||||
assert query["X-Amz-Credential"][0].startswith("role-access-key/")
|
||||
assert "/eu-west-1/s3/aws4_request" in query["X-Amz-Credential"][0]
|
||||
|
||||
@override_settings(
|
||||
**{**PRESIGN_SETTINGS, "DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION": ""},
|
||||
DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="",
|
||||
DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL="http://minio:9000",
|
||||
)
|
||||
def test_internal_endpoint_without_public_endpoint_signs_against_it(self):
|
||||
# No browser-reachable host was configured, so the internal one is the best
|
||||
# available target instead of falling through to the real AWS host.
|
||||
url = urlparse(_presign(get_s3_presign_client()))
|
||||
|
||||
assert url.netloc == "minio:9000"
|
||||
assert url.path == "/output-bucket/tenant/scan/report.zip"
|
||||
|
||||
@override_settings(
|
||||
**PRESIGN_SETTINGS,
|
||||
DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="https://storage.example.com",
|
||||
DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL="http://minio:9000",
|
||||
)
|
||||
def test_public_endpoint_wins_over_the_internal_endpoint(self):
|
||||
url = urlparse(_presign(get_s3_presign_client()))
|
||||
|
||||
assert url.netloc == "storage.example.com"
|
||||
def test_no_public_endpoint_returns_none(self):
|
||||
assert get_s3_presign_client() is None
|
||||
|
||||
@override_settings(
|
||||
**PRESIGN_SETTINGS,
|
||||
|
||||
@@ -5967,112 +5967,6 @@ class TestResetEphemeralResourceFindingsCount:
|
||||
resource2.refresh_from_db()
|
||||
assert resource2.failed_findings_count == 5
|
||||
|
||||
def test_runs_when_newer_scan_is_not_full_scope(
|
||||
self, tenants_fixture, scans_fixture, aws_provider, resources_fixture
|
||||
):
|
||||
"""A newer scoped scan must not block the full-scope scan's cleanup.
|
||||
|
||||
The race guard used to pick the newest COMPLETED scan of any scope
|
||||
despite being named after full-scope ones, so a single scoped scan
|
||||
landing after a complete one made the complete scan skip its cleanup
|
||||
and leave ephemeral resources with a stale count permanently.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
tenant, *_ = tenants_fixture
|
||||
scan1, *_ = scans_fixture
|
||||
resource1, resource2, _ = resources_fixture
|
||||
|
||||
Resource.objects.filter(id=resource2.id).update(failed_findings_count=5)
|
||||
self._make_scan_summary(tenant.id, scan1.id, resource1)
|
||||
|
||||
newer_completed_at = scan1.completed_at + timedelta(minutes=5)
|
||||
Scan.objects.create(
|
||||
name="Newer scoped scan",
|
||||
provider=aws_provider,
|
||||
trigger=Scan.TriggerChoices.MANUAL,
|
||||
state=StateChoices.COMPLETED,
|
||||
tenant_id=tenant.id,
|
||||
started_at=newer_completed_at,
|
||||
completed_at=newer_completed_at,
|
||||
scanner_args={"checks": ["check1"]},
|
||||
)
|
||||
|
||||
result = reset_ephemeral_resource_findings_count(
|
||||
tenant_id=str(tenant.id), scan_id=str(scan1.id)
|
||||
)
|
||||
|
||||
assert result["status"] == "completed"
|
||||
|
||||
resource2.refresh_from_db()
|
||||
assert resource2.failed_findings_count == 0
|
||||
|
||||
def test_runs_when_many_newer_scans_are_not_full_scope(
|
||||
self, tenants_fixture, scans_fixture, aws_provider, resources_fixture
|
||||
):
|
||||
"""The walk must not give up before it reaches the full-scope scan.
|
||||
|
||||
A fixed look-back window returned None once more newer scoped scans
|
||||
had landed than it inspected, and skipped the cleanup exactly like the
|
||||
original bug did with one.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
tenant, *_ = tenants_fixture
|
||||
scan1, *_ = scans_fixture
|
||||
resource1, resource2, _ = resources_fixture
|
||||
|
||||
Resource.objects.filter(id=resource2.id).update(failed_findings_count=5)
|
||||
self._make_scan_summary(tenant.id, scan1.id, resource1)
|
||||
|
||||
for minutes in range(1, 41):
|
||||
newer_completed_at = scan1.completed_at + timedelta(minutes=minutes)
|
||||
Scan.objects.create(
|
||||
name=f"Newer scoped scan {minutes}",
|
||||
provider=aws_provider,
|
||||
trigger=Scan.TriggerChoices.MANUAL,
|
||||
state=StateChoices.COMPLETED,
|
||||
tenant_id=tenant.id,
|
||||
started_at=newer_completed_at,
|
||||
completed_at=newer_completed_at,
|
||||
scanner_args={"checks": ["check1"]},
|
||||
)
|
||||
|
||||
result = reset_ephemeral_resource_findings_count(
|
||||
tenant_id=str(tenant.id), scan_id=str(scan1.id)
|
||||
)
|
||||
|
||||
assert result["status"] == "completed"
|
||||
|
||||
resource2.refresh_from_db()
|
||||
assert resource2.failed_findings_count == 0
|
||||
|
||||
def test_runs_when_completed_at_is_null(
|
||||
self, tenants_fixture, scans_fixture, aws_provider, resources_fixture
|
||||
):
|
||||
"""NULL `completed_at` used to make the reset never run at all.
|
||||
|
||||
The old guard filtered `completed_at__isnull=False`, so a provider
|
||||
whose completed scans all had a NULL `completed_at` resolved the
|
||||
"latest" scan to None, which never equals `scan.id`.
|
||||
"""
|
||||
tenant, *_ = tenants_fixture
|
||||
scan1, *_ = scans_fixture
|
||||
resource1, resource2, _ = resources_fixture
|
||||
|
||||
Scan.all_objects.filter(id=scan1.id).update(completed_at=None)
|
||||
Resource.objects.filter(id=resource2.id).update(failed_findings_count=5)
|
||||
self._make_scan_summary(tenant.id, scan1.id, resource1)
|
||||
|
||||
result = reset_ephemeral_resource_findings_count(
|
||||
tenant_id=str(tenant.id), scan_id=str(scan1.id)
|
||||
)
|
||||
|
||||
assert result["status"] == "completed"
|
||||
|
||||
resource2.refresh_from_db()
|
||||
assert resource2.failed_findings_count == 0
|
||||
|
||||
def test_does_not_touch_other_providers_resources(
|
||||
self, tenants_fixture, scans_fixture, aws_provider, resources_fixture
|
||||
):
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import json
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC, datetime
|
||||
@@ -15,7 +14,6 @@ from api.models import (
|
||||
StateChoices,
|
||||
Task,
|
||||
)
|
||||
from api.v1.serializers import TaskSerializer
|
||||
from botocore.exceptions import ClientError
|
||||
from celery import states
|
||||
from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
||||
@@ -34,7 +32,6 @@ from tasks.tasks import (
|
||||
_scan_tmp_output_directory,
|
||||
check_integrations_task,
|
||||
check_lighthouse_provider_connection_task,
|
||||
create_scan_task_record,
|
||||
generate_outputs_task,
|
||||
mute_findings_in_latest_scans_task,
|
||||
perform_attack_paths_scan_task,
|
||||
@@ -3387,79 +3384,3 @@ class TestTaskTimeLimits:
|
||||
"lighthouse-provider-connection-check",
|
||||
):
|
||||
assert celery_app.tasks[name].time_limit < default
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestCreateScanTaskRecord:
|
||||
"""`task_kwargs` is what a response built before the publish can report."""
|
||||
|
||||
def _scan(self, tenant, provider):
|
||||
"""A manual scan, like the one `POST /api/v1/scans` creates."""
|
||||
return Scan.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
provider=provider,
|
||||
name="Manual scan",
|
||||
trigger=Scan.TriggerChoices.MANUAL,
|
||||
state=StateChoices.AVAILABLE,
|
||||
)
|
||||
|
||||
def _publish_kwargs(self, tenant, scan):
|
||||
"""What `enqueue_scan_execution_on_commit` publishes for this scan."""
|
||||
return {
|
||||
"tenant_id": str(tenant.id),
|
||||
"scan_id": str(scan.id),
|
||||
"provider_id": str(scan.provider_id),
|
||||
}
|
||||
|
||||
def _task_args(self, task):
|
||||
"""Read the record back the way `TaskSerializer` does."""
|
||||
return TaskSerializer(task).data["task_args"]
|
||||
|
||||
def test_the_stored_kwargs_are_the_ones_the_publish_would_send(
|
||||
self, tenants_fixture, aws_provider
|
||||
):
|
||||
"""The 202 reports what is stored here, so it has to be the dispatch kwargs."""
|
||||
tenant = tenants_fixture[0]
|
||||
scan = self._scan(tenant, aws_provider)
|
||||
|
||||
task = create_scan_task_record(
|
||||
tenant_id=str(tenant.id),
|
||||
task_id=str(uuid.uuid4()),
|
||||
task_kwargs=self._publish_kwargs(tenant, scan),
|
||||
)
|
||||
|
||||
assert self._task_args(task) == {
|
||||
"scan_id": str(scan.id),
|
||||
"provider_id": str(aws_provider.id),
|
||||
}
|
||||
|
||||
def test_a_record_created_without_kwargs_reports_none(self, tenants_fixture):
|
||||
"""The argument is optional, so the other callers keep their behaviour."""
|
||||
task = create_scan_task_record(
|
||||
tenant_id=str(tenants_fixture[0].id),
|
||||
task_id=str(uuid.uuid4()),
|
||||
)
|
||||
|
||||
assert self._task_args(task) == {}
|
||||
|
||||
def test_the_publish_can_overwrite_the_stored_kwargs(
|
||||
self, tenants_fixture, aws_provider
|
||||
):
|
||||
"""django-celery-results stores a Python repr; both must decode alike."""
|
||||
tenant = tenants_fixture[0]
|
||||
scan = self._scan(tenant, aws_provider)
|
||||
task_id = str(uuid.uuid4())
|
||||
kwargs = self._publish_kwargs(tenant, scan)
|
||||
|
||||
task = create_scan_task_record(
|
||||
tenant_id=str(tenant.id), task_id=task_id, task_kwargs=kwargs
|
||||
)
|
||||
before = self._task_args(task)
|
||||
|
||||
# What `before_task_publish` writes once the task reaches the broker.
|
||||
task_result = TaskResult.objects.get(task_id=task_id)
|
||||
task_result.task_kwargs = json.dumps(repr(kwargs))
|
||||
task_result.save(update_fields=["task_kwargs"])
|
||||
task.refresh_from_db()
|
||||
|
||||
assert self._task_args(task) == before
|
||||
|
||||
Generated
+1
-1
@@ -4938,7 +4938,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler-api"
|
||||
version = "1.45.0"
|
||||
version = "1.44.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "cartography" },
|
||||
|
||||
@@ -15,7 +15,6 @@ services:
|
||||
api:
|
||||
hostname: "prowler-api"
|
||||
image: prowlercloud/prowler-api:${PROWLER_API_VERSION:-stable}
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
@@ -45,7 +44,6 @@ services:
|
||||
|
||||
ui:
|
||||
image: prowlercloud/prowler-ui:${PROWLER_UI_VERSION:-stable}
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
@@ -63,7 +61,6 @@ services:
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
|
||||
restart: unless-stopped
|
||||
hostname: "postgres-db"
|
||||
volumes:
|
||||
- ./_data/postgres:/var/lib/postgresql/data
|
||||
@@ -84,7 +81,6 @@ services:
|
||||
|
||||
valkey:
|
||||
image: valkey/valkey:8-alpine@sha256:a038175878d66b9d274fbf8be73c0305e93798b83917647f167e18cef3c71eec
|
||||
restart: unless-stopped
|
||||
hostname: "valkey"
|
||||
volumes:
|
||||
- ./_data/valkey:/data
|
||||
@@ -101,7 +97,6 @@ services:
|
||||
|
||||
neo4j:
|
||||
image: graphstack/dozerdb:5.26.27.0@sha256:9b54d6b3a98a76c00bd23e8e78d8c82081ff168162aebd47b25c234e092cb0a0
|
||||
restart: unless-stopped
|
||||
hostname: "neo4j"
|
||||
volumes:
|
||||
- ./_data/neo4j:/data
|
||||
@@ -134,7 +129,6 @@ services:
|
||||
|
||||
worker:
|
||||
image: prowlercloud/prowler-api:${PROWLER_API_VERSION:-stable}
|
||||
restart: unless-stopped
|
||||
# Give Celery soft shutdown time to drain/re-queue in-flight tasks on stop.
|
||||
stop_grace_period: 120s
|
||||
env_file:
|
||||
@@ -155,7 +149,6 @@ services:
|
||||
|
||||
worker-beat:
|
||||
image: prowlercloud/prowler-api:${PROWLER_API_VERSION:-stable}
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- path: ./.env
|
||||
required: false
|
||||
@@ -172,7 +165,6 @@ services:
|
||||
|
||||
mcp-server:
|
||||
image: prowlercloud/prowler-mcp:${PROWLER_MCP_VERSION:-stable}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- PROWLER_MCP_TRANSPORT_MODE=http
|
||||
env_file:
|
||||
|
||||
+7
-74
@@ -4,73 +4,6 @@ description: "New features and improvements in each Prowler release"
|
||||
rss: true
|
||||
---
|
||||
|
||||
<Update label="v5.43.0" description="September 21, 2026">
|
||||
### 🏛️ Compliance — FedRAMP 20x Consolidated Rules 2026
|
||||
|
||||
The FedRAMP 20x Phase One pilot frameworks (`fedramp_20x_ksi_low_aws`, `fedramp_20x_ksi_low_azure` and `fedramp_20x_ksi_low_gcp`) are replaced by two universal frameworks built from the FedRAMP Consolidated Rules for 2026, each covering AWS, Azure, GCP, Kubernetes and Microsoft 365 from a single definition:
|
||||
|
||||
- **FedRAMP 20x KSI** (`fedramp_20x_ksi_2026`): the 46 Key Security Indicators across 10 themes. There is one indicator catalog for every class instead of a separate Low fork; each indicator carries its class applicability and NIST SP 800-53 controls.
|
||||
- **FedRAMP 20x Class C FRR** (`fedramp_20x_frr_class_c_2026`): the 158 FedRAMP Rules of the Class C ruleset that bind cloud service providers. Most are program obligations (reports, notifications, certification package) and stay manual; checks are mapped only where they evidence part of the rule text. Configurable checks carry configuration requirements, so a relaxed `audit_config` cannot turn a requirement green.
|
||||
|
||||
Automation or stored results that reference the pilot framework IDs need to move to `fedramp_20x_ksi_2026`. In Prowler App, both frameworks show the per-provider breakdown in the cross-provider compliance view and can be downloaded as OCSF.
|
||||
|
||||
Read more in the [Compliance documentation](https://docs.prowler.com/user-guide/compliance/tutorials/compliance).
|
||||
|
||||
### 🔎 AWS — Inspector Coverage, CISA KEV and FIPS Checks
|
||||
|
||||
Seven new AWS checks back the vulnerability detection and cryptography rules of FedRAMP 20x Class C:
|
||||
|
||||
- `inspector2_coverage_scan_status_active` and `inspector2_coverage_recently_scanned` report resources Amazon Inspector is not scanning, or last scanned more than `inspector2_max_days_since_last_scan` days ago (default 3).
|
||||
- `inspector2_active_findings_no_known_exploited_vulnerabilities` and `inspector2_active_findings_kev_within_due_date` report active findings whose CVE is in the CISA Known Exploited Vulnerabilities catalog, and those still open past the CISA due date. The KEV data comes from Inspector itself through `inspector2:BatchGetFindingDetails`, so no external feed is needed.
|
||||
- `inspector2_active_findings_within_max_age` reports active findings first observed more than `inspector2_active_finding_max_age_days` days ago (default 192).
|
||||
- `elbv2_listener_fips_tls_enabled` and `transfer_server_fips_security_policy_enabled` report HTTPS/TLS load balancer listeners and Transfer Family servers without a FIPS security policy.
|
||||
|
||||
`inspector2:BatchGetFindingDetails` is not part of `SecurityAudit`, so it is now included in the Prowler additions policy and the CloudFormation scan role. Without it, the KEV checks report `MANUAL` naming the missing permission instead of a false `FAIL`.
|
||||
|
||||
Explore all AWS checks at [Prowler Hub](https://hub.prowler.com/check?provider=aws).
|
||||
|
||||
### ☁️ AWS — Partition Bootstrap Failover
|
||||
|
||||
When `PROWLER_AWS_PARTITION` is set, the bootstrap STS calls (validating credentials, assuming a role and getting an MFA session token) now try up to two more regions of the partition if the first one cannot be reached. A GovCloud host whose configured region belongs to another partition was still sent to `us-gov-east-1`, and on a network that routes only to `us-gov-west-1` the connection check and the scan failed on perfectly valid credentials. Only connection errors and timeouts move on to the next region; credential errors are reported from the first one as before. Later STS calls reuse the region that answered, and nothing changes when `PROWLER_AWS_PARTITION` is unset.
|
||||
|
||||
Read more in the [AWS Regions and Partitions documentation](https://docs.prowler.com/user-guide/providers/aws/regions-and-partitions).
|
||||
|
||||
### 🌐 Azure — Sovereign Cloud Endpoints for Defender and Key Vault
|
||||
|
||||
Defender security contacts and Key Vault key rotation policies now use the endpoints of the cloud selected with `--azure-region` instead of the hardcoded `management.azure.com` and `vault.azure.net` hosts, so both work on `AzureUSGovernment` and `AzureChinaCloud`. Key Vault clients are built from the vault URI that Azure returns for each vault.
|
||||
|
||||
Read more in the [Azure non-default cloud documentation](https://docs.prowler.com/user-guide/providers/azure/use-non-default-cloud).
|
||||
|
||||
### ✉️ Invitations — Expired Invitations No Longer Block Re-Invites
|
||||
|
||||
A pending invitation past its expiry date is now reported as expired, and inviting the same email again marks it as expired and creates the new invitation instead of returning a generic error. The Invitations table disables Edit and Revoke on expired and revoked invitations, and `filter[state__in]` on the invitations endpoint no longer returns a server error.
|
||||
|
||||
In Prowler Cloud, new organizations are offered an **Invite your team** step once the first provider is connected, and Prowler Private Cloud deployments can set `UI_SELF_REGISTRATION_ENABLED=false` to make sign-up invitation-only.
|
||||
|
||||
Read more in the [Invitations documentation](https://docs.prowler.com/user-guide/tutorials/prowler-app-rbac#invitations).
|
||||
|
||||
### 📄 Reports — Downloads on Self-Hosted Storage
|
||||
|
||||
Report downloads no longer depend on the storage host being the same inside and outside the container network. `DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL` signs the download URL against a browser-reachable host, so a deployment whose object storage answers only on an internal address serves the file instead of a link the browser cannot open. Leaving `DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION` unset, common on S3-compatible storage with no meaningful region, no longer makes the download fail with a server error.
|
||||
|
||||
### 🔍 Checks
|
||||
|
||||
- **Huawei Cloud:** new `smn_topic_subscriptions` check reports SMN topics without any subscription.
|
||||
- **Cloudflare:** the API token links in the provider wizard request the SSL and Certificates, Bot Management and Zone WAF read permissions the checks need, so a token created from the wizard no longer produces failures on permissions it was never granted.
|
||||
- **Microsoft 365:** five Defender malware, anti-phishing and inbound anti-spam checks no longer fail with `KeyError` on tenants that use the Standard or Strict preset security policies, which dropped every finding of those checks. Preset policies are covered by `defender_strict_preset_security_policy_enabled`.
|
||||
- **Google Workspace:** `security_2sv_enforced` reports domain-wide 2-Step Verification failures as `FAIL` even when every failing setting is overridden for a group or organizational unit.
|
||||
|
||||
Explore all checks at [Prowler Hub](https://hub.prowler.com/check).
|
||||
|
||||
### 🔐 Security Updates
|
||||
|
||||
- `libsqlite3-0`, `gzip`, `perl-base`, `libssh2-1t64` and `libpcre2-8-0` upgraded in the SDK and API container images, patching high-severity Debian CVEs.
|
||||
- PowerShell upgraded to 7.5.11 in the SDK and API container images, bundling .NET runtime 9.0.20 and patching CVE-2026-62901.
|
||||
- `anyio` upgraded to 4.14.2 in the SDK, the API and the MCP Server, patching CVE-2026-63374.
|
||||
|
||||
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.43.0) for the complete list of changes.
|
||||
</Update>
|
||||
|
||||
<Update label="v5.42.0" description="September 11, 2026">
|
||||
### ☁️ AWS — ISO Partitions
|
||||
|
||||
@@ -368,7 +301,7 @@ rss: true
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
Azure subscriptions no longer onboard one at a time. Select "Add Multiple Subscriptions With Azure Management Group" in the add-provider wizard, enter the Microsoft Entra tenant ID, and authenticate once with a single tenant-wide service principal: Prowler discovers the entire management-group hierarchy under the tenant root, lets you select the subscriptions to onboard, and creates their providers with the management-group structure preserved. Azure now matches the one-step onboarding that AWS Organizations and GCP organizations already have.
|
||||
Azure subscriptions no longer onboard one at a time. Choose "Add Multiple Subscriptions With Azure Management Group" in the add-provider wizard, enter the Microsoft Entra tenant ID, and authenticate once with a single tenant-wide service principal: Prowler discovers the entire management-group hierarchy under the tenant root, lets you select the subscriptions to onboard, and creates their providers with the management-group structure preserved. Azure now matches the one-step onboarding that AWS Organizations and GCP organizations already have.
|
||||
|
||||

|
||||
|
||||
@@ -573,7 +506,7 @@ rss: true
|
||||
|
||||
Read more in the [Prowler MCP tools reference](/getting-started/basic-usage/prowler-mcp-tools#prowler-cloud-tools).
|
||||
|
||||
### 🧭 Compliance — Grouped by Provider of the Same Type
|
||||
### 🧭 Compliance — Grouped by provider of the same type
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
@@ -781,7 +714,7 @@ rss: true
|
||||
|
||||
Read more in the [Lighthouse AI capabilities](/getting-started/products/prowler-cloud-lighthouse#capabilities).
|
||||
|
||||
### ☁️ One-Step AWS Organizations Onboarding
|
||||
### ☁️ One-step AWS Organizations onboarding
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
@@ -795,7 +728,7 @@ rss: true
|
||||
|
||||
Read more in the [AWS Organizations documentation](/user-guide/tutorials/prowler-cloud-aws-organizations).
|
||||
|
||||
### 🎯 Scan Configurations: Exclude Checks and Services
|
||||
### 🎯 Scan configurations: exclude checks and services
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
@@ -805,7 +738,7 @@ rss: true
|
||||
|
||||
Read more in the [Scan Configuration documentation](/user-guide/tutorials/prowler-app-scan-configuration#limiting-the-scan-scope).
|
||||
|
||||
### 🧭 Redesigned Sidebar Navigation
|
||||
### 🧭 Redesigned sidebar navigation
|
||||
|
||||
The sidebar was redesigned around how you actually work: grouped sections for security, settings, and help, a Home/Chat switch at the top, collapsible configuration entries, clearer active states, and a responsive mobile overlay.
|
||||
|
||||
@@ -833,7 +766,7 @@ rss: true
|
||||
</Update>
|
||||
|
||||
<Update label="v5.34.0" description="July 15, 2026">
|
||||
### 🏷️ New Product Names
|
||||
### 🏷️ New product names
|
||||
|
||||
The Prowler family has grown, and the names now say what each product is. Same products, clearer names:
|
||||
|
||||
@@ -1097,7 +1030,7 @@ rss: true
|
||||
-H 'Accept: application/vnd.api+json'
|
||||
```
|
||||
|
||||
### 🕸️ Attack Paths — Neptune as a Persistent Sink
|
||||
### 🕸️ Attack Paths — Neptune as a persistent sink
|
||||
|
||||
Attack Paths can now persist its graph in **AWS Neptune** in addition to Neo4j, selectable via `ATTACK_PATHS_SINK_DATABASE=neptune` (default `neo4j`). Cartography's per-scan ingest database stays on Neo4j. The scan task preflights the ingest database and the configured sink before ingestion, and provider graph cleanup now deletes relationships in directed batches before deleting nodes.
|
||||
|
||||
|
||||
@@ -40,8 +40,7 @@ The former build-time variables map to the new runtime variables as follows:
|
||||
|
||||
`UI_CLOUD_ENABLED` is a plain runtime boolean flag that enables Prowler Cloud behavior when set to the exact string `"true"` and defaults to off; unlike the other renamed variables it has no legacy fallback, so `NEXT_PUBLIC_IS_CLOUD_ENV` is no longer read.
|
||||
|
||||
`UI_SELF_REGISTRATION_ENABLED` is a runtime opt-out flag that Prowler Local Server reads only when `UI_CLOUD_ENABLED` is `"true"`. It defaults to on and turns off when set to `"false"`, matched case-insensitively so the same value can be shared with a backend setting written `False`. When it is off, the sign-up page only opens with an invitation token, the sign-in page drops its "Sign up" link, and the profile hides "Create organization". Invited users can still complete their registration. Outside a Prowler Cloud deployment the flag is ignored and account creation stays open.
|
||||
|
||||
`UI_SELF_REGISTRATION_ENABLED` is a runtime opt-out flag that Prowler Local Server reads only when `UI_CLOUD_ENABLED` is `"true"`. It defaults to on and turns off when set to `"false"`, matched case-insensitively so the same value can be shared with a backend setting written `False`. When it is off, the sign-up page only opens with an invitation token, the sign-in page drops its "Sign up" link, and the profile hides "Create organization"; invited users can still complete their registration. Outside a Prowler Cloud deployment the flag is ignored and account creation stays open.
|
||||
## Registry UI Rollout and Rollback
|
||||
|
||||
`UI_REGISTRY_ENABLED` is an optional runtime flag for Prowler Cloud and Private Cloud. Registry is eligible only when both `UI_CLOUD_ENABLED` and `UI_REGISTRY_ENABLED` are the exact string `"true"` and the current user has the backend-authorized `manage_registry` permission. Unset, `"false"`, or malformed values fail closed. The flag defaults to off and is not a replacement for backend authorization. Registry access is independent of billing; Private Cloud can use it with `CLOUD_BILLING_ENABLED=false`.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: 'Introduction to Developing in Prowler'
|
||||
title: 'Introduction to developing in Prowler'
|
||||
---
|
||||
|
||||
Thanks for your interest in contributing to Prowler!
|
||||
|
||||
@@ -468,7 +468,7 @@ For complete installation and deployment options, see:
|
||||
- [Installation Guide](/getting-started/installation/prowler-mcp#from-source-development) - Development setup instructions
|
||||
- [Configuration Guide](/getting-started/basic-usage/prowler-mcp) - MCP client configuration
|
||||
|
||||
For development, use the [Model Context Protocol Inspector](https://github.com/modelcontextprotocol/inspector) as MCP client to test and debug your tools.
|
||||
For development I recommend to use the [Model Context Protocol Inspector](https://github.com/modelcontextprotocol/inspector) as MCP client to test and debug your tools.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ StackIT uses service account keys for API authentication. Service account keys a
|
||||
- Select your service account
|
||||
- Navigate to **Service Account Keys**
|
||||
- Click **Create key**
|
||||
- Select one of the following options:
|
||||
- Choose one of the following options:
|
||||
- **STACKIT-generated key pair** (Recommended): Let STACKIT automatically generate an RSA key-pair
|
||||
- **User-provided key pair**: Upload your own RSA 2048 public key
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ Most users should use the **Cloud MCP Server** — it needs no installation and
|
||||
- **Cloud MCP Server (HTTP)**: the managed server at `https://mcp.prowler.com/mcp` (or your own self-hosted HTTP server).
|
||||
- **Local MCP Server (STDIO)**: local installation only (runs as a subprocess of your MCP client).
|
||||
|
||||
### Step-by-Step Guides per Agent
|
||||
### Step-by-Step Guides Per Agent
|
||||
|
||||
The tabs below are a quick configuration reference. For a walkthrough with screenshots, troubleshooting, and client-specific caveats, follow the dedicated guide for your agent:
|
||||
|
||||
|
||||
@@ -128,8 +128,8 @@ To update the environment file:
|
||||
Edit the `.env` file and change version values:
|
||||
|
||||
```env
|
||||
PROWLER_UI_VERSION="5.43.0"
|
||||
PROWLER_API_VERSION="5.43.0"
|
||||
PROWLER_UI_VERSION="5.42.0"
|
||||
PROWLER_API_VERSION="5.42.0"
|
||||
```
|
||||
|
||||
<Note>
|
||||
|
||||
@@ -33,7 +33,7 @@ The fastest way to get started is the **Cloud MCP Server** at `https://mcp.prowl
|
||||
Prefer to run it yourself? The **Local MCP Server** runs on your own machine or infrastructure. The Cloud MCP Server additionally provides the `prowler_cloud_*` tools for Prowler Cloud-specific features: [Alerts](/user-guide/tutorials/prowler-alerts), [Findings Triage](/user-guide/tutorials/prowler-app-findings-triage), [Scan Scheduling](/user-guide/tutorials/prowler-scan-scheduling), and Scan Configurations. See [Cloud vs Local MCP Server](#cloud-vs-local-mcp-server).
|
||||
</Note>
|
||||
|
||||
## What Is the Model Context Protocol?
|
||||
## What is the Model Context Protocol?
|
||||
|
||||
The [Model Context Protocol (MCP)](https://modelcontextprotocol.io) is an open standard developed by Anthropic that enables AI assistants to securely connect to external data sources and tools. It functions as a universal adapter enabling AI assistants to interact with various services through a standardized interface.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# What Is Prowler?
|
||||
# What is Prowler?
|
||||
|
||||
**Prowler** is the world’s most widely used open-source cloud security platform that **automates security and compliance** across any cloud environment. With thousands of ready-to-use security checks, remediation guidance, and compliance frameworks, Prowler delivers AI-driven, customizable, and easy-to-use monitoring and integrations, making cloud security simple, scalable, and cost-effective for organizations of any size.
|
||||
|
||||
@@ -71,7 +71,7 @@ Prowler supports a wide range of providers organized by category:
|
||||
| [LLM](/user-guide/providers/llm/getting-started-llm) | Official | Models | CLI |
|
||||
| [M365](/user-guide/providers/microsoft365/getting-started-m365) | Official | Tenants | UI, API, CLI |
|
||||
| [MongoDB Atlas](/user-guide/providers/mongodbatlas/getting-started-mongodbatlas) | Official | Organizations | UI, API, CLI |
|
||||
| [Okta](/user-guide/providers/okta/getting-started-okta) | Official | Organizations | UI, API, CLI |
|
||||
| [Okta](/user-guide/providers/okta/getting-started-okta) | Official | Organizations | CLI |
|
||||
| [Vercel](/user-guide/providers/vercel/getting-started-vercel) | Official | Teams / Projects | UI, API, CLI |
|
||||
|
||||
### Kubernetes
|
||||
|
||||
@@ -23,7 +23,7 @@ Security controls cover six domains, each detailed below:
|
||||
|
||||
Every GitHub Actions workflow uses runner hardening, pinned action versions, and audited permissions.
|
||||
|
||||
### Runner Hardening with StepSecurity
|
||||
### Runner Hardening With StepSecurity
|
||||
|
||||
- [**`step-security/harden-runner`**](https://github.com/step-security/harden-runner) runs as the first step in every workflow, pinned by commit SHA.
|
||||
- Workflows are being migrated to explicit egress controls: some already declare an egress allow-list with `egress-policy: block`, while others still run in `egress-policy: audit` until their allowed endpoints are fully defined.
|
||||
@@ -39,7 +39,7 @@ Every GitHub Actions workflow uses runner hardening, pinned action versions, and
|
||||
- Workflows declare `permissions: {}` at the top level and grant the minimum required scopes per job.
|
||||
- Code review covers permission changes; zizmor enforces the rules (see below).
|
||||
|
||||
### Workflow Security Audit with Zizmor
|
||||
### Workflow Security Audit With Zizmor
|
||||
|
||||
- **[zizmor](https://github.com/zizmorcore/zizmor)** audits every workflow file for known security anti-patterns. Runs on every pull request and push.
|
||||
- Triggers on every push, every pull request that touches `.github/`, and on a daily schedule.
|
||||
|
||||
@@ -270,7 +270,7 @@ Chat reads `claude_desktop_config.json` and reaches the Prowler MCP Server throu
|
||||
| Skill not invoked when expected | The prompt didn't match any skill's description | Name the task explicitly. For compliance triage, mention the framework plus "compliance" or "compliant". |
|
||||
| "Framework not supported" | Prowler Hub does not list the framework for that provider | Open an issue or PR at [github.com/prowler-cloud/prowler](https://github.com/prowler-cloud/prowler). |
|
||||
|
||||
### Authentication Fails with 401
|
||||
### Authentication Fails With 401
|
||||
|
||||
- Confirm the header value includes the `Bearer ` prefix.
|
||||
- Check that `PROWLER_API_KEY` was set when you ran `claude mcp add` — the shell expands it at that moment and stores the resulting literal value. If the variable was empty, the stored header reads `Bearer ` with nothing after it. Verify with `claude mcp get prowler`.
|
||||
|
||||
@@ -110,7 +110,7 @@ Open a Chat conversation and ask questions that use the Prowler tools:
|
||||
|
||||
Expected. The Chat tab does not read `~/.claude.json`, so servers added with `claude mcp add` never appear here. The Chat tab needs an entry in `claude_desktop_config.json`, which is what this guide sets up.
|
||||
|
||||
### Authentication Fails with 401
|
||||
### Authentication Fails With 401
|
||||
|
||||
- Confirm the header value includes the `Bearer ` prefix.
|
||||
- Confirm the key has not been revoked in Prowler Cloud.
|
||||
|
||||
@@ -45,7 +45,7 @@ Each tab below is a complete setup — follow the one that matches the surface y
|
||||
<Tab title="Codex / ChatGPT desktop app">
|
||||
1. Open **Settings** and select **Plugins → MCPs**
|
||||
2. Click **Add server**
|
||||
3. Enter `prowler` as the name and select type **Streamable HTTP**
|
||||
3. Enter `prowler` as the name and choose type **Streamable HTTP**
|
||||
4. Enter the URL `https://mcp.prowler.com/mcp`
|
||||
5. Add two headers:
|
||||
|
||||
@@ -135,7 +135,7 @@ Ask Codex questions that use the Prowler tools:
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Startup Fails with HTTP 403 Forbidden
|
||||
### Startup Fails With HTTP 403 Forbidden
|
||||
|
||||
Codex reports a handshake failure on startup, with an HTML error page rather than a JSON response:
|
||||
|
||||
@@ -147,7 +147,7 @@ Codex reports a handshake failure on startup, with an HTML error page rather tha
|
||||
|
||||
The `User-Agent` header is missing. Codex's HTTP client does not send one, and requests without it are rejected before reaching the MCP server. Note this is a **403**, not a 401 — so it is not an API key problem. Add the header as shown in [Step 2](#step-2-add-the-prowler-mcp-server); the value itself does not matter, only that the header is present.
|
||||
|
||||
### Authentication Fails with 401
|
||||
### Authentication Fails With 401
|
||||
|
||||
- Run `codex mcp get prowler` and confirm the entry has the headers you expect. Values are masked, but a missing header shows as `-`.
|
||||
- If you used a literal header, confirm the value starts with `Bearer ` and contains the full key.
|
||||
|
||||
@@ -139,7 +139,7 @@ You can toggle individual tools on or off from the tools list at the top of the
|
||||
- Open **MCP Logs** in the Output panel for the specific error.
|
||||
- Confirm the URL is exactly `https://mcp.prowler.com/mcp`.
|
||||
|
||||
### Authentication Fails with 401
|
||||
### Authentication Fails With 401
|
||||
|
||||
- Verify the header value includes the `Bearer ` prefix: `"Bearer pk_..."`, not just the key.
|
||||
- Confirm the key has not been revoked in Prowler Cloud.
|
||||
|
||||
@@ -116,7 +116,7 @@ Copilot asks for confirmation before running an MCP tool for the first time.
|
||||
- Check that `mcp.json` is valid JSON.
|
||||
- Verify your VS Code version is 1.102 or later.
|
||||
|
||||
### Authentication Fails with 401
|
||||
### Authentication Fails With 401
|
||||
|
||||
- Verify the header value includes the `Bearer ` prefix.
|
||||
- Confirm the key has not been revoked in Prowler Cloud.
|
||||
|
||||
@@ -164,7 +164,7 @@ This split is intentional. It reduces expensive per-resource analysis calls with
|
||||
| `max_ecs_task_definitions` | ECS task definitions (`ecs_task_definitions_*`) | Integer |
|
||||
| `max_codeartifact_packages` | CodeArtifact packages (`codeartifact_packages_*`) | Integer |
|
||||
|
||||
#### Resource Limit Behavior by Resource Path
|
||||
#### Resource Limit Behavior By Resource Path
|
||||
|
||||
| Resource Path | What Prowler Discovers | What A Positive Limit Selects For Analysis | Ordering And Latest Behavior | AWS Calls Reduced | Drawbacks And Consequences |
|
||||
|---------------|------------------------|--------------------------------------------|------------------------------|-------------------|----------------------------|
|
||||
|
||||
@@ -81,7 +81,7 @@ When the selected scan includes Prowler ThreatScore data, a dedicated card appea
|
||||
|
||||
<img src="/images/compliance/prowler-app-compliance-threatscore-card.png" alt="Prowler ThreatScore badge on the Compliance overview showing the overall score and per-pillar bars" width="900" />
|
||||
|
||||
Selecting the card opens the ThreatScore framework detail page, covered in [Working with the Framework Detail Page](#working-with-the-framework-detail-page).
|
||||
Selecting the card opens the ThreatScore framework detail page, covered in [Working With the Framework Detail Page](#working-with-the-framework-detail-page).
|
||||
|
||||
For a complete explanation of the methodology, formula, and weighting, see [Prowler ThreatScore](/user-guide/compliance/tutorials/threatscore).
|
||||
|
||||
@@ -103,7 +103,7 @@ Select any card to open the framework detail page.
|
||||
Score color coding follows three thresholds: red for severely low compliance, amber for partial compliance, and green for healthy posture. Hover over the score for the exact percentage.
|
||||
</Note>
|
||||
|
||||
### Tracking Frameworks with the Compliance Watchlist
|
||||
### Tracking Frameworks With the Compliance Watchlist
|
||||
|
||||
<VersionBadge version="5.38.0" />
|
||||
|
||||
@@ -125,7 +125,7 @@ Every framework card carries a pin button in its top-right corner. Select the pi
|
||||
Universal frameworks (CSA CCM, CIS Controls, DORA) are a single watchlist entry. Pinning one of them from any surface shows it as pinned on the others.
|
||||
</Note>
|
||||
|
||||
#### Filtering with the Watchlist
|
||||
#### Filtering With the Watchlist
|
||||
|
||||
Two controls sit above the tabs, because both tabs read the same watchlist:
|
||||
|
||||
@@ -146,7 +146,7 @@ The **Compliance Watchlist** card on the Overview page lists exactly the pinned
|
||||
In Prowler Local Server, where the watchlist is not available, the card keeps its previous behavior and ranks every framework with scan data.
|
||||
</Note>
|
||||
|
||||
### Working with the Framework Detail Page
|
||||
### Working With the Framework Detail Page
|
||||
|
||||
The detail page provides everything needed to evaluate a single framework: aggregate metrics, top failure sections, and a requirement-by-requirement view.
|
||||
|
||||
@@ -176,7 +176,7 @@ Select a requirement to open the detail panel and review the failing checks, the
|
||||
|
||||
<img src="/images/compliance/prowler-app-compliance-requirements-accordion.png" alt="Expanded CIS requirement showing description, rationale, remediation procedure, audit procedure, profile and assessment tags, references, and the underlying check" width="900" />
|
||||
|
||||
##### Frameworks with Custom Detail Layouts
|
||||
##### Frameworks With Custom Detail Layouts
|
||||
|
||||
Several frameworks include enriched detail panels that highlight fields specific to the standard:
|
||||
|
||||
@@ -209,7 +209,7 @@ The cap is configurable per deployment via the `DJANGO_PDF_MAX_FINDINGS_PER_CHEC
|
||||
Only **failed** findings are rendered in the detail section. PASS findings for the same check are excluded at query time. The PDF surfaces what needs attention, and the CSV/JSON exports surface everything for forensic review.
|
||||
</Note>
|
||||
|
||||
#### Downloading from the Detail Page
|
||||
#### Downloading From the Detail Page
|
||||
|
||||
Inside any framework detail page, the **CSV** and **PDF** buttons in the header trigger the same downloads as the overview dropdown. The PDF button only appears for frameworks that support it.
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ To confirm which providers made it into an aggregation, read the coverage summar
|
||||
The **Providers** filter on the detail page lists every provider of the type, including ones that have never been scanned. Narrowing to providers with no completed scan leaves the view with no evidence to aggregate: the coverage card reports nothing scanned, requirements show no per-provider status, and any report generated for that selection is empty. Clear the filter or select providers that have already been scanned.
|
||||
</Warning>
|
||||
|
||||
## Working with the Framework Detail Page
|
||||
## Working With the Framework Detail Page
|
||||
|
||||
Selecting a card opens a detail page with the same layout as the cross-provider-type detail, with the column axis swapped from provider type to provider:
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ Select **Clear filters** to reset all filters. Filters applied on the overview a
|
||||
Filters narrow **which providers contribute** to the aggregation. They do not change how a requirement rolls up (see [Understanding the Roll-Up Status](#understanding-the-roll-up-status)).
|
||||
</Note>
|
||||
|
||||
## Working with the Framework Detail Page
|
||||
## Working With the Framework Detail Page
|
||||
|
||||
The detail page provides the full breakdown for a single universal framework: aggregate metrics, provider coverage, top failing sections, and a requirement-by-requirement view with per-provider status.
|
||||
|
||||
|
||||
@@ -16,21 +16,21 @@ Prowler supports multiple Alibaba Cloud authentication flows. If more than one i
|
||||
Do not use the AccessKey pair of the main Alibaba Cloud account for Prowler. Use a RAM user, a RAM role, or another temporary credential flow instead.
|
||||
</Warning>
|
||||
|
||||
## Choose the Right Method
|
||||
## Choose The Right Method
|
||||
|
||||
| Where Prowler runs | What you need to create | Recommended method |
|
||||
| --- | --- | --- |
|
||||
| Local workstation | RAM user + AccessKey pair | [RAM User and AccessKey](#ram-user-and-accesskey) |
|
||||
| Local workstation | RAM user + AccessKey pair | [RAM User And AccessKey](#ram-user-and-accesskey) |
|
||||
| CI runner outside Alibaba Cloud | RAM user + AccessKey pair, optionally a target RAM role | [RAM Role Assumption](#ram-role-assumption-recommended) |
|
||||
| ECS instance | ECS RAM role attached to the instance | [ECS RAM Role](#ecs-ram-role) |
|
||||
| ACK / Kubernetes | OIDC IdP + RAM role + OIDC token file | [OIDC Role Authentication](#oidc-role-authentication) |
|
||||
| Internal credential broker | An HTTP endpoint that returns STS credentials | [Credentials URI](#credentials-uri) |
|
||||
|
||||
## RAM User and AccessKey
|
||||
## RAM User And AccessKey
|
||||
|
||||
This is the simplest setup for a workstation or a basic CI runner.
|
||||
|
||||
### Create the RAM User
|
||||
### Create The RAM User
|
||||
|
||||
1. Open the [RAM console](https://ram.console.alibabacloud.com/).
|
||||
2. Go to `Identities` > `Users`.
|
||||
@@ -51,7 +51,7 @@ Alibaba Cloud walkthroughs with current console screenshots:
|
||||
- [Create an AccessKey pair](https://www.alibabacloud.com/help/en/ram/user-guide/create-an-accesskey-pair)
|
||||
- [Grant permissions to a RAM user](https://www.alibabacloud.com/help/en/ram/user-guide/grant-permissions-to-the-ram-user)
|
||||
|
||||
### Use the AccessKey with Prowler
|
||||
### Use The AccessKey With Prowler
|
||||
|
||||
```bash
|
||||
export ALIBABA_CLOUD_ACCESS_KEY_ID="your-access-key-id"
|
||||
@@ -62,7 +62,7 @@ prowler alibabacloud
|
||||
|
||||
Prowler also accepts `ALIYUN_ACCESS_KEY_ID` and `ALIYUN_ACCESS_KEY_SECRET` for compatibility, but `ALIBABA_CLOUD_*` is the preferred naming.
|
||||
|
||||
### Use the Default Credential Chain
|
||||
### Use The Default Credential Chain
|
||||
|
||||
If you prefer not to export credentials in every shell, you can store them with the Alibaba Cloud CLI and let Prowler reuse the default credential chain from `~/.aliyun/config.json`.
|
||||
|
||||
@@ -87,17 +87,17 @@ This flow has two parts:
|
||||
1. A source identity that can call `sts:AssumeRole`.
|
||||
2. A target RAM role that has the scan permissions.
|
||||
|
||||
### Create the Source Identity
|
||||
### Create The Source Identity
|
||||
|
||||
Create a RAM user with an AccessKey pair by following the steps in [RAM User and AccessKey](#ram-user-and-accesskey), or reuse an existing automation identity.
|
||||
Create a RAM user with an AccessKey pair by following the steps in [RAM User And AccessKey](#ram-user-and-accesskey), or reuse an existing automation identity.
|
||||
|
||||
### Create the Target Role
|
||||
### Create The Target Role
|
||||
|
||||
1. Open the [RAM console](https://ram.console.alibabacloud.com/).
|
||||
2. Go to `Identities` > `Roles`.
|
||||
3. Click `Create Role`.
|
||||
4. Set `Principal Type` to `Cloud Account`.
|
||||
5. Select:
|
||||
5. Choose:
|
||||
- `Current Account` if the RAM user and the role are in the same account.
|
||||
- `Other Account` if the RAM user belongs to a different Alibaba Cloud account.
|
||||
6. Give the role a name such as `ProwlerAuditRole`.
|
||||
@@ -111,7 +111,7 @@ Helpful references:
|
||||
- [Create a RAM role for a trusted Alibaba Cloud account](https://www.alibabacloud.com/help/en/ram/user-guide/create-a-ram-role-for-a-trusted-alibaba-cloud-account)
|
||||
- [Assume a RAM role](https://www.alibabacloud.com/help/doc-detail/116820.html)
|
||||
|
||||
### Allow the Source Identity to Assume the Role
|
||||
### Allow The Source Identity To Assume The Role
|
||||
|
||||
The source RAM user must be able to call `sts:AssumeRole`.
|
||||
|
||||
@@ -164,7 +164,7 @@ Prowler does not mint standalone STS sessions for you. If you use this method, y
|
||||
|
||||
Use this when Prowler runs on an ECS instance and you do not want to store any AccessKeys on disk.
|
||||
|
||||
### Create and Attach the Role
|
||||
### Create And Attach The Role
|
||||
|
||||
1. Open the [RAM console](https://ram.console.alibabacloud.com/).
|
||||
2. Go to `Identities` > `Roles`.
|
||||
@@ -196,7 +196,7 @@ prowler alibabacloud
|
||||
|
||||
Use this when Prowler runs in ACK or another Kubernetes environment that provides an OIDC token file.
|
||||
|
||||
### Create the OIDC Identity Provider
|
||||
### Create The OIDC Identity Provider
|
||||
|
||||
1. Open the [RAM console](https://ram.console.alibabacloud.com/).
|
||||
2. Go to `Integrations` > `SSO`.
|
||||
@@ -214,13 +214,13 @@ Alibaba Cloud guides:
|
||||
- [Manage an OIDC IdP](https://www.alibabacloud.com/help/en/ram/manage-an-oidc-idp)
|
||||
- [Overview of role-based OIDC SSO](https://www.alibabacloud.com/help/en/ram/overview-of-oidc-based-sso)
|
||||
|
||||
### Create the RAM Role Trusted by That IdP
|
||||
### Create The RAM Role Trusted By That IdP
|
||||
|
||||
Create a RAM role whose trusted entity is the OIDC IdP, then attach the scan permissions to that role.
|
||||
|
||||
If you are running in ACK with RRSA, this is typically the role bound to the service account that runs Prowler.
|
||||
|
||||
### Provide the OIDC Variables to Prowler
|
||||
### Provide The OIDC Variables To Prowler
|
||||
|
||||
Prowler currently expects:
|
||||
|
||||
@@ -284,7 +284,7 @@ The exact minimum policy depends on the checks and services you enable.
|
||||
|
||||
If you are using the RAM console's `Grant Permission` screen, search for the **system policy names** below. Alibaba Cloud often uses product policy names that differ from the service name shown in Prowler.
|
||||
|
||||
### System Policies in the RAM Console
|
||||
### System Policies In The RAM Console
|
||||
|
||||
| Prowler use case | Policy name in RAM console | Notes |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -44,7 +44,7 @@ This method grants permanent access and is the recommended setup for production
|
||||
|
||||

|
||||
|
||||
4. In **Specify Template**, select "Upload a template file" and select the downloaded file
|
||||
4. In **Specify Template**, choose "Upload a template file" and select the downloaded file
|
||||
|
||||

|
||||

|
||||
|
||||
@@ -25,26 +25,10 @@ export PROWLER_AWS_BOTO3_READ_TIMEOUT=30
|
||||
CLI flags take precedence over the environment variables. Prowler sets both timeouts explicitly, so `AWS_DEFAULTS_MODE` and a `connect_timeout` in `~/.aws/config` are ignored; use the flag or the environment variable instead.
|
||||
|
||||
<Note>
|
||||
Boto3 defaults both timeouts to 60 seconds. In networks with restricted egress (for example VPC endpoints for a subset of services, GovCloud or private deployments), every AWS service without a reachable endpoint used to cost up to 4 attempts × 60 seconds (the first call plus the 3 retries) for each region. Prowler lowers the connect timeout to 10 seconds so unreachable endpoints fail fast. If a scan still spends most of its time waiting on unreachable services, lower it further together with `--aws-retries-max-attempts 0`, which disables retries and leaves a single attempt per call.
|
||||
Boto3 defaults both timeouts to 60 seconds. In networks with restricted egress (for example VPC endpoints for a subset of services, GovCloud or private deployments), every AWS service without a reachable endpoint used to cost up to 4 attempts × 60 seconds (the first call plus the 3 retries) for each region. Prowler lowers the connect timeout to 10 seconds so unreachable endpoints fail fast; lower it further together with `--aws-retries-max-attempts 0`, which disables retries and leaves a single attempt per call, if a scan still spends most of its time waiting on unreachable services.
|
||||
|
||||
</Note>
|
||||
|
||||
## Retries Configuration
|
||||
|
||||
<VersionBadge version="5.44.0" />
|
||||
|
||||
The number of retries is set with `--aws-retries-max-attempts`, where `0` disables retries. It can also be set through an environment variable, which is the way to tune it in Prowler Cloud and other deployments without a CLI:
|
||||
|
||||
```console
|
||||
export PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS=0
|
||||
```
|
||||
|
||||
The CLI flag takes precedence over the environment variable. The value must be a non-negative integer; when neither is set, Prowler uses 3 retries.
|
||||
|
||||
<Warning>
|
||||
The environment variable is process-wide: it applies to every AWS provider built in the process where it is set, not only to a connection check. A scan started in that same process picks it up too. Boto3's Standard retry mode, which Prowler uses, also retries service-side throttling responses (see the errors listed below), so `0` disables retries for those as well. On a large account a scan can hit throttling under normal load, and with retries disabled that throttling becomes a hard failure instead of a retried call. Set the variable only on the processes that run connection checks. Leave scan workers on the default, or raise their retry count instead of lowering it.
|
||||
</Warning>
|
||||
|
||||
## Retry Behavior Overview
|
||||
|
||||
Boto3's Standard retry mode includes the following mechanisms:
|
||||
|
||||
@@ -83,7 +83,7 @@ When onboarding multiple AWS accounts into Prowler Cloud, it is important to dep
|
||||
|
||||
The [Prowler Scan IAM Role CloudFormation template](https://github.com/prowler-cloud/prowler/blob/master/permissions/templates/cloudformation/prowler-scan-role.yml) can deploy the role across your entire AWS Organization on its own—no third-party modules required. When launched in the **Management Account** (or a **Delegated Administrator** account) with `DeployStackSet=true` and `EnableOrganizations=true`, it creates a service-managed CloudFormation StackSet that rolls the ProwlerScan role out to every account under the target Organizational Unit (or the organization root), and keeps new accounts covered automatically through auto-deployment.
|
||||
|
||||
To deploy from the CloudFormation console: open **CloudFormation → Create stack → With new resources**, select **Upload a template file** and select `prowler-scan-role.yml` (or paste its S3 URL), then set the parameters below on the **Specify stack details** step. Leave the **Configure stack options** step at its defaults.
|
||||
To deploy from the CloudFormation console: open **CloudFormation → Create stack → With new resources**, choose **Upload a template file** and select `prowler-scan-role.yml` (or paste its S3 URL), then set the parameters below on the **Specify stack details** step. Leave the **Configure stack options** step at its defaults.
|
||||
|
||||
Deploy a single CloudFormation Stack in the Management Account with the following parameters:
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ When scanning the China (`aws-cn`), European Sovereign Cloud (`aws-eusc`) or Gov
|
||||
Refer to: https://boto3.amazonaws.com/v1/documentation/api/latest/guide/credentials.html#configuring-credentials for more information about the AWS credential configuration.
|
||||
|
||||
</Note>
|
||||
|
||||
### Declaring the Partition
|
||||
|
||||
`PROWLER_AWS_PARTITION` tells Prowler which partition the scan runs against, without relying on a region being configured:
|
||||
@@ -36,11 +35,11 @@ Refer to: https://boto3.amazonaws.com/v1/documentation/api/latest/guide/credenti
|
||||
export PROWLER_AWS_PARTITION="aws-us-gov"
|
||||
```
|
||||
|
||||
The variable matters most when nothing else declares the partition. Resolving an identity means calling STS before anything is known about the credentials, and with no region configured Prowler would otherwise start from the commercial endpoints. Declaring the partition makes that first call go to the right place, which is the difference between a scan that starts and one that fails on an endpoint the credentials cannot use.
|
||||
It matters most where nothing else says. Resolving an identity means calling STS before anything is known about the credentials, and with no region configured Prowler would otherwise start from the commercial endpoints. Declaring the partition makes that first call go to the right place, which is the difference between a scan that starts and one that fails on an endpoint the credentials cannot use.
|
||||
|
||||
A region configured for the session still wins when it belongs to the declared partition, so a deployment in `us-gov-west-1` is not sent to `us-gov-east-1`. A region belonging to a different partition is ignored, since a partition that has been declared explicitly is the more deliberate statement of the two.
|
||||
|
||||
When no configured region indicates which one to prefer, Prowler tries the first region of the partition, and up to two more follow if it cannot be reached. A network that routes to only one region of its partition therefore works without having to declare which one that is. Only a connection failure moves on to the next region: a credential error is reported from the first, since it would be the same everywhere. A region excluded from the scan is tried last, so it is avoided whenever another region of the partition answers.
|
||||
When no configured region says which one to prefer, the first region of the partition is tried, and up to two more follow if it cannot be reached. A network that routes to only one region of its partition therefore works without having to declare which one that is. Only a connection failure moves on to the next region: a credential error is reported from the first, since it would be the same everywhere. A region excluded from the scan is tried last, so it is avoided whenever another region of the partition answers.
|
||||
|
||||
<Note>
|
||||
Set it wherever the scan runs. For deployments that scan from containers, that means the environment of the containers doing the scanning, not only the one accepting the request.
|
||||
|
||||
@@ -32,7 +32,7 @@ If AWS Security Hub is already enabled, you can proceed to the [next section](#e
|
||||
|
||||
3. In the “Security Standards” section, review the supported security standards. Select the checkbox for each standard you want to enable, or clear it to disable a standard.
|
||||
|
||||
4. Select “**Enable Security Hub**”. 
|
||||
4. Choose “**Enable Security Hub**”. 
|
||||
|
||||
#### Enabling Prowler Integration in AWS Security Hub
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: 'Tag-based Scan'
|
||||
title: 'Tag-based scan'
|
||||
---
|
||||
|
||||
Prowler provides the capability to scan only resources containing specific tags. To execute this, use the designated flag `--resource-tags` followed by the tags `Key=Value`, separated by spaces.
|
||||
|
||||
@@ -121,7 +121,7 @@ The checks requiring this `ProwlerRole` can be found in this [section](/user-gui
|
||||
|
||||

|
||||
|
||||
4. Click "+ Add" > "Add custom role", select "Start from JSON" and upload the modified file
|
||||
4. Click "+ Add" > "Add custom role", choose "Start from JSON" and upload the modified file
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ To use this service account with `--organization-id`, additionally grant `roles/
|
||||
|
||||
### Step 3: Generate a JSON Key
|
||||
|
||||
1. Open the newly created service account, move to the **Keys** tab, and select **Add key > Create new key**.
|
||||
1. Open the newly created service account, move to the **Keys** tab, and choose **Add key > Create new key**.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ A host with no internet access needs a pre-populated vulnerability database in a
|
||||
|
||||
Trivy tries to refresh the database when it considers it stale, and that download fails without network access. Set `TRIVY_SKIP_DB_UPDATE=true` (and `TRIVY_SKIP_JAVA_DB_UPDATE=true` if Java scanning is enabled) so it uses the supplied database as is.
|
||||
|
||||
The database ages. A scan run against an old one reports only the vulnerabilities known when it was built, and nothing in the output says so. Keep track of when the database was last refreshed.
|
||||
The database ages. A scan run against an old one reports only the vulnerabilities known when it was built, and nothing in the output says so, so keep track of when it was last refreshed.
|
||||
</Note>
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ To scan multiple images, repeat the `-I` flag:
|
||||
prowler image -I nginx:latest -I redis:7 -I python:3.12-slim
|
||||
```
|
||||
|
||||
#### Scan from an Image List File
|
||||
#### Scan From an Image List File
|
||||
|
||||
For large-scale scanning, provide a file containing one image per line:
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ The following steps apply to Prowler Cloud and Prowler Local Server.
|
||||
1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler Local Server](/user-guide/tutorials/prowler-app).
|
||||
2. Go to **Configuration** → **Providers** and click **Add Provider**.
|
||||

|
||||
3. Select **Oracle Cloud** and enter the **Tenancy OCID** and an optional alias, then select **Next**.
|
||||
3. Select **Oracle Cloud** and enter the **Tenancy OCID** and an optional alias, then choose **Next**.
|
||||

|
||||
|
||||
### Step 3: Add OCI API Key Credentials
|
||||
|
||||
@@ -430,7 +430,7 @@ clouds:
|
||||
identity_api_version: "3"
|
||||
```
|
||||
|
||||
## Creating a User with Reader Role
|
||||
## Creating a User With Reader Role
|
||||
|
||||
For security auditing, Prowler only needs **read-only access** to your OpenStack resources.
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ StackIT uses RSA key-pair based service account keys. They are issued once, must
|
||||
- `iaas.viewer` for the IaaS security group checks currently shipped, or
|
||||
- `project.owner` if you want to cover any future service Prowler adds.
|
||||
4. Open the service account and go to **Service Account Keys**.
|
||||
5. Click **Create key** and select **STACKIT-generated key pair** (recommended). Download the resulting JSON file and store it securely (for example, `~/.stackit/sa-key.json`). The private material is only shown once.
|
||||
5. Click **Create key** and choose **STACKIT-generated key pair** (recommended). Download the resulting JSON file and store it securely (for example, `~/.stackit/sa-key.json`). The private material is only shown once.
|
||||
|
||||
### Option 2: Create the Key via the StackIT CLI
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ Prowler for Vercel includes security checks across the following services:
|
||||
| **Security** | Web Application Firewall (WAF), rate limiting, IP blocking, and managed rulesets |
|
||||
| **Team** | SSO enforcement, directory sync, member access, and invitation hygiene |
|
||||
|
||||
## Checks with Explicit Plan-Based Behavior
|
||||
## Checks With Explicit Plan-Based Behavior
|
||||
|
||||
Prowler currently includes 26 Vercel checks. The 11 checks below have explicit billing-plan handling in the provider metadata or check logic. When the scanned scope reports a billing plan, Prowler adds plan-aware context to findings for these checks. If the API does not expose the required configuration, Prowler may return `MANUAL` and require verification in the Vercel dashboard.
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ Alerts run on one of three schedules:
|
||||
| Daily digest | Evaluates the Alert once per day and sends a digest when findings match. |
|
||||
| After each scan and daily | Evaluates the Alert after every scan and in the daily digest. |
|
||||
|
||||
## Creating an Alert from Findings
|
||||
## Creating an Alert From Findings
|
||||
|
||||
To create an Alert:
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ To send every Finding in a Finding Group:
|
||||

|
||||
|
||||
4. Select the Jira project and issue type.
|
||||
5. Select an issue creation mode:
|
||||
5. Choose an issue creation mode:
|
||||
* **Create one Jira issue for all selected Findings in this Finding Group:** Keeps the complete Finding Group in one Jira issue.
|
||||
* **Create separate Jira issues:** Creates one Jira issue per selected Finding so that each affected resource can be tracked independently.
|
||||
6. Click **Send to Jira**.
|
||||
|
||||
@@ -134,7 +134,7 @@ When invited to join an organization, the invited user receives a link to accept
|
||||
|
||||
2. If already authenticated, the invitation is accepted automatically and the user is redirected to Prowler Cloud.
|
||||
|
||||
3. If not authenticated, select **I have an account -- Sign in**, authenticate with existing credentials, and the invitation is accepted upon sign-in.
|
||||
3. If not authenticated, choose **I have an account -- Sign in**, authenticate with existing credentials, and the invitation is accepted upon sign-in.
|
||||
|
||||
<img src="/images/prowler-app/multi-tenant/sign-in-invitation.png" alt="Sign in screen after choosing I have an account from invitation" width="700" />
|
||||
|
||||
@@ -142,7 +142,7 @@ When invited to join an organization, the invited user receives a link to accept
|
||||
|
||||
1. Open the invitation link.
|
||||
|
||||
2. Select **I'm new -- Create an account**.
|
||||
2. Choose **I'm new -- Create an account**.
|
||||
|
||||
3. Complete the sign-up process. Upon account creation, the invitation is accepted and the user joins the inviter's organization.
|
||||
|
||||
@@ -151,7 +151,7 @@ Invitations expire after 7 days. If an invitation has expired, contact the organ
|
||||
|
||||
</Note>
|
||||
|
||||
## Expelling a User from an Organization
|
||||
## Expelling a User From an Organization
|
||||
|
||||
Organization owners can expel a member from the organization. Expelling removes the membership immediately, revoking access to all providers, scans, and findings scoped to that organization. Owners expelling themselves are blocked if they are the last remaining owner of the organization.
|
||||
|
||||
|
||||
@@ -159,8 +159,8 @@ Because a provider can belong to only one configuration, associating a provider
|
||||
|
||||
On the **Scan Config** page, open the **⋮** menu on a configuration row:
|
||||
|
||||
- **Edit:** Select **Edit** to open the editor, change its name, YAML, or attached providers, and click **Update**. Editing the YAML always happens here, never from the provider row.
|
||||
- **Delete:** Select **Delete** (in the danger zone) and confirm. Providers that were attached fall back to the built-in defaults from `config.yaml` on their next scan.
|
||||
- **Edit:** Choose **Edit** to open the editor, change its name, YAML, or attached providers, and click **Update**. Editing the YAML always happens here, never from the provider row.
|
||||
- **Delete:** Choose **Delete** (in the danger zone) and confirm. Providers that were attached fall back to the built-in defaults from `config.yaml` on their next scan.
|
||||
|
||||
## How It's Applied
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ To configure AWS Security Hub integration in Prowler Cloud:
|
||||
|
||||
4. Configure authentication:
|
||||
|
||||
Select the appropriate authentication method:
|
||||
Choose the appropriate authentication method:
|
||||
|
||||
* **Use Provider Credentials** (recommended): Leverages the AWS provider's existing credentials
|
||||
|
||||
|
||||
@@ -181,7 +181,7 @@ The Prowler app has not been invited to it. In Slack, run `/invite @Prowler Clou
|
||||
* For a private authorized channel, confirm the Prowler app is still a member of it.
|
||||
* Confirm the Prowler app is still installed in the workspace.
|
||||
|
||||
### A Channel Is Missing from an Alert's Channel List
|
||||
### A Channel Is Missing From an Alert's Channel List
|
||||
|
||||
The channel is authorized here but not confirmed yet. Click **Test connection**: it confirms every authorized channel it has not confirmed, and confirmed channels become selectable on Alerts.
|
||||
|
||||
|
||||
@@ -89,10 +89,6 @@ After adding your cloud account credentials, click the `Check connection` button
|
||||
|
||||
<img src="/images/test-connection-button.png" alt="Test Connection" width="700" />
|
||||
|
||||
<Note>
|
||||
For a single AWS account, Prowler tests the connection as part of the `Connect account` step, so the wizard moves straight to launching the scan.
|
||||
</Note>
|
||||
|
||||
## Step 6: Scan Started
|
||||
After the connection check succeeds, save the provider and start your first scan with the `Launch Scan` button. The `Scans` section shows the scan in progress:
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ The Prowler wizard walks you through the entire flow: deploying both roles from
|
||||
<img src="/images/organizations/select-aws-provider.png" alt="Provider selection modal with Amazon Web Services highlighted" />
|
||||
</Frame>
|
||||
|
||||
3. Select **Add Multiple Accounts With AWS Organizations**.
|
||||
3. Choose **Add Multiple Accounts With AWS Organizations**.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/organizations/select-organizations-method.png" alt="Method selector showing Add Multiple Accounts With AWS Organizations option highlighted" />
|
||||
@@ -295,7 +295,7 @@ Organizational unit rows carry the same **Test Connections** and **Delete Organi
|
||||
To refresh the account membership of an existing AWS Organization, repeat the same discovery flow used during onboarding:
|
||||
|
||||
1. Navigate to **Providers**, click **Add Provider**, and select **Amazon Web Services**.
|
||||
2. Select **Add Multiple Accounts With AWS Organizations**.
|
||||
2. Choose **Add Multiple Accounts With AWS Organizations**.
|
||||
3. Enter the existing **Organization ID**, proceed to **Authentication Details**, and use the existing deployment account **Role ARN**.
|
||||
4. Confirm that the stack is deployed and click **Authenticate**. Prowler reuses the existing organization and starts a new discovery instead of creating a duplicate.
|
||||
|
||||
@@ -478,7 +478,7 @@ Deploy the ProwlerScan role to every member account with a [CloudFormation Stack
|
||||
</Note>
|
||||
|
||||
1. In your management account, navigate to **CloudFormation > StackSets > Create StackSet** ([open directly](https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacksets/create)).
|
||||
2. Select **Service-managed permissions**.
|
||||
2. Choose **Service-managed permissions**.
|
||||
3. Enable **Automatic deployment** so CloudFormation deploys the role to accounts added to the targeted root or OUs. Configure the account removal behavior based on whether the stack and its resources should be retained when an account leaves the target.
|
||||
4. Select **Amazon S3 URL** as the template source and paste:
|
||||
```
|
||||
|
||||
@@ -109,7 +109,7 @@ In the Azure portal, the same value sits on your service principal's **App regis
|
||||
<img src="/images/organizations/azure/select-azure-provider.png" alt="Provider selection modal with Microsoft Azure highlighted" />
|
||||
</Frame>
|
||||
|
||||
3. Select **Add Multiple Subscriptions With Azure Management Group**.
|
||||
3. Choose **Add Multiple Subscriptions With Azure Management Group**.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/organizations/azure/select-azure-management-groups-method.png" alt="Method selector showing the Add Multiple Subscriptions With Azure Management Group option highlighted" />
|
||||
|
||||
@@ -107,7 +107,7 @@ In the Google Cloud console, the ID sits in the **ID** column next to the organi
|
||||
<img src="/images/organizations/gcp/select-gcp-provider.png" alt="Provider selection modal with Google Cloud highlighted" />
|
||||
</Frame>
|
||||
|
||||
3. Select **Add Multiple Projects With GCP Organization**.
|
||||
3. Choose **Add Multiple Projects With GCP Organization**.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/organizations/gcp/select-gcp-organizations-method.png" alt="Method selector showing Add Multiple Projects With GCP Organization option highlighted" />
|
||||
|
||||
@@ -21,7 +21,7 @@ The annual plan is paid upfront for a fixed number of cloud provider accounts, b
|
||||
|
||||
## Change a Customer's Plan
|
||||
|
||||
Open the actions menu on a customer's row and select **Change plan**.
|
||||
Open the actions menu on a customer's row and choose **Change plan**.
|
||||
|
||||
<Warning>
|
||||
**Plan changes are one way: trial to paid.** The action is only offered while a customer is on trial or its trial has expired. Once a customer holds a paid subscription, **Change plan** no longer appears on the row, and the trial is never a valid target.
|
||||
|
||||
@@ -60,7 +60,7 @@ Above the table, search by name and filter by provider or status. The download b
|
||||
|
||||
## Open a Customer's Prowler Cloud Tenant
|
||||
|
||||
Open the actions menu at the end of a customer's row and select **Access Organization**. You are redirected into that customer's tenant in Prowler Cloud, signed in as yourself acting on their behalf.
|
||||
Open the actions menu at the end of a customer's row and choose **Access Organization**. You are redirected into that customer's tenant in Prowler Cloud, signed in as yourself acting on their behalf.
|
||||
|
||||
While you are in the tenant you see what a customer administrator sees, and every action is recorded in the Prowler Cloud audit log against both your identity and the customer you are acting for.
|
||||
|
||||
@@ -72,7 +72,7 @@ Opening a tenant requires a role with **Access tenants**. The action fails with
|
||||
|
||||
## Edit a Customer
|
||||
|
||||
Select **Edit** from the row actions to open the **Edit customer** panel and rename the customer. The new name must still be unique within the partner organization.
|
||||
Choose **Edit** from the row actions to open the **Edit customer** panel and rename the customer. The new name must still be unique within the partner organization.
|
||||
|
||||
## Link an Existing Customer with Your Partner Code
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ A Prowler Cloud or Prowler Private Cloud subscription supports the following cus
|
||||
|
||||
The scan time is always selected on the hour (for example, 14:00); minutes cannot be set. The schedule time uses the browser timezone when the schedule is saved. Prowler displays the next scheduled scan in that timezone.
|
||||
|
||||
## Create a Schedule from Scans
|
||||
## Create a Schedule From Scans
|
||||
|
||||
To create a schedule from the **Scans** page:
|
||||
|
||||
@@ -41,7 +41,7 @@ To create a schedule from the **Scans** page:
|
||||
2. Click **Launch Scan**.
|
||||
3. Select a connected provider.
|
||||
4. Select **On a schedule**.
|
||||
5. Select the **Scan Time** and **Repeats** values.
|
||||
5. Choose the **Scan Time** and **Repeats** values.
|
||||
6. Optional: select **Launch an initial scan now for immediate findings** to run a scan immediately after saving the recurring schedule.
|
||||
7. Click **Save Schedule**.
|
||||
|
||||
@@ -51,7 +51,7 @@ To create a schedule from the **Scans** page:
|
||||
|
||||
After the schedule is saved, Prowler shows a confirmation toast with a link to the **Scheduled** tab.
|
||||
|
||||
## Edit Schedules from Providers
|
||||
## Edit Schedules From Providers
|
||||
|
||||
The **Providers** page shows each provider's current schedule in the **Scan Schedule** column. Providers without a recurring schedule show **None**.
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
`PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS` environment variable to set the Boto3 retries for deployments without CLI flags
|
||||
@@ -1 +0,0 @@
|
||||
STS calls after role assumption use the answering region, avoiding a second wait for an unreachable partition region
|
||||
@@ -1 +0,0 @@
|
||||
Pass the E2E AWS credentials to the UI E2E workflow through environment variables instead of template expansion
|
||||
@@ -52,7 +52,7 @@ class _MutableTimestamp:
|
||||
|
||||
timestamp = _MutableTimestamp(datetime.today())
|
||||
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
|
||||
prowler_version = "5.44.0"
|
||||
prowler_version = "5.43.0"
|
||||
html_logo_url = "https://github.com/prowler-cloud/prowler/"
|
||||
square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png"
|
||||
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
|
||||
|
||||
@@ -112,7 +112,7 @@ class AwsProvider(Provider):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
retries_max_attempts: Optional[int] = None,
|
||||
retries_max_attempts: int = 3,
|
||||
role_arn: str = None,
|
||||
session_duration: int = 3600,
|
||||
external_id: str = None,
|
||||
@@ -141,7 +141,6 @@ class AwsProvider(Provider):
|
||||
|
||||
Args:
|
||||
- retries_max_attempts: The maximum number of retries for the AWS client.
|
||||
Defaults to the PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS environment variable or, if unset, to 3.
|
||||
- role_arn: The ARN of the IAM role to assume.
|
||||
- session_duration: The duration of the session in seconds, between 900 and 43200.
|
||||
- external_id: The external ID to use when assuming the IAM role.
|
||||
@@ -1231,8 +1230,7 @@ class AwsProvider(Provider):
|
||||
|
||||
Args:
|
||||
- session: The AWS session object
|
||||
- assumed_role_info: The AWSAssumeRoleInfo object. Its sts_region is
|
||||
updated to the region that answered, so later calls go straight there
|
||||
- assumed_role_info: The AWSAssumeRoleInfo object
|
||||
|
||||
Returns:
|
||||
- AWSCredentials: The AWS credentials for the assumed role
|
||||
@@ -1258,14 +1256,11 @@ class AwsProvider(Provider):
|
||||
mfa_info = AwsProvider.input_role_mfa_token_and_code()
|
||||
assume_role_arguments["SerialNumber"] = mfa_info.arn
|
||||
assume_role_arguments["TokenCode"] = mfa_info.totp
|
||||
sts_region, assumed_credentials = (
|
||||
AwsProvider.sts_call_with_partition_failover(
|
||||
session,
|
||||
assumed_role_info.sts_region,
|
||||
lambda sts_client: sts_client.assume_role(**assume_role_arguments),
|
||||
)
|
||||
_, assumed_credentials = AwsProvider.sts_call_with_partition_failover(
|
||||
session,
|
||||
assumed_role_info.sts_region,
|
||||
lambda sts_client: sts_client.assume_role(**assume_role_arguments),
|
||||
)
|
||||
assumed_role_info.sts_region = sts_region
|
||||
# Convert the UTC datetime object to your local timezone
|
||||
credentials_expiration_local_time = (
|
||||
assumed_credentials["Credentials"]["Expiration"]
|
||||
@@ -1563,8 +1558,6 @@ class AwsProvider(Provider):
|
||||
session,
|
||||
assumed_role_information,
|
||||
)
|
||||
# Validate where the role was assumed, not where it timed out
|
||||
aws_region = assumed_role_information.sts_region
|
||||
session = Session(
|
||||
aws_access_key_id=assumed_role_credentials.aws_access_key_id,
|
||||
aws_secret_access_key=assumed_role_credentials.aws_secret_access_key,
|
||||
|
||||
@@ -2,10 +2,7 @@ import os
|
||||
|
||||
from botocore.config import Config
|
||||
|
||||
from prowler.providers.aws.exceptions.exceptions import (
|
||||
AWSInvalidBoto3RetriesError,
|
||||
AWSInvalidBoto3TimeoutError,
|
||||
)
|
||||
from prowler.providers.aws.exceptions.exceptions import AWSInvalidBoto3TimeoutError
|
||||
|
||||
AWS_STS_GLOBAL_ENDPOINT_REGION = "us-east-1"
|
||||
AWS_REGION_US_EAST_1 = "us-east-1"
|
||||
@@ -30,28 +27,10 @@ def get_boto3_timeout_from_env(name: str, default: int) -> int:
|
||||
return int(raw)
|
||||
|
||||
|
||||
def get_boto3_retries_from_env(name: str, default: int) -> int:
|
||||
"""Non-negative integer retries read from the environment, or default when unset."""
|
||||
raw = os.getenv(name, "").strip()
|
||||
if not raw:
|
||||
return default
|
||||
if not raw.isdecimal():
|
||||
raise AWSInvalidBoto3RetriesError(
|
||||
file=os.path.basename(__file__),
|
||||
message=f"{name} must be a non-negative integer number of retries, got {raw!r}",
|
||||
)
|
||||
return int(raw)
|
||||
|
||||
|
||||
def get_default_session_config() -> Config:
|
||||
return Config(
|
||||
user_agent_extra=BOTO3_USER_AGENT_EXTRA,
|
||||
retries={
|
||||
"max_attempts": get_boto3_retries_from_env(
|
||||
"PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS", BOTO3_RETRIES_MAX_ATTEMPTS
|
||||
),
|
||||
"mode": "standard",
|
||||
},
|
||||
retries={"max_attempts": BOTO3_RETRIES_MAX_ATTEMPTS, "mode": "standard"},
|
||||
connect_timeout=get_boto3_timeout_from_env(
|
||||
"PROWLER_AWS_BOTO3_CONNECT_TIMEOUT", BOTO3_CONNECT_TIMEOUT
|
||||
),
|
||||
|
||||
@@ -82,10 +82,6 @@ class AWSBaseException(ProwlerException):
|
||||
"message": "The Boto3 timeout configured through the environment is invalid",
|
||||
"remediation": "Set PROWLER_AWS_BOTO3_CONNECT_TIMEOUT and PROWLER_AWS_BOTO3_READ_TIMEOUT to a positive integer number of seconds.",
|
||||
},
|
||||
(1919, "AWSInvalidBoto3RetriesError"): {
|
||||
"message": "The Boto3 retries configured through the environment are invalid",
|
||||
"remediation": "Set PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS to a non-negative integer, 0 disables retries.",
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(self, code, file=None, original_exception=None, message=None):
|
||||
@@ -248,12 +244,3 @@ class AWSInvalidBoto3TimeoutError(AWSBaseException):
|
||||
super().__init__(
|
||||
1918, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class AWSInvalidBoto3RetriesError(AWSBaseException):
|
||||
"""Boto3 retries configured through the environment are not a non-negative integer."""
|
||||
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
1919, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
@@ -85,7 +85,7 @@ class S3:
|
||||
aws_access_key_id: str = None,
|
||||
aws_secret_access_key: str = None,
|
||||
aws_session_token: Optional[str] = None,
|
||||
retries_max_attempts: Optional[int] = None,
|
||||
retries_max_attempts: int = 3,
|
||||
regions: set = set(),
|
||||
) -> None:
|
||||
"""
|
||||
|
||||
@@ -106,7 +106,7 @@ class SecurityHub:
|
||||
aws_access_key_id: str = None,
|
||||
aws_secret_access_key: str = None,
|
||||
aws_session_token: Optional[str] = None,
|
||||
retries_max_attempts: Optional[int] = None,
|
||||
retries_max_attempts: int = 3,
|
||||
regions: set = set(),
|
||||
) -> "SecurityHub":
|
||||
"""
|
||||
|
||||
@@ -40,7 +40,7 @@ class AwsSetUpSession:
|
||||
aws_access_key_id: str = None,
|
||||
aws_secret_access_key: str = None,
|
||||
aws_session_token: Optional[str] = None,
|
||||
retries_max_attempts: Optional[int] = None,
|
||||
retries_max_attempts: int = 3,
|
||||
regions: set = set(),
|
||||
connect_timeout: Optional[int] = None,
|
||||
read_timeout: Optional[int] = None,
|
||||
@@ -106,8 +106,6 @@ class AwsSetUpSession:
|
||||
session=self._session.current_session,
|
||||
aws_region=sts_region,
|
||||
)
|
||||
# Later STS calls go where validation got an answer, not where it timed out
|
||||
sts_region = caller_identity.region
|
||||
|
||||
logger.info("Credentials validated")
|
||||
########
|
||||
|
||||
+1
-1
@@ -144,7 +144,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
|
||||
name = "prowler"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10,<3.14"
|
||||
version = "5.44.0"
|
||||
version = "5.43.0"
|
||||
|
||||
[project.scripts]
|
||||
prowler = "prowler.__main__:prowler"
|
||||
|
||||
@@ -28,19 +28,15 @@ from prowler.providers.aws.config import (
|
||||
AWS_STS_GLOBAL_ENDPOINT_REGION,
|
||||
BOTO3_CONNECT_TIMEOUT,
|
||||
BOTO3_READ_TIMEOUT,
|
||||
BOTO3_RETRIES_MAX_ATTEMPTS,
|
||||
BOTO3_USER_AGENT_EXTRA,
|
||||
ROLE_SESSION_NAME,
|
||||
get_boto3_retries_from_env,
|
||||
get_boto3_timeout_from_env,
|
||||
get_default_session_config,
|
||||
)
|
||||
from prowler.providers.aws.exceptions.exceptions import (
|
||||
AWSAccessKeyIDInvalidError,
|
||||
AWSArgumentTypeValidationError,
|
||||
AWSAssumeRoleError,
|
||||
AWSIAMRoleARNInvalidResourceTypeError,
|
||||
AWSInvalidBoto3RetriesError,
|
||||
AWSInvalidBoto3TimeoutError,
|
||||
AWSInvalidPartitionError,
|
||||
AWSInvalidProviderIdError,
|
||||
@@ -1849,143 +1845,6 @@ aws:
|
||||
]
|
||||
assert isinstance(credentials, AWSCredentials)
|
||||
assert credentials.aws_access_key_id == "AKIAIOSFODNN7EXAMPLE"
|
||||
# Refreshing the credentials later goes straight to the region that answered
|
||||
assert assumed_role_info.sts_region == AWS_REGION_GOV_CLOUD_US_WEST_1
|
||||
|
||||
def test_assume_role_does_not_retry_a_credential_error(self, monkeypatch):
|
||||
monkeypatch.setenv("PROWLER_AWS_PARTITION", AWS_GOV_CLOUD_PARTITION)
|
||||
current_session = session.Session(region_name=AWS_REGION_US_EAST_1)
|
||||
attempted_regions = []
|
||||
|
||||
def create_sts_session(session, aws_region):
|
||||
attempted_regions.append(aws_region)
|
||||
sts_client = mock.MagicMock()
|
||||
sts_client.assume_role.side_effect = botocore.exceptions.ClientError(
|
||||
{"Error": {"Code": "AccessDenied", "Message": "denied"}},
|
||||
"AssumeRole",
|
||||
)
|
||||
return sts_client
|
||||
|
||||
assumed_role_info = AWSAssumeRoleInfo(
|
||||
role_arn=ARN(
|
||||
arn=f"arn:{AWS_GOV_CLOUD_PARTITION}:iam::{AWS_ACCOUNT_NUMBER}:role/test-role"
|
||||
),
|
||||
session_duration=3600,
|
||||
external_id=None,
|
||||
mfa_enabled=False,
|
||||
role_session_name=ROLE_SESSION_NAME,
|
||||
sts_region=AWS_REGION_GOV_CLOUD_US_EAST_1,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"prowler.providers.aws.aws_provider.AwsProvider.create_sts_session",
|
||||
new=create_sts_session,
|
||||
):
|
||||
with raises(AWSAssumeRoleError):
|
||||
AwsProvider.assume_role(current_session, assumed_role_info)
|
||||
|
||||
assert attempted_regions == [AWS_REGION_GOV_CLOUD_US_EAST_1]
|
||||
assert assumed_role_info.sts_region == AWS_REGION_GOV_CLOUD_US_EAST_1
|
||||
|
||||
def test_test_connection_role_validates_where_the_role_was_assumed(
|
||||
self, monkeypatch
|
||||
):
|
||||
monkeypatch.setenv("PROWLER_AWS_PARTITION", AWS_GOV_CLOUD_PARTITION)
|
||||
monkeypatch.delenv("AWS_DEFAULT_REGION", raising=False)
|
||||
attempted_calls = []
|
||||
|
||||
def create_sts_session(session, aws_region):
|
||||
if aws_region == AWS_REGION_GOV_CLOUD_US_EAST_1:
|
||||
attempted_calls.append(aws_region)
|
||||
raise botocore.exceptions.ConnectTimeoutError(
|
||||
endpoint_url=f"https://sts.{aws_region}.amazonaws.com"
|
||||
)
|
||||
sts_client = mock.MagicMock()
|
||||
|
||||
def assume_role(**_):
|
||||
attempted_calls.append(("AssumeRole", aws_region))
|
||||
return {
|
||||
"Credentials": {
|
||||
"AccessKeyId": "AKIAIOSFODNN7EXAMPLE",
|
||||
"SecretAccessKey": "secret",
|
||||
"SessionToken": "token",
|
||||
"Expiration": datetime.now() + timedelta(seconds=3600),
|
||||
}
|
||||
}
|
||||
|
||||
def get_caller_identity():
|
||||
attempted_calls.append(("GetCallerIdentity", aws_region))
|
||||
return {
|
||||
"UserId": "test-user-id",
|
||||
"Account": AWS_ACCOUNT_NUMBER,
|
||||
"Arn": AWS_GOV_CLOUD_ACCOUNT_ARN,
|
||||
}
|
||||
|
||||
sts_client.assume_role.side_effect = assume_role
|
||||
sts_client.get_caller_identity.side_effect = get_caller_identity
|
||||
return sts_client
|
||||
|
||||
with patch(
|
||||
"prowler.providers.aws.aws_provider.AwsProvider.create_sts_session",
|
||||
new=create_sts_session,
|
||||
):
|
||||
connection = AwsProvider.test_connection(
|
||||
role_arn=f"arn:{AWS_GOV_CLOUD_PARTITION}:iam::{AWS_ACCOUNT_NUMBER}:role/test-role",
|
||||
aws_access_key_id="test-access-key",
|
||||
aws_secret_access_key="test-secret-key",
|
||||
raise_on_exception=False,
|
||||
)
|
||||
|
||||
assert connection.is_connected
|
||||
# The unreachable region is paid for once, not again for the validation
|
||||
assert attempted_calls == [
|
||||
AWS_REGION_GOV_CLOUD_US_EAST_1,
|
||||
("AssumeRole", AWS_REGION_GOV_CLOUD_US_WEST_1),
|
||||
("GetCallerIdentity", AWS_REGION_GOV_CLOUD_US_WEST_1),
|
||||
]
|
||||
|
||||
@mock_aws
|
||||
def test_aws_set_up_session_assumes_the_role_where_validation_got_an_answer(
|
||||
self, monkeypatch
|
||||
):
|
||||
monkeypatch.setenv("PROWLER_AWS_PARTITION", AWS_GOV_CLOUD_PARTITION)
|
||||
monkeypatch.setenv("AWS_DEFAULT_REGION", AWS_REGION_US_EAST_1)
|
||||
answered = AWSCallerIdentity(
|
||||
user_id="test-user-id",
|
||||
account=AWS_ACCOUNT_NUMBER,
|
||||
arn=ARN(AWS_GOV_CLOUD_ACCOUNT_ARN),
|
||||
region=AWS_REGION_GOV_CLOUD_US_WEST_1,
|
||||
)
|
||||
sts_regions = []
|
||||
|
||||
class RoleAssumed(Exception):
|
||||
pass
|
||||
|
||||
def assume_role(session, assumed_role_info):
|
||||
sts_regions.append(assumed_role_info.sts_region)
|
||||
raise RoleAssumed
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.aws.aws_provider.AwsProvider.validate_credentials",
|
||||
return_value=answered,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.aws.aws_provider.AwsProvider.assume_role",
|
||||
side_effect=assume_role,
|
||||
),
|
||||
):
|
||||
with raises(RoleAssumed):
|
||||
AwsSetUpSession(
|
||||
role_arn=f"arn:{AWS_GOV_CLOUD_PARTITION}:iam::{AWS_ACCOUNT_NUMBER}:role/test-role",
|
||||
session_duration=900,
|
||||
external_id="test-external-id",
|
||||
role_session_name=ROLE_SESSION_NAME,
|
||||
aws_access_key_id="testing",
|
||||
aws_secret_access_key="testing",
|
||||
)
|
||||
|
||||
assert sts_regions == [AWS_REGION_GOV_CLOUD_US_WEST_1]
|
||||
|
||||
def test_setup_session_mfa_falls_back_to_the_next_partition_region(
|
||||
self, monkeypatch
|
||||
@@ -3493,123 +3352,6 @@ aws:
|
||||
):
|
||||
get_boto3_timeout_from_env("PROWLER_AWS_BOTO3_CONNECT_TIMEOUT", 10)
|
||||
|
||||
def test_get_default_session_config_retries_from_env(self):
|
||||
with mock.patch.dict(
|
||||
os.environ, {"PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS": "1"}
|
||||
):
|
||||
config = get_default_session_config()
|
||||
|
||||
assert config.retries == {"max_attempts": 1, "mode": "standard"}
|
||||
|
||||
def test_get_default_session_config_retries_from_env_0_disables_retries(self):
|
||||
with mock.patch.dict(
|
||||
os.environ, {"PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS": "0"}
|
||||
):
|
||||
config = get_default_session_config()
|
||||
|
||||
assert config.retries == {"max_attempts": 0, "mode": "standard"}
|
||||
|
||||
def test_set_session_config_argument_overrides_env_retries(self):
|
||||
with mock.patch.dict(
|
||||
os.environ, {"PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS": "1"}
|
||||
):
|
||||
config = AwsProvider.set_session_config(5)
|
||||
|
||||
assert config.retries == {"max_attempts": 5, "mode": "standard"}
|
||||
|
||||
@mock_aws
|
||||
def test_aws_provider_without_retries_argument_uses_env_retries(self):
|
||||
with mock.patch.dict(
|
||||
os.environ, {"PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS": "0"}
|
||||
):
|
||||
aws_provider = AwsProvider()
|
||||
client = aws_provider.session.current_session.client(
|
||||
"ec2", region_name=AWS_REGION_US_EAST_1
|
||||
)
|
||||
|
||||
# botocore rewrites max_attempts into total_max_attempts (retries + 1)
|
||||
assert client.meta.config.retries["total_max_attempts"] == 1
|
||||
|
||||
@mock_aws
|
||||
def test_aws_provider_retries_argument_overrides_env_retries(self):
|
||||
with mock.patch.dict(
|
||||
os.environ, {"PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS": "0"}
|
||||
):
|
||||
aws_provider = AwsProvider(retries_max_attempts=7)
|
||||
client = aws_provider.session.current_session.client(
|
||||
"ec2", region_name=AWS_REGION_US_EAST_1
|
||||
)
|
||||
|
||||
assert client.meta.config.retries["total_max_attempts"] == 8
|
||||
|
||||
@mock_aws
|
||||
def test_aws_set_up_session_without_retries_argument_uses_env_retries(self):
|
||||
with mock.patch.dict(
|
||||
os.environ, {"PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS": "1"}
|
||||
):
|
||||
aws_session = AwsSetUpSession(
|
||||
aws_access_key_id="testing",
|
||||
aws_secret_access_key="testing",
|
||||
)
|
||||
client = aws_session._session.current_session.client(
|
||||
"ec2", region_name=AWS_REGION_US_EAST_1
|
||||
)
|
||||
|
||||
assert client.meta.config.retries["total_max_attempts"] == 2
|
||||
|
||||
def test_test_connection_session_uses_env_retries(self):
|
||||
with (
|
||||
mock.patch.dict(
|
||||
os.environ, {"PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS": "0"}
|
||||
),
|
||||
mock.patch.object(
|
||||
AwsProvider,
|
||||
"validate_credentials",
|
||||
return_value=AWSCallerIdentity(
|
||||
user_id="test-user-id",
|
||||
account=AWS_ACCOUNT_NUMBER,
|
||||
arn=ARN(AWS_ACCOUNT_ARN),
|
||||
region=AWS_REGION_US_EAST_1,
|
||||
),
|
||||
) as mock_validate_credentials,
|
||||
):
|
||||
connection = AwsProvider.test_connection(
|
||||
aws_access_key_id="test-access-key",
|
||||
aws_secret_access_key="test-secret-key",
|
||||
raise_on_exception=False,
|
||||
)
|
||||
|
||||
assert connection.is_connected
|
||||
validated_session = mock_validate_credentials.call_args.args[0]
|
||||
assert validated_session._session.get_default_client_config().retries == {
|
||||
"max_attempts": 0,
|
||||
"mode": "standard",
|
||||
}
|
||||
|
||||
@pytest.mark.parametrize("raw", ["-1", "three", "1.5"])
|
||||
def test_get_boto3_retries_from_env_rejects_anything_but_non_negative_integers(
|
||||
self, raw
|
||||
):
|
||||
with mock.patch.dict(
|
||||
os.environ, {"PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS": raw}
|
||||
):
|
||||
with raises(
|
||||
AWSInvalidBoto3RetriesError,
|
||||
match="PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS",
|
||||
):
|
||||
get_boto3_retries_from_env("PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS", 3)
|
||||
|
||||
def test_get_boto3_retries_from_env_blank_falls_back_to_default(self):
|
||||
with mock.patch.dict(
|
||||
os.environ, {"PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS": " "}
|
||||
):
|
||||
assert (
|
||||
get_boto3_retries_from_env(
|
||||
"PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS", BOTO3_RETRIES_MAX_ATTEMPTS
|
||||
)
|
||||
== BOTO3_RETRIES_MAX_ATTEMPTS
|
||||
)
|
||||
|
||||
def test_get_boto3_timeout_from_env_blank_falls_back_to_default(self):
|
||||
with mock.patch.dict(os.environ, {"PROWLER_AWS_BOTO3_CONNECT_TIMEOUT": " "}):
|
||||
assert (
|
||||
|
||||
@@ -302,7 +302,6 @@ export const handlersForOrganizations = (
|
||||
organizations.map((o) => o.secretId).filter((id): id is string => !!id),
|
||||
);
|
||||
let orgSeq = 0;
|
||||
let providerSeq = 0;
|
||||
let secretSeq = 0;
|
||||
/** Reads per connection task, so `executingPolls` can hold one task running. */
|
||||
const connectionTaskReads = new Map<string, number>();
|
||||
@@ -566,37 +565,6 @@ export const handlersForOrganizations = (
|
||||
HttpResponse.json({ data: [], meta: collectionMeta(0) }),
|
||||
),
|
||||
|
||||
// --- single-account connect (AWS one-step form) -----------------------
|
||||
http.post(`${API}/providers`, async ({ request }) => {
|
||||
const body = (await request.json()) as {
|
||||
data: { attributes: { provider: string; uid: string; alias?: string } };
|
||||
};
|
||||
providerSeq += 1;
|
||||
return HttpResponse.json(
|
||||
{
|
||||
data: {
|
||||
id: `provider-created-${providerSeq}`,
|
||||
type: "providers",
|
||||
attributes: {
|
||||
...body.data.attributes,
|
||||
connection: { connected: false, last_checked_at: null },
|
||||
},
|
||||
},
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
}),
|
||||
|
||||
http.post(`${API}/providers/secrets`, () => {
|
||||
secretSeq += 1;
|
||||
return HttpResponse.json(
|
||||
{
|
||||
data: { id: `secret-created-${secretSeq}`, type: "provider-secrets" },
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
}),
|
||||
|
||||
// --- providers (uid resolution) + connection testing -----------------
|
||||
http.get<{ id: string }>(`${API}/providers/:id`, ({ params }) => {
|
||||
const provider = fx.providers.find((p) => p.id === params.id);
|
||||
|
||||
@@ -215,72 +215,3 @@ describe("adaptFindingsByResourceResponse — malformed input", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("adaptFindingsByResourceResponse — provider id", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should carry the provider id resolved through the scan include", () => {
|
||||
// Given — scan.provider include path, as the drawer requests it
|
||||
createDictMock.mockImplementation((type: string) => {
|
||||
if (type === "scans") {
|
||||
return {
|
||||
"scan-1": {
|
||||
id: "scan-1",
|
||||
attributes: {},
|
||||
relationships: { provider: { data: { id: "provider-1" } } },
|
||||
},
|
||||
};
|
||||
}
|
||||
if (type === "providers") {
|
||||
return {
|
||||
"provider-1": {
|
||||
id: "provider-1",
|
||||
attributes: { provider: "aws", alias: "prod", uid: "123" },
|
||||
},
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const input = {
|
||||
data: {
|
||||
id: "finding-1",
|
||||
attributes: {
|
||||
uid: "uid-1",
|
||||
check_id: "s3_check",
|
||||
status: "FAIL",
|
||||
severity: "high",
|
||||
check_metadata: {},
|
||||
},
|
||||
relationships: {
|
||||
resources: { data: [] },
|
||||
scan: { data: { id: "scan-1" } },
|
||||
},
|
||||
},
|
||||
included: [],
|
||||
};
|
||||
|
||||
// When
|
||||
const [finding] = adaptFindingsByResourceResponse(input);
|
||||
|
||||
// Then — the partial-scan request needs the id, not only the uid
|
||||
expect(finding.providerId).toBe("provider-1");
|
||||
expect(finding.providerUid).toBe("123");
|
||||
});
|
||||
|
||||
it("should leave the provider id empty when the scan is not included", () => {
|
||||
createDictMock.mockReturnValue({});
|
||||
|
||||
const [finding] = adaptFindingsByResourceResponse({
|
||||
data: {
|
||||
id: "finding-1",
|
||||
attributes: { uid: "uid-1", check_id: "s3_check", status: "FAIL" },
|
||||
relationships: { resources: { data: [] }, scan: { data: null } },
|
||||
},
|
||||
});
|
||||
|
||||
expect(finding.providerId).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,7 +64,6 @@ export interface ResourceDrawerFinding {
|
||||
resourceDetails: string | null;
|
||||
resourceMetadata: Record<string, unknown> | string | null;
|
||||
// Provider
|
||||
providerId: string;
|
||||
providerType: ProviderType;
|
||||
providerAlias: string;
|
||||
providerUid: string;
|
||||
@@ -281,7 +280,6 @@ export function adaptFindingsByResourceResponse(
|
||||
| null
|
||||
| undefined) ?? null,
|
||||
// Provider
|
||||
providerId: providerRelId ?? "",
|
||||
providerType: ((providerAttrs.provider as string | undefined) ||
|
||||
"aws") as ProviderType,
|
||||
providerAlias: (providerAttrs.alias as string | undefined) || "",
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { fetchMock, getAuthHeadersMock, revalidatePathMock } = vi.hoisted(
|
||||
() => ({
|
||||
fetchMock: vi.fn(),
|
||||
getAuthHeadersMock: vi.fn(),
|
||||
revalidatePathMock: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@/lib/helper", () => ({
|
||||
apiBaseUrl: "https://api.test/api/v1",
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
vi.mock("next/cache", () => ({
|
||||
revalidatePath: revalidatePathMock,
|
||||
}));
|
||||
|
||||
import { createMuteRule } from "./mute-rules";
|
||||
|
||||
const NAME_CONFLICT_DETAIL = "A mute rule with this name already exists.";
|
||||
|
||||
const errorResponse = (contentType: string, body: string, status = 400) =>
|
||||
new Response(body, {
|
||||
status,
|
||||
headers: { "Content-Type": contentType },
|
||||
});
|
||||
|
||||
const nameConflictBody = JSON.stringify({
|
||||
errors: [
|
||||
{
|
||||
detail: NAME_CONFLICT_DETAIL,
|
||||
status: "400",
|
||||
source: { pointer: "/data/attributes/name" },
|
||||
code: "invalid",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const muteRuleFormData = () => {
|
||||
const formData = new FormData();
|
||||
formData.set("name", "Root account has a hardware MFA device enabled");
|
||||
formData.set("reason", "Not our approach here with SSO");
|
||||
formData.set("finding_ids", JSON.stringify(["finding-1"]));
|
||||
return formData;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
});
|
||||
|
||||
describe("createMuteRule", () => {
|
||||
it("should return only the error detail for a JSON:API error response", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
errorResponse("application/vnd.api+json", nameConflictBody),
|
||||
);
|
||||
|
||||
const result = await createMuteRule(null, muteRuleFormData());
|
||||
|
||||
expect(result?.errors?.general).toBe(NAME_CONFLICT_DETAIL);
|
||||
expect(revalidatePathMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return only the error detail for a plain JSON error response", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
errorResponse("application/json", nameConflictBody),
|
||||
);
|
||||
|
||||
const result = await createMuteRule(null, muteRuleFormData());
|
||||
|
||||
expect(result?.errors?.general).toBe(NAME_CONFLICT_DETAIL);
|
||||
});
|
||||
|
||||
it("should return the response text for a non-JSON error response", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
errorResponse("text/plain", "Bad gateway", 502),
|
||||
);
|
||||
|
||||
const result = await createMuteRule(null, muteRuleFormData());
|
||||
|
||||
expect(result?.errors?.general).toBe("Bad gateway");
|
||||
});
|
||||
});
|
||||
@@ -156,8 +156,7 @@ export const createMuteRule = async (
|
||||
let errorMessage = `Failed to create mute rule: ${response.statusText}`;
|
||||
const responseContentType = response.headers.get("content-type");
|
||||
try {
|
||||
// The API answers with application/vnd.api+json
|
||||
if (responseContentType?.includes("json")) {
|
||||
if (responseContentType?.includes("application/json")) {
|
||||
const errorData = await response.json();
|
||||
const jsonApiError = (
|
||||
errorData as {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user