mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 10:14:20 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30ad5249d3 | ||
|
|
c453b7293e | ||
|
|
2d4ab4cc25 | ||
|
|
6519927d64 | ||
|
|
234c272da0 | ||
|
|
270490e93e | ||
|
|
ea9171c77c | ||
|
|
aecac60c12 | ||
|
|
abc5bedc45 | ||
|
|
fa6eb9d5d9 | ||
|
|
9c5ed27a7a |
@@ -174,7 +174,7 @@ SENTRY_RELEASE=local
|
||||
# REO_DEV_CLIENT_ID=
|
||||
|
||||
#### Prowler release version ####
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.44.1
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.44.0
|
||||
|
||||
# Social login credentials
|
||||
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
|
||||
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
|
||||
### Changes
|
||||
|
||||
This PR updates the `OCI_COMMERCIAL_REGIONS` dictionary in `prowler/providers/oraclecloud/config.py` and the matching list in `ui/lib/provider-credentials/oci-regions.ts` with the latest regions fetched from the OCI Identity API (`list_regions()`).
|
||||
This PR updates the `OCI_COMMERCIAL_REGIONS` dictionary in `prowler/providers/oraclecloud/config.py` with the latest regions fetched from the OCI Identity API (`list_regions()`).
|
||||
|
||||
- Government regions (`OCI_GOVERNMENT_REGIONS`) are preserved unchanged
|
||||
- DOD regions (`OCI_US_DOD_REGIONS`) are preserved unchanged
|
||||
|
||||
+1
-5
@@ -68,7 +68,7 @@ vulnerabilities:
|
||||
expired_at: 2026-11-30
|
||||
|
||||
# Declared in the SPDX manifest that ships inside PowerShell's MicrosoftTeams module
|
||||
# (Modules/MicrosoftTeams/8.0.0/_manifest/spdx_2.2/manifest.spdx.json). Trivy reads that
|
||||
# (Modules/MicrosoftTeams/7.9.0/_manifest/spdx_2.2/manifest.spdx.json). Trivy reads that
|
||||
# SBOM and reports what it declares, which is not the same as what the image contains:
|
||||
# there is no Node runtime and no node_modules anywhere in the image, and the .NET
|
||||
# assemblies target net472, a Windows-only framework. Nothing here is reachable, and none
|
||||
@@ -129,10 +129,6 @@ vulnerabilities:
|
||||
purls:
|
||||
- "pkg:npm/fast-uri"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-84292
|
||||
purls:
|
||||
- "pkg:npm/fast-uri"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-69192
|
||||
purls:
|
||||
- "pkg:npm/ip-address"
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
API key authentication no longer locks the key row on every request and now throttles `last_used_at` updates to once per 60 seconds, preventing a hot key from serializing all its requests onto a single locked row
|
||||
@@ -1 +0,0 @@
|
||||
OCI provider secrets keep the region as home region for credential validation and scans, instead of always using us-ashburn-1
|
||||
@@ -1 +0,0 @@
|
||||
Provider deletion no longer fails when the provider has Attack Paths scans recorded on a sink that is no longer configured, such as Neptune after moving back to Neo4j
|
||||
@@ -1 +0,0 @@
|
||||
`pyjwt` from 2.13.0 to 2.14.0, patching GHSA-ffc3-869f-jxw9
|
||||
@@ -1 +0,0 @@
|
||||
`DELETE /api/v1/tasks/{id}` requires the permission of the operation that queued the task and rejects provider deletions, and `GET /api/v1/tasks` hides tasks of providers outside the visibility of the role
|
||||
+6
-6
@@ -45,7 +45,7 @@ dependencies = [
|
||||
"gunicorn==26.0.0",
|
||||
"uvloop==0.22.1",
|
||||
"lxml==6.1.0",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.44",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
|
||||
"psycopg2-binary==2.9.9",
|
||||
"pytest-celery[redis] (==1.3.0)",
|
||||
"sentry-sdk[django] (==2.56.0)",
|
||||
@@ -71,7 +71,7 @@ name = "prowler-api"
|
||||
package-mode = false
|
||||
# Needed for the SDK compatibility
|
||||
requires-python = ">=3.11,<3.13"
|
||||
version = "1.45.1"
|
||||
version = "1.45.0"
|
||||
|
||||
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
|
||||
# target-version tracks this project's lowest supported Python.
|
||||
@@ -375,7 +375,7 @@ constraint-dependencies = [
|
||||
"pydantic-core==2.41.5",
|
||||
"pygithub==2.8.0",
|
||||
"pygments==2.20.0",
|
||||
"pyjwt==2.14.0",
|
||||
"pyjwt==2.13.0",
|
||||
"pylint==3.2.5",
|
||||
"pymsalruntime==0.18.1",
|
||||
"pynacl==1.6.2",
|
||||
@@ -476,8 +476,8 @@ constraint-dependencies = [
|
||||
# to 1.9.10 until the SDK bump propagates to the pinned master rev.
|
||||
#
|
||||
# prowler@master hard-pins dulwich==0.23.0 and pyjwt==2.12.1 in [project.dependencies].
|
||||
# dulwich 1.2.5 patches GHSA-897w-fcg9-f6xj (arbitrary file write) and pyjwt 2.14.0
|
||||
# patches GHSA-ffc3-869f-jxw9 (HMAC/PEM key-confusion); a constraint cannot satisfy these
|
||||
# dulwich 1.2.5 patches GHSA-897w-fcg9-f6xj (arbitrary file write) and pyjwt 2.13.0
|
||||
# patches PYSEC-2026-179 (HMAC/JWK key-confusion); a constraint cannot satisfy these
|
||||
# against the SDK's hard pins, so override them to the patched versions until the SDK
|
||||
# bump propagates to the pinned master rev. pyjwt keeps the [crypto] extra because an
|
||||
# override replaces the whole requirement; bare pyjwt would drop it from the consumers
|
||||
@@ -500,5 +500,5 @@ override-dependencies = [
|
||||
"microsoft-kiota-serialization-multipart==1.9.10",
|
||||
"microsoft-kiota-serialization-text==1.9.10",
|
||||
"dulwich==1.2.5",
|
||||
"pyjwt[crypto]==2.14.0"
|
||||
"pyjwt[crypto]==2.13.0"
|
||||
]
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from math import isfinite
|
||||
from uuid import UUID
|
||||
|
||||
@@ -7,7 +6,7 @@ from api.db_router import MainRouter
|
||||
from api.models import TenantAPIKey, TenantAPIKeyManager
|
||||
from cryptography.fernet import InvalidToken
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db.models import Q
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from drf_simple_apikey.backends import APIKeyAuthentication as BaseAPIKeyAuth
|
||||
from drf_simple_apikey.crypto import get_crypto
|
||||
@@ -19,15 +18,12 @@ from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Writing on every request makes all requests of a busy key contend on one row
|
||||
API_KEY_LAST_USED_AT_THROTTLE_SECONDS = 60
|
||||
|
||||
|
||||
class OrphanedAPIKeyError(Exception):
|
||||
"""Raised when an API key outlived the user that owns it.
|
||||
|
||||
The revocation is written by a plain `update()` before this is raised, so it is
|
||||
already persisted by the time `authenticate` catches it and rejects the request.
|
||||
Handled by `authenticate`, which commits the revocation written while detecting it
|
||||
and then rejects the request with `AuthenticationFailed`.
|
||||
"""
|
||||
|
||||
|
||||
@@ -41,9 +37,8 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
|
||||
"""
|
||||
Override to use admin connection, bypassing RLS during authentication.
|
||||
|
||||
Returns the validated API key row from a single read. `authenticate` builds
|
||||
the auth claims from that same row instead of looking it up again, so a key
|
||||
revoked or orphaned right after validation can't still authenticate.
|
||||
Returns the validated API key row, locked with `select_for_update`, so callers
|
||||
must run inside `transaction.atomic(using=MainRouter.admin_db)`.
|
||||
"""
|
||||
try:
|
||||
payload = self.key_crypto.decrypt(key)
|
||||
@@ -72,11 +67,9 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
|
||||
raise AuthenticationFailed("API Key has already expired.")
|
||||
|
||||
try:
|
||||
# Loading `entity` in the same query keeps a user deleted after this read
|
||||
# from turning the later `api_key.entity` access into a 500
|
||||
api_key = (
|
||||
self.model.objects.using(MainRouter.admin_db)
|
||||
.select_related("entity")
|
||||
.select_for_update()
|
||||
.get(id=api_key_pk)
|
||||
)
|
||||
except ObjectDoesNotExist:
|
||||
@@ -92,9 +85,8 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
|
||||
# Revoke it as well, so it stops showing up as active and later attempts fail
|
||||
# the `revoked` check above like any other revoked key.
|
||||
if api_key.entity_id is None:
|
||||
self.model.objects.using(MainRouter.admin_db).filter(
|
||||
id=api_key.id, revoked=False
|
||||
).update(revoked=True)
|
||||
api_key.revoked = True
|
||||
api_key.save(update_fields=["revoked"], using=MainRouter.admin_db)
|
||||
logger.warning(
|
||||
"Revoked orphaned API key: prefix=%s tenant=%s",
|
||||
api_key.prefix,
|
||||
@@ -120,38 +112,34 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
|
||||
except ValueError:
|
||||
raise AuthenticationFailed("Invalid API Key.")
|
||||
|
||||
try:
|
||||
api_key = self._authenticate_credentials(request, key)
|
||||
except OrphanedAPIKeyError:
|
||||
raise AuthenticationFailed("No entity matching this api key.")
|
||||
# Validation, the `last_used_at` update and the auth claims all read the same
|
||||
# row, locked until the transaction ends. Looking the key up a second time to
|
||||
# build the claims used to leave a window where a key revoked or orphaned right
|
||||
# after passing validation still authenticated.
|
||||
with transaction.atomic(using=MainRouter.admin_db):
|
||||
try:
|
||||
api_key = self._authenticate_credentials(request, key)
|
||||
except OrphanedAPIKeyError:
|
||||
# Rejected below instead of here: leaving the block normally commits
|
||||
# the revocation `_authenticate_credentials` wrote, while raising from
|
||||
# inside would roll it back.
|
||||
pass
|
||||
else:
|
||||
# The prefix used to be checked by the second lookup
|
||||
if api_key.prefix != prefix:
|
||||
raise AuthenticationFailed("Invalid API Key.")
|
||||
|
||||
# The prefix used to be checked by the second lookup
|
||||
if api_key.prefix != prefix:
|
||||
raise AuthenticationFailed("Invalid API Key.")
|
||||
api_key.last_used_at = timezone.now()
|
||||
api_key.save(update_fields=["last_used_at"], using=MainRouter.admin_db)
|
||||
|
||||
self._throttled_touch_last_used_at(api_key)
|
||||
entity = api_key.entity
|
||||
return entity, {
|
||||
"tenant_id": str(api_key.tenant_id),
|
||||
"sub": str(entity.id),
|
||||
"api_key_prefix": api_key.prefix,
|
||||
}
|
||||
|
||||
entity = api_key.entity
|
||||
return entity, {
|
||||
"tenant_id": str(api_key.tenant_id),
|
||||
"sub": str(entity.id),
|
||||
"api_key_prefix": api_key.prefix,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _throttled_touch_last_used_at(api_key: TenantAPIKey) -> None:
|
||||
"""Write `last_used_at` at most once per throttle interval, without locking the row."""
|
||||
now = timezone.now()
|
||||
stale_before = now - timedelta(seconds=API_KEY_LAST_USED_AT_THROTTLE_SECONDS)
|
||||
|
||||
if api_key.last_used_at is not None and api_key.last_used_at >= stale_before:
|
||||
return
|
||||
|
||||
TenantAPIKey.objects.using(MainRouter.admin_db).filter(
|
||||
id=api_key.id, revoked=False
|
||||
).filter(
|
||||
Q(last_used_at__isnull=True) | Q(last_used_at__lt=stale_before)
|
||||
).update(last_used_at=now)
|
||||
raise AuthenticationFailed("No entity matching this api key.")
|
||||
|
||||
|
||||
class CombinedJWTOrAPIKeyAuthentication(BaseAuthentication):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from enum import Enum
|
||||
|
||||
from api.db_router import MainRouter
|
||||
from api.models import Integration, Provider, Role, Task, User
|
||||
from api.models import Integration, Provider, Role, User
|
||||
from django.db.models import Q, QuerySet
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.permissions import BasePermission
|
||||
@@ -17,50 +17,6 @@ class Permissions(Enum):
|
||||
UNLIMITED_VISIBILITY = "unlimited_visibility"
|
||||
|
||||
|
||||
# Revoking a task needs the permission of the operation that queued it.
|
||||
# None and unmapped names are not revocable; a revoked provider deletion
|
||||
# would leave the provider soft-deleted with nothing re-queuing the cleanup.
|
||||
TASK_REVOKE_PERMISSIONS: dict[str, list[Permissions] | None] = {
|
||||
"provider-connection-check": [Permissions.MANAGE_PROVIDERS],
|
||||
"provider-deletion": None,
|
||||
"integration-connection-check": [Permissions.MANAGE_INTEGRATIONS],
|
||||
"integration-s3": [Permissions.MANAGE_INTEGRATIONS],
|
||||
"integration-security-hub": [Permissions.MANAGE_INTEGRATIONS],
|
||||
"integration-jira": [Permissions.MANAGE_INTEGRATIONS],
|
||||
"scan-perform": [Permissions.MANAGE_SCANS],
|
||||
"scan-perform-scheduled": [Permissions.MANAGE_SCANS],
|
||||
"scan-compliance-overviews": [Permissions.MANAGE_SCANS],
|
||||
"scan-compliance-reports": [Permissions.MANAGE_SCANS],
|
||||
"scan-finding-group-summaries": [Permissions.MANAGE_SCANS],
|
||||
"scan-report": [Permissions.MANAGE_SCANS],
|
||||
"attack-paths-scan-perform": [Permissions.MANAGE_SCANS],
|
||||
"findings-mute-latest-scans": [Permissions.MANAGE_SCANS],
|
||||
"lighthouse-connection-check": [],
|
||||
"lighthouse-provider-connection-check": [],
|
||||
"lighthouse-provider-models-refresh": [],
|
||||
}
|
||||
|
||||
|
||||
def get_user_roles(user: User, tenant_id: str) -> list[Role]:
|
||||
"""Return every role assigned to the user in the tenant."""
|
||||
return list(
|
||||
User.objects.using(MainRouter.admin_db)
|
||||
.get(id=user.id)
|
||||
.roles.using(MainRouter.admin_db)
|
||||
.filter(tenant_id=tenant_id)
|
||||
)
|
||||
|
||||
|
||||
def roles_have_permissions(
|
||||
roles: list[Role], required_permissions: list[Permissions]
|
||||
) -> bool:
|
||||
"""Return True when every required permission is granted by at least one role."""
|
||||
return all(
|
||||
any(getattr(role, permission.value, False) for role in roles)
|
||||
for permission in required_permissions
|
||||
)
|
||||
|
||||
|
||||
class HasPermissions(BasePermission):
|
||||
"""
|
||||
Custom permission to check if the user's role has the required permissions.
|
||||
@@ -78,11 +34,19 @@ class HasPermissions(BasePermission):
|
||||
if not tenant_id:
|
||||
return False
|
||||
|
||||
user_roles = get_user_roles(request.user, tenant_id)
|
||||
user_roles = list(
|
||||
User.objects.using(MainRouter.admin_db)
|
||||
.get(id=request.user.id)
|
||||
.roles.using(MainRouter.admin_db)
|
||||
.filter(tenant_id=tenant_id)
|
||||
)
|
||||
if not user_roles:
|
||||
return False
|
||||
|
||||
return roles_have_permissions(user_roles, required_permissions)
|
||||
return all(
|
||||
any(getattr(role, permission.value, False) for role in user_roles)
|
||||
for permission in required_permissions
|
||||
)
|
||||
|
||||
|
||||
def get_role(user: User, tenant_id: str) -> Role:
|
||||
@@ -121,25 +85,6 @@ def get_providers(role: Role) -> QuerySet[Provider]:
|
||||
).distinct()
|
||||
|
||||
|
||||
def get_tasks(role: Role) -> QuerySet[Task]:
|
||||
"""Return the tasks visible to the role: tenant-wide ones and those of its providers."""
|
||||
queryset = Task.objects.filter(tenant_id=role.tenant_id)
|
||||
if role.unlimited_visibility:
|
||||
return queryset
|
||||
|
||||
# Task has no provider FK, so match provider ids inside the stored kwargs.
|
||||
# all_objects keeps a soft-deleted provider visible to its own groups, so the
|
||||
# role that queued its deletion can still follow the task.
|
||||
hidden = Q()
|
||||
for provider_id in (
|
||||
Provider.all_objects.filter(tenant_id=role.tenant_id)
|
||||
.exclude(provider_groups__in=role.provider_groups.all())
|
||||
.values_list("id", flat=True)
|
||||
):
|
||||
hidden |= Q(task_runner_task__task_kwargs__contains=str(provider_id))
|
||||
return queryset.exclude(hidden) if hidden else queryset
|
||||
|
||||
|
||||
def get_integrations(
|
||||
role: Role, providers: QuerySet[Provider] | None = None
|
||||
) -> QuerySet[Integration]:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Prowler API
|
||||
version: 1.45.1
|
||||
version: 1.45.0
|
||||
description: |-
|
||||
Prowler API specification.
|
||||
|
||||
@@ -14823,9 +14823,7 @@ paths:
|
||||
get:
|
||||
operationId: api_v1_tasks_list
|
||||
description: Retrieve a list of all tasks with options for filtering by name,
|
||||
state, and other criteria. Tasks that reference a provider are only returned
|
||||
when the role can access it; tasks without a provider reference are returned
|
||||
for every role.
|
||||
state, and other criteria.
|
||||
summary: List all tasks
|
||||
parameters:
|
||||
- in: query
|
||||
@@ -14924,8 +14922,7 @@ paths:
|
||||
/api/v1/tasks/{id}:
|
||||
get:
|
||||
operationId: api_v1_tasks_retrieve
|
||||
description: Fetch detailed information about a specific task by its ID. Tasks
|
||||
tied to a provider outside the visibility of the role are not found.
|
||||
description: Fetch detailed information about a specific task by its ID.
|
||||
summary: Retrieve data from a specific task
|
||||
parameters:
|
||||
- in: query
|
||||
@@ -14966,9 +14963,7 @@ paths:
|
||||
delete:
|
||||
operationId: api_v1_tasks_destroy
|
||||
description: Try to revoke a task using its ID. Only tasks that are not yet
|
||||
in progress can be revoked, and the caller needs the same permission as the
|
||||
operation that queued the task (for example MANAGE_SCANS for a scan). Provider
|
||||
deletions cannot be revoked.
|
||||
in progress can be revoked.
|
||||
summary: Revoke a task
|
||||
parameters:
|
||||
- in: path
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import json
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from api.authentication import API_KEY_LAST_USED_AT_THROTTLE_SECONDS
|
||||
from api.db_router import MainRouter
|
||||
from api.models import Membership, Role, TenantAPIKey, User, UserRoleRelationship
|
||||
from api.signals import revoke_membership_api_keys, revoke_user_api_keys
|
||||
@@ -11,7 +11,6 @@ from conftest import TEST_PASSWORD, get_api_tokens, get_authorization_header
|
||||
from django.db.utils import ConnectionDoesNotExist
|
||||
from django.urls import reverse
|
||||
from drf_simple_apikey.crypto import get_crypto
|
||||
from freezegun import freeze_time
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework_simplejwt.token_blacklist.models import (
|
||||
BlacklistedToken,
|
||||
@@ -528,7 +527,7 @@ class TestAPIKeyAuthentication:
|
||||
def test_last_used_at_tracking(
|
||||
self, create_test_user, tenants_fixture, api_keys_fixture
|
||||
):
|
||||
"""Verify last_used_at timestamp is set on first use and throttled after that."""
|
||||
"""Verify last_used_at timestamp updates on each authentication."""
|
||||
client = APIClient()
|
||||
api_key = api_keys_fixture[0]
|
||||
|
||||
@@ -537,11 +536,7 @@ class TestAPIKeyAuthentication:
|
||||
|
||||
# Use API key to authenticate
|
||||
api_key_headers = get_api_key_header(api_key._raw_key)
|
||||
start = datetime.now(UTC)
|
||||
with freeze_time(start):
|
||||
first_response = client.get(
|
||||
reverse("provider-list"), headers=api_key_headers
|
||||
)
|
||||
first_response = client.get(reverse("provider-list"), headers=api_key_headers)
|
||||
assert first_response.status_code == 200
|
||||
|
||||
# Reload from database and check last_used_at is set
|
||||
@@ -549,23 +544,17 @@ class TestAPIKeyAuthentication:
|
||||
first_used_at = api_key.last_used_at
|
||||
assert first_used_at is not None
|
||||
|
||||
# Using the same key again within the throttle interval does not rewrite it
|
||||
# Use the same key again after a small delay
|
||||
time.sleep(0.1)
|
||||
|
||||
second_response = client.get(reverse("provider-list"), headers=api_key_headers)
|
||||
assert second_response.status_code == 200
|
||||
|
||||
# Reload and verify last_used_at was updated
|
||||
api_key.refresh_from_db()
|
||||
assert api_key.last_used_at == first_used_at
|
||||
|
||||
# Past the throttle interval, the next use refreshes it
|
||||
later = start + timedelta(seconds=API_KEY_LAST_USED_AT_THROTTLE_SECONDS + 1)
|
||||
with freeze_time(later):
|
||||
third_response = client.get(
|
||||
reverse("provider-list"), headers=api_key_headers
|
||||
)
|
||||
assert third_response.status_code == 200
|
||||
|
||||
api_key.refresh_from_db()
|
||||
assert api_key.last_used_at > first_used_at
|
||||
second_used_at = api_key.last_used_at
|
||||
assert second_used_at is not None
|
||||
assert second_used_at > first_used_at
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -1452,7 +1441,6 @@ class TestAPIKeyRLSBypass:
|
||||
|
||||
The update to last_used_at during authentication must also use the
|
||||
admin database since it occurs before RLS context is established.
|
||||
Past the throttle interval, using the key again refreshes the timestamp.
|
||||
"""
|
||||
client = APIClient()
|
||||
api_key = api_keys_fixture[0]
|
||||
@@ -1460,11 +1448,7 @@ class TestAPIKeyRLSBypass:
|
||||
assert api_key.last_used_at is None
|
||||
|
||||
api_key_headers = get_api_key_header(api_key._raw_key)
|
||||
start = datetime.now(UTC)
|
||||
with freeze_time(start):
|
||||
first_response = client.get(
|
||||
reverse("provider-list"), headers=api_key_headers
|
||||
)
|
||||
first_response = client.get(reverse("provider-list"), headers=api_key_headers)
|
||||
|
||||
assert first_response.status_code == 200
|
||||
|
||||
@@ -1472,11 +1456,9 @@ class TestAPIKeyRLSBypass:
|
||||
first_timestamp = api_key.last_used_at
|
||||
assert first_timestamp is not None
|
||||
|
||||
later = start + timedelta(seconds=API_KEY_LAST_USED_AT_THROTTLE_SECONDS + 1)
|
||||
with freeze_time(later):
|
||||
second_response = client.get(
|
||||
reverse("provider-list"), headers=api_key_headers
|
||||
)
|
||||
time.sleep(0.1)
|
||||
|
||||
second_response = client.get(reverse("provider-list"), headers=api_key_headers)
|
||||
assert second_response.status_code == 200
|
||||
|
||||
api_key.refresh_from_db()
|
||||
|
||||
@@ -5,18 +5,16 @@ from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from api.authentication import (
|
||||
API_KEY_LAST_USED_AT_THROTTLE_SECONDS,
|
||||
OrphanedAPIKeyError,
|
||||
SSEAuthentication,
|
||||
TenantAPIKeyAuthentication,
|
||||
)
|
||||
from api.db_router import MainRouter
|
||||
from api.models import TenantAPIKey, User
|
||||
from api.models import TenantAPIKey
|
||||
from django.db import connections
|
||||
from django.db.models.query import QuerySet
|
||||
from django.test import RequestFactory
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
from freezegun import freeze_time
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
|
||||
@@ -288,15 +286,14 @@ class TestTenantAPIKeyAuthentication:
|
||||
|
||||
assert str(exc_info.value.detail) == "This API Key has been revoked."
|
||||
|
||||
def test_authenticate_reads_the_api_key_once_without_a_row_lock(
|
||||
def test_authenticate_reads_the_api_key_once_under_a_row_lock(
|
||||
self, auth_backend, api_keys_fixture, request_factory
|
||||
):
|
||||
"""Test the API key is read a single time and no row is locked.
|
||||
"""Test the API key is read a single time and the row is locked.
|
||||
|
||||
Validation, the `last_used_at` update and the claims must all come from the
|
||||
same authoritative row: a second lookup would reopen the window where a key
|
||||
revoked in between still authenticates. `SELECT ... FOR UPDATE` serialized
|
||||
every request for a hot key onto one locked row and is not used any more.
|
||||
same authoritative row: a second, unlocked lookup would reopen the window
|
||||
where a key revoked in between still authenticates.
|
||||
"""
|
||||
api_key = api_keys_fixture[0]
|
||||
|
||||
@@ -313,40 +310,33 @@ class TestTenantAPIKeyAuthentication:
|
||||
]
|
||||
|
||||
assert len(api_key_selects) == 1
|
||||
assert "FOR UPDATE" not in api_key_selects[0]
|
||||
assert "FOR UPDATE" in api_key_selects[0]
|
||||
|
||||
def test_authenticate_ignores_revocation_after_the_single_read(
|
||||
def test_authenticate_ignores_revocation_after_the_locked_read(
|
||||
self, auth_backend, api_keys_fixture, request_factory
|
||||
):
|
||||
"""Test the claims describe the row that was validated, not a later state.
|
||||
|
||||
Regression test: the key used to be looked up again to build the auth dict,
|
||||
without rechecking `revoked` or `entity`. A key revoked or orphaned between
|
||||
both reads still authenticated, and the claims came from that stale row.
|
||||
There is now only a single read, so this race is closed by construction and
|
||||
the revocation only takes effect on the next request.
|
||||
both reads still authenticated, and the claims came from that stale row. With
|
||||
a single locked read the write below cannot land mid-authentication, and the
|
||||
revocation only takes effect on the next request.
|
||||
"""
|
||||
api_key = api_keys_fixture[0]
|
||||
entity_at_validation = api_key.entity
|
||||
original_authenticate_credentials = (
|
||||
TenantAPIKeyAuthentication._authenticate_credentials
|
||||
)
|
||||
original_save = TenantAPIKey.save
|
||||
|
||||
def revoke_and_orphan_after_reading(self, request, key):
|
||||
# Runs right after the single read `authenticate` will use to build the
|
||||
# claims: the exact window a concurrent revocation used to slip into
|
||||
result = original_authenticate_credentials(self, request, key)
|
||||
def revoke_and_orphan_before_saving(instance, *args, **kwargs):
|
||||
# Runs after validation, right before the claims are built: the exact
|
||||
# window a concurrent revocation or user deletion used to slip into
|
||||
TenantAPIKey.objects.filter(id=api_key.id).update(revoked=True, entity=None)
|
||||
return result
|
||||
return original_save(instance, *args, **kwargs)
|
||||
|
||||
request = request_factory.get("/")
|
||||
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {api_key._raw_key}"
|
||||
|
||||
with patch.object(
|
||||
TenantAPIKeyAuthentication,
|
||||
"_authenticate_credentials",
|
||||
revoke_and_orphan_after_reading,
|
||||
):
|
||||
with patch.object(TenantAPIKey, "save", revoke_and_orphan_before_saving):
|
||||
entity, auth_dict = auth_backend.authenticate(request)
|
||||
|
||||
assert entity == entity_at_validation
|
||||
@@ -360,43 +350,6 @@ class TestTenantAPIKeyAuthentication:
|
||||
|
||||
assert str(exc_info.value.detail) == "This API Key has been revoked."
|
||||
|
||||
def test_authenticate_survives_owner_deleted_after_the_single_read(
|
||||
self, auth_backend, api_keys_fixture, request_factory
|
||||
):
|
||||
"""Test a user deleted right after the read does not turn into a 500.
|
||||
|
||||
Without the row lock a concurrent user deletion can land between the read
|
||||
and building the claims. `entity` is loaded by the same query, so no later
|
||||
lookup can raise `DoesNotExist`.
|
||||
"""
|
||||
api_key = api_keys_fixture[0]
|
||||
owner_id = api_key.entity_id
|
||||
original_authenticate_credentials = (
|
||||
TenantAPIKeyAuthentication._authenticate_credentials
|
||||
)
|
||||
|
||||
def delete_owner_after_reading(self, request, key):
|
||||
result = original_authenticate_credentials(self, request, key)
|
||||
User.objects.using(MainRouter.admin_db).filter(id=owner_id).delete()
|
||||
return result
|
||||
|
||||
request = request_factory.get("/")
|
||||
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {api_key._raw_key}"
|
||||
|
||||
with patch.object(
|
||||
TenantAPIKeyAuthentication,
|
||||
"_authenticate_credentials",
|
||||
delete_owner_after_reading,
|
||||
):
|
||||
entity, auth_dict = auth_backend.authenticate(request)
|
||||
|
||||
assert auth_dict["sub"] == str(owner_id)
|
||||
assert entity.id == owner_id
|
||||
|
||||
# From the next request on, the orphaned key is rejected with a 401
|
||||
with pytest.raises(AuthenticationFailed):
|
||||
auth_backend.authenticate(request)
|
||||
|
||||
def test_authenticate_expired_api_key(
|
||||
self, auth_backend, create_test_user, tenants_fixture, request_factory
|
||||
):
|
||||
@@ -468,90 +421,24 @@ class TestTenantAPIKeyAuthentication:
|
||||
if original_last_used:
|
||||
assert api_key.last_used_at > original_last_used
|
||||
|
||||
def test_authenticate_updates_last_used_at_on_admin_database(
|
||||
def test_authenticate_saves_to_admin_database(
|
||||
self, auth_backend, api_keys_fixture, request_factory
|
||||
):
|
||||
"""Test that the `last_used_at` update runs against the admin database."""
|
||||
"""Test that the API key save operation uses admin database."""
|
||||
api_key = api_keys_fixture[0]
|
||||
raw_key = api_key._raw_key
|
||||
|
||||
request = request_factory.get("/")
|
||||
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}"
|
||||
|
||||
with CaptureQueriesContext(connections[MainRouter.admin_db]) as captured:
|
||||
# Mock the save method to verify it's called with using='admin'
|
||||
with patch.object(TenantAPIKey, "save") as mock_save:
|
||||
auth_backend.authenticate(request)
|
||||
|
||||
api_key_updates = [
|
||||
query["sql"]
|
||||
for query in captured.captured_queries
|
||||
if query["sql"].startswith("UPDATE") and '"api_keys"' in query["sql"]
|
||||
]
|
||||
|
||||
assert len(api_key_updates) == 1
|
||||
assert "last_used_at" in api_key_updates[0]
|
||||
|
||||
def test_authenticate_does_not_rewrite_last_used_at_within_throttle_interval(
|
||||
self, auth_backend, api_keys_fixture, request_factory
|
||||
):
|
||||
"""Test that a second authentication within the throttle interval is a no-op write."""
|
||||
api_key = api_keys_fixture[0]
|
||||
raw_key = api_key._raw_key
|
||||
|
||||
request = request_factory.get("/")
|
||||
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}"
|
||||
|
||||
# First call sets last_used_at
|
||||
auth_backend.authenticate(request)
|
||||
api_key.refresh_from_db()
|
||||
first_used_at = api_key.last_used_at
|
||||
assert first_used_at is not None
|
||||
|
||||
# Second call, still within the throttle interval, must issue no UPDATE
|
||||
with CaptureQueriesContext(connections[MainRouter.admin_db]) as captured:
|
||||
auth_backend.authenticate(request)
|
||||
|
||||
api_key_updates = [
|
||||
query["sql"]
|
||||
for query in captured.captured_queries
|
||||
if query["sql"].startswith("UPDATE") and '"api_keys"' in query["sql"]
|
||||
]
|
||||
assert api_key_updates == []
|
||||
|
||||
api_key.refresh_from_db()
|
||||
assert api_key.last_used_at == first_used_at
|
||||
|
||||
def test_authenticate_rewrites_last_used_at_after_throttle_interval(
|
||||
self, auth_backend, api_keys_fixture, request_factory
|
||||
):
|
||||
"""Test that `last_used_at` is refreshed once it is older than the throttle interval."""
|
||||
api_key = api_keys_fixture[0]
|
||||
raw_key = api_key._raw_key
|
||||
|
||||
request = request_factory.get("/")
|
||||
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}"
|
||||
|
||||
start = datetime.now(UTC)
|
||||
with freeze_time(start):
|
||||
auth_backend.authenticate(request)
|
||||
|
||||
api_key.refresh_from_db()
|
||||
first_used_at = api_key.last_used_at
|
||||
assert first_used_at is not None
|
||||
|
||||
later = start + timedelta(seconds=API_KEY_LAST_USED_AT_THROTTLE_SECONDS + 1)
|
||||
with freeze_time(later):
|
||||
with CaptureQueriesContext(connections[MainRouter.admin_db]) as captured:
|
||||
auth_backend.authenticate(request)
|
||||
|
||||
api_key_updates = [
|
||||
query["sql"]
|
||||
for query in captured.captured_queries
|
||||
if query["sql"].startswith("UPDATE") and '"api_keys"' in query["sql"]
|
||||
]
|
||||
assert len(api_key_updates) == 1
|
||||
|
||||
api_key.refresh_from_db()
|
||||
assert api_key.last_used_at > first_used_at
|
||||
# Verify save was called with using=admin_db
|
||||
mock_save.assert_called_once_with(
|
||||
update_fields=["last_used_at"], using=MainRouter.admin_db
|
||||
)
|
||||
|
||||
def test_authenticate_returns_correct_auth_dict(
|
||||
self, auth_backend, api_keys_fixture, request_factory
|
||||
|
||||
@@ -215,34 +215,36 @@ class TestOracleCloudProviderSecret:
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
assert "region" not in serializer.validated_data
|
||||
|
||||
def test_keeps_region_as_home_region(self):
|
||||
serializer = OracleCloudProviderSecret(
|
||||
data=self.valid_secret(region=" me-abudhabi-1 ")
|
||||
)
|
||||
def test_accepts_and_ignores_region_field(self):
|
||||
secret = self.valid_secret(region="us-phoenix-1")
|
||||
serializer = OracleCloudProviderSecret(data=secret)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
assert serializer.validated_data["region"] == "me-abudhabi-1"
|
||||
|
||||
def test_rejects_unknown_region(self):
|
||||
serializer = OracleCloudProviderSecret(
|
||||
data=self.valid_secret(region="mars-north-1")
|
||||
)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert "region" in serializer.errors
|
||||
|
||||
@pytest.mark.parametrize("legacy_value", [None, "", {"name": "us-ashburn-1"}])
|
||||
def test_drops_blank_or_non_string_region(self, legacy_value):
|
||||
serializer = OracleCloudProviderSecret(
|
||||
data=self.valid_secret(region=legacy_value)
|
||||
)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
assert "region" not in serializer.validated_data
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"legacy_field, legacy_value",
|
||||
[
|
||||
("region", None),
|
||||
("region", ""),
|
||||
("region", {"name": "us-ashburn-1"}),
|
||||
],
|
||||
)
|
||||
def test_accepts_and_ignores_any_legacy_region_value(
|
||||
self, legacy_field, legacy_value
|
||||
):
|
||||
serializer = OracleCloudProviderSecret(
|
||||
data=self.valid_secret(**{legacy_field: legacy_value})
|
||||
)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
|
||||
assert legacy_field not in serializer.validated_data
|
||||
|
||||
|
||||
class TestProviderSecretFieldSchema:
|
||||
def test_oraclecloud_schema_region_is_not_deprecated(self):
|
||||
def test_oraclecloud_schema_includes_legacy_region_field(self):
|
||||
schema = ProviderSecretField._spectacular_annotation["field"]
|
||||
oraclecloud_schema = next(
|
||||
credential_schema
|
||||
@@ -251,7 +253,7 @@ class TestProviderSecretFieldSchema:
|
||||
== "Oracle Cloud Infrastructure (OCI) API Key Credentials"
|
||||
)
|
||||
|
||||
assert "deprecated" not in oraclecloud_schema["properties"]["region"]
|
||||
assert oraclecloud_schema["properties"]["region"]["deprecated"] is True
|
||||
|
||||
|
||||
class TestKubernetesProviderSecret:
|
||||
|
||||
@@ -172,7 +172,7 @@ class TestInitializeProwlerProvider:
|
||||
)
|
||||
|
||||
@patch("api.utils.return_prowler_provider")
|
||||
def test_initialize_oraclecloud_provider_passes_region_as_home_region(
|
||||
def test_initialize_oraclecloud_provider_removes_region_string(
|
||||
self, mock_return_prowler_provider
|
||||
):
|
||||
provider = MagicMock()
|
||||
@@ -182,7 +182,7 @@ class TestInitializeProwlerProvider:
|
||||
"fingerprint": "00:11:22:33:44:55:66:77",
|
||||
"key_content": "fake-base64-key-content",
|
||||
"tenancy": "ocid1.tenancy.oc1..fake",
|
||||
"region": "me-abudhabi-1",
|
||||
"region": "us-ashburn-1",
|
||||
}
|
||||
mock_return_prowler_provider.return_value = MagicMock()
|
||||
|
||||
@@ -193,7 +193,6 @@ class TestInitializeProwlerProvider:
|
||||
fingerprint="00:11:22:33:44:55:66:77",
|
||||
key_content="fake-base64-key-content",
|
||||
tenancy="ocid1.tenancy.oc1..fake",
|
||||
home_region="me-abudhabi-1",
|
||||
)
|
||||
|
||||
@patch("api.utils.return_prowler_provider")
|
||||
@@ -255,35 +254,11 @@ class TestProwlerProviderConnectionTest:
|
||||
fingerprint="00:11:22:33:44:55:66:77",
|
||||
key_content="fake-base64-key-content",
|
||||
tenancy="ocid1.tenancy.oc1..aaaaaaaexample",
|
||||
region=OraclecloudProvider._bootstrap_region,
|
||||
provider_id="ocid1.tenancy.oc1..aaaaaaaexample",
|
||||
raise_on_exception=False,
|
||||
)
|
||||
|
||||
@patch("api.utils.return_prowler_provider")
|
||||
def test_oraclecloud_connection_test_uses_stored_region_for_identity(
|
||||
self, mock_return_prowler_provider
|
||||
):
|
||||
provider = MagicMock()
|
||||
provider.uid = "ocid1.tenancy.oc1..aaaaaaaexample"
|
||||
provider.provider = Provider.ProviderChoices.ORACLECLOUD.value
|
||||
provider.secret.secret = {
|
||||
"user": "ocid1.user.oc1..aaaaaaaexample",
|
||||
"fingerprint": "00:11:22:33:44:55:66:77",
|
||||
"key_content": "fake-base64-key-content",
|
||||
"tenancy": "ocid1.tenancy.oc1..aaaaaaaexample",
|
||||
"region": "me-abudhabi-1",
|
||||
}
|
||||
mock_return_prowler_provider.return_value = MagicMock()
|
||||
|
||||
prowler_provider_connection_test(provider)
|
||||
|
||||
mock_return_prowler_provider.return_value.test_connection.assert_called_once_with(
|
||||
user="ocid1.user.oc1..aaaaaaaexample",
|
||||
fingerprint="00:11:22:33:44:55:66:77",
|
||||
key_content="fake-base64-key-content",
|
||||
tenancy="ocid1.tenancy.oc1..aaaaaaaexample",
|
||||
region="me-abudhabi-1",
|
||||
region=getattr(
|
||||
OraclecloudProvider,
|
||||
"_bootstrap_region",
|
||||
OraclecloudProvider._home_region,
|
||||
),
|
||||
provider_id="ocid1.tenancy.oc1..aaaaaaaexample",
|
||||
raise_on_exception=False,
|
||||
)
|
||||
@@ -459,7 +434,7 @@ class TestGetProwlerProviderKwargs:
|
||||
expected_result = {**secret_dict, **expected_extra_kwargs}
|
||||
assert result == expected_result
|
||||
|
||||
def test_get_prowler_provider_kwargs_oraclecloud_maps_region_to_home_region(
|
||||
def test_get_prowler_provider_kwargs_oraclecloud_removes_region(
|
||||
self,
|
||||
):
|
||||
secret_dict = {
|
||||
@@ -486,7 +461,6 @@ class TestGetProwlerProviderKwargs:
|
||||
"key_content": "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----",
|
||||
"tenancy": "ocid1.tenancy.oc1..fake",
|
||||
"pass_phrase": "fake-passphrase",
|
||||
"home_region": "us-ashburn-1",
|
||||
}
|
||||
|
||||
def test_get_prowler_provider_kwargs_with_mutelist(self):
|
||||
|
||||
@@ -60,7 +60,6 @@ from api.models import (
|
||||
User,
|
||||
UserRoleRelationship,
|
||||
)
|
||||
from api.rbac.permissions import TASK_REVOKE_PERMISSIONS
|
||||
from api.rls import Tenant
|
||||
from api.uuid_utils import datetime_to_uuid7
|
||||
from api.v1.views import (
|
||||
@@ -3363,7 +3362,7 @@ current-context: test-context
|
||||
provider_secret = ProviderSecret.objects.get()
|
||||
assert "region" not in provider_secret.secret
|
||||
|
||||
def test_provider_secrets_create_oraclecloud_stores_region(
|
||||
def test_provider_secrets_create_oraclecloud_accepts_and_ignores_region(
|
||||
self,
|
||||
authenticated_client,
|
||||
oraclecloud_provider,
|
||||
@@ -3372,14 +3371,14 @@ current-context: test-context
|
||||
authenticated_client,
|
||||
oraclecloud_provider,
|
||||
self._oraclecloud_secret(
|
||||
key_content=" test-key-content ", region=" me-abudhabi-1 "
|
||||
key_content=" test-key-content ", region=" us-ashburn-1 "
|
||||
),
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
provider_secret = ProviderSecret.objects.get()
|
||||
assert provider_secret.secret["key_content"] == "test-key-content"
|
||||
assert provider_secret.secret["region"] == "me-abudhabi-1"
|
||||
assert "region" not in provider_secret.secret
|
||||
|
||||
def test_provider_secrets_update_oraclecloud_without_region_stores_no_region(
|
||||
self,
|
||||
@@ -3412,7 +3411,7 @@ current-context: test-context
|
||||
provider_secret.refresh_from_db()
|
||||
assert "region" not in provider_secret.secret
|
||||
|
||||
def test_provider_secrets_update_oraclecloud_stores_region(
|
||||
def test_provider_secrets_update_oraclecloud_accepts_and_ignores_region(
|
||||
self,
|
||||
authenticated_client,
|
||||
oraclecloud_provider,
|
||||
@@ -3430,7 +3429,7 @@ current-context: test-context
|
||||
"type": "provider-secrets",
|
||||
"id": str(provider_secret.id),
|
||||
"attributes": {
|
||||
"secret": self._oraclecloud_secret(region=" me-abudhabi-1 ")
|
||||
"secret": self._oraclecloud_secret(region=" us-ashburn-1 ")
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -3443,7 +3442,7 @@ current-context: test-context
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
provider_secret.refresh_from_db()
|
||||
assert provider_secret.secret["region"] == "me-abudhabi-1"
|
||||
assert "region" not in provider_secret.secret
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"attributes, error_code, error_pointer",
|
||||
@@ -5240,7 +5239,6 @@ class TestTaskViewSet:
|
||||
@patch("api.v1.views.AsyncResult", return_value=Mock())
|
||||
def test_tasks_revoke(self, mock_async_result, authenticated_client, tasks_fixture):
|
||||
_, task2 = tasks_fixture
|
||||
self._set_task_name(task2, "scan-perform")
|
||||
response = authenticated_client.delete(
|
||||
reverse("task-detail", kwargs={"pk": task2.id})
|
||||
)
|
||||
@@ -5256,311 +5254,12 @@ class TestTaskViewSet:
|
||||
|
||||
def test_tasks_revoke_invalid_status(self, authenticated_client, tasks_fixture):
|
||||
task1, _ = tasks_fixture
|
||||
self._set_task_name(task1, "scan-perform")
|
||||
response = authenticated_client.delete(
|
||||
reverse("task-detail", kwargs={"pk": task1.id})
|
||||
)
|
||||
# Task status is SUCCESS
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
@staticmethod
|
||||
def _set_task_name(task, name):
|
||||
task.task_runner_task.task_name = name
|
||||
task.task_runner_task.save(update_fields=["task_name"])
|
||||
|
||||
@staticmethod
|
||||
def _set_task_kwargs(task, kwargs):
|
||||
task.task_runner_task.task_kwargs = json.dumps(repr(kwargs))
|
||||
task.task_runner_task.save(update_fields=["task_kwargs"])
|
||||
|
||||
@staticmethod
|
||||
def _client_with_role(tenant, factory, **permissions):
|
||||
user = User.objects.create_user(
|
||||
name=f"revoker-{uuid4()}",
|
||||
email=f"revoker-{uuid4()}@prowler.com",
|
||||
password=TEST_PASSWORD,
|
||||
)
|
||||
Membership.objects.create(
|
||||
user=user, tenant=tenant, role=Membership.RoleChoices.MEMBER
|
||||
)
|
||||
flags = {
|
||||
"manage_users": False,
|
||||
"manage_account": False,
|
||||
"manage_billing": False,
|
||||
"manage_providers": False,
|
||||
"manage_integrations": False,
|
||||
"manage_scans": False,
|
||||
"unlimited_visibility": True,
|
||||
**permissions,
|
||||
}
|
||||
role = Role.objects.create(
|
||||
name=f"revoker-{uuid4()}", tenant_id=tenant.id, **flags
|
||||
)
|
||||
UserRoleRelationship.objects.create(user=user, role=role, tenant_id=tenant.id)
|
||||
return factory(user, tenant)
|
||||
|
||||
@patch("api.v1.views.AsyncResult")
|
||||
def test_tasks_revoke_without_permission_is_forbidden(
|
||||
self, mock_async_result, authenticated_client_no_permissions_rbac, tasks_fixture
|
||||
):
|
||||
_, pending_task = tasks_fixture
|
||||
self._set_task_name(pending_task, "provider-connection-check")
|
||||
|
||||
response = authenticated_client_no_permissions_rbac.delete(
|
||||
reverse("task-detail", kwargs={"pk": pending_task.id})
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
mock_async_result.return_value.revoke.assert_not_called()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"task_name, permissions, expected_status",
|
||||
[
|
||||
(
|
||||
"provider-connection-check",
|
||||
{"manage_providers": True},
|
||||
status.HTTP_202_ACCEPTED,
|
||||
),
|
||||
(
|
||||
"provider-connection-check",
|
||||
{"manage_scans": True},
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
),
|
||||
("scan-perform", {"manage_scans": True}, status.HTTP_202_ACCEPTED),
|
||||
(
|
||||
"scan-perform-scheduled",
|
||||
{"manage_providers": True},
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
),
|
||||
(
|
||||
"integration-jira",
|
||||
{"manage_integrations": True},
|
||||
status.HTTP_202_ACCEPTED,
|
||||
),
|
||||
("integration-jira", {"manage_providers": True}, status.HTTP_403_FORBIDDEN),
|
||||
("lighthouse-connection-check", {}, status.HTTP_202_ACCEPTED),
|
||||
],
|
||||
)
|
||||
@patch("api.v1.views.AsyncResult")
|
||||
def test_tasks_revoke_requires_originating_operation_permission(
|
||||
self,
|
||||
mock_async_result,
|
||||
authenticated_client_for_tenant_factory,
|
||||
tenants_fixture,
|
||||
tasks_fixture,
|
||||
task_name,
|
||||
permissions,
|
||||
expected_status,
|
||||
):
|
||||
tenant, *_ = tenants_fixture
|
||||
_, pending_task = tasks_fixture
|
||||
self._set_task_name(pending_task, task_name)
|
||||
client = self._client_with_role(
|
||||
tenant, authenticated_client_for_tenant_factory, **permissions
|
||||
)
|
||||
|
||||
response = client.delete(reverse("task-detail", kwargs={"pk": pending_task.id}))
|
||||
|
||||
assert response.status_code == expected_status
|
||||
if expected_status == status.HTTP_202_ACCEPTED:
|
||||
mock_async_result.return_value.revoke.assert_called_once()
|
||||
else:
|
||||
mock_async_result.return_value.revoke.assert_not_called()
|
||||
|
||||
@patch("api.v1.views.AsyncResult")
|
||||
def test_tasks_revoke_provider_deletion_is_forbidden_even_for_admin(
|
||||
self, mock_async_result, authenticated_client, tasks_fixture
|
||||
):
|
||||
_, pending_task = tasks_fixture
|
||||
self._set_task_name(pending_task, "provider-deletion")
|
||||
|
||||
response = authenticated_client.delete(
|
||||
reverse("task-detail", kwargs={"pk": pending_task.id})
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
mock_async_result.return_value.revoke.assert_not_called()
|
||||
|
||||
@patch("api.v1.views.AsyncResult")
|
||||
def test_tasks_revoke_unmapped_task_is_forbidden(
|
||||
self, mock_async_result, authenticated_client, tasks_fixture
|
||||
):
|
||||
_, pending_task = tasks_fixture
|
||||
assert pending_task.task_runner_task.task_name not in TASK_REVOKE_PERMISSIONS
|
||||
|
||||
response = authenticated_client.delete(
|
||||
reverse("task-detail", kwargs={"pk": pending_task.id})
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
mock_async_result.return_value.revoke.assert_not_called()
|
||||
|
||||
def test_every_rls_task_has_revoke_permissions(self):
|
||||
from config.celery import RLSTask, celery_app
|
||||
|
||||
rls_task_names = {
|
||||
name for name, task in celery_app.tasks.items() if isinstance(task, RLSTask)
|
||||
}
|
||||
assert rls_task_names
|
||||
assert rls_task_names <= set(TASK_REVOKE_PERMISSIONS)
|
||||
|
||||
@patch("api.v1.views.AsyncResult")
|
||||
def test_tasks_hidden_for_providers_outside_role_visibility(
|
||||
self,
|
||||
mock_async_result,
|
||||
authenticated_client_no_permissions_rbac,
|
||||
tasks_fixture,
|
||||
aws_provider_pair,
|
||||
):
|
||||
client = authenticated_client_no_permissions_rbac
|
||||
limited_user = client.user
|
||||
tenant = Membership.objects.filter(user=limited_user).first().tenant
|
||||
allowed_provider, denied_provider = aws_provider_pair
|
||||
allowed_task, denied_task = tasks_fixture
|
||||
self._set_task_kwargs(
|
||||
allowed_task,
|
||||
{"tenant_id": str(tenant.id), "provider_id": str(allowed_provider.id)},
|
||||
)
|
||||
self._set_task_name(denied_task, "provider-deletion")
|
||||
self._set_task_kwargs(
|
||||
denied_task,
|
||||
{"tenant_id": str(tenant.id), "provider_id": str(denied_provider.id)},
|
||||
)
|
||||
provider_group = ProviderGroup.objects.create(
|
||||
name="limited-task-group", tenant_id=tenant.id
|
||||
)
|
||||
ProviderGroupMembership.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
provider_group=provider_group,
|
||||
provider=allowed_provider,
|
||||
)
|
||||
RoleProviderGroupRelationship.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
role=limited_user.roles.first(),
|
||||
provider_group=provider_group,
|
||||
)
|
||||
|
||||
response = client.get(reverse("task-list"))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert [item["id"] for item in response.json()["data"]] == [
|
||||
str(allowed_task.id)
|
||||
]
|
||||
|
||||
response = client.get(reverse("task-detail", kwargs={"pk": denied_task.id}))
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
response = client.delete(reverse("task-detail", kwargs={"pk": denied_task.id}))
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
mock_async_result.return_value.revoke.assert_not_called()
|
||||
|
||||
@patch("api.v1.views.AsyncResult")
|
||||
def test_tasks_of_soft_deleted_provider_stay_visible_to_its_groups(
|
||||
self,
|
||||
mock_async_result,
|
||||
authenticated_client_for_tenant_factory,
|
||||
tenants_fixture,
|
||||
tasks_fixture,
|
||||
aws_provider_pair,
|
||||
):
|
||||
tenant, *_ = tenants_fixture
|
||||
provider, _ = aws_provider_pair
|
||||
finished_task, pending_task = tasks_fixture
|
||||
client = self._client_with_role(
|
||||
tenant,
|
||||
authenticated_client_for_tenant_factory,
|
||||
manage_providers=True,
|
||||
unlimited_visibility=False,
|
||||
)
|
||||
provider_group = ProviderGroup.objects.create(
|
||||
name="own-group", tenant_id=tenant.id
|
||||
)
|
||||
ProviderGroupMembership.objects.create(
|
||||
tenant_id=tenant.id, provider_group=provider_group, provider=provider
|
||||
)
|
||||
RoleProviderGroupRelationship.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
role=client.user.roles.first(),
|
||||
provider_group=provider_group,
|
||||
)
|
||||
for task, name in (
|
||||
(finished_task, "provider-deletion"),
|
||||
(pending_task, "provider-connection-check"),
|
||||
):
|
||||
self._set_task_name(task, name)
|
||||
self._set_task_kwargs(
|
||||
task, {"tenant_id": str(tenant.id), "provider_id": str(provider.id)}
|
||||
)
|
||||
provider.is_deleted = True
|
||||
provider.save()
|
||||
|
||||
response = client.get(reverse("task-detail", kwargs={"pk": finished_task.id}))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
response = client.delete(reverse("task-detail", kwargs={"pk": pending_task.id}))
|
||||
assert response.status_code == status.HTTP_202_ACCEPTED
|
||||
mock_async_result.return_value.revoke.assert_called_once()
|
||||
|
||||
def test_tasks_without_provider_stay_visible_for_limited_roles(
|
||||
self, authenticated_client_no_permissions_rbac, tasks_fixture, aws_provider_pair
|
||||
):
|
||||
response = authenticated_client_no_permissions_rbac.get(reverse("task-list"))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.json()["data"]) == len(tasks_fixture)
|
||||
|
||||
def test_tasks_list_without_role_is_forbidden(
|
||||
self, authenticated_client_rbac_noroles, tasks_fixture
|
||||
):
|
||||
response = authenticated_client_rbac_noroles.get(reverse("task-list"))
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_tasks_revoke_without_permission_hides_task_status(
|
||||
self, authenticated_client_no_permissions_rbac, tasks_fixture
|
||||
):
|
||||
finished_task, _ = tasks_fixture
|
||||
self._set_task_name(finished_task, "provider-connection-check")
|
||||
|
||||
response = authenticated_client_no_permissions_rbac.delete(
|
||||
reverse("task-detail", kwargs={"pk": finished_task.id})
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
@patch("api.v1.views.AsyncResult")
|
||||
def test_tasks_revoke_unauthenticated_returns_401(
|
||||
self, mock_async_result, tasks_fixture
|
||||
):
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
_, pending_task = tasks_fixture
|
||||
self._set_task_name(pending_task, "scan-perform")
|
||||
|
||||
response = APIClient().delete(
|
||||
reverse("task-detail", kwargs={"pk": pending_task.id})
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
mock_async_result.return_value.revoke.assert_not_called()
|
||||
|
||||
@patch("api.v1.views.AsyncResult")
|
||||
def test_tasks_revoke_foreign_tenant_task_returns_404(
|
||||
self,
|
||||
mock_async_result,
|
||||
authenticated_client_for_tenant_factory,
|
||||
tenants_fixture,
|
||||
tasks_fixture,
|
||||
):
|
||||
_, foreign_tenant, *_ = tenants_fixture
|
||||
_, pending_task = tasks_fixture
|
||||
self._set_task_name(pending_task, "scan-perform")
|
||||
client = self._client_with_role(
|
||||
foreign_tenant, authenticated_client_for_tenant_factory, manage_scans=True
|
||||
)
|
||||
|
||||
response = client.delete(reverse("task-detail", kwargs={"pk": pending_task.id}))
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
mock_async_result.return_value.revoke.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestAttackPathsScanViewSet:
|
||||
|
||||
@@ -302,26 +302,17 @@ def get_prowler_provider_kwargs(
|
||||
def _normalize_oraclecloud_provider_kwargs(secret: dict) -> dict:
|
||||
"""Normalize external OCI secret fields into SDK provider kwargs."""
|
||||
prowler_provider_kwargs = secret.copy()
|
||||
home_region = _oraclecloud_home_region(prowler_provider_kwargs.pop("region", None))
|
||||
if home_region:
|
||||
prowler_provider_kwargs["home_region"] = home_region
|
||||
prowler_provider_kwargs.pop("region", None)
|
||||
|
||||
return prowler_provider_kwargs
|
||||
|
||||
|
||||
def _oraclecloud_home_region(region) -> str | None:
|
||||
"""Return the stored OCI region as a home region, ignoring blank or non-string legacy values."""
|
||||
if isinstance(region, str) and region.strip():
|
||||
return region.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_oraclecloud_connection_test_kwargs(secret: dict) -> dict:
|
||||
"""Normalize external OCI secret fields into test_connection kwargs."""
|
||||
from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider
|
||||
|
||||
prowler_provider_kwargs = secret.copy()
|
||||
home_region = _oraclecloud_home_region(prowler_provider_kwargs.pop("region", None))
|
||||
prowler_provider_kwargs.pop("region", None)
|
||||
|
||||
if (
|
||||
prowler_provider_kwargs.get("user")
|
||||
@@ -332,9 +323,11 @@ def _normalize_oraclecloud_connection_test_kwargs(secret: dict) -> dict:
|
||||
or prowler_provider_kwargs.get("key_file")
|
||||
)
|
||||
):
|
||||
# Identity calls only succeed in a region the tenancy is subscribed to.
|
||||
prowler_provider_kwargs["region"] = (
|
||||
home_region or OraclecloudProvider._bootstrap_region
|
||||
# Connection validation needs one OCI endpoint, but scans remain unfiltered.
|
||||
prowler_provider_kwargs["region"] = getattr(
|
||||
OraclecloudProvider,
|
||||
"_bootstrap_region",
|
||||
OraclecloudProvider._home_region,
|
||||
)
|
||||
|
||||
return prowler_provider_kwargs
|
||||
|
||||
@@ -301,7 +301,8 @@ from rest_framework_json_api import serializers
|
||||
},
|
||||
"region": {
|
||||
"type": "string",
|
||||
"description": "Optional OCI home region (or any region the tenancy is subscribed to) used to validate the credentials. It does not filter the scan, which covers all subscribed regions. Defaults to us-ashburn-1.",
|
||||
"deprecated": True,
|
||||
"description": "Legacy OCI region field accepted for backwards compatibility but ignored; OCI scans all regions.",
|
||||
},
|
||||
},
|
||||
"required": ["user", "fingerprint", "tenancy"],
|
||||
|
||||
@@ -71,7 +71,6 @@ from django.db import IntegrityError, transaction
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from jwt.exceptions import InvalidKeyError
|
||||
from prowler.lib.mutelist.mutelist import Mutelist
|
||||
from prowler.providers.oraclecloud.config import OCI_REGIONS
|
||||
from rest_framework.reverse import reverse
|
||||
from rest_framework.validators import UniqueTogetherValidator
|
||||
from rest_framework_json_api import serializers
|
||||
@@ -1918,16 +1917,9 @@ class IacProviderSecret(serializers.Serializer):
|
||||
resource_name = "provider-secrets"
|
||||
|
||||
|
||||
class OCIHomeRegionField(serializers.Field):
|
||||
"""Optional OCI home region; blank or non-string legacy values are dropped."""
|
||||
|
||||
class LegacyOCIRegionField(serializers.Field):
|
||||
def to_internal_value(self, data):
|
||||
if not isinstance(data, str) or not data.strip():
|
||||
return None
|
||||
region = data.strip()
|
||||
if region not in OCI_REGIONS:
|
||||
raise serializers.ValidationError(f"Invalid OCI region: {region}")
|
||||
return region
|
||||
return data
|
||||
|
||||
def to_representation(self, value):
|
||||
return value
|
||||
@@ -1940,11 +1932,10 @@ class OracleCloudProviderSecret(serializers.Serializer):
|
||||
key_content = serializers.CharField(required=False)
|
||||
tenancy = serializers.CharField()
|
||||
pass_phrase = serializers.CharField(required=False)
|
||||
region = OCIHomeRegionField(required=False, allow_null=True)
|
||||
region = LegacyOCIRegionField(required=False, allow_null=True)
|
||||
|
||||
def validate(self, attrs):
|
||||
if not attrs.get("region"):
|
||||
attrs.pop("region", None)
|
||||
attrs.pop("region", None)
|
||||
|
||||
if "key_file" not in attrs and "key_content" not in attrs:
|
||||
raise serializers.ValidationError(
|
||||
|
||||
@@ -125,14 +125,10 @@ from api.models import (
|
||||
)
|
||||
from api.pagination import ComplianceOverviewPagination
|
||||
from api.rbac.permissions import (
|
||||
TASK_REVOKE_PERMISSIONS,
|
||||
Permissions,
|
||||
get_integrations,
|
||||
get_providers,
|
||||
get_role,
|
||||
get_tasks,
|
||||
get_user_roles,
|
||||
roles_have_permissions,
|
||||
)
|
||||
from api.renderers import APIJSONRenderer, PlainTextRenderer
|
||||
from api.rls import Tenant
|
||||
@@ -2862,29 +2858,17 @@ class ScanViewSet(ProviderVisibilityMixin, BaseRLSViewSet):
|
||||
list=extend_schema(
|
||||
tags=["Task"],
|
||||
summary="List all tasks",
|
||||
description=(
|
||||
"Retrieve a list of all tasks with options for filtering by name, state, and other "
|
||||
"criteria. Tasks that reference a provider are only returned when the role can "
|
||||
"access it; tasks without a provider reference are returned for every role."
|
||||
),
|
||||
description="Retrieve a list of all tasks with options for filtering by name, state, and other criteria.",
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
tags=["Task"],
|
||||
summary="Retrieve data from a specific task",
|
||||
description=(
|
||||
"Fetch detailed information about a specific task by its ID. Tasks tied to a provider "
|
||||
"outside the visibility of the role are not found."
|
||||
),
|
||||
description="Fetch detailed information about a specific task by its ID.",
|
||||
),
|
||||
destroy=extend_schema(
|
||||
tags=["Task"],
|
||||
summary="Revoke a task",
|
||||
description=(
|
||||
"Try to revoke a task using its ID. Only tasks that are not yet in progress can be "
|
||||
"revoked, and the caller needs the same permission as the operation that queued "
|
||||
"the task (for example MANAGE_SCANS for a scan). Provider deletions cannot be "
|
||||
"revoked."
|
||||
),
|
||||
description="Try to revoke a task using its ID. Only tasks that are not yet in progress can be revoked.",
|
||||
responses={202: OpenApiResponse(response=TaskSerializer)},
|
||||
),
|
||||
)
|
||||
@@ -2900,26 +2884,13 @@ class TaskViewSet(BaseRLSViewSet):
|
||||
required_permissions = []
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
get_tasks(self.user_role)
|
||||
.annotate(
|
||||
name=F("task_runner_task__task_name"),
|
||||
state=F("task_runner_task__status"),
|
||||
)
|
||||
.select_related("task_runner_task")
|
||||
)
|
||||
return Task.objects.annotate(
|
||||
name=F("task_runner_task__task_name"),
|
||||
state=F("task_runner_task__status"),
|
||||
).select_related("task_runner_task")
|
||||
|
||||
def destroy(self, request, *args, pk=None, **kwargs):
|
||||
task = self.get_object()
|
||||
required_permissions = TASK_REVOKE_PERMISSIONS.get(
|
||||
task.task_runner_task.task_name
|
||||
)
|
||||
# Same multi-role semantics as HasPermissions.
|
||||
if required_permissions is None or not roles_have_permissions(
|
||||
get_user_roles(request.user, request.tenant_id), required_permissions
|
||||
):
|
||||
raise PermissionDenied("You do not have permission to revoke this task.")
|
||||
|
||||
task = get_object_or_404(Task, pk=pk)
|
||||
if task.task_runner_task.status not in ["PENDING", "RECEIVED"]:
|
||||
serializer = TaskSerializer(task)
|
||||
return Response(
|
||||
|
||||
@@ -13,7 +13,6 @@ from api.models import (
|
||||
Tenant,
|
||||
)
|
||||
from celery.utils.log import get_task_logger
|
||||
from django.conf import settings
|
||||
from django.db import DatabaseError
|
||||
from tasks.jobs.queries import (
|
||||
COMPLIANCE_DELETE_EMPTY_TENANT_SUMMARY_SQL,
|
||||
@@ -107,19 +106,9 @@ def delete_provider(tenant_id: str, pk: str):
|
||||
try:
|
||||
if attack_paths_sink_backends:
|
||||
for sink_backend in attack_paths_sink_backends:
|
||||
try:
|
||||
backend = sink_module.get_backend_for_name(sink_backend)
|
||||
|
||||
except RuntimeError as sink_error:
|
||||
# A retired sink has no connection settings left, and no graph left to drop
|
||||
if sink_backend == settings.ATTACK_PATHS_SINK_DATABASE.lower():
|
||||
raise
|
||||
logger.warning(
|
||||
f"Skipping graph cleanup on unconfigured sink {sink_backend}: {sink_error}"
|
||||
)
|
||||
continue
|
||||
|
||||
backend.drop_subgraph(tenant_database_name, str(pk))
|
||||
sink_module.get_backend_for_name(sink_backend).drop_subgraph(
|
||||
tenant_database_name, str(pk)
|
||||
)
|
||||
else:
|
||||
graph_database.drop_subgraph(tenant_database_name, str(pk))
|
||||
|
||||
|
||||
@@ -2113,9 +2113,7 @@ def aggregate_attack_surface(tenant_id: str, scan_id: str):
|
||||
.annotate(
|
||||
total=Count("id"),
|
||||
failed=Count("id", filter=Q(status="FAIL", muted=False)),
|
||||
# Not `muted`: an annotation named after a model field comes
|
||||
# back as `muted_new` from the psqlextra queryset.
|
||||
muted_count=Count("id", filter=Q(status="FAIL", muted=True)),
|
||||
muted=Count("id", filter=Q(status="FAIL", muted=True)),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -2126,7 +2124,7 @@ def aggregate_attack_surface(tenant_id: str, scan_id: str):
|
||||
|
||||
aggregated_counts[attack_surface_type]["total"] += stats["total"] or 0
|
||||
aggregated_counts[attack_surface_type]["failed"] += stats["failed"] or 0
|
||||
aggregated_counts[attack_surface_type]["muted"] += stats["muted_count"] or 0
|
||||
aggregated_counts[attack_surface_type]["muted"] += stats["muted"] or 0
|
||||
|
||||
overview_objects = []
|
||||
for attack_surface_type, counts in aggregated_counts.items():
|
||||
|
||||
@@ -4,7 +4,6 @@ import pytest
|
||||
from api.attack_paths import database as graph_database
|
||||
from api.models import Provider, Tenant, TenantComplianceSummary
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.test import override_settings
|
||||
from tasks.jobs.deletion import delete_provider, delete_tenant
|
||||
|
||||
|
||||
@@ -124,60 +123,6 @@ class TestDeleteProvider:
|
||||
"tenant-db", str(instance.id)
|
||||
)
|
||||
|
||||
@override_settings(ATTACK_PATHS_SINK_DATABASE="neo4j")
|
||||
def test_delete_provider_skips_unconfigured_retired_sink(
|
||||
self, aws_provider, create_attack_paths_scan
|
||||
):
|
||||
instance = aws_provider
|
||||
tenant_id = str(instance.tenant_id)
|
||||
create_attack_paths_scan(instance, sink_backend="neo4j")
|
||||
create_attack_paths_scan(instance, sink_backend="neptune")
|
||||
neo4j_backend = MagicMock()
|
||||
|
||||
def get_backend_for_name(name):
|
||||
if name == "neptune":
|
||||
raise RuntimeError("NEPTUNE_WRITER_ENDPOINT and AWS_REGION must be set")
|
||||
return neo4j_backend
|
||||
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.deletion.graph_database.get_database_name",
|
||||
return_value="tenant-db",
|
||||
),
|
||||
patch(
|
||||
"tasks.jobs.deletion.sink_module.get_backend_for_name",
|
||||
side_effect=get_backend_for_name,
|
||||
),
|
||||
patch("tasks.jobs.deletion.graph_database.drop_database"),
|
||||
):
|
||||
result = delete_provider(tenant_id, instance.id)
|
||||
|
||||
assert result
|
||||
assert not Provider.all_objects.filter(pk=instance.id).exists()
|
||||
neo4j_backend.drop_subgraph.assert_called_once_with(
|
||||
"tenant-db", str(instance.id)
|
||||
)
|
||||
|
||||
@override_settings(ATTACK_PATHS_SINK_DATABASE="neo4j")
|
||||
def test_delete_provider_raises_when_active_sink_unconfigured(
|
||||
self, aws_provider, create_attack_paths_scan
|
||||
):
|
||||
instance = aws_provider
|
||||
tenant_id = str(instance.tenant_id)
|
||||
create_attack_paths_scan(instance, sink_backend="neo4j")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.deletion.sink_module.get_backend_for_name",
|
||||
side_effect=RuntimeError("NEO4J_HOST / NEO4J_PORT must be set"),
|
||||
),
|
||||
patch("tasks.jobs.deletion.graph_database.drop_database"),
|
||||
pytest.raises(RuntimeError),
|
||||
):
|
||||
delete_provider(tenant_id, instance.id)
|
||||
|
||||
assert Provider.all_objects.filter(pk=instance.id).exists()
|
||||
|
||||
def test_delete_provider_continues_when_temp_db_drop_fails(
|
||||
self, aws_provider, create_attack_paths_scan
|
||||
):
|
||||
@@ -204,10 +149,10 @@ class TestDeleteProvider:
|
||||
assert result
|
||||
assert not Provider.all_objects.filter(pk=instance.id).exists()
|
||||
|
||||
@pytest.mark.usefixtures("provider_compliance_scores_fixture")
|
||||
def test_delete_provider_recalculates_tenant_compliance_summary(
|
||||
self,
|
||||
aws_provider_pair,
|
||||
provider_compliance_scores_fixture,
|
||||
):
|
||||
instance = aws_provider_pair[0]
|
||||
tenant_id = instance.tenant_id
|
||||
|
||||
@@ -12,7 +12,6 @@ from api.db_router import MainRouter
|
||||
from api.db_utils import rls_transaction
|
||||
from api.exceptions import ProviderConnectionError, ProviderDeletedException
|
||||
from api.models import (
|
||||
AttackSurfaceOverview,
|
||||
Finding,
|
||||
MuteRule,
|
||||
Provider,
|
||||
@@ -5328,13 +5327,8 @@ class TestAggregateAttackSurface:
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.values.return_value = mock_queryset
|
||||
mock_queryset.annotate.return_value = [
|
||||
{
|
||||
"check_id": "check_internet_1",
|
||||
"total": 10,
|
||||
"failed": 3,
|
||||
"muted_count": 1,
|
||||
},
|
||||
{"check_id": "check_secrets_1", "total": 5, "failed": 2, "muted_count": 0},
|
||||
{"check_id": "check_internet_1", "total": 10, "failed": 3, "muted": 1},
|
||||
{"check_id": "check_secrets_1", "total": 5, "failed": 2, "muted": 0},
|
||||
]
|
||||
|
||||
ctx = MagicMock()
|
||||
@@ -5383,7 +5377,7 @@ class TestAggregateAttackSurface:
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.values.return_value = mock_queryset
|
||||
mock_queryset.annotate.return_value = [
|
||||
{"check_id": "check_internet_1", "total": 5, "failed": 1, "muted_count": 0},
|
||||
{"check_id": "check_internet_1", "total": 5, "failed": 1, "muted": 0},
|
||||
]
|
||||
|
||||
ctx = MagicMock()
|
||||
@@ -5466,13 +5460,8 @@ class TestAggregateAttackSurface:
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.values.return_value = mock_queryset
|
||||
mock_queryset.annotate.return_value = [
|
||||
{
|
||||
"check_id": "check_internet_1",
|
||||
"total": 10,
|
||||
"failed": 3,
|
||||
"muted_count": 1,
|
||||
},
|
||||
{"check_id": "check_internet_2", "total": 5, "failed": 2, "muted_count": 0},
|
||||
{"check_id": "check_internet_1", "total": 10, "failed": 3, "muted": 1},
|
||||
{"check_id": "check_internet_2", "total": 5, "failed": 2, "muted": 0},
|
||||
]
|
||||
|
||||
ctx = MagicMock()
|
||||
@@ -5493,62 +5482,6 @@ class TestAggregateAttackSurface:
|
||||
assert overview.failed_findings == 5 # 3 + 2
|
||||
assert overview.muted_failed_findings == 1 # 1 + 0
|
||||
|
||||
@patch("tasks.jobs.scan._get_attack_surface_mapping_from_provider")
|
||||
def test_aggregate_attack_surface_counts_real_findings(
|
||||
self, mock_get_mapping, tenants_fixture, scans_fixture
|
||||
):
|
||||
"""Run the aggregation query against real Finding rows.
|
||||
|
||||
The other tests mock the queryset, so they never execute the real
|
||||
`annotate`. This one guards the row keys the query returns."""
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
|
||||
mock_get_mapping.return_value = {
|
||||
"privilege-escalation": {"check_privesc_1"},
|
||||
"secrets": {"check_secrets_1"},
|
||||
}
|
||||
|
||||
def create_finding(uid, check_id, status, muted):
|
||||
Finding.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
uid=uid,
|
||||
scan=scan,
|
||||
status=status,
|
||||
status_extended="status extended",
|
||||
impact=Severity.high,
|
||||
severity=Severity.high,
|
||||
raw_result={"status": status},
|
||||
check_id=check_id,
|
||||
check_metadata={"CheckId": check_id},
|
||||
muted=muted,
|
||||
first_seen_at="2024-01-02T00:00:00Z",
|
||||
)
|
||||
|
||||
create_finding("privesc_fail", "check_privesc_1", Status.FAIL, False)
|
||||
create_finding("privesc_fail_2", "check_privesc_1", Status.FAIL, False)
|
||||
create_finding("privesc_fail_muted", "check_privesc_1", Status.FAIL, True)
|
||||
create_finding("privesc_pass", "check_privesc_1", Status.PASS, False)
|
||||
create_finding("secrets_pass_muted", "check_secrets_1", Status.PASS, True)
|
||||
create_finding("unmapped_fail", "check_unmapped", Status.FAIL, False)
|
||||
|
||||
aggregate_attack_surface(str(tenant.id), str(scan.id))
|
||||
|
||||
overviews = {
|
||||
overview.attack_surface_type: overview
|
||||
for overview in AttackSurfaceOverview.objects.filter(
|
||||
tenant_id=tenant.id, scan_id=scan.id
|
||||
)
|
||||
}
|
||||
|
||||
assert set(overviews) == {"privilege-escalation", "secrets"}
|
||||
assert overviews["privilege-escalation"].total_findings == 4
|
||||
assert overviews["privilege-escalation"].failed_findings == 2
|
||||
assert overviews["privilege-escalation"].muted_failed_findings == 1
|
||||
assert overviews["secrets"].total_findings == 1
|
||||
assert overviews["secrets"].failed_findings == 0
|
||||
assert overviews["secrets"].muted_failed_findings == 0
|
||||
|
||||
@patch("tasks.jobs.scan.Scan.all_objects.select_related")
|
||||
@patch("tasks.jobs.scan.rls_transaction")
|
||||
def test_aggregate_attack_surface_uses_select_related(
|
||||
|
||||
Generated
+9
-21
@@ -291,7 +291,7 @@ constraints = [
|
||||
{ name = "pydantic-core", specifier = "==2.41.5" },
|
||||
{ name = "pygithub", specifier = "==2.8.0" },
|
||||
{ name = "pygments", specifier = "==2.20.0" },
|
||||
{ name = "pyjwt", specifier = "==2.14.0" },
|
||||
{ name = "pyjwt", specifier = "==2.13.0" },
|
||||
{ name = "pylint", specifier = "==3.2.5" },
|
||||
{ name = "pymsalruntime", specifier = "==0.18.1" },
|
||||
{ name = "pynacl", specifier = "==1.6.2" },
|
||||
@@ -387,7 +387,7 @@ overrides = [
|
||||
{ name = "microsoft-kiota-serialization-multipart", specifier = "==1.9.10" },
|
||||
{ name = "microsoft-kiota-serialization-text", specifier = "==1.9.10" },
|
||||
{ name = "okta", specifier = "==3.4.2" },
|
||||
{ name = "pyjwt", extras = ["crypto"], specifier = "==2.14.0" },
|
||||
{ name = "pyjwt", extras = ["crypto"], specifier = "==2.13.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3501,17 +3501,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/7d/721f162c46e3de604a73674223bf6c6bc6cf7ade25b3751a71288f4dd122/huaweicloudsdkrds-3.1.204-py3-none-any.whl", hash = "sha256:a790b5b3c457a608e5679c101f463b4d037dd9a8a66f6e46144a9e5a4b37780f", size = 1626906, upload-time = "2026-07-09T09:04:06.936Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "huaweicloudsdksmn"
|
||||
version = "3.1.204"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "huaweicloudsdkcore" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/5d/c0de47d011f1932c9c0ddf669c238d9fad01653d438d38203703526799d7/huaweicloudsdksmn-3.1.204-py3-none-any.whl", hash = "sha256:b0818ea9293e27458c8fa1d2da021c98006cbf9ce01cf17a0f1df598c4da3a6c", size = 323674, upload-time = "2026-07-09T09:04:24.804Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "huaweicloudsdkvpc"
|
||||
version = "3.1.204"
|
||||
@@ -4846,8 +4835,8 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler"
|
||||
version = "5.44.0"
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=v5.44#60b936005cc109c611ad8b7f7c41cadb586fb4b6" }
|
||||
version = "5.41.0"
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#f05a490cd74a2c0f11a5d66d8ce29d03fa5c64a2" }
|
||||
dependencies = [
|
||||
{ name = "alibabacloud-actiontrail20200706" },
|
||||
{ name = "alibabacloud-credentials" },
|
||||
@@ -4911,7 +4900,6 @@ dependencies = [
|
||||
{ name = "huaweicloudsdkkms" },
|
||||
{ name = "huaweicloudsdkobs" },
|
||||
{ name = "huaweicloudsdkrds" },
|
||||
{ name = "huaweicloudsdksmn" },
|
||||
{ name = "huaweicloudsdkvpc" },
|
||||
{ name = "huaweicloudsdkwaf" },
|
||||
{ name = "jsonschema" },
|
||||
@@ -4950,7 +4938,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler-api"
|
||||
version = "1.45.1"
|
||||
version = "1.45.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "cartography" },
|
||||
@@ -5050,7 +5038,7 @@ requires-dist = [
|
||||
{ name = "matplotlib", specifier = "==3.10.8" },
|
||||
{ name = "neo4j", specifier = "==6.1.0" },
|
||||
{ name = "openai", specifier = "==1.109.1" },
|
||||
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=v5.44" },
|
||||
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=master" },
|
||||
{ name = "psycopg2-binary", specifier = "==2.9.9" },
|
||||
{ name = "pytest-celery", extras = ["redis"], specifier = "==1.3.0" },
|
||||
{ name = "reportlab", specifier = "==4.4.10" },
|
||||
@@ -5344,11 +5332,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.14.0"
|
||||
version = "2.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177, upload-time = "2026-09-11T13:11:54.638Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896, upload-time = "2026-09-11T13:11:53.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: "Changelog"
|
||||
description: "New features and improvements in each Prowler release"
|
||||
description: "Track new features, improvements, provider updates, and bug fixes shipped in each Prowler release across CLI, App, Cloud, and MCP products."
|
||||
rss: true
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'AI Skills System'
|
||||
title: 'AI Skills System for Prowler Contributors'
|
||||
description: 'Enable AI coding assistants like Claude Code, Cursor, and Copilot to follow Prowler patterns using structured skills, setup scripts, and triggers.'
|
||||
---
|
||||
|
||||
This guide explains the AI Skills system that provides on-demand context and patterns to AI agents working with the Prowler codebase.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Alibaba Cloud Provider'
|
||||
title: 'Alibaba Cloud Provider Implementation'
|
||||
description: 'Explore the Alibaba Cloud provider in Prowler, covering session management, RAM authentication, STS credentials, region discovery, and service classes.'
|
||||
---
|
||||
|
||||
This page details the [Alibaba Cloud](https://www.alibabacloud.com/) provider implementation in Prowler.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: "Attack Paths Queries"
|
||||
description: "Write and maintain read-only openCypher Attack Paths queries in Prowler that traverse the Cartography cloud graph to detect privilege escalation and exposure."
|
||||
---
|
||||
|
||||
This guide explains how to write and maintain Prowler Attack Paths queries: the read-only openCypher queries that traverse the Cartography-ingested cloud graph to detect privilege escalation chains, network exposure, and other graph-shaped security risks.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'AWS Provider'
|
||||
title: 'AWS Provider Implementation'
|
||||
description: 'Understand the AWS provider internals in Prowler: boto3 sessions, IAM role assumption, region and Organizations discovery, and service base classes.'
|
||||
---
|
||||
|
||||
In this page you can find all the details about [Amazon Web Services (AWS)](https://aws.amazon.com/) provider implementation in Prowler.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Azure Provider'
|
||||
title: 'Azure Provider Implementation'
|
||||
description: 'Learn how the Azure provider in Prowler manages Service Principal, CLI, browser, and Managed Identity auth, subscription discovery, and Entra ID scanning.'
|
||||
---
|
||||
|
||||
In this page you can find all the details about [Microsoft Azure](https://azure.microsoft.com/) provider implementation in Prowler.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Check Metadata Guidelines'
|
||||
title: 'Check Metadata Writing Guidelines'
|
||||
description: 'Follow Prowler conventions for writing check titles, descriptions, risk, remediation, and status_extended fields when authoring check metadata JSON files.'
|
||||
---
|
||||
|
||||
## Introduction
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Prowler Checks'
|
||||
title: 'Creating New Prowler Checks'
|
||||
description: 'Step-by-step guide to add a new security check to Prowler, covering folder structure, naming conventions, metadata files, test scenarios, and CLI validation.'
|
||||
---
|
||||
|
||||
This guide explains how to create new checks in Prowler.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Configurable Checks in Prowler'
|
||||
title: 'Configurable Checks with audit_config'
|
||||
description: 'Make Prowler checks configurable through the audit_config object and config.yaml, letting users override thresholds, defaults, and check behavior at scan time.'
|
||||
---
|
||||
|
||||
Prowler empowers users to extend and adapt cloud security coverage by making checks configurable through the use of the `audit_config` object. This approach enables customization of checks to meet specific requirements through a configuration file.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Debugging in Prowler'
|
||||
title: 'Debugging Prowler with VSCode'
|
||||
description: 'Set up Visual Studio Code launch configurations and debugpy to step through Prowler checks, services, and provider code for AWS, Azure, GCP, and more.'
|
||||
---
|
||||
|
||||
Debugging in Prowler simplifies the development process, allowing developers to efficiently inspect and resolve unexpected issues during execution.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Contributing to Documentation'
|
||||
title: 'Contributing to Prowler Documentation'
|
||||
description: 'Contribute to Prowler docs built with Mintlify: learn the section structure, AI agent guidelines, local preview setup, and style conventions for MDX pages.'
|
||||
---
|
||||
|
||||
Prowler documentation is built using [Mintlify](https://www.mintlify.com/docs), allowing contributors to easily add or enhance documentation.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'End-2-End Tests for Prowler Local Server'
|
||||
title: 'End-to-End Tests for Prowler App'
|
||||
description: 'Write Playwright end-to-end tests for Prowler App covering user journeys, Page Object Models, storage state reuse, and organizing spec files by feature area.'
|
||||
---
|
||||
|
||||
End-to-end (E2E) tests validate complete user flows in Prowler Local Server (UI + API). These tests are implemented with [Playwright](https://playwright.dev/) under the `ui/tests` folder and are designed to run against a Prowler Local Server environment.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Environment Variable Naming Convention'
|
||||
title: 'Environment Variable Naming Conventions'
|
||||
description: 'Namespace Prowler App, API, SDK, and MCP Server environment variables with component prefixes like UI_ and API_ to avoid conflicts in a shared .env file.'
|
||||
---
|
||||
|
||||
Prowler is a monorepo composed of several runtime components — Prowler Local Server (the web user interface), Prowler API (the backend), Prowler SDK, and Prowler MCP Server (Model Context Protocol) — that frequently share a single `.env` file. To keep that shared configuration unambiguous, each component namespaces its environment variables with a component-specific prefix.
|
||||
@@ -47,8 +48,6 @@ The former build-time variables map to the new runtime variables as follows:
|
||||
|
||||
Roll out Registry only after the Registry backend dependency is deployed, intended roles have `manage_registry`, and acceptance with real credentials has exercised installation, provider account creation, credentials, connection, and scan launch. Deploy the UI with `UI_REGISTRY_ENABLED` unset or `"false"`; set it to `"true"` only in the prepared process environment, then restart or otherwise apply the environment update required by the platform. A Registry key must belong to the configured Registry environment; a production key does not authenticate against a development Registry.
|
||||
|
||||
The Registry links read `PROWLER_REGISTRY_INDEX_URL`, the same base URL the backend installs artifacts from. The Registry page and the credential banner link to it, and its origin is allowed for images. `UI_REGISTRY_MEDIA_URL` adds the Registry media service origin so artifact logos load. When `PROWLER_REGISTRY_INDEX_URL` is unset or invalid, the links are hidden instead of pointing to the public Prowler Registry, which keeps private and air-gapped deployments from sending users to an unreachable host. `UI_REGISTRY_URL` is no longer read.
|
||||
|
||||
The catalog displays all artifacts, including built-ins and packages containing only checks or compliance frameworks. Only external provider artifacts support Add. After confirmed installation, open Providers and select the option labeled Registry to configure an account. Creating accounts and running scans also require the corresponding provider and scan permissions. Removing an artifact keeps existing provider accounts, but future connections or scans can fail until the artifact is installed again.
|
||||
|
||||
To roll back, set `UI_REGISTRY_ENABLED=false` or remove it and apply the environment update. Proxy, page, and action checks deny on their next request. Navigation refreshes from server-authorized access when the page is requested again. Rollback does not delete Registry credentials, tenant artifact records, or provider accounts.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Google Cloud Provider'
|
||||
title: 'Google Cloud (GCP) Provider Implementation'
|
||||
description: 'Dive into the GCP provider in Prowler: Application Default Credentials, service accounts, OAuth, impersonation, and multi-project and organization discovery.'
|
||||
---
|
||||
|
||||
This page details the [Google Cloud Platform (GCP)](https://cloud.google.com/) provider implementation in Prowler.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'GitHub Provider'
|
||||
title: 'GitHub Provider Implementation'
|
||||
description: 'See how the GitHub provider in Prowler handles Personal Access Token, OAuth App, and GitHub App authentication to audit repositories and organizations.'
|
||||
---
|
||||
|
||||
This page details the [GitHub](https://github.com/) provider implementation in Prowler.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Integration Tests'
|
||||
title: 'Integration Tests for Prowler'
|
||||
description: 'Placeholder page tracking upcoming guidance on Prowler integration tests, covering how to validate multi-component flows across the SDK, API, and UI.'
|
||||
---
|
||||
|
||||
Coming soon ...
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Creating a New Integration'
|
||||
title: 'Creating New Prowler Integrations'
|
||||
description: 'Build integrations that ship Prowler findings to Slack, Jira, AWS Security Hub, and other platforms by scripting API calls and configuring output pipelines.'
|
||||
---
|
||||
|
||||
## Introduction
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Introduction to developing in Prowler'
|
||||
title: 'Introduction to Developing in Prowler'
|
||||
description: 'Get started as a Prowler contributor: pick a good first issue, extend checks, services, or integrations, and follow the AI-driven contribution workflow.'
|
||||
---
|
||||
|
||||
Thanks for your interest in contributing to Prowler!
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Kubernetes Provider'
|
||||
title: 'Kubernetes Provider Implementation'
|
||||
description: 'Explore the Kubernetes provider in Prowler, including kubeconfig context loading, namespace discovery, in-cluster execution, and service class scaffolding.'
|
||||
---
|
||||
|
||||
This page details the [Kubernetes](https://kubernetes.io/) provider implementation in Prowler.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Lighthouse AI Architecture'
|
||||
title: 'Prowler Lighthouse AI Architecture'
|
||||
description: 'Inside Prowler Lighthouse AI: a three-tier Next.js, API route, and Langchain agent design that connects LLMs to security data through MCP tool calls.'
|
||||
---
|
||||
|
||||
This document describes the internal architecture of Prowler Lighthouse AI, enabling developers to understand how components interact and where to add new functionality.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'LLM Provider'
|
||||
title: 'LLM Provider Implementation'
|
||||
description: 'Learn how the Prowler LLM provider uses promptfoo to run red team security tests against OpenAI and other large language models with reusable test suites.'
|
||||
---
|
||||
|
||||
This page details the [Large Language Model (LLM)](https://en.wikipedia.org/wiki/Large_language_model) provider implementation in Prowler.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Microsoft 365 (M365) Provider'
|
||||
title: 'Microsoft 365 (M365) Provider Implementation'
|
||||
description: 'Understand the Microsoft 365 provider in Prowler, PowerShell 7.4+ requirements, Exchange Online and Teams modules, and Entra ID tenant scanning.'
|
||||
---
|
||||
|
||||
This page details the [Microsoft 365 (M365)](https://www.microsoft.com/en-us/microsoft-365) provider implementation in Prowler.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Extending the MCP Server'
|
||||
title: 'Extending the Prowler MCP Server'
|
||||
description: 'Add new tools and sub-servers to the Prowler MCP Server so AI assistants like Claude Desktop and Cursor can query Prowler App, Hub, and documentation content.'
|
||||
---
|
||||
|
||||
This guide explains how to extend the Prowler MCP Server with new tools and features.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Create a Custom Output Format'
|
||||
title: 'Creating Custom Prowler Output Formats'
|
||||
description: 'Add a new output format to Prowler by subclassing the Output class, defining the schema, and generating JSON, CSV, HTML, or SARIF-style reports from findings.'
|
||||
---
|
||||
|
||||
## Introduction
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Prowler Providers'
|
||||
title: 'Creating New Prowler Providers'
|
||||
description: 'Design and implement new Prowler providers across cloud, SaaS, and container platforms, following the base Provider class patterns, models, and CLI wiring.'
|
||||
---
|
||||
|
||||
## Introduction
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Prowler Studio'
|
||||
title: 'Prowler Studio AI Check Workflow'
|
||||
description: 'Prowler Studio is a Claude Code workflow that generates consistent, tested Prowler security checks by enforcing skills, guardrails, and provider conventions.'
|
||||
---
|
||||
|
||||
**Prowler Studio is an AI workflow that ensures Claude Code follows Prowler's skills, guardrails, and best practices when creating new security checks.** What lands in the resulting pull request is consistent, tested, and ready for human review — not half-correct boilerplate that needs to be rewritten.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Renaming Checks in Prowler'
|
||||
title: 'Renaming Existing Prowler Checks'
|
||||
description: 'Safely rename a Prowler check by updating folder paths, class names, metadata JSON, CheckAliases, and compliance mappings without breaking existing users.'
|
||||
---
|
||||
|
||||
To rename a check in Prowler, follow these steps when aligning with Check ID structure, fixing typos, or updating check logic that requires a new name.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Secret-Scanning Checks'
|
||||
title: 'Building Secret-Scanning Checks'
|
||||
description: 'Author efficient secret-scanning checks in Prowler using the Kingfisher engine and detect_secrets_scan_batch helper to scan payloads in chunked subprocesses.'
|
||||
---
|
||||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Creating a New Security Compliance Framework in Prowler'
|
||||
title: 'Adding New Compliance Frameworks to Prowler'
|
||||
description: 'Map catalogs like CIS, NIST 800-53, PCI DSS, or DORA into Prowler by defining JSON schemas, Pydantic models, check mappings, and validation tests.'
|
||||
---
|
||||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Server-Sent Events (SSE)'
|
||||
title: 'Adding Server-Sent Events (SSE) Endpoints'
|
||||
description: 'Stream real-time updates from the Prowler API using Server-Sent Events with django-eventstream, including scan progress and streamed LLM token output.'
|
||||
---
|
||||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Prowler Services'
|
||||
title: 'Creating New Prowler Services'
|
||||
description: 'Add new services like EC2 or Exchange Online to a Prowler provider by scaffolding the service class, data models, API client calls, and shared client instances.'
|
||||
---
|
||||
|
||||
Here you can find how to create a new service, or to complement an existing one, for a [Prowler Provider](/developer-guide/provider).
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'StackIT Provider'
|
||||
title: 'StackIT Cloud Provider Implementation'
|
||||
description: 'Discover how the StackIT provider in Prowler authenticates using service account keys, manages token refresh via the SDK, and audits a single StackIT project.'
|
||||
---
|
||||
|
||||
This page details the [StackIT Cloud](https://www.stackit.de/) provider implementation in Prowler.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Test Impact Analysis'
|
||||
title: 'Test Impact Analysis in CI'
|
||||
description: 'Prowler test impact analysis maps changed files to relevant SDK, API, and Playwright E2E tests through a GitHub Actions workflow and configurable path rules.'
|
||||
---
|
||||
|
||||
Test impact analysis (TIA) determines which tests to run based on the files changed in a pull request. Instead of running the full test suite on every pull request, TIA maps changed files to the specific Prowler SDK, API, and end-to-end (E2E) tests that cover them. This approach reduces continuous integration (CI) time and resource usage while maintaining confidence that relevant code paths are tested.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Unit Tests for Prowler Checks'
|
||||
title: 'Writing Unit Tests for Prowler Checks'
|
||||
description: 'Write robust unit tests for Prowler checks with pytest and mock, covering zero-finding, PASS, FAIL, and multi-resource scenarios across every provider.'
|
||||
---
|
||||
|
||||
Unit tests for Prowler checks vary based on the provider being evaluated.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Basic Usage'
|
||||
title: 'Get started with the Prowler App web interface'
|
||||
description: 'Sign up, add a cloud provider, launch a scan, and review findings in the Prowler App web UI for AWS, Azure, GCP, Kubernetes, and Microsoft 365.'
|
||||
---
|
||||
|
||||
## Access Prowler Local Server
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Basic Usage'
|
||||
title: 'Run scans with the Prowler CLI'
|
||||
description: 'Learn the essential Prowler CLI commands to run multi-cloud security scans, list checks and services, and export CSV, JSON-OCSF, and HTML reports.'
|
||||
---
|
||||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: "Tools Reference"
|
||||
title: 'Prowler MCP Server tools reference'
|
||||
description: 'Complete reference for Prowler MCP Server tools, grouped by namespace: Prowler Hub catalog, documentation search, and Prowler Cloud or App management.'
|
||||
---
|
||||
|
||||
Complete reference guide for all tools available in the Prowler MCP Server. Tools are organized by namespace.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: "Configuration"
|
||||
title: 'Configure the Prowler MCP Server for AI clients'
|
||||
description: 'Connect Claude Desktop, Cursor, and other MCP clients to Prowler MCP Server using HTTP or STDIO mode, and set up API key authentication for Prowler Cloud.'
|
||||
---
|
||||
|
||||
Configure your MCP client to connect to Prowler MCP Server.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'AWS Security Hub'
|
||||
title: 'Prowler vs AWS Security Hub'
|
||||
description: 'Compare Prowler with AWS Security Hub for cloud security posture management, multi-account scanning, compliance checks, and multi-cloud coverage beyond AWS.'
|
||||
---
|
||||
|
||||
AWS Security Hub remains a managed service designed for centralizing security alerts and compliance status within AWS environments. It integrates with various AWS security services and provides a consolidated view of security findings.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'GCP Cloud Security Command Center (Cloud SCC)'
|
||||
title: 'Prowler vs Google Cloud Security Command Center'
|
||||
description: 'Compare Prowler with GCP Cloud Security Command Center for asset visibility, threat detection, compliance monitoring, and cloud-agnostic security scanning.'
|
||||
---
|
||||
|
||||
Google Cloud Security Command Center (Cloud SCC) is a centralized security and risk management platform for Google Cloud Platform (GCP). It provides visibility into assets, vulnerabilities, and threats across GCP environments, helping organizations to manage and improve their security posture.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Comparison'
|
||||
title: 'Prowler vs other cloud security platforms'
|
||||
description: 'Compare Prowler with AWS Security Hub, Microsoft Sentinel, Microsoft Defender for Cloud, and Google Cloud Security Command Center for cloud security posture.'
|
||||
---
|
||||
|
||||
Click to learn more about each cloud security provider and learn how Prowler is differentiated.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Microsoft Defender for Cloud'
|
||||
title: 'Prowler vs Microsoft Defender for Cloud'
|
||||
description: 'Use Prowler open-source scans alongside Microsoft Defender for Cloud to validate Azure posture, customize checks, and extend coverage to AWS, GCP, and K8s.'
|
||||
---
|
||||
|
||||
**Use open-source scanning to validate and extend Microsoft Defender for Cloud**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Microsoft Sentinel'
|
||||
title: 'Prowler vs Microsoft Sentinel'
|
||||
description: 'Compare Prowler with Microsoft Sentinel SIEM and SOAR for threat detection, log ingestion, compliance scanning, and multi-cloud security posture management.'
|
||||
---
|
||||
|
||||
Microsoft Sentinel is a scalable, cloud-native security information and event management (SIEM) and security orchestration automated response (SOAR) solution. It's designed to collect, detect, investigate, and respond to threats across the enterprise, primarily within the Azure cloud environment but also extending to on-premises and other cloud environments through various connectors.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
---
|
||||
title: "Prowler API Reference"
|
||||
title: 'Prowler API reference'
|
||||
description: 'Open the interactive Prowler Cloud API v1 reference to explore endpoints for scans, findings, providers, compliance, and integrations with auth details.'
|
||||
url: "https://api.prowler.com/api/v1/docs"
|
||||
---
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
---
|
||||
title: "Go to Cloud"
|
||||
title: 'Open Prowler Cloud'
|
||||
description: 'Jump to cloud.prowler.com to sign in or sign up for Prowler Cloud, the managed SaaS for cloud security scanning across AWS, Azure, GCP, Kubernetes, and M365.'
|
||||
url: "https://cloud.prowler.com"
|
||||
---
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
---
|
||||
title: "Go to Hub"
|
||||
title: 'Open Prowler Hub'
|
||||
description: 'Jump to hub.prowler.com to browse the searchable public library of Prowler checks, cloud service artifacts, and compliance framework mappings with versioning.'
|
||||
url: "https://hub.prowler.com"
|
||||
---
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: "Prowler MCP"
|
||||
title: 'Prowler MCP Server on GitHub'
|
||||
description: 'Jump to the Prowler MCP Server source code on GitHub to review the Model Context Protocol implementation, tools, and integration examples for AI assistants.'
|
||||
url: "https://github.com/prowler-cloud/prowler/tree/master/mcp_server"
|
||||
tag: "new!"
|
||||
---
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: "Installation"
|
||||
title: 'Install the Prowler App'
|
||||
description: 'Install the self-hosted Prowler App with Docker Compose or from source to run the Prowler UI, API, and workers locally for multi-cloud security scans.'
|
||||
---
|
||||
|
||||
### Installation
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Installation'
|
||||
title: 'Install the Prowler CLI'
|
||||
description: 'Install the Prowler CLI on macOS, Linux, or Windows using pipx, pip, Docker, or the GitHub repository, then verify the version and run your first scan.'
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: "Installation"
|
||||
title: 'Install the Prowler MCP Server locally'
|
||||
description: 'Run the Prowler MCP Server locally using Docker, PyPI, or source with uv, or use the hosted mcp.prowler.com endpoint managed by Prowler for AI assistants.'
|
||||
---
|
||||
|
||||
There are **two ways** to use Prowler MCP Server:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: 'Prowler Product Families'
|
||||
description: 'Official names for Prowler Open Source projects and Prowler Products, including former product names.'
|
||||
description: "Reference for Prowler's product families, listing Prowler Cloud, Private Cloud, Hub, Lighthouse AI, MCP, CLI, Local Server, SDK, and their former names."
|
||||
boost: 2
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Overview'
|
||||
title: 'Prowler App overview'
|
||||
description: 'Learn how the self-hosted Prowler App combines the Prowler UI, API, SDK, and MCP Server to configure scans, view findings, and manage cloud security posture.'
|
||||
---
|
||||
|
||||
Prowler Local Server is a self-hosted web application that simplifies running Prowler. It provides:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Overview'
|
||||
title: 'Prowler CLI overview'
|
||||
description: 'Discover the Prowler open-source CLI: run multi-cloud security scans, launch the dashboard, and audit against CIS, NIST, PCI DSS, ISO 27001, SOC 2, and more.'
|
||||
---
|
||||
|
||||
Prowler CLI is a command-line interface for running Prowler scans from the terminal.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: "AWS Marketplace"
|
||||
title: 'Subscribe to Prowler Cloud via AWS Marketplace'
|
||||
description: 'Subscribe to Prowler Cloud through the AWS Marketplace listing, set up your account, and manage billing for cloud security scanning directly from AWS Billing.'
|
||||
---
|
||||
|
||||
This section contains the instructions to subscribe to **Prowler Cloud** through the **AWS Marketplace**.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Overview'
|
||||
title: 'Lighthouse AI on Prowler Cloud'
|
||||
description: 'Explore the enhanced Lighthouse AI on Prowler Cloud: persistent chat sessions, GPT-5.5 by default, an agentic chat view, and automatic provider validation.'
|
||||
---
|
||||
|
||||
import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
---
|
||||
title: "Pricing"
|
||||
title: 'Prowler Cloud pricing'
|
||||
description: 'Jump to prowler.com/pricing to compare Prowler Cloud plans, transparent per-resource pricing, free trial details, and enterprise support options for teams.'
|
||||
url: "https://prowler.com/pricing"
|
||||
---
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: "Overview"
|
||||
title: 'Prowler Cloud overview'
|
||||
description: 'Prowler Cloud is the managed SaaS on Prowler open source, with continuous monitoring, auto-scaling scan workers, Slack and Jira alerts, and SOC 2 controls.'
|
||||
---
|
||||
|
||||
[Prowler Cloud](https://prowler.com) makes Cloud Security easy and enables your team to build trust in their deployed services and applications.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
title: "Prowler for MSPs and MSSPs"
|
||||
sidebarTitle: "Overview"
|
||||
description: "Prowler for MSPs and MSSPs is a partner console to onboard customers, manage teams, and operate each customer's Prowler Cloud tenant on their behalf."
|
||||
---
|
||||
|
||||
Prowler for MSPs and MSSPs is a dedicated console for Managed Service Providers (MSPs), Managed Security Service Providers (MSSPs), resellers and consultants who run cloud security for other organizations. It lets a provider onboard customers, group them, manage a team, and operate each customer's Prowler Cloud tenant on their behalf.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: "Overview"
|
||||
title: 'Prowler Hub overview'
|
||||
description: 'Prowler Hub is a public library of versioned security checks, cloud service artifacts, and compliance framework mappings, with a documented API for automation.'
|
||||
---
|
||||
|
||||
**Prowler Hub** is our growing public library of versioned checks, cloud service artifacts, and compliance frameworks with its mappings. It’s searchable, explainable, and built to serve the community.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Overview'
|
||||
title: 'Prowler Lighthouse AI overview'
|
||||
description: 'Prowler Lighthouse AI is an open-source cloud security analyst chatbot that helps teams query findings in natural language and get step-by-step remediation.'
|
||||
---
|
||||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: "Overview"
|
||||
title: 'Prowler MCP Server overview'
|
||||
description: 'The Prowler MCP Server exposes Prowler Cloud, Prowler App, Prowler Hub, and docs to Claude Desktop, Cursor, and other AI assistants over Model Context Protocol.'
|
||||
---
|
||||
|
||||
**Prowler MCP Server** brings the entire Prowler ecosystem to AI assistants through the Model Context Protocol (MCP). It enables seamless integration with AI tools like Claude Desktop, Cursor, and other MCP clients, allowing interaction with Prowler's security capabilities through natural language.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Prowler SDK'
|
||||
description: 'Prowler SDK is the Python library that powers Prowler CLI and Local Server, implementing providers, services, and security checks for every Prowler product.'
|
||||
---
|
||||
|
||||
Prowler SDK is the Python library that powers Prowler CLI and Prowler Local Server. It implements the providers, services, and security checks that every Prowler product runs.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
title: 'Prowler open-source cloud security platform overview'
|
||||
description: 'Prowler is an open-source cloud security platform that automates security checks, compliance frameworks, and remediation across AWS, Azure, GCP, and more.'
|
||||
---
|
||||
|
||||
# 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.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Data Regions & Availability'
|
||||
title: 'Prowler Cloud data regions and availability'
|
||||
description: 'Prowler Cloud AWS regions, EU Ireland hosting, high availability, disaster recovery, RPO, RTO, and status page for uptime and incidents.'
|
||||
---
|
||||
|
||||
Prowler Cloud runs on AWS with high availability built in.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Encryption'
|
||||
title: 'Prowler Cloud data encryption at rest and in transit'
|
||||
description: 'How Prowler Cloud encrypts findings, backups, and API traffic with AES-256 at rest and TLS 1.2+ in transit across databases, storage, and services.'
|
||||
---
|
||||
|
||||
Prowler Cloud uses encryption everywhere possible. All data and communications are encrypted at rest and in transit.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Security & Compliance'
|
||||
title: 'Prowler security and compliance overview'
|
||||
description: 'Prowler Cloud vs self-managed security posture: SOC 2 Type II, AWS FTR, encryption, backups, and the security tooling applied to every Prowler build.'
|
||||
---
|
||||
|
||||
**Prowler secures itself with Prowler.** As an open-source cloud security platform trusted by thousands of organizations, Prowler applies the same rigorous security standards internally that customers achieve externally.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Networking'
|
||||
title: 'Prowler Cloud networking and egress IP allowlist'
|
||||
description: 'Prowler Cloud outbound egress IP addresses and DNS records to allowlist in firewalls and cloud security groups when scanning provider accounts.'
|
||||
---
|
||||
|
||||
## Egress IP Addresses
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Software Security'
|
||||
title: 'Prowler software security: SAST, SCA, and CI/CD'
|
||||
description: 'Security controls applied to every Prowler build: SAST, SCA, container scanning, secrets detection, supply-chain pinning, and hardened GitHub Actions runners.'
|
||||
---
|
||||
|
||||
Prowler applies security-by-design across the development lifecycle. Every change passes automated checks at each stage: pre-commit hooks locally, multiple CI gates on pull requests, branch protection before merge, container scanning before publish, and registry monitoring after release.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: 'Support'
|
||||
description: 'Get help with Prowler'
|
||||
description: 'Get help with Prowler through the support desk, GitHub Discussions, the community Slack, office hours, and the Prowler Lighthouse AI security analyst chatbot.'
|
||||
---
|
||||
|
||||
import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: 'Troubleshooting'
|
||||
description: 'Troubleshoot Prowler CLI, Prowler Local Server, and Docker Compose issues, including file descriptor limits, IAM role assumption, and empty scan reports.'
|
||||
---
|
||||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
title: "Connect Claude Code to Prowler MCP Server"
|
||||
sidebarTitle: "Claude Code"
|
||||
description: "Connect Claude Code CLI and the Claude desktop app Code tab to the Prowler Cloud MCP server, with the Prowler plugin or an MCP-only setup."
|
||||
---
|
||||
|
||||
Connect [Claude Code](https://www.claude.com/product/claude-code) to the Prowler Cloud MCP Server at `https://mcp.prowler.com/mcp`.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
title: "Connect the Claude App Chat to Prowler MCP Server"
|
||||
sidebarTitle: "Claude App (Chat)"
|
||||
description: "Connect the Claude desktop app Chat tab to the Prowler Cloud MCP server through a local mcp-remote bridge with your Prowler API key."
|
||||
---
|
||||
|
||||
Connect the **Chat** tab of the Claude desktop app to the Prowler Cloud MCP Server at `https://mcp.prowler.com/mcp`.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
title: "Connect Codex / ChatGPT Desktop to Prowler MCP Server"
|
||||
sidebarTitle: "Codex / ChatGPT"
|
||||
description: "Connect the OpenAI Codex CLI and ChatGPT desktop app to Prowler Cloud MCP server to query findings, inspect checks, and manage providers from Codex."
|
||||
---
|
||||
|
||||
Connect [OpenAI Codex](https://learn.chatgpt.com/docs/extend/mcp) to the Prowler Cloud MCP Server at `https://mcp.prowler.com/mcp` so Codex can query findings, inspect checks, and manage your Prowler providers.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
title: "Connect Cursor to Prowler MCP Server"
|
||||
sidebarTitle: "Cursor"
|
||||
description: "Connect Cursor's agent to the Prowler Cloud MCP server over HTTP so it can query findings, inspect security checks, and manage Prowler providers."
|
||||
---
|
||||
|
||||
Connect [Cursor](https://cursor.com/docs/mcp) to the Prowler Cloud MCP Server at `https://mcp.prowler.com/mcp` so the Cursor agent can query findings, inspect security checks, and manage your Prowler providers while you work.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user