chore(ui): merge master into expired-session fix
No files matched your search
@@ -158,7 +158,7 @@ SENTRY_RELEASE=local
|
||||
# REO_DEV_CLIENT_ID=
|
||||
|
||||
#### Prowler release version ####
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.36.0
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.37.0
|
||||
|
||||
# Social login credentials
|
||||
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
|
||||
|
||||
@@ -113,6 +113,15 @@ jobs:
|
||||
api/changelog.d/**
|
||||
api/AGENTS.md
|
||||
|
||||
# api-container-build-push.yml resolves the SDK pin to the branch tip
|
||||
# before building, so match it here and scan what ships. Push only: PRs
|
||||
# stay deterministic against the committed lock.
|
||||
- name: Refresh prowler SDK pin to current branch tip
|
||||
if: steps.check-changes.outputs.any_changed == 'true' && github.event_name == 'push'
|
||||
run: |
|
||||
pip install --no-cache-dir "uv==0.11.14"
|
||||
(cd api && uv lock --upgrade-package prowler)
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
@@ -38,11 +38,14 @@ jobs:
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
|
||||
|
||||
- name: Set appVersion from release tag
|
||||
- name: Set chart version and appVersion from release tag
|
||||
run: |
|
||||
RELEASE_TAG="${GITHUB_EVENT_RELEASE_TAG_NAME}"
|
||||
echo "Setting appVersion to ${RELEASE_TAG}"
|
||||
sed -i "s/^appVersion:.*/appVersion: \"${RELEASE_TAG}\"/" ${{ env.CHART_PATH }}/Chart.yaml
|
||||
# Strip any leading "v" so the chart version is valid SemVer 2.
|
||||
RELEASE_TAG="${GITHUB_EVENT_RELEASE_TAG_NAME#v}"
|
||||
echo "Setting chart version and appVersion to ${RELEASE_TAG}"
|
||||
# Publish an immutable chart version per release instead of the static
|
||||
# 0.0.1 in source, so every release is a distinct, addressable artifact.
|
||||
yq -i ".version = \"${RELEASE_TAG}\" | .appVersion = \"${RELEASE_TAG}\"" ${{ env.CHART_PATH }}/Chart.yaml
|
||||
env:
|
||||
GITHUB_EVENT_RELEASE_TAG_NAME: ${{ github.event.release.tag_name }}
|
||||
|
||||
|
||||
@@ -35,6 +35,20 @@ CVE-2026-13221 pkg:perl-base exp:2026-08-15
|
||||
CVE-2026-13221 pkg:perl-modules-5.36 exp:2026-08-15
|
||||
CVE-2026-13221 pkg:libperl5.36 exp:2026-08-15
|
||||
|
||||
# CVE-2026-57433 — Perl Storable signed integer overflow when deserializing a
|
||||
# crafted SX_HOOK record (retrieve_hook_common passes a wrapped negative count
|
||||
# to av_extend).
|
||||
# Packages: perl, perl-base, perl-modules-5.36, libperl5.36.
|
||||
# Why ignored: perl-base is part of Debian's "Essential: yes" set; it cannot be
|
||||
# removed without breaking dpkg. Prowler does not invoke perl at runtime and
|
||||
# never calls Storable's thaw/retrieve on attacker-controlled blobs, so the
|
||||
# vulnerable deserialization path is unreachable. Fixed upstream in
|
||||
# Storable 3.41; no Debian bookworm fix is available yet.
|
||||
CVE-2026-57433 pkg:perl exp:2026-08-15
|
||||
CVE-2026-57433 pkg:perl-base exp:2026-08-15
|
||||
CVE-2026-57433 pkg:perl-modules-5.36 exp:2026-08-15
|
||||
CVE-2026-57433 pkg:libperl5.36 exp:2026-08-15
|
||||
|
||||
# CVE-2025-7458 — SQLite integer overflow.
|
||||
# Package: libsqlite3-0.
|
||||
# Why ignored: transitive dependency of CPython's stdlib sqlite3 module. The
|
||||
|
||||
@@ -62,6 +62,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
|
||||
| Action | Skill |
|
||||
|--------|-------|
|
||||
| Add changelog entry for a PR or feature | `prowler-changelog` |
|
||||
| Adding ConfigRequirements guardrails to compliance requirements | `prowler-compliance` |
|
||||
| Adding DRF pagination or permissions | `django-drf` |
|
||||
| Adding a compliance output formatter (per-provider class + table dispatcher) | `prowler-compliance` |
|
||||
| Adding indexes or constraints to database tables | `django-migration-psql` |
|
||||
@@ -84,6 +85,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
|
||||
| Creating ViewSets, serializers, or filters in api/ | `django-drf` |
|
||||
| Creating Zod schemas | `zod-4` |
|
||||
| Creating a git commit | `prowler-commit` |
|
||||
| Creating a universal (multi-provider) compliance framework | `prowler-compliance` |
|
||||
| Creating new checks | `prowler-sdk-check` |
|
||||
| Creating new skills | `skill-creator` |
|
||||
| Creating or reviewing Django migrations | `django-migration-psql` |
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
<b><i>Prowler</b> is the Open Cloud Security Platform trusted by thousands to automate security and compliance in any cloud environment. With thousands of ready-to-use checks and compliance frameworks, Prowler delivers real-time, customizable monitoring and seamless integrations, making cloud security simple, scalable, and cost-effective for organizations of any size.
|
||||
</p>
|
||||
<p align="center">
|
||||
<b>Secure ANY cloud at AI Speed at <a href="https://prowler.com">prowler.com</i></b>
|
||||
<b>The Agentic Cloud Defender</i></b>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://cloud.prowler.com/sign-up">Try Prowler Cloud</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -56,7 +59,7 @@ Prowler includes hundreds of built-in controls to ensure compliance with standar
|
||||
|
||||
## Prowler Cloud & Prowler Local Server
|
||||
|
||||
[Prowler Cloud](https://cloud.prowler.com/) and Prowler Local Server, its self-hosted open-source version, are web applications that simplify running Prowler across your cloud provider accounts. They provide a user-friendly interface to visualize the results and streamline your security assessments.
|
||||
[Prowler Cloud](https://cloud.prowler.com/sign-up) and Prowler Local Server, its self-hosted open-source version, are web applications that simplify running Prowler across your cloud provider accounts. They provide a user-friendly interface to visualize the results and streamline your security assessments.
|
||||
|
||||

|
||||

|
||||
@@ -135,12 +138,13 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically
|
||||
| IaC | [See `trivy` docs.](https://trivy.dev/latest/docs/coverage/iac/) | N/A | N/A | N/A | Official | UI, API, CLI |
|
||||
| MongoDB Atlas | 10 | 3 | 1 | 8 | Official | UI, API, CLI |
|
||||
| LLM | [See `promptfoo` docs.](https://www.promptfoo.dev/docs/red-team/plugins/) | N/A | N/A | N/A | Official | CLI |
|
||||
| Image | N/A | N/A | N/A | N/A | Official | CLI, API |
|
||||
| Image | N/A | N/A | N/A | N/A | Official | UI, API, CLI |
|
||||
| Google Workspace | 65 | 11 | 3 | 6 | Official | UI, API, CLI |
|
||||
| OpenStack | 34 | 5 | 1 | 9 | Official | UI, API, CLI |
|
||||
| Vercel | 26 | 6 | 1 | 8 | Official | UI, API, CLI |
|
||||
| Okta | 29 | 8 | 2 | 2 | Official | UI, API, CLI |
|
||||
| Linode [Contact us](https://prowler.com/contact) | 10 | 3 | 1 | 4 | Unofficial | CLI |
|
||||
| Huawei Cloud [Contact us](https://prowler.com/contact) | 25 | 10 | 1 | 6 | Unofficial | CLI |
|
||||
| E2E Networks [Contact us](https://prowler.com/contact) | 27 | 6 | 0 | 2 | Unofficial | CLI |
|
||||
| Scaleway [Contact us](https://prowler.com/contact) | 1 | 1 | 1 | 1 | Unofficial | CLI |
|
||||
| StackIT [Contact us](https://prowler.com/contact) | 7 | 2 | 1 | 3 | Unofficial | CLI |
|
||||
|
||||
@@ -4,6 +4,28 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
<!-- changelog: release notes start -->
|
||||
|
||||
## [1.37.0] (Prowler v5.36.0)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- OCI provider secrets no longer require `region`; legacy `region` input is accepted for backwards compatibility but ignored before storing or scanning [(#11741)](https://github.com/prowler-cloud/prowler/pull/11741)
|
||||
- Compliance overview ingest now runs in a single transaction per scan with a configurable `COPY` batch size (`DJANGO_COMPLIANCE_COPY_BATCH_SIZE`, default 2000), reducing write pressure on the database [(#11875)](https://github.com/prowler-cloud/prowler/pull/11875)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Scan findings now recover resources missing from the in-memory cache after resource pre-resolution, preventing valid findings from being skipped [(#12002)](https://github.com/prowler-cloud/prowler/pull/12002)
|
||||
- Tenant-wide integrations that are not attached to any provider, such as Jira, are now visible and manageable by roles with `manage_integrations` and without unlimited visibility [(#12060)](https://github.com/prowler-cloud/prowler/pull/12060)
|
||||
- Output generation now removes the scan's temporary output directory before writing, so a re-run of the task for the same scan (e.g. broker redelivery after a worker is killed mid-run) no longer appends to the previous run's files and duplicates finding rows in the exported CSV and other outputs [(#12097)](https://github.com/prowler-cloud/prowler/pull/12097)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- Integration responses no longer disclose providers outside the visibility of the role, including the resources sideloaded through `?include=providers` [(#12060)](https://github.com/prowler-cloud/prowler/pull/12060)
|
||||
- Integration connection checks, Jira issue type lookups and Jira dispatches now resolve the integration through the provider visibility of the role instead of the whole tenant [(#12060)](https://github.com/prowler-cloud/prowler/pull/12060)
|
||||
- Roles without unlimited visibility can no longer attach an integration to providers they cannot see, nor edit or delete an integration bound to them [(#12060)](https://github.com/prowler-cloud/prowler/pull/12060)
|
||||
- Kubernetes kubeconfig validation now rejects legacy `auth-provider.config.cmd-path` command authentication in Prowler Cloud/API [(#12091)](https://github.com/prowler-cloud/prowler/pull/12091)
|
||||
|
||||
---
|
||||
|
||||
## [1.36.0] (Prowler v5.35.0)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
@@ -102,7 +102,9 @@ ENV PATH="/home/prowler/.local/bin:$PATH"
|
||||
RUN uv sync --locked --no-install-project && \
|
||||
rm -rf ~/.cache/uv
|
||||
|
||||
RUN .venv/bin/python .venv/lib/python3.12/site-packages/prowler/providers/m365/lib/powershell/m365_powershell.py
|
||||
# Invoked as a module so the base image's Python minor version is not baked
|
||||
# into a site-packages path.
|
||||
RUN .venv/bin/python -m prowler.providers.m365.lib.powershell.m365_powershell
|
||||
|
||||
USER root
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
Compliance overview ingest now runs in a single transaction per scan with a configurable `COPY` batch size (`DJANGO_COMPLIANCE_COPY_BATCH_SIZE`, default 2000), reducing write pressure on the database
|
||||
@@ -1 +0,0 @@
|
||||
OCI provider secrets no longer require `region`; legacy `region` input is accepted for backwards compatibility but ignored before storing or scanning
|
||||
@@ -71,7 +71,7 @@ name = "prowler-api"
|
||||
package-mode = false
|
||||
# Needed for the SDK compatibility
|
||||
requires-python = ">=3.11,<3.13"
|
||||
version = "1.37.0"
|
||||
version = "1.38.0"
|
||||
|
||||
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
|
||||
# target-version tracks this project's lowest supported Python.
|
||||
|
||||
@@ -3,9 +3,10 @@ from api.db_router import MainRouter, reset_read_db_alias, set_read_db_alias
|
||||
from api.db_utils import POSTGRES_USER_VAR, rls_transaction
|
||||
from api.filters import CustomDjangoFilterBackend
|
||||
from api.models import Role, UserRoleRelationship
|
||||
from api.rbac.permissions import HasPermissions
|
||||
from api.rbac.permissions import HasPermissions, get_role
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.utils.functional import cached_property
|
||||
from rest_framework import permissions
|
||||
from rest_framework.exceptions import NotAuthenticated
|
||||
from rest_framework.filters import SearchFilter
|
||||
@@ -100,6 +101,11 @@ class BaseRLSViewSet(BaseViewSet):
|
||||
context["tenant_id"] = self.request.tenant_id
|
||||
return context
|
||||
|
||||
@cached_property
|
||||
def user_role(self):
|
||||
"""Role of the requesting user in the active tenant, resolved once per request."""
|
||||
return get_role(self.request.user, self.request.tenant_id)
|
||||
|
||||
|
||||
class BaseTenantViewset(BaseViewSet):
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from enum import Enum
|
||||
|
||||
from api.db_router import MainRouter
|
||||
from api.models import Provider, Role, User
|
||||
from django.db.models import QuerySet
|
||||
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
|
||||
|
||||
@@ -83,3 +83,32 @@ def get_providers(role: Role) -> QuerySet[Provider]:
|
||||
return Provider.objects.filter(
|
||||
tenant_id=tenant_id, provider_groups__in=provider_groups
|
||||
).distinct()
|
||||
|
||||
|
||||
def get_integrations(
|
||||
role: Role, providers: QuerySet[Provider] | None = None
|
||||
) -> QuerySet[Integration]:
|
||||
"""
|
||||
Return a distinct queryset of Integrations visible to the given role.
|
||||
|
||||
Integrations with no providers attached are tenant-wide, as is always the case for
|
||||
Jira, and stay visible regardless of the provider visibility of the role. Integrations
|
||||
attached to providers are only visible when the role can access at least one of them.
|
||||
|
||||
Args:
|
||||
role: A Role instance.
|
||||
providers: Optional queryset of the providers accessible by the role, to reuse
|
||||
an already resolved `get_providers(role)` result within the same request.
|
||||
|
||||
Returns:
|
||||
A QuerySet of Integration objects visible to the role.
|
||||
"""
|
||||
queryset = Integration.objects.filter(tenant_id=role.tenant_id)
|
||||
if role.unlimited_visibility:
|
||||
return queryset
|
||||
|
||||
if providers is None:
|
||||
providers = get_providers(role)
|
||||
return queryset.filter(
|
||||
Q(providers__isnull=True) | Q(providers__in=providers)
|
||||
).distinct()
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Prowler API
|
||||
version: 1.37.0
|
||||
version: 1.38.0
|
||||
description: |-
|
||||
Prowler API specification.
|
||||
|
||||
@@ -6629,8 +6629,10 @@ paths:
|
||||
/api/v1/integrations:
|
||||
get:
|
||||
operationId: api_v1_integrations_list
|
||||
description: Retrieve a list of all configured integrations with options for
|
||||
filtering by various criteria.
|
||||
description: |-
|
||||
Retrieve a list of all configured integrations with options for filtering by various criteria.
|
||||
|
||||
Integrations attached to one or more providers are only returned when the role can access at least one of those providers, and each integration lists only the providers visible to the role. Integrations not attached to any provider, such as Jira, are tenant-wide and are returned for every role.
|
||||
summary: List all integrations
|
||||
parameters:
|
||||
- in: query
|
||||
@@ -6781,7 +6783,8 @@ paths:
|
||||
post:
|
||||
operationId: api_v1_integrations_create
|
||||
description: Register a new integration with the system, providing necessary
|
||||
configuration details.
|
||||
configuration details. Only providers visible to the role can be attached
|
||||
to the integration.
|
||||
summary: Create a new integration
|
||||
tags:
|
||||
- Integration
|
||||
@@ -6810,7 +6813,7 @@ paths:
|
||||
post:
|
||||
operationId: api_v1_integrations_jira_dispatches_create
|
||||
description: |-
|
||||
Send a set of filtered findings to the given integration. At least one finding filter must be provided.
|
||||
Send a set of filtered findings to the given integration. At least one finding filter must be provided. Jira integrations are tenant-wide and do not require unlimited visibility, while the findings sent are limited to the providers the role can access.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
@@ -6883,7 +6886,8 @@ paths:
|
||||
get:
|
||||
operationId: api_v1_integrations_jira_issue_types_retrieve
|
||||
description: Fetch the available issue types from Jira for a given project key
|
||||
and update the integration configuration.
|
||||
and update the integration configuration. Jira integrations are tenant-wide
|
||||
and do not require unlimited visibility.
|
||||
summary: Get available issue types for a Jira project
|
||||
parameters:
|
||||
- in: query
|
||||
@@ -6924,7 +6928,8 @@ paths:
|
||||
get:
|
||||
operationId: api_v1_integrations_retrieve
|
||||
description: Fetch detailed information about a specific integration by its
|
||||
ID.
|
||||
ID. Integrations outside the provider visibility of the role are reported
|
||||
the same way as one that does not exist.
|
||||
summary: Retrieve integration details
|
||||
parameters:
|
||||
- in: query
|
||||
@@ -6978,7 +6983,8 @@ paths:
|
||||
patch:
|
||||
operationId: api_v1_integrations_partial_update
|
||||
description: Modify certain fields of an existing integration without affecting
|
||||
other settings.
|
||||
other settings. Integrations attached to providers outside the visibility
|
||||
of the role cannot be modified by it.
|
||||
summary: Partially update an integration
|
||||
parameters:
|
||||
- in: path
|
||||
@@ -7013,7 +7019,8 @@ paths:
|
||||
description: ''
|
||||
delete:
|
||||
operationId: api_v1_integrations_destroy
|
||||
description: Remove an integration from the system by its ID.
|
||||
description: Remove an integration from the system by its ID. Integrations attached
|
||||
to providers outside the visibility of the role cannot be deleted by it.
|
||||
summary: Delete an integration
|
||||
parameters:
|
||||
- in: path
|
||||
@@ -7033,7 +7040,9 @@ paths:
|
||||
/api/v1/integrations/{id}/connection:
|
||||
post:
|
||||
operationId: api_v1_integrations_connection_create
|
||||
description: Try to verify integration connection
|
||||
description: Try to verify integration connection. Integrations outside the
|
||||
provider visibility of the role are reported the same way as one that does
|
||||
not exist.
|
||||
summary: Check integration connection
|
||||
parameters:
|
||||
- in: path
|
||||
|
||||
@@ -3,6 +3,8 @@ from unittest.mock import ANY, Mock, patch
|
||||
|
||||
import pytest
|
||||
from api.models import (
|
||||
Integration,
|
||||
IntegrationProviderRelationship,
|
||||
Membership,
|
||||
ProviderGroup,
|
||||
ProviderGroupMembership,
|
||||
@@ -681,6 +683,363 @@ class TestLimitedVisibility:
|
||||
response.json()["data"]["relationships"]["providers"]["meta"]["count"] == 1
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def jira_integration(self, tenants_fixture):
|
||||
# Jira is a tenant-wide integration: it is not attached to any provider
|
||||
return Integration.objects.create(
|
||||
tenant_id=tenants_fixture[0].id,
|
||||
enabled=True,
|
||||
connected=True,
|
||||
integration_type=Integration.IntegrationChoices.JIRA,
|
||||
configuration={"projects": {"TEST": "Test project"}},
|
||||
credentials={
|
||||
"domain": "test",
|
||||
"user_mail": "a@b.com",
|
||||
"api_token": "token",
|
||||
},
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def out_of_scope_integration(self, tenants_fixture, provider_factory):
|
||||
tenant_id = tenants_fixture[0].id
|
||||
integration = Integration.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
enabled=True,
|
||||
connected=True,
|
||||
integration_type=Integration.IntegrationChoices.AMAZON_S3,
|
||||
configuration={
|
||||
"bucket_name": "bucket",
|
||||
"output_directory": "output",
|
||||
},
|
||||
credentials={"aws_access_key_id": "key"},
|
||||
)
|
||||
IntegrationProviderRelationship.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
integration=integration,
|
||||
provider=provider_factory(),
|
||||
)
|
||||
return integration
|
||||
|
||||
def test_integrations_list_includes_tenant_wide_integration(
|
||||
self,
|
||||
authenticated_client_rbac_limited,
|
||||
integrations_fixture,
|
||||
jira_integration,
|
||||
aws_provider_pair,
|
||||
):
|
||||
# Integration 2 is attached to both providers, so make both visible to the role
|
||||
# to assert the provider join does not duplicate it in the listing
|
||||
ProviderGroupMembership.objects.create(
|
||||
tenant_id=aws_provider_pair[1].tenant_id,
|
||||
provider=aws_provider_pair[1],
|
||||
provider_group=ProviderGroup.objects.get(name="limited_visibility_group"),
|
||||
)
|
||||
|
||||
response = authenticated_client_rbac_limited.get(reverse("integration-list"))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
integration_ids = [item["id"] for item in response.json()["data"]]
|
||||
# The tenant-wide Jira integration is visible without unlimited visibility
|
||||
assert str(jira_integration.id) in integration_ids
|
||||
# Integrations attached to more than one visible provider are not duplicated
|
||||
assert integration_ids.count(str(integrations_fixture[1].id)) == 1
|
||||
assert response.json()["meta"]["pagination"]["count"] == len(integration_ids)
|
||||
|
||||
def test_integrations_list_without_provider_groups_keeps_tenant_wide_integration(
|
||||
self, authenticated_client_rbac_limited, integrations_fixture, jira_integration
|
||||
):
|
||||
# A role with no provider group at all sees no provider, but still needs Jira
|
||||
RoleProviderGroupRelationship.objects.all().delete()
|
||||
|
||||
response = authenticated_client_rbac_limited.get(reverse("integration-list"))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
integration_ids = [item["id"] for item in response.json()["data"]]
|
||||
assert integration_ids == [str(jira_integration.id)]
|
||||
|
||||
def test_integrations_include_providers_hides_out_of_scope_providers(
|
||||
self, authenticated_client_rbac_limited, integrations_fixture, aws_provider_pair
|
||||
):
|
||||
# Integration 2 is related to provider1 (visible) and provider2 (not visible)
|
||||
hidden_provider = aws_provider_pair[1]
|
||||
|
||||
response = authenticated_client_rbac_limited.get(
|
||||
reverse("integration-list"), {"include": "providers"}
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
included_ids = {item["id"] for item in response.json().get("included", [])}
|
||||
assert str(aws_provider_pair[0].id) in included_ids
|
||||
# Sideloaded resources must not disclose the provider the role cannot see
|
||||
assert str(hidden_provider.id) not in included_ids
|
||||
|
||||
def test_integrations_list_with_sparse_fields(
|
||||
self, authenticated_client_rbac_limited, integrations_fixture
|
||||
):
|
||||
response = authenticated_client_rbac_limited.get(
|
||||
reverse("integration-list"), {"fields[integrations]": "enabled"}
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert all(
|
||||
list(item["attributes"].keys()) == ["enabled"]
|
||||
for item in response.json()["data"]
|
||||
)
|
||||
|
||||
def test_integrations_list_excludes_out_of_scope_integration(
|
||||
self, authenticated_client_rbac_limited, out_of_scope_integration
|
||||
):
|
||||
response = authenticated_client_rbac_limited.get(reverse("integration-list"))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
integration_ids = [item["id"] for item in response.json()["data"]]
|
||||
assert str(out_of_scope_integration.id) not in integration_ids
|
||||
|
||||
def test_integration_detail_out_of_scope_returns_404(
|
||||
self, authenticated_client_rbac_limited, out_of_scope_integration
|
||||
):
|
||||
response = authenticated_client_rbac_limited.get(
|
||||
reverse("integration-detail", kwargs={"pk": out_of_scope_integration.id})
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
def test_integration_connection_out_of_scope_returns_404(
|
||||
self, authenticated_client_rbac_limited, out_of_scope_integration
|
||||
):
|
||||
response = authenticated_client_rbac_limited.post(
|
||||
reverse(
|
||||
"integration-connection", kwargs={"pk": out_of_scope_integration.id}
|
||||
)
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
def test_integration_update_allowed_when_fully_visible(
|
||||
self, authenticated_client_rbac_limited, integrations_fixture, jira_integration
|
||||
):
|
||||
# Integration 1 is only related to provider1, which the role can access
|
||||
integration = integrations_fixture[0]
|
||||
payload = {
|
||||
"data": {
|
||||
"type": "integrations",
|
||||
"id": str(integration.id),
|
||||
"attributes": {
|
||||
"enabled": False,
|
||||
# integration_type is `amazon_s3`
|
||||
"credentials": {"aws_access_key_id": "new_value"},
|
||||
"configuration": {
|
||||
"bucket_name": "new_bucket_name",
|
||||
"output_directory": "new_output_directory",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
response = authenticated_client_rbac_limited.patch(
|
||||
reverse("integration-detail", kwargs={"pk": integration.id}),
|
||||
data=json.dumps(payload),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
integration.refresh_from_db()
|
||||
assert integration.enabled is False
|
||||
|
||||
# Tenant-wide integrations have no provider restricting the role
|
||||
payload = {
|
||||
"data": {
|
||||
"type": "integrations",
|
||||
"id": str(jira_integration.id),
|
||||
"attributes": {"enabled": False},
|
||||
}
|
||||
}
|
||||
|
||||
response = authenticated_client_rbac_limited.patch(
|
||||
reverse("integration-detail", kwargs={"pk": jira_integration.id}),
|
||||
data=json.dumps(payload),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
jira_integration.refresh_from_db()
|
||||
assert jira_integration.enabled is False
|
||||
|
||||
def test_integration_create_rejects_out_of_scope_provider(
|
||||
self, authenticated_client_rbac_limited, aws_provider_pair
|
||||
):
|
||||
# provider2 is not in any provider group assigned to the role
|
||||
payload = {
|
||||
"data": {
|
||||
"type": "integrations",
|
||||
"attributes": {
|
||||
"integration_type": "amazon_s3",
|
||||
"configuration": {
|
||||
"bucket_name": "attacker_bucket",
|
||||
"output_directory": "output",
|
||||
},
|
||||
"credentials": {"aws_access_key_id": "key"},
|
||||
},
|
||||
"relationships": {
|
||||
"providers": {
|
||||
"data": [
|
||||
{"type": "providers", "id": str(aws_provider_pair[1].id)}
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
response = authenticated_client_rbac_limited.post(
|
||||
reverse("integration-list"),
|
||||
data=json.dumps(payload),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert not Integration.objects.filter(
|
||||
integrationproviderrelationship__provider=aws_provider_pair[1],
|
||||
configuration__bucket_name="attacker_bucket",
|
||||
).exists()
|
||||
|
||||
@pytest.mark.parametrize("submitted_providers", [True, False])
|
||||
def test_integration_update_denied_when_shared_with_hidden_provider(
|
||||
self,
|
||||
authenticated_client_rbac_limited,
|
||||
integrations_fixture,
|
||||
aws_provider_pair,
|
||||
submitted_providers,
|
||||
):
|
||||
# Integration 2 is related to provider1 (visible) and provider2 (not visible).
|
||||
# Editing it would reach beyond the visibility of the role, just like deleting
|
||||
# it, so both are rejected consistently
|
||||
integration = integrations_fixture[1]
|
||||
visible_provider, hidden_provider = aws_provider_pair
|
||||
payload = {
|
||||
"data": {
|
||||
"type": "integrations",
|
||||
"id": str(integration.id),
|
||||
"attributes": {
|
||||
"enabled": False,
|
||||
# integration_type is `amazon_s3`
|
||||
"credentials": {"aws_access_key_id": "new_value"},
|
||||
"configuration": {
|
||||
"bucket_name": "new_bucket_name",
|
||||
"output_directory": "new_output_directory",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
if submitted_providers:
|
||||
payload["data"]["relationships"] = {
|
||||
"providers": {
|
||||
"data": [{"type": "providers", "id": str(visible_provider.id)}]
|
||||
}
|
||||
}
|
||||
|
||||
response = authenticated_client_rbac_limited.patch(
|
||||
reverse("integration-detail", kwargs={"pk": integration.id}),
|
||||
data=json.dumps(payload),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
integration.refresh_from_db()
|
||||
assert integration.enabled is True
|
||||
assert integration.providers.filter(id=hidden_provider.id).exists()
|
||||
assert integration.providers.filter(id=visible_provider.id).exists()
|
||||
|
||||
def test_integration_delete_denied_when_shared_with_hidden_provider(
|
||||
self, authenticated_client_rbac_limited, integrations_fixture
|
||||
):
|
||||
# Integration 2 is related to provider1 (visible) and provider2 (not visible)
|
||||
integration = integrations_fixture[1]
|
||||
|
||||
response = authenticated_client_rbac_limited.delete(
|
||||
reverse("integration-detail", kwargs={"pk": integration.id})
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert Integration.objects.filter(id=integration.id).exists()
|
||||
|
||||
def test_integration_delete_allowed_when_fully_visible(
|
||||
self, authenticated_client_rbac_limited, integrations_fixture, jira_integration
|
||||
):
|
||||
# Integration 1 is only related to provider1, which the role can access
|
||||
integration = integrations_fixture[0]
|
||||
|
||||
response = authenticated_client_rbac_limited.delete(
|
||||
reverse("integration-detail", kwargs={"pk": integration.id})
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert not Integration.objects.filter(id=integration.id).exists()
|
||||
|
||||
# Tenant-wide integrations have no provider restricting the role
|
||||
response = authenticated_client_rbac_limited.delete(
|
||||
reverse("integration-detail", kwargs={"pk": jira_integration.id})
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
|
||||
def test_jira_issue_types_allowed_without_unlimited_visibility(
|
||||
self, authenticated_client_rbac_limited, jira_integration
|
||||
):
|
||||
with patch("api.v1.views.initialize_prowler_integration") as mock_jira:
|
||||
mock_jira.return_value.get_available_issue_types.return_value = ["Task"]
|
||||
response = authenticated_client_rbac_limited.get(
|
||||
reverse(
|
||||
"integration-jira-issue-types",
|
||||
kwargs={"integration_pk": jira_integration.id},
|
||||
),
|
||||
{"project_key": "TEST"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["data"]["attributes"]["issue_types"] == ["Task"]
|
||||
|
||||
def test_jira_issue_types_out_of_scope_returns_404(
|
||||
self, authenticated_client_rbac_limited, out_of_scope_integration
|
||||
):
|
||||
response = authenticated_client_rbac_limited.get(
|
||||
reverse(
|
||||
"integration-jira-issue-types",
|
||||
kwargs={"integration_pk": out_of_scope_integration.id},
|
||||
),
|
||||
{"project_key": "TEST"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
def test_jira_dispatches_out_of_scope_returns_404(
|
||||
self, authenticated_client_rbac_limited, out_of_scope_integration
|
||||
):
|
||||
response = authenticated_client_rbac_limited.post(
|
||||
reverse(
|
||||
"integration-jira-dispatches",
|
||||
kwargs={"integration_pk": out_of_scope_integration.id},
|
||||
),
|
||||
data=json.dumps({}),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
def test_jira_dispatches_allowed_without_unlimited_visibility(
|
||||
self, authenticated_client_rbac_limited, jira_integration
|
||||
):
|
||||
response = authenticated_client_rbac_limited.post(
|
||||
reverse(
|
||||
"integration-jira-dispatches",
|
||||
kwargs={"integration_pk": jira_integration.id},
|
||||
),
|
||||
data=json.dumps({}),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
# The integration is reachable: the request fails on payload validation, not RBAC
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
@pytest.mark.usefixtures("scan_summaries_fixture")
|
||||
def test_overviews_providers(
|
||||
self,
|
||||
|
||||
@@ -309,6 +309,36 @@ current-context: test-context
|
||||
assert not serializer.is_valid()
|
||||
assert "kubeconfig_content" in serializer.errors
|
||||
|
||||
def test_kubeconfig_with_auth_provider_cmd_path_is_rejected(self):
|
||||
kubeconfig_content = """
|
||||
apiVersion: v1
|
||||
kind: Config
|
||||
clusters:
|
||||
- name: test-cluster
|
||||
cluster:
|
||||
server: https://kubernetes.example.test
|
||||
users:
|
||||
- name: test-user
|
||||
user:
|
||||
auth-provider:
|
||||
name: gcp
|
||||
config:
|
||||
cmd-path: /bin/sh
|
||||
contexts:
|
||||
- name: test-context
|
||||
context:
|
||||
cluster: test-cluster
|
||||
user: test-user
|
||||
current-context: test-context
|
||||
"""
|
||||
|
||||
serializer = KubernetesProviderSecret(
|
||||
data={"kubeconfig_content": kubeconfig_content}
|
||||
)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert "kubeconfig_content" in serializer.errors
|
||||
|
||||
def test_malformed_kubeconfig_is_rejected(self):
|
||||
serializer = KubernetesProviderSecret(
|
||||
data={"kubeconfig_content": "apiVersion: ["}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import os
|
||||
import re
|
||||
|
||||
from api.models import Integration, IntegrationProviderRelationship, Provider
|
||||
from api.v1.serializer_utils.base import BaseValidateSerializer
|
||||
from django.db import transaction
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework_json_api import serializers
|
||||
|
||||
@@ -10,6 +12,24 @@ ATLASSIAN_SITE_NAME_REGEX = re.compile(
|
||||
)
|
||||
|
||||
|
||||
def replace_integration_providers(
|
||||
integration: Integration, providers: list[Provider], tenant_id: str
|
||||
) -> None:
|
||||
"""Replace the provider relationships of an integration with the given set."""
|
||||
# Atomic on its own, so callers without an ambient transaction cannot leave the
|
||||
# integration with no relationships if the recreation fails halfway
|
||||
with transaction.atomic():
|
||||
IntegrationProviderRelationship.objects.filter(integration=integration).delete()
|
||||
IntegrationProviderRelationship.objects.bulk_create(
|
||||
[
|
||||
IntegrationProviderRelationship(
|
||||
integration=integration, provider=provider, tenant_id=tenant_id
|
||||
)
|
||||
for provider in providers
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class S3ConfigSerializer(BaseValidateSerializer):
|
||||
bucket_name = serializers.CharField()
|
||||
output_directory = serializers.CharField(allow_blank=True)
|
||||
|
||||
@@ -214,7 +214,7 @@ from rest_framework_json_api import serializers
|
||||
"kubeconfig_content": {
|
||||
"type": "string",
|
||||
"description": "The content of the Kubernetes kubeconfig file, encoded as a string. "
|
||||
"Kubeconfig exec authentication is not supported in Prowler Cloud for security reasons.",
|
||||
"Kubeconfig command-based authentication is not supported in Prowler Cloud for security reasons.",
|
||||
}
|
||||
},
|
||||
"required": ["kubeconfig_content"],
|
||||
|
||||
@@ -47,6 +47,7 @@ from api.v1.serializer_utils.integrations import (
|
||||
JiraCredentialSerializer,
|
||||
S3ConfigSerializer,
|
||||
SecurityHubConfigSerializer,
|
||||
replace_integration_providers,
|
||||
)
|
||||
from api.v1.serializer_utils.lighthouse import (
|
||||
BedrockCredentialsSerializer,
|
||||
@@ -1568,14 +1569,14 @@ class FindingMetadataSerializer(BaseSerializerV1):
|
||||
|
||||
|
||||
# Provider secrets
|
||||
KUBERNETES_KUBECONFIG_EXEC_ERROR = (
|
||||
"Kubernetes kubeconfig exec authentication is not supported in Prowler Cloud "
|
||||
"for security reasons."
|
||||
KUBERNETES_KUBECONFIG_UNSUPPORTED_COMMAND_AUTH_ERROR = (
|
||||
"Kubernetes kubeconfig command-based authentication is not supported in "
|
||||
"Prowler Cloud for security reasons."
|
||||
)
|
||||
KUBERNETES_KUBECONFIG_INVALID_ERROR = "Invalid Kubernetes kubeconfig content."
|
||||
|
||||
|
||||
def kubeconfig_contains_exec_auth(kubeconfig: dict) -> bool:
|
||||
def kubeconfig_contains_unsupported_command_auth(kubeconfig: dict) -> bool:
|
||||
users = kubeconfig.get("users", [])
|
||||
if not isinstance(users, list):
|
||||
raise ValidationError(KUBERNETES_KUBECONFIG_INVALID_ERROR)
|
||||
@@ -1591,6 +1592,17 @@ def kubeconfig_contains_exec_auth(kubeconfig: dict) -> bool:
|
||||
if "exec" in user:
|
||||
return True
|
||||
|
||||
auth_provider = user.get("auth-provider", {})
|
||||
if not isinstance(auth_provider, dict):
|
||||
continue
|
||||
|
||||
auth_provider_config = auth_provider.get("config", {})
|
||||
if not isinstance(auth_provider_config, dict):
|
||||
continue
|
||||
|
||||
if "cmd-path" in auth_provider_config:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@@ -1787,8 +1799,10 @@ class KubernetesProviderSecret(serializers.Serializer):
|
||||
if not isinstance(kubeconfig, dict):
|
||||
raise serializers.ValidationError(KUBERNETES_KUBECONFIG_INVALID_ERROR)
|
||||
|
||||
if kubeconfig_contains_exec_auth(kubeconfig):
|
||||
raise serializers.ValidationError(KUBERNETES_KUBECONFIG_EXEC_ERROR)
|
||||
if kubeconfig_contains_unsupported_command_auth(kubeconfig):
|
||||
raise serializers.ValidationError(
|
||||
KUBERNETES_KUBECONFIG_UNSUPPORTED_COMMAND_AUTH_ERROR
|
||||
)
|
||||
|
||||
return kubeconfig_content
|
||||
|
||||
@@ -2743,6 +2757,37 @@ class ScheduleDailyCreateSerializer(BaseSerializerV1):
|
||||
# Integrations
|
||||
|
||||
|
||||
class IntegrationProviderVisibilityMixin:
|
||||
"""
|
||||
Keep the `providers` relationship within the provider visibility of the role.
|
||||
|
||||
The view injects `allowed_providers` in the serializer context: `None` when the role
|
||||
has unlimited visibility, and the queryset of visible providers otherwise. Roles with
|
||||
limited visibility can neither attach providers they cannot see nor discover, through
|
||||
the serialized output, the ones already attached.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
allowed_providers = self.context.get("allowed_providers")
|
||||
if allowed_providers is not None:
|
||||
self.fields["providers"].child_relation.queryset = allowed_providers
|
||||
|
||||
def hide_restricted_providers(self, representation: dict) -> dict:
|
||||
allowed_providers = self.context.get("allowed_providers")
|
||||
# `providers` is missing when the request asks for a subset of the fields
|
||||
if allowed_providers is None or "providers" not in representation:
|
||||
return representation
|
||||
|
||||
allowed_provider_ids = {str(provider.id) for provider in allowed_providers}
|
||||
representation["providers"] = [
|
||||
provider
|
||||
for provider in representation["providers"]
|
||||
if provider["id"] in allowed_provider_ids
|
||||
]
|
||||
return representation
|
||||
|
||||
|
||||
class BaseWriteIntegrationSerializer(BaseWriteSerializer):
|
||||
def validate(self, attrs):
|
||||
integration_type = attrs.get("integration_type")
|
||||
@@ -2875,7 +2920,7 @@ class BaseWriteIntegrationSerializer(BaseWriteSerializer):
|
||||
)
|
||||
|
||||
|
||||
class IntegrationSerializer(RLSSerializer):
|
||||
class IntegrationSerializer(IntegrationProviderVisibilityMixin, RLSSerializer):
|
||||
"""
|
||||
Serializer for the Integration model.
|
||||
"""
|
||||
@@ -2904,15 +2949,9 @@ class IntegrationSerializer(RLSSerializer):
|
||||
}
|
||||
|
||||
def to_representation(self, instance):
|
||||
representation = super().to_representation(instance)
|
||||
allowed_providers = self.context.get("allowed_providers")
|
||||
if allowed_providers:
|
||||
allowed_provider_ids = {str(provider.id) for provider in allowed_providers}
|
||||
representation["providers"] = [
|
||||
provider
|
||||
for provider in representation["providers"]
|
||||
if provider["id"] in allowed_provider_ids
|
||||
]
|
||||
representation = self.hide_restricted_providers(
|
||||
super().to_representation(instance)
|
||||
)
|
||||
if instance.integration_type == Integration.IntegrationChoices.JIRA:
|
||||
representation["configuration"].update(
|
||||
{"domain": instance.credentials.get("domain")}
|
||||
@@ -2920,7 +2959,9 @@ class IntegrationSerializer(RLSSerializer):
|
||||
return representation
|
||||
|
||||
|
||||
class IntegrationCreateSerializer(BaseWriteIntegrationSerializer):
|
||||
class IntegrationCreateSerializer(
|
||||
IntegrationProviderVisibilityMixin, BaseWriteIntegrationSerializer
|
||||
):
|
||||
credentials = IntegrationCredentialField(write_only=True)
|
||||
configuration = IntegrationConfigField()
|
||||
providers = serializers.ResourceRelatedField(
|
||||
@@ -2971,22 +3012,18 @@ class IntegrationCreateSerializer(BaseWriteIntegrationSerializer):
|
||||
tenant_id = self.context.get("tenant_id")
|
||||
|
||||
providers = validated_data.pop("providers", [])
|
||||
integration = Integration.objects.create(tenant_id=tenant_id, **validated_data)
|
||||
|
||||
through_model_instances = [
|
||||
IntegrationProviderRelationship(
|
||||
integration=integration,
|
||||
provider=provider,
|
||||
tenant_id=tenant_id,
|
||||
with transaction.atomic():
|
||||
integration = Integration.objects.create(
|
||||
tenant_id=tenant_id, **validated_data
|
||||
)
|
||||
for provider in providers
|
||||
]
|
||||
IntegrationProviderRelationship.objects.bulk_create(through_model_instances)
|
||||
replace_integration_providers(integration, providers, tenant_id)
|
||||
|
||||
return integration
|
||||
|
||||
|
||||
class IntegrationUpdateSerializer(BaseWriteIntegrationSerializer):
|
||||
class IntegrationUpdateSerializer(
|
||||
IntegrationProviderVisibilityMixin, BaseWriteIntegrationSerializer
|
||||
):
|
||||
credentials = IntegrationCredentialField(write_only=True, required=False)
|
||||
configuration = IntegrationConfigField(required=False)
|
||||
providers = serializers.ResourceRelatedField(
|
||||
@@ -3031,15 +3068,13 @@ class IntegrationUpdateSerializer(BaseWriteIntegrationSerializer):
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
tenant_id = self.context.get("tenant_id")
|
||||
if validated_data.get("providers") is not None:
|
||||
instance.providers.clear()
|
||||
new_relationships = [
|
||||
IntegrationProviderRelationship(
|
||||
integration=instance, provider=provider, tenant_id=tenant_id
|
||||
)
|
||||
for provider in validated_data["providers"]
|
||||
]
|
||||
IntegrationProviderRelationship.objects.bulk_create(new_relationships)
|
||||
# Relationships are replaced here, so they are kept out of the default
|
||||
# `ModelSerializer.update()`, which would otherwise reset them all. The view
|
||||
# rejects updates on integrations shared with providers hidden to the role, so
|
||||
# every existing relationship is visible to the requester at this point
|
||||
providers = validated_data.pop("providers", None)
|
||||
if providers is not None:
|
||||
replace_integration_providers(instance, providers, tenant_id)
|
||||
|
||||
# Preserve regions field for Security Hub integrations
|
||||
if instance.integration_type == Integration.IntegrationChoices.AWS_SECURITY_HUB:
|
||||
@@ -3051,7 +3086,9 @@ class IntegrationUpdateSerializer(BaseWriteIntegrationSerializer):
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
def to_representation(self, instance):
|
||||
representation = super().to_representation(instance)
|
||||
representation = self.hide_restricted_providers(
|
||||
super().to_representation(instance)
|
||||
)
|
||||
# Ensure JIRA integrations show updated domain in configuration from credentials
|
||||
if instance.integration_type == Integration.IntegrationChoices.JIRA:
|
||||
representation["configuration"].update(
|
||||
|
||||
@@ -124,7 +124,12 @@ from api.models import (
|
||||
UserRoleRelationship,
|
||||
)
|
||||
from api.pagination import ComplianceOverviewPagination
|
||||
from api.rbac.permissions import Permissions, get_providers, get_role
|
||||
from api.rbac.permissions import (
|
||||
Permissions,
|
||||
get_integrations,
|
||||
get_providers,
|
||||
get_role,
|
||||
)
|
||||
from api.renderers import APIJSONRenderer, PlainTextRenderer
|
||||
from api.rls import Tenant
|
||||
from api.utils import (
|
||||
@@ -281,6 +286,7 @@ from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.dateparse import parse_date
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.functional import cached_property
|
||||
from django.views.decorators.cache import cache_control
|
||||
from django_celery_beat.models import PeriodicTask
|
||||
from drf_spectacular.settings import spectacular_settings
|
||||
@@ -6652,27 +6658,34 @@ class ScheduleViewSet(BaseRLSViewSet):
|
||||
list=extend_schema(
|
||||
tags=["Integration"],
|
||||
summary="List all integrations",
|
||||
description="Retrieve a list of all configured integrations with options for filtering by various criteria.",
|
||||
description="Retrieve a list of all configured integrations with options for filtering by various criteria.\n\n"
|
||||
"Integrations attached to one or more providers are only returned when the role can access at least one of "
|
||||
"those providers, and each integration lists only the providers visible to the role. Integrations not "
|
||||
"attached to any provider, such as Jira, are tenant-wide and are returned for every role.",
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
tags=["Integration"],
|
||||
summary="Retrieve integration details",
|
||||
description="Fetch detailed information about a specific integration by its ID.",
|
||||
description="Fetch detailed information about a specific integration by its ID. Integrations outside the "
|
||||
"provider visibility of the role are reported the same way as one that does not exist.",
|
||||
),
|
||||
create=extend_schema(
|
||||
tags=["Integration"],
|
||||
summary="Create a new integration",
|
||||
description="Register a new integration with the system, providing necessary configuration details.",
|
||||
description="Register a new integration with the system, providing necessary configuration details. Only "
|
||||
"providers visible to the role can be attached to the integration.",
|
||||
),
|
||||
partial_update=extend_schema(
|
||||
tags=["Integration"],
|
||||
summary="Partially update an integration",
|
||||
description="Modify certain fields of an existing integration without affecting other settings.",
|
||||
description="Modify certain fields of an existing integration without affecting other settings. Integrations "
|
||||
"attached to providers outside the visibility of the role cannot be modified by it.",
|
||||
),
|
||||
destroy=extend_schema(
|
||||
tags=["Integration"],
|
||||
summary="Delete an integration",
|
||||
description="Remove an integration from the system by its ID.",
|
||||
description="Remove an integration from the system by its ID. Integrations attached to providers outside "
|
||||
"the visibility of the role cannot be deleted by it.",
|
||||
),
|
||||
)
|
||||
@method_decorator(CACHE_DECORATOR, name="list")
|
||||
@@ -6685,18 +6698,27 @@ class IntegrationViewSet(BaseRLSViewSet):
|
||||
ordering = ["integration_type", "-inserted_at"]
|
||||
# RBAC required permissions
|
||||
required_permissions = [Permissions.MANAGE_INTEGRATIONS]
|
||||
allowed_providers = None
|
||||
|
||||
@cached_property
|
||||
def allowed_providers(self):
|
||||
"""
|
||||
Providers the role can access, or None when it has unlimited visibility.
|
||||
|
||||
Resolved per request and independently of the action, so that writes are scoped
|
||||
as tightly as reads.
|
||||
"""
|
||||
if self.user_role.unlimited_visibility:
|
||||
return None
|
||||
return get_providers(self.user_role)
|
||||
|
||||
def get_queryset(self):
|
||||
user_roles = get_role(self.request.user, self.request.tenant_id)
|
||||
if user_roles.unlimited_visibility:
|
||||
# User has unlimited visibility, return all integrations
|
||||
queryset = Integration.objects.filter(tenant_id=self.request.tenant_id)
|
||||
else:
|
||||
# User lacks permission, filter providers based on provider groups associated with the role
|
||||
allowed_providers = get_providers(user_roles)
|
||||
queryset = Integration.objects.filter(providers__in=allowed_providers)
|
||||
self.allowed_providers = allowed_providers
|
||||
queryset = get_integrations(self.user_role, providers=self.allowed_providers)
|
||||
if self.allowed_providers is not None and self.action in ("list", "retrieve"):
|
||||
# Restrict the relationship itself, so that the providers hidden to the role
|
||||
# are left out of the sideloaded resources of `?include=providers` too
|
||||
queryset = queryset.prefetch_related(
|
||||
Prefetch("providers", queryset=self.allowed_providers)
|
||||
)
|
||||
return queryset
|
||||
|
||||
def get_serializer_class(self):
|
||||
@@ -6711,16 +6733,33 @@ class IntegrationViewSet(BaseRLSViewSet):
|
||||
context["allowed_providers"] = self.allowed_providers
|
||||
return context
|
||||
|
||||
def get_object(self):
|
||||
instance = super().get_object()
|
||||
# Writes on an integration shared with providers hidden to the role would reach
|
||||
# beyond its visibility, so both editing and deleting are rejected consistently
|
||||
if (
|
||||
self.action in ("partial_update", "destroy")
|
||||
and self.allowed_providers is not None
|
||||
and instance.providers.exclude(
|
||||
id__in=self.allowed_providers.values("id")
|
||||
).exists()
|
||||
):
|
||||
raise PermissionDenied(
|
||||
"The integration is attached to providers outside the visibility of your role."
|
||||
)
|
||||
return instance
|
||||
|
||||
@extend_schema(
|
||||
tags=["Integration"],
|
||||
summary="Check integration connection",
|
||||
description="Try to verify integration connection",
|
||||
description="Try to verify integration connection. Integrations outside the provider visibility of the role "
|
||||
"are reported the same way as one that does not exist.",
|
||||
request=None,
|
||||
responses={202: OpenApiResponse(response=TaskSerializer)},
|
||||
)
|
||||
@action(detail=True, methods=["post"], url_name="connection")
|
||||
def connection(self, request, pk=None):
|
||||
get_object_or_404(Integration, pk=pk)
|
||||
get_object_or_404(self.get_queryset(), pk=pk)
|
||||
with transaction.atomic():
|
||||
task = check_integration_connection_task.delay(
|
||||
integration_id=pk, tenant_id=self.request.tenant_id
|
||||
@@ -6743,7 +6782,8 @@ class IntegrationViewSet(BaseRLSViewSet):
|
||||
tags=["Integration"],
|
||||
summary="Send findings to a Jira integration",
|
||||
description="Send a set of filtered findings to the given integration. At least one finding filter must be "
|
||||
"provided.\n\n"
|
||||
"provided. Jira integrations are tenant-wide and do not require unlimited visibility, while the findings "
|
||||
"sent are limited to the providers the role can access.\n\n"
|
||||
"## Known Limitations\n\n"
|
||||
"### Issue Types with Required Custom Fields\n\n"
|
||||
"Certain Jira issue types (such as Epic) may require mandatory custom fields that Prowler does not "
|
||||
@@ -6787,24 +6827,37 @@ class IntegrationJiraViewSet(BaseRLSViewSet):
|
||||
return []
|
||||
return super().get_filter_backends()
|
||||
|
||||
def get_queryset(self):
|
||||
tenant_id = self.request.tenant_id
|
||||
user_roles = get_role(self.request.user, self.request.tenant_id)
|
||||
if user_roles.unlimited_visibility:
|
||||
# User has unlimited visibility, return all findings
|
||||
queryset = Finding.all_objects.filter(tenant_id=tenant_id)
|
||||
else:
|
||||
# User lacks permission, filter findings based on provider groups associated with the role
|
||||
queryset = Finding.all_objects.filter(
|
||||
scan__provider__in=get_providers(user_roles)
|
||||
)
|
||||
@cached_property
|
||||
def allowed_providers(self):
|
||||
"""
|
||||
Providers the role can access, or None when it has unlimited visibility.
|
||||
|
||||
return queryset
|
||||
Resolved once per request and shared between the findings queryset and the
|
||||
integration lookup.
|
||||
"""
|
||||
if self.user_role.unlimited_visibility:
|
||||
return None
|
||||
return get_providers(self.user_role)
|
||||
|
||||
def get_queryset(self):
|
||||
if self.allowed_providers is None:
|
||||
# User has unlimited visibility, return all findings
|
||||
return Finding.all_objects.filter(tenant_id=self.request.tenant_id)
|
||||
# Findings are limited to the providers the role can access
|
||||
return Finding.all_objects.filter(scan__provider__in=self.allowed_providers)
|
||||
|
||||
def get_integration(self, integration_pk):
|
||||
"""Retrieve the integration, honoring the provider visibility of the user's role."""
|
||||
return get_object_or_404(
|
||||
get_integrations(self.user_role, providers=self.allowed_providers),
|
||||
pk=integration_pk,
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
tags=["Integration"],
|
||||
summary="Get available issue types for a Jira project",
|
||||
description="Fetch the available issue types from Jira for a given project key and update the integration configuration.",
|
||||
description="Fetch the available issue types from Jira for a given project key and update the integration "
|
||||
"configuration. Jira integrations are tenant-wide and do not require unlimited visibility.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="project_key",
|
||||
@@ -6817,7 +6870,7 @@ class IntegrationJiraViewSet(BaseRLSViewSet):
|
||||
)
|
||||
@action(detail=False, methods=["get"], url_name="issue-types")
|
||||
def issue_types(self, request, integration_pk=None):
|
||||
integration = get_object_or_404(Integration, pk=integration_pk)
|
||||
integration = self.get_integration(integration_pk)
|
||||
|
||||
project_key = request.query_params.get("project_key")
|
||||
if not project_key:
|
||||
@@ -6862,23 +6915,23 @@ class IntegrationJiraViewSet(BaseRLSViewSet):
|
||||
|
||||
@action(detail=False, methods=["post"], url_name="dispatches")
|
||||
def dispatches(self, request, integration_pk=None):
|
||||
get_object_or_404(Integration, pk=integration_pk)
|
||||
self.get_integration(integration_pk)
|
||||
serializer = self.get_serializer(
|
||||
data=request.data, context={"integration_id": integration_pk}
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
if self.filter_queryset(self.get_queryset()).count() == 0:
|
||||
raise ValidationError(
|
||||
{"findings": "No findings match the provided filters"}
|
||||
)
|
||||
|
||||
finding_ids = [
|
||||
str(finding_id)
|
||||
for finding_id in self.filter_queryset(self.get_queryset()).values_list(
|
||||
"id", flat=True
|
||||
)
|
||||
]
|
||||
if not finding_ids:
|
||||
raise ValidationError(
|
||||
{"findings": "No findings match the provided filters"}
|
||||
)
|
||||
|
||||
project_key = serializer.validated_data["project_key"]
|
||||
issue_type = serializer.validated_data["issue_type"]
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import copy
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
@@ -695,6 +696,45 @@ def _process_finding_micro_batch(
|
||||
scan_resource_groups_cache: Dict tracking resource group counts {(resource_group, severity): {"total", "failed", "new_failed"}}.
|
||||
group_resources_cache: Dict tracking unique resources per group {resource_group: set(resource_uids)}.
|
||||
"""
|
||||
|
||||
def build_resource_defaults_from_finding(finding: ProwlerFinding) -> dict[str, Any]:
|
||||
check_metadata = finding.get_metadata()
|
||||
group = check_metadata.get("resourcegroup") or None
|
||||
return {
|
||||
"tenant_id": tenant_id,
|
||||
"provider": provider_instance,
|
||||
"uid": finding.resource_uid,
|
||||
"region": finding.region,
|
||||
"service": finding.service_name,
|
||||
"type": finding.resource_type,
|
||||
"name": finding.resource_name,
|
||||
"groups": [group] if group else None,
|
||||
}
|
||||
|
||||
def recover_resource_after_cache_miss(finding: ProwlerFinding) -> Resource:
|
||||
resource_uid = finding.resource_uid
|
||||
resource_instance = Resource.objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
provider_id=provider_instance.id,
|
||||
uid=resource_uid,
|
||||
).first()
|
||||
if resource_instance is None:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
resource_instance = Resource.objects.create(
|
||||
**build_resource_defaults_from_finding(finding)
|
||||
)
|
||||
except IntegrityError:
|
||||
resource_instance = Resource.objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
provider_id=provider_instance.id,
|
||||
uid=resource_uid,
|
||||
).first()
|
||||
if resource_instance is None:
|
||||
raise
|
||||
|
||||
return cache_resource(resource_uid, resource_instance)
|
||||
|
||||
# Accumulate objects for bulk operations
|
||||
findings_to_create = []
|
||||
dirty_resources = {}
|
||||
@@ -733,7 +773,103 @@ def _process_finding_micro_batch(
|
||||
|
||||
# All DB writes for this micro-batch run inside ONE rls_transaction,
|
||||
# with deadlock-retry at micro-batch granularity instead of per-finding.
|
||||
missing_cache_value = object()
|
||||
for attempt in range(CELERY_DEADLOCK_ATTEMPTS):
|
||||
resource_cache_originals: dict[str, Resource | object] = {}
|
||||
failed_count_originals: dict[str, int | None] = {}
|
||||
resource_field_originals: dict[str, dict[str, Any]] = {}
|
||||
tag_cache_original = dict(tag_cache)
|
||||
scan_resource_cache_original = set(scan_resource_cache)
|
||||
scan_categories_cache_original = {
|
||||
key: value.copy() for key, value in scan_categories_cache.items()
|
||||
}
|
||||
scan_resource_groups_cache_original = {
|
||||
key: value.copy() for key, value in scan_resource_groups_cache.items()
|
||||
}
|
||||
group_resources_cache_original = {
|
||||
key: set(value) for key, value in group_resources_cache.items()
|
||||
}
|
||||
|
||||
def cache_resource(resource_uid: str, resource_instance: Resource) -> Resource:
|
||||
if resource_uid not in resource_cache_originals:
|
||||
resource_cache_originals[resource_uid] = resource_cache.get(
|
||||
resource_uid, missing_cache_value
|
||||
)
|
||||
resource_cache[resource_uid] = resource_instance
|
||||
if resource_uid not in resource_failed_findings_cache:
|
||||
failed_count_originals[resource_uid] = None
|
||||
resource_failed_findings_cache[resource_uid] = 0
|
||||
return resource_instance
|
||||
|
||||
def snapshot_failed_count(resource_uid: str) -> None:
|
||||
if resource_uid not in failed_count_originals:
|
||||
failed_count_originals[resource_uid] = (
|
||||
resource_failed_findings_cache.get(resource_uid)
|
||||
)
|
||||
|
||||
def snapshot_resource_fields(
|
||||
resource_uid: str, resource_instance: Resource
|
||||
) -> None:
|
||||
if resource_uid in resource_field_originals:
|
||||
return
|
||||
resource_field_originals[resource_uid] = {
|
||||
field: copy.deepcopy(getattr(resource_instance, field))
|
||||
for field in (
|
||||
"name",
|
||||
"metadata",
|
||||
"details",
|
||||
"partition",
|
||||
"region",
|
||||
"service",
|
||||
"type",
|
||||
"groups",
|
||||
"updated_at",
|
||||
)
|
||||
}
|
||||
|
||||
def restore_attempt_caches() -> None:
|
||||
for resource_uid, original_fields in resource_field_originals.items():
|
||||
resource_instance = resource_cache.get(resource_uid)
|
||||
if resource_instance is None:
|
||||
continue
|
||||
for field, value in original_fields.items():
|
||||
setattr(resource_instance, field, value)
|
||||
for resource_uid, original_resource in resource_cache_originals.items():
|
||||
if original_resource is missing_cache_value:
|
||||
resource_cache.pop(resource_uid, None)
|
||||
else:
|
||||
resource_cache[resource_uid] = original_resource
|
||||
for resource_uid, original_count in failed_count_originals.items():
|
||||
if original_count is None:
|
||||
resource_failed_findings_cache.pop(resource_uid, None)
|
||||
else:
|
||||
resource_failed_findings_cache[resource_uid] = original_count
|
||||
tag_cache.clear()
|
||||
tag_cache.update(tag_cache_original)
|
||||
scan_resource_cache.clear()
|
||||
scan_resource_cache.update(scan_resource_cache_original)
|
||||
scan_categories_cache.clear()
|
||||
scan_categories_cache.update(
|
||||
{
|
||||
key: value.copy()
|
||||
for key, value in scan_categories_cache_original.items()
|
||||
}
|
||||
)
|
||||
scan_resource_groups_cache.clear()
|
||||
scan_resource_groups_cache.update(
|
||||
{
|
||||
key: value.copy()
|
||||
for key, value in scan_resource_groups_cache_original.items()
|
||||
}
|
||||
)
|
||||
group_resources_cache.clear()
|
||||
group_resources_cache.update(
|
||||
{
|
||||
key: set(value)
|
||||
for key, value in group_resources_cache_original.items()
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
with rls_transaction(tenant_id):
|
||||
# 1) Pre-resolve Resources in bulk
|
||||
@@ -768,19 +904,8 @@ def _process_finding_micro_batch(
|
||||
resources_to_create = []
|
||||
for uid in missing_uids:
|
||||
f = first_finding_per_uid[uid]
|
||||
check_metadata = f.get_metadata()
|
||||
group = check_metadata.get("resourcegroup") or None
|
||||
resources_to_create.append(
|
||||
Resource(
|
||||
tenant_id=tenant_id,
|
||||
provider=provider_instance,
|
||||
uid=uid,
|
||||
region=f.region,
|
||||
service=f.service_name,
|
||||
type=f.resource_type,
|
||||
name=f.resource_name,
|
||||
groups=[group] if group else None,
|
||||
)
|
||||
Resource(**build_resource_defaults_from_finding(f))
|
||||
)
|
||||
Resource.objects.bulk_create(
|
||||
resources_to_create,
|
||||
@@ -801,8 +926,7 @@ def _process_finding_micro_batch(
|
||||
}
|
||||
)
|
||||
for uid, r in existing_resources.items():
|
||||
resource_cache[uid] = r
|
||||
resource_failed_findings_cache.setdefault(uid, 0)
|
||||
cache_resource(uid, r)
|
||||
|
||||
# 2) Pre-resolve ResourceTags in bulk
|
||||
batch_tag_kv: set[tuple[str, str]] = set()
|
||||
@@ -848,47 +972,50 @@ def _process_finding_micro_batch(
|
||||
resource_uid = finding.resource_uid
|
||||
resource_instance = resource_cache.get(resource_uid)
|
||||
if resource_instance is None:
|
||||
# Should be unreachable after the pre-resolve step. Defensive log.
|
||||
logger.error(
|
||||
f"Resource {resource_uid} missing from cache after pre-resolve "
|
||||
f"on scan {scan_instance.id}; skipping finding."
|
||||
)
|
||||
continue
|
||||
resource_instance = recover_resource_after_cache_miss(finding)
|
||||
|
||||
# Detect resource field changes (defer save until end-of-batch bulk_update).
|
||||
check_metadata = finding.get_metadata()
|
||||
group = check_metadata.get("resourcegroup") or None
|
||||
updated = False
|
||||
if finding.region and resource_instance.region != finding.region:
|
||||
snapshot_resource_fields(resource_uid, resource_instance)
|
||||
resource_instance.region = finding.region
|
||||
updated = True
|
||||
if (
|
||||
finding.resource_name
|
||||
and resource_instance.name != finding.resource_name
|
||||
):
|
||||
snapshot_resource_fields(resource_uid, resource_instance)
|
||||
resource_instance.name = finding.resource_name
|
||||
updated = True
|
||||
if resource_instance.service != finding.service_name:
|
||||
snapshot_resource_fields(resource_uid, resource_instance)
|
||||
resource_instance.service = finding.service_name
|
||||
updated = True
|
||||
if resource_instance.type != finding.resource_type:
|
||||
snapshot_resource_fields(resource_uid, resource_instance)
|
||||
resource_instance.type = finding.resource_type
|
||||
updated = True
|
||||
if resource_instance.metadata != finding.resource_metadata:
|
||||
snapshot_resource_fields(resource_uid, resource_instance)
|
||||
resource_instance.metadata = json.dumps(
|
||||
finding.resource_metadata, cls=CustomEncoder
|
||||
)
|
||||
updated = True
|
||||
if resource_instance.details != finding.resource_details:
|
||||
snapshot_resource_fields(resource_uid, resource_instance)
|
||||
resource_instance.details = finding.resource_details
|
||||
updated = True
|
||||
if resource_instance.partition != finding.partition:
|
||||
snapshot_resource_fields(resource_uid, resource_instance)
|
||||
resource_instance.partition = finding.partition
|
||||
updated = True
|
||||
if group and (
|
||||
not resource_instance.groups
|
||||
or group not in resource_instance.groups
|
||||
):
|
||||
snapshot_resource_fields(resource_uid, resource_instance)
|
||||
resource_instance.groups = (resource_instance.groups or []) + [
|
||||
group
|
||||
]
|
||||
@@ -950,6 +1077,7 @@ def _process_finding_micro_batch(
|
||||
muted_reason = mute_rules_cache[finding_uid]
|
||||
|
||||
if status == FindingStatus.FAIL and not is_muted:
|
||||
snapshot_failed_count(resource_uid)
|
||||
resource_failed_findings_cache[resource_uid] += 1
|
||||
|
||||
check_metadata["compliance"] = finding.compliance
|
||||
@@ -1107,6 +1235,7 @@ def _process_finding_micro_batch(
|
||||
if r is None:
|
||||
continue
|
||||
# Manually bump updated_at since bulk_update bypasses auto_now.
|
||||
snapshot_resource_fields(uid, r)
|
||||
r.updated_at = now_utc
|
||||
resources_to_bulk_update.append(r)
|
||||
if resources_to_bulk_update:
|
||||
@@ -1128,6 +1257,7 @@ def _process_finding_micro_batch(
|
||||
# Successful execution: leave deadlock retry loop.
|
||||
break
|
||||
except (OperationalError, IntegrityError) as db_err:
|
||||
restore_attempt_caches()
|
||||
if attempt < CELERY_DEADLOCK_ATTEMPTS - 1:
|
||||
logger.warning(
|
||||
f"{'Deadlock error' if isinstance(db_err, OperationalError) else 'Integrity error'} "
|
||||
|
||||
@@ -797,12 +797,34 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str):
|
||||
if name not in frameworks_bulk and universal_bulk[name].outputs
|
||||
}
|
||||
frameworks_avail = get_compliance_frameworks(provider_type)
|
||||
# Idempotency: a previous run of this task for the same scan may have left
|
||||
# output files behind (e.g. broker redelivery after a worker was killed
|
||||
# mid-run with task_acks_late, or a successful run on a deployment without
|
||||
# S3 where the tmp dir is not removed). Output writers open files in append
|
||||
# mode with a deterministic path (derived from scan.started_at), so reusing
|
||||
# them would append every finding row again and duplicate the CSV/output
|
||||
# rows. Start from a clean slate before (re)generating.
|
||||
scan_tmp_dir = _scan_tmp_output_directory(tenant_id, scan_id)
|
||||
if os.path.exists(scan_tmp_dir):
|
||||
rmtree(scan_tmp_dir, ignore_errors=True)
|
||||
# The writers below open output files in append mode with deterministic
|
||||
# paths (derived from scan.started_at). Any stale file that survives the
|
||||
# cleanup would get every finding row appended again, which is the exact
|
||||
# duplication this guards against. Continuing is therefore unsafe: abort
|
||||
# so `ScanReportRLSTask.on_failure` removes the tmp dir and the retry
|
||||
# starts from a clean slate instead of publishing duplicated rows.
|
||||
if os.path.exists(scan_tmp_dir):
|
||||
raise RuntimeError(
|
||||
"Could not remove stale output directory for scan "
|
||||
f"{scan_id} before generating outputs; aborting to avoid "
|
||||
"duplicated rows in appended outputs."
|
||||
)
|
||||
|
||||
out_dir, comp_dir = _generate_output_directory(
|
||||
DJANGO_TMP_OUTPUT_DIRECTORY, provider_uid, tenant_id, scan_id
|
||||
)
|
||||
# Removed on success here and on failure by ScanReportRLSTask.on_failure,
|
||||
# so partial artifacts do not accumulate and fill the disk (ENOSPC).
|
||||
scan_tmp_dir = _scan_tmp_output_directory(tenant_id, scan_id)
|
||||
|
||||
def get_writer(writer_map, name, factory, is_last):
|
||||
"""
|
||||
|
||||
@@ -2,6 +2,7 @@ import csv
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from collections.abc import MutableMapping
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC, datetime
|
||||
from io import StringIO
|
||||
@@ -15,13 +16,16 @@ from api.models import (
|
||||
MuteRule,
|
||||
Provider,
|
||||
Resource,
|
||||
ResourceFindingMapping,
|
||||
ResourceScanSummary,
|
||||
ResourceTag,
|
||||
ResourceTagMapping,
|
||||
Scan,
|
||||
ScanSummary,
|
||||
StateChoices,
|
||||
StatusChoices,
|
||||
)
|
||||
from django.db import IntegrityError, OperationalError
|
||||
from django.db import IntegrityError, OperationalError, transaction
|
||||
from prowler.lib.check.models import Severity
|
||||
from prowler.lib.outputs.finding import Status
|
||||
from tasks.jobs.scan import (
|
||||
@@ -53,6 +57,12 @@ def noop_rls_transaction(*args, **kwargs):
|
||||
yield
|
||||
|
||||
|
||||
@contextmanager
|
||||
def atomic_rls_transaction(*args, **kwargs):
|
||||
with transaction.atomic():
|
||||
yield
|
||||
|
||||
|
||||
class FakeFinding:
|
||||
def __init__(self, **attrs):
|
||||
self.metadata = attrs.pop("metadata", {})
|
||||
@@ -71,6 +81,32 @@ class FakeFinding:
|
||||
return self.metadata
|
||||
|
||||
|
||||
class CacheMissAfterPreResolve(MutableMapping):
|
||||
def __init__(self, missing_uid):
|
||||
self._cache = {}
|
||||
self.missing_uid = missing_uid
|
||||
|
||||
def __contains__(self, key):
|
||||
if key == self.missing_uid:
|
||||
return True
|
||||
return key in self._cache
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self._cache[key]
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
self._cache[key] = value
|
||||
|
||||
def __delitem__(self, key):
|
||||
del self._cache[key]
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._cache)
|
||||
|
||||
def __len__(self):
|
||||
return len(self._cache)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestPerformScan:
|
||||
def test_perform_prowler_scan_success(
|
||||
@@ -1055,8 +1091,12 @@ class TestPerformScan:
|
||||
perform_prowler_scan(tenant_id, scan_id, provider_id, [])
|
||||
|
||||
# Verify findings are muted with correct reason
|
||||
fail_finding_db = Finding.objects.get(uid=finding_uid_1)
|
||||
pass_finding_db = Finding.objects.get(uid=finding_uid_2)
|
||||
fail_finding_db = Finding.objects.get(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding_uid_1
|
||||
)
|
||||
pass_finding_db = Finding.objects.get(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding_uid_2
|
||||
)
|
||||
|
||||
assert fail_finding_db.muted
|
||||
assert fail_finding_db.muted_reason == mute_rule_reason
|
||||
@@ -1067,7 +1107,9 @@ class TestPerformScan:
|
||||
assert pass_finding_db.muted_at is not None
|
||||
|
||||
# Verify failed_findings_count is 0 for muted FAIL finding
|
||||
resource_1 = Resource.objects.get(uid="resource_uid_1")
|
||||
resource_1 = Resource.objects.get(
|
||||
tenant_id=tenant.id, provider_id=provider.id, uid="resource_uid_1"
|
||||
)
|
||||
assert resource_1.failed_findings_count == 0
|
||||
|
||||
def test_perform_prowler_scan_with_inactive_mute_rules(
|
||||
@@ -1147,13 +1189,17 @@ class TestPerformScan:
|
||||
perform_prowler_scan(tenant_id, scan_id, provider_id, [])
|
||||
|
||||
# Verify finding is NOT muted
|
||||
finding_db = Finding.objects.get(uid=finding_uid)
|
||||
finding_db = Finding.objects.get(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding_uid
|
||||
)
|
||||
assert not finding_db.muted
|
||||
assert finding_db.muted_reason is None
|
||||
assert finding_db.muted_at is None
|
||||
|
||||
# Verify failed_findings_count increments for FAIL finding
|
||||
resource = Resource.objects.get(uid="resource_uid_inactive")
|
||||
resource = Resource.objects.get(
|
||||
tenant_id=tenant.id, provider_id=provider.id, uid="resource_uid_inactive"
|
||||
)
|
||||
assert resource.failed_findings_count == 1
|
||||
|
||||
def test_perform_prowler_scan_mutelist_overrides_mute_rules(
|
||||
@@ -1233,13 +1279,17 @@ class TestPerformScan:
|
||||
perform_prowler_scan(tenant_id, scan_id, provider_id, [])
|
||||
|
||||
# Verify mutelist reason takes precedence
|
||||
finding_db = Finding.objects.get(uid=finding_uid)
|
||||
finding_db = Finding.objects.get(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding_uid
|
||||
)
|
||||
assert finding_db.muted
|
||||
assert finding_db.muted_reason == "Muted by mutelist"
|
||||
assert finding_db.muted_at is not None
|
||||
|
||||
# Verify failed_findings_count is 0
|
||||
resource = Resource.objects.get(uid="resource_both")
|
||||
resource = Resource.objects.get(
|
||||
tenant_id=tenant.id, provider_id=provider.id, uid="resource_both"
|
||||
)
|
||||
assert resource.failed_findings_count == 0
|
||||
|
||||
def test_perform_prowler_scan_mute_rules_multiple_findings(
|
||||
@@ -1331,14 +1381,20 @@ class TestPerformScan:
|
||||
|
||||
# Verify all findings are muted with same reason
|
||||
for uid in finding_uids:
|
||||
finding_db = Finding.objects.get(uid=uid)
|
||||
finding_db = Finding.objects.get(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=uid
|
||||
)
|
||||
assert finding_db.muted
|
||||
assert finding_db.muted_reason == mute_rule_reason
|
||||
assert finding_db.muted_at is not None
|
||||
|
||||
# Verify all resources have failed_findings_count = 0
|
||||
for i in range(len(finding_uids)):
|
||||
resource = Resource.objects.get(uid=f"resource_bulk_{i}")
|
||||
resource = Resource.objects.get(
|
||||
tenant_id=tenant.id,
|
||||
provider_id=provider.id,
|
||||
uid=f"resource_bulk_{i}",
|
||||
)
|
||||
assert resource.failed_findings_count == 0
|
||||
|
||||
def test_perform_prowler_scan_mute_rules_error_handling(
|
||||
@@ -1416,12 +1472,18 @@ class TestPerformScan:
|
||||
assert scan.state == StateChoices.COMPLETED
|
||||
|
||||
# Verify finding is not muted (mute_rules_cache was empty dict)
|
||||
finding_db = Finding.objects.get(uid="finding_error_handling")
|
||||
finding_db = Finding.objects.get(
|
||||
tenant_id=tenant.id,
|
||||
scan_id=scan.id,
|
||||
uid="finding_error_handling",
|
||||
)
|
||||
assert not finding_db.muted
|
||||
assert finding_db.muted_reason is None
|
||||
|
||||
# Verify failed_findings_count increments
|
||||
resource = Resource.objects.get(uid="resource_error")
|
||||
resource = Resource.objects.get(
|
||||
tenant_id=tenant.id, provider_id=provider.id, uid="resource_error"
|
||||
)
|
||||
assert resource.failed_findings_count == 1
|
||||
|
||||
def test_perform_prowler_scan_muted_at_timestamp(
|
||||
@@ -1503,7 +1565,9 @@ class TestPerformScan:
|
||||
after_scan = datetime.now(UTC)
|
||||
|
||||
# Verify muted_at is within the scan time window
|
||||
finding_db = Finding.objects.get(uid=finding_uid)
|
||||
finding_db = Finding.objects.get(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding_uid
|
||||
)
|
||||
assert finding_db.muted
|
||||
assert finding_db.muted_at is not None
|
||||
assert before_scan <= finding_db.muted_at <= after_scan
|
||||
@@ -1514,6 +1578,548 @@ class TestPerformScan:
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestProcessFindingMicroBatch:
|
||||
def _process_one_finding_micro_batch(
|
||||
self,
|
||||
tenant,
|
||||
scan,
|
||||
provider,
|
||||
finding,
|
||||
resource_cache=None,
|
||||
resource_failed_findings_cache=None,
|
||||
):
|
||||
resource_cache = resource_cache if resource_cache is not None else {}
|
||||
resource_failed_findings_cache = (
|
||||
resource_failed_findings_cache
|
||||
if resource_failed_findings_cache is not None
|
||||
else {}
|
||||
)
|
||||
caches = {
|
||||
"resource_cache": resource_cache,
|
||||
"tag_cache": {},
|
||||
"last_status_cache": {},
|
||||
"resource_failed_findings_cache": resource_failed_findings_cache,
|
||||
"unique_resources": set(),
|
||||
"scan_resource_cache": set(),
|
||||
"mute_rules_cache": {},
|
||||
"scan_categories_cache": {},
|
||||
"scan_resource_groups_cache": {},
|
||||
"group_resources_cache": {},
|
||||
}
|
||||
|
||||
with (
|
||||
patch("tasks.jobs.scan.rls_transaction", new=noop_rls_transaction),
|
||||
patch("api.db_utils.rls_transaction", new=noop_rls_transaction),
|
||||
):
|
||||
_process_finding_micro_batch(
|
||||
str(tenant.id),
|
||||
[finding],
|
||||
scan,
|
||||
provider,
|
||||
caches["resource_cache"],
|
||||
caches["tag_cache"],
|
||||
caches["last_status_cache"],
|
||||
caches["resource_failed_findings_cache"],
|
||||
caches["unique_resources"],
|
||||
caches["scan_resource_cache"],
|
||||
caches["mute_rules_cache"],
|
||||
caches["scan_categories_cache"],
|
||||
caches["scan_resource_groups_cache"],
|
||||
caches["group_resources_cache"],
|
||||
)
|
||||
|
||||
return caches
|
||||
|
||||
def test_process_finding_micro_batch_fallback_creates_resource_after_cache_miss(
|
||||
self, tenants_fixture, scans_fixture
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
provider = scan.provider
|
||||
resource_uid = "arn:aws:accessanalyzer:us-east-1:123456789012:analyzer/unknown"
|
||||
|
||||
finding = FakeFinding(
|
||||
uid="finding-cache-miss-create",
|
||||
status=StatusChoices.FAIL,
|
||||
status_extended="missing analyzer",
|
||||
severity=Severity.medium,
|
||||
check_id="accessanalyzer_enabled",
|
||||
resource_uid=resource_uid,
|
||||
resource_name="analyzer/unknown",
|
||||
region="us-east-1",
|
||||
service_name="accessanalyzer",
|
||||
resource_type="analyzer",
|
||||
resource_tags={},
|
||||
resource_metadata={},
|
||||
resource_details={},
|
||||
partition="aws",
|
||||
raw={},
|
||||
compliance={},
|
||||
metadata={"resourcegroup": "identity"},
|
||||
muted=False,
|
||||
)
|
||||
|
||||
caches = self._process_one_finding_micro_batch(
|
||||
tenant,
|
||||
scan,
|
||||
provider,
|
||||
finding,
|
||||
resource_cache=CacheMissAfterPreResolve(resource_uid),
|
||||
)
|
||||
|
||||
resource = Resource.objects.get(
|
||||
tenant_id=tenant.id, provider_id=provider.id, uid=resource_uid
|
||||
)
|
||||
created_finding = Finding.objects.get(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
|
||||
)
|
||||
|
||||
assert created_finding.scan_id == scan.id
|
||||
assert resource.provider_id == provider.id
|
||||
assert resource.region == finding.region
|
||||
assert resource.service == finding.service_name
|
||||
assert resource.type == finding.resource_type
|
||||
assert resource.name == finding.resource_name
|
||||
assert resource.groups == ["identity"]
|
||||
assert resource.findings.filter(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
|
||||
).exists()
|
||||
assert caches["resource_cache"][resource_uid].id == resource.id
|
||||
assert caches["resource_failed_findings_cache"][resource_uid] == 1
|
||||
|
||||
def test_process_finding_micro_batch_fallback_recovers_existing_resource_after_cache_miss(
|
||||
self, tenants_fixture, scans_fixture
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
provider = scan.provider
|
||||
resource_uid = "arn:aws:guardduty:us-east-1:123456789012:detector/unknown"
|
||||
existing_resource = Resource.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
provider=provider,
|
||||
uid=resource_uid,
|
||||
name="detector/unknown",
|
||||
region="us-east-1",
|
||||
service="guardduty",
|
||||
type="detector",
|
||||
)
|
||||
|
||||
finding = FakeFinding(
|
||||
uid="finding-cache-miss-existing",
|
||||
status=StatusChoices.FAIL,
|
||||
status_extended="missing detector",
|
||||
severity=Severity.high,
|
||||
check_id="guardduty_enabled",
|
||||
resource_uid=resource_uid,
|
||||
resource_name=existing_resource.name,
|
||||
region=existing_resource.region,
|
||||
service_name=existing_resource.service,
|
||||
resource_type=existing_resource.type,
|
||||
resource_tags={},
|
||||
resource_metadata={},
|
||||
resource_details={},
|
||||
partition="aws",
|
||||
raw={},
|
||||
compliance={},
|
||||
metadata={},
|
||||
muted=False,
|
||||
)
|
||||
|
||||
caches = self._process_one_finding_micro_batch(
|
||||
tenant,
|
||||
scan,
|
||||
provider,
|
||||
finding,
|
||||
resource_cache=CacheMissAfterPreResolve(resource_uid),
|
||||
)
|
||||
|
||||
assert (
|
||||
Resource.objects.filter(
|
||||
tenant_id=tenant.id, provider_id=provider.id, uid=resource_uid
|
||||
).count()
|
||||
== 1
|
||||
)
|
||||
created_finding = Finding.objects.get(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
|
||||
)
|
||||
existing_resource.refresh_from_db()
|
||||
|
||||
assert created_finding.scan_id == scan.id
|
||||
assert existing_resource.findings.filter(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
|
||||
).exists()
|
||||
assert caches["resource_cache"][resource_uid].id == existing_resource.id
|
||||
assert caches["resource_failed_findings_cache"][resource_uid] == 1
|
||||
|
||||
def test_process_finding_micro_batch_fallback_recovers_after_create_race(
|
||||
self, tenants_fixture, scans_fixture
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
provider = scan.provider
|
||||
resource_uid = "arn:aws:securityhub:us-east-1:123456789012:hub/unknown"
|
||||
raced_resource = Resource.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
provider=provider,
|
||||
uid=resource_uid,
|
||||
name="hub/unknown",
|
||||
region="us-east-1",
|
||||
service="securityhub",
|
||||
type="hub",
|
||||
)
|
||||
|
||||
finding = FakeFinding(
|
||||
uid="finding-cache-miss-failure",
|
||||
status=StatusChoices.FAIL,
|
||||
status_extended="missing hub",
|
||||
severity=Severity.high,
|
||||
check_id="securityhub_enabled",
|
||||
resource_uid=resource_uid,
|
||||
resource_name="hub/unknown",
|
||||
region="us-east-1",
|
||||
service_name="securityhub",
|
||||
resource_type="hub",
|
||||
resource_tags={},
|
||||
resource_metadata={},
|
||||
resource_details={},
|
||||
partition="aws",
|
||||
raw={},
|
||||
compliance={},
|
||||
metadata={},
|
||||
muted=False,
|
||||
)
|
||||
|
||||
resource_filter_result = MagicMock()
|
||||
resource_filter_result.first.side_effect = [None, raced_resource]
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
Resource.objects,
|
||||
"filter",
|
||||
return_value=resource_filter_result,
|
||||
),
|
||||
patch.object(
|
||||
Resource.objects,
|
||||
"create",
|
||||
side_effect=IntegrityError("duplicate resource"),
|
||||
),
|
||||
):
|
||||
caches = self._process_one_finding_micro_batch(
|
||||
tenant,
|
||||
scan,
|
||||
provider,
|
||||
finding,
|
||||
resource_cache=CacheMissAfterPreResolve(resource_uid),
|
||||
)
|
||||
|
||||
assert (
|
||||
Resource.objects.filter(
|
||||
tenant_id=tenant.id, provider_id=provider.id, uid=resource_uid
|
||||
).count()
|
||||
== 1
|
||||
)
|
||||
created_finding = Finding.objects.get(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
|
||||
)
|
||||
raced_resource.refresh_from_db()
|
||||
|
||||
assert created_finding.scan_id == scan.id
|
||||
assert raced_resource.findings.filter(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
|
||||
).exists()
|
||||
assert caches["resource_cache"][resource_uid].id == raced_resource.id
|
||||
assert caches["resource_failed_findings_cache"][resource_uid] == 1
|
||||
|
||||
def test_process_finding_micro_batch_cache_miss_retry_drops_rolled_back_resource(
|
||||
self, tenants_fixture, scans_fixture
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
provider = scan.provider
|
||||
resource_uid = "generic-resource-cache-miss-retry"
|
||||
cached_resource = Resource.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
provider=provider,
|
||||
uid="generic-cached-resource-retry",
|
||||
name="old-cached-resource",
|
||||
region="us-west-2",
|
||||
service="old-service",
|
||||
type="old-type",
|
||||
)
|
||||
finding = FakeFinding(
|
||||
uid="finding-cache-miss-retry-clean-resource-cache",
|
||||
status=StatusChoices.FAIL,
|
||||
status_extended="missing resource",
|
||||
severity=Severity.high,
|
||||
check_id="generic_resource_check",
|
||||
resource_uid=resource_uid,
|
||||
resource_name="generic-resource",
|
||||
region="us-east-1",
|
||||
service_name="generic-service",
|
||||
resource_type="generic-type",
|
||||
resource_tags={"team": "platform"},
|
||||
resource_metadata={"owner": "security"},
|
||||
resource_details={"id": "generic-resource"},
|
||||
partition="aws",
|
||||
raw={},
|
||||
compliance={},
|
||||
metadata={"categories": ["security"], "resourcegroup": "identity"},
|
||||
muted=False,
|
||||
)
|
||||
cached_resource_finding = FakeFinding(
|
||||
uid="finding-cache-miss-retry-restores-dirty-resource",
|
||||
status=StatusChoices.FAIL,
|
||||
status_extended="cached resource changed",
|
||||
severity=Severity.high,
|
||||
check_id="generic_cached_resource_check",
|
||||
resource_uid=cached_resource.uid,
|
||||
resource_name="new-cached-resource",
|
||||
region="eu-west-1",
|
||||
service_name="new-service",
|
||||
resource_type="new-type",
|
||||
resource_tags={},
|
||||
resource_metadata={"owner": "platform"},
|
||||
resource_details={"id": "cached-resource"},
|
||||
partition="aws",
|
||||
raw={},
|
||||
compliance={},
|
||||
metadata={"categories": ["security"], "resourcegroup": "identity"},
|
||||
muted=False,
|
||||
)
|
||||
resource_cache = CacheMissAfterPreResolve(resource_uid)
|
||||
resource_cache[cached_resource.uid] = cached_resource
|
||||
tag_cache = {}
|
||||
resource_failed_findings_cache = {cached_resource.uid: 0}
|
||||
scan_resource_cache: set[tuple[str, str, str, str]] = set()
|
||||
scan_categories_cache: dict[tuple[str, str], dict[str, int]] = {}
|
||||
scan_resource_groups_cache: dict[tuple[str, str], dict[str, int]] = {}
|
||||
group_resources_cache: dict[str, set] = {}
|
||||
original_bulk_create = ResourceFindingMapping.objects.bulk_create
|
||||
original_tag_mapping_bulk_create = ResourceTagMapping.objects.bulk_create
|
||||
mapping_bulk_create_calls = []
|
||||
tag_mapping_bulk_create_calls = []
|
||||
|
||||
def fail_once_then_bulk_create(objects, *args, **kwargs):
|
||||
mapping_bulk_create_calls.append([str(obj.resource_id) for obj in objects])
|
||||
if len(mapping_bulk_create_calls) == 1:
|
||||
raise IntegrityError("rollback after fallback resource creation")
|
||||
return original_bulk_create(objects, *args, **kwargs)
|
||||
|
||||
def track_tag_mappings_bulk_create(objects, *args, **kwargs):
|
||||
tag_mapping_bulk_create_calls.append([str(obj.tag_id) for obj in objects])
|
||||
return original_tag_mapping_bulk_create(objects, *args, **kwargs)
|
||||
|
||||
with (
|
||||
patch("tasks.jobs.scan.CELERY_DEADLOCK_ATTEMPTS", 2),
|
||||
patch("tasks.jobs.scan.rls_transaction", new=atomic_rls_transaction),
|
||||
patch("api.db_utils.rls_transaction", new=atomic_rls_transaction),
|
||||
patch.object(
|
||||
ResourceTagMapping.objects,
|
||||
"bulk_create",
|
||||
side_effect=track_tag_mappings_bulk_create,
|
||||
),
|
||||
patch.object(
|
||||
ResourceFindingMapping.objects,
|
||||
"bulk_create",
|
||||
side_effect=fail_once_then_bulk_create,
|
||||
),
|
||||
):
|
||||
_process_finding_micro_batch(
|
||||
str(tenant.id),
|
||||
[finding, cached_resource_finding],
|
||||
scan,
|
||||
provider,
|
||||
resource_cache,
|
||||
tag_cache,
|
||||
{},
|
||||
resource_failed_findings_cache,
|
||||
set(),
|
||||
scan_resource_cache,
|
||||
{},
|
||||
scan_categories_cache,
|
||||
scan_resource_groups_cache,
|
||||
group_resources_cache,
|
||||
)
|
||||
|
||||
resource = Resource.objects.get(
|
||||
tenant_id=tenant.id,
|
||||
provider_id=provider.id,
|
||||
uid=resource_uid,
|
||||
)
|
||||
created_finding = Finding.objects.get(
|
||||
tenant_id=tenant.id,
|
||||
scan_id=scan.id,
|
||||
uid=finding.uid,
|
||||
)
|
||||
cached_resource.refresh_from_db()
|
||||
|
||||
assert len(mapping_bulk_create_calls) == 2
|
||||
assert mapping_bulk_create_calls[0] != mapping_bulk_create_calls[1]
|
||||
assert len(tag_mapping_bulk_create_calls) == 2
|
||||
assert tag_mapping_bulk_create_calls[0] != tag_mapping_bulk_create_calls[1]
|
||||
assert created_finding.scan_id == scan.id
|
||||
assert resource.findings.filter(
|
||||
tenant_id=tenant.id,
|
||||
scan_id=scan.id,
|
||||
uid=finding.uid,
|
||||
).exists()
|
||||
assert cached_resource.findings.filter(
|
||||
tenant_id=tenant.id,
|
||||
scan_id=scan.id,
|
||||
uid=cached_resource_finding.uid,
|
||||
).exists()
|
||||
assert cached_resource.name == cached_resource_finding.resource_name
|
||||
assert cached_resource.region == cached_resource_finding.region
|
||||
assert cached_resource.service == cached_resource_finding.service_name
|
||||
assert cached_resource.type == cached_resource_finding.resource_type
|
||||
assert resource_cache[resource_uid].id == resource.id
|
||||
assert resource_failed_findings_cache[resource_uid] == 1
|
||||
assert resource_failed_findings_cache[cached_resource.uid] == 1
|
||||
assert scan_resource_cache == {
|
||||
(
|
||||
str(resource.id),
|
||||
finding.service_name,
|
||||
finding.region,
|
||||
finding.resource_type,
|
||||
),
|
||||
(
|
||||
str(cached_resource.id),
|
||||
cached_resource_finding.service_name,
|
||||
cached_resource_finding.region,
|
||||
cached_resource_finding.resource_type,
|
||||
),
|
||||
}
|
||||
assert (
|
||||
tag_cache[("team", "platform")].id
|
||||
== ResourceTag.objects.get(
|
||||
tenant_id=tenant.id,
|
||||
key="team",
|
||||
value="platform",
|
||||
).id
|
||||
)
|
||||
assert scan_categories_cache == {
|
||||
("security", "high"): {"total": 2, "failed": 2, "new_failed": 2}
|
||||
}
|
||||
assert scan_resource_groups_cache == {
|
||||
("identity", "high"): {"total": 2, "failed": 2, "new_failed": 2}
|
||||
}
|
||||
assert group_resources_cache == {
|
||||
"identity": {resource_uid, cached_resource.uid}
|
||||
}
|
||||
|
||||
def test_process_finding_micro_batch_propagates_retryable_cache_miss_db_errors(
|
||||
self, tenants_fixture, scans_fixture
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
provider = scan.provider
|
||||
resource_uid = "arn:aws:securityhub:us-east-1:123456789012:hub/retryable"
|
||||
|
||||
finding = FakeFinding(
|
||||
uid="finding-cache-miss-retryable-error",
|
||||
status=StatusChoices.FAIL,
|
||||
status_extended="missing hub",
|
||||
severity=Severity.high,
|
||||
check_id="securityhub_enabled",
|
||||
resource_uid=resource_uid,
|
||||
resource_name="hub/retryable",
|
||||
region="us-east-1",
|
||||
service_name="securityhub",
|
||||
resource_type="hub",
|
||||
resource_tags={},
|
||||
resource_metadata={},
|
||||
resource_details={},
|
||||
partition="aws",
|
||||
raw={},
|
||||
compliance={},
|
||||
metadata={},
|
||||
muted=False,
|
||||
)
|
||||
|
||||
with (
|
||||
patch("tasks.jobs.scan.CELERY_DEADLOCK_ATTEMPTS", 1),
|
||||
patch.object(
|
||||
Resource.objects,
|
||||
"create",
|
||||
side_effect=OperationalError("deadlock detected"),
|
||||
),
|
||||
):
|
||||
with pytest.raises(OperationalError, match="deadlock detected"):
|
||||
self._process_one_finding_micro_batch(
|
||||
tenant,
|
||||
scan,
|
||||
provider,
|
||||
finding,
|
||||
resource_cache=CacheMissAfterPreResolve(resource_uid),
|
||||
)
|
||||
|
||||
assert not Finding.objects.filter(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
|
||||
).exists()
|
||||
|
||||
def test_process_finding_micro_batch_propagates_unrecovered_cache_miss_integrity_error(
|
||||
self, tenants_fixture, scans_fixture
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
provider = scan.provider
|
||||
resource_uid = "arn:aws:securityhub:us-east-1:123456789012:hub/unrecovered"
|
||||
|
||||
finding = FakeFinding(
|
||||
uid="finding-cache-miss-unrecovered-integrity-error",
|
||||
status=StatusChoices.FAIL,
|
||||
status_extended="missing hub",
|
||||
severity=Severity.high,
|
||||
check_id="securityhub_enabled",
|
||||
resource_uid=resource_uid,
|
||||
resource_name="hub/unrecovered",
|
||||
region="us-east-1",
|
||||
service_name="securityhub",
|
||||
resource_type="hub",
|
||||
resource_tags={},
|
||||
resource_metadata={},
|
||||
resource_details={},
|
||||
partition="aws",
|
||||
raw={},
|
||||
compliance={},
|
||||
metadata={},
|
||||
muted=False,
|
||||
)
|
||||
|
||||
original_resource_filter = Resource.objects.filter
|
||||
resource_filter_result = MagicMock()
|
||||
resource_filter_result.first.side_effect = [None, None]
|
||||
|
||||
def resource_filter_side_effect(*args, **kwargs):
|
||||
if kwargs.get("uid") == resource_uid:
|
||||
return resource_filter_result
|
||||
return original_resource_filter(*args, **kwargs)
|
||||
|
||||
with (
|
||||
patch("tasks.jobs.scan.CELERY_DEADLOCK_ATTEMPTS", 1),
|
||||
patch.object(
|
||||
Resource.objects,
|
||||
"filter",
|
||||
side_effect=resource_filter_side_effect,
|
||||
),
|
||||
patch.object(
|
||||
Resource.objects,
|
||||
"create",
|
||||
side_effect=IntegrityError("constraint violation"),
|
||||
),
|
||||
):
|
||||
with pytest.raises(IntegrityError, match="constraint violation"):
|
||||
self._process_one_finding_micro_batch(
|
||||
tenant,
|
||||
scan,
|
||||
provider,
|
||||
finding,
|
||||
resource_cache=CacheMissAfterPreResolve(resource_uid),
|
||||
)
|
||||
|
||||
assert not Finding.objects.filter(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
|
||||
).exists()
|
||||
|
||||
def test_process_finding_micro_batch_creates_records_and_updates_caches(
|
||||
self, tenants_fixture, scans_fixture
|
||||
):
|
||||
@@ -1574,8 +2180,12 @@ class TestProcessFindingMicroBatch:
|
||||
group_resources_cache,
|
||||
)
|
||||
|
||||
created_finding = Finding.objects.get(uid=finding.uid)
|
||||
resource = Resource.objects.get(uid=finding.resource_uid)
|
||||
created_finding = Finding.objects.get(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
|
||||
)
|
||||
resource = Resource.objects.get(
|
||||
tenant_id=tenant.id, provider_id=provider.id, uid=finding.resource_uid
|
||||
)
|
||||
|
||||
assert created_finding.scan_id == scan.id
|
||||
assert created_finding.status == StatusChoices.PASS
|
||||
@@ -1603,7 +2213,9 @@ class TestProcessFindingMicroBatch:
|
||||
assert set(resource.tags.values_list("key", "value")) == set(
|
||||
finding.resource_tags.items()
|
||||
)
|
||||
assert resource.findings.filter(uid=finding.uid).exists()
|
||||
assert resource.findings.filter(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
|
||||
).exists()
|
||||
|
||||
assert resource_cache[finding.resource_uid].id == resource.id
|
||||
assert resource_failed_findings_cache[finding.resource_uid] == 0
|
||||
@@ -1692,7 +2304,9 @@ class TestProcessFindingMicroBatch:
|
||||
)
|
||||
|
||||
existing_resource.refresh_from_db()
|
||||
created_finding = Finding.objects.get(uid=finding.uid)
|
||||
created_finding = Finding.objects.get(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
|
||||
)
|
||||
|
||||
assert created_finding.delta == Finding.DeltaChoices.CHANGED
|
||||
assert created_finding.status == StatusChoices.FAIL
|
||||
@@ -1726,7 +2340,9 @@ class TestProcessFindingMicroBatch:
|
||||
assert set(existing_resource.tags.values_list("key", "value")) == {
|
||||
("team", "devsec")
|
||||
}
|
||||
assert existing_resource.findings.filter(uid=finding.uid).exists()
|
||||
assert existing_resource.findings.filter(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
|
||||
).exists()
|
||||
|
||||
assert resource_cache[finding.resource_uid].region == finding.region
|
||||
assert resource_cache[finding.resource_uid].service == finding.service_name
|
||||
@@ -1892,10 +2508,14 @@ class TestProcessFindingMicroBatch:
|
||||
)
|
||||
|
||||
# Verify the long UID finding was NOT created
|
||||
assert not Finding.objects.filter(uid=long_uid).exists()
|
||||
assert not Finding.objects.filter(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=long_uid
|
||||
).exists()
|
||||
|
||||
# Verify the normal finding WAS created
|
||||
assert Finding.objects.filter(uid=normal_finding.uid).exists()
|
||||
assert Finding.objects.filter(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=normal_finding.uid
|
||||
).exists()
|
||||
|
||||
# Verify logging was called for skipped finding
|
||||
assert mock_logger.warning.called
|
||||
@@ -2020,8 +2640,12 @@ class TestProcessFindingMicroBatch:
|
||||
"new_failed": 1,
|
||||
}
|
||||
|
||||
created_finding1 = Finding.objects.get(uid="finding-cat-1")
|
||||
created_finding2 = Finding.objects.get(uid="finding-cat-2")
|
||||
created_finding1 = Finding.objects.get(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid="finding-cat-1"
|
||||
)
|
||||
created_finding2 = Finding.objects.get(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid="finding-cat-2"
|
||||
)
|
||||
assert set(created_finding1.categories) == {"gen-ai", "security"}
|
||||
assert set(created_finding2.categories) == {"security", "iam"}
|
||||
|
||||
|
||||
@@ -420,6 +420,124 @@ class TestGenerateOutputs:
|
||||
assert result == {"upload": False}
|
||||
mock_scan_update.return_value.update.assert_called_once()
|
||||
|
||||
def test_generate_outputs_removes_previous_run_artifacts(self):
|
||||
"""Regression for PROWLER-2266.
|
||||
|
||||
Output writers open files in append mode with a deterministic path
|
||||
(derived from scan.started_at). If this task runs again for the same
|
||||
scan (e.g. broker redelivery after a worker is killed mid-run with
|
||||
task_acks_late), reusing the leftover files appends every finding row
|
||||
again, duplicating rows in the CSV/output while the API console keeps
|
||||
showing a single finding. The task must start from a clean slate by
|
||||
removing the scan's tmp output directory before (re)generating.
|
||||
"""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_root:
|
||||
# Simulate artifacts left behind by a previous run of the same scan.
|
||||
scan_tmp_dir = Path(tmp_root) / self.tenant_id / self.scan_id
|
||||
scan_tmp_dir.mkdir(parents=True)
|
||||
stale_artifact = scan_tmp_dir / "prowler-output-aws-20260723120000.csv"
|
||||
stale_artifact.write_text("HEADER\nold-finding-row\n")
|
||||
|
||||
with (
|
||||
patch("tasks.tasks.DJANGO_TMP_OUTPUT_DIRECTORY", tmp_root),
|
||||
patch("tasks.tasks.ScanSummary.objects.filter") as mock_filter,
|
||||
patch("tasks.tasks.Provider.objects.get"),
|
||||
patch("tasks.tasks.initialize_prowler_provider"),
|
||||
patch("tasks.tasks.Compliance.get_bulk"),
|
||||
patch("tasks.tasks.get_compliance_frameworks"),
|
||||
patch("tasks.tasks.get_prowler_provider_compliance", return_value={}),
|
||||
patch("tasks.tasks.Finding.all_objects.filter") as mock_findings,
|
||||
patch(
|
||||
"tasks.tasks._generate_output_directory",
|
||||
return_value=("/tmp/test/out", "/tmp/test/comp"),
|
||||
),
|
||||
patch("tasks.tasks.FindingOutput._transform_findings_stats"),
|
||||
patch("tasks.tasks.FindingOutput.transform_api_finding"),
|
||||
patch(
|
||||
"tasks.tasks.OUTPUT_FORMATS_MAPPING",
|
||||
{
|
||||
"json": {
|
||||
"class": MagicMock(name="Writer"),
|
||||
"suffix": ".json",
|
||||
"kwargs": {},
|
||||
}
|
||||
},
|
||||
),
|
||||
patch("tasks.tasks.COMPLIANCE_CLASS_MAP", {"aws": []}),
|
||||
patch(
|
||||
"tasks.tasks._compress_output_files", return_value="/tmp/compressed"
|
||||
),
|
||||
patch("tasks.tasks._upload_to_s3", return_value=None),
|
||||
patch("tasks.tasks.Scan.all_objects.filter"),
|
||||
):
|
||||
mock_filter.return_value.exists.return_value = True
|
||||
mock_findings.return_value.order_by.return_value.iterator.return_value = [
|
||||
[MagicMock()],
|
||||
True,
|
||||
]
|
||||
|
||||
generate_outputs_task(
|
||||
scan_id=self.scan_id,
|
||||
provider_id=self.provider_id,
|
||||
tenant_id=self.tenant_id,
|
||||
)
|
||||
|
||||
# The stale artifacts from the previous run must be gone, so the
|
||||
# append-mode writers cannot duplicate rows onto them.
|
||||
assert not stale_artifact.exists()
|
||||
assert not scan_tmp_dir.exists()
|
||||
|
||||
def test_generate_outputs_aborts_when_stale_cleanup_fails(self):
|
||||
"""Regression for PROWLER-2266.
|
||||
|
||||
If the stale output directory cannot be removed (e.g. permission error),
|
||||
the leftover files would be reopened in append mode and every finding
|
||||
row would be duplicated. The task must abort instead of continuing and
|
||||
publishing duplicated rows, so the retry can start from a clean slate.
|
||||
"""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_root:
|
||||
scan_tmp_dir = Path(tmp_root) / self.tenant_id / self.scan_id
|
||||
scan_tmp_dir.mkdir(parents=True)
|
||||
stale_artifact = scan_tmp_dir / "prowler-output-aws-20260723120000.csv"
|
||||
stale_artifact.write_text("HEADER\nold-finding-row\n")
|
||||
|
||||
with (
|
||||
patch("tasks.tasks.DJANGO_TMP_OUTPUT_DIRECTORY", tmp_root),
|
||||
patch("tasks.tasks.ScanSummary.objects.filter") as mock_filter,
|
||||
patch("tasks.tasks.Provider.objects.get"),
|
||||
patch("tasks.tasks.initialize_prowler_provider"),
|
||||
patch("tasks.tasks.Compliance.get_bulk"),
|
||||
patch("tasks.tasks.get_compliance_frameworks"),
|
||||
patch("tasks.tasks.get_prowler_provider_compliance", return_value={}),
|
||||
# `rmtree(ignore_errors=True)` swallows the failure and leaves the
|
||||
# directory behind; simulate that with a no-op so the guard fires.
|
||||
patch("tasks.tasks.rmtree"),
|
||||
patch("tasks.tasks._generate_output_directory") as mock_gen_dir,
|
||||
patch("tasks.tasks._compress_output_files") as mock_compress,
|
||||
patch("tasks.tasks._upload_to_s3") as mock_upload,
|
||||
patch("tasks.tasks.Scan.all_objects.filter") as mock_scan_update,
|
||||
):
|
||||
mock_filter.return_value.exists.return_value = True
|
||||
|
||||
with pytest.raises(RuntimeError, match="stale output directory"):
|
||||
generate_outputs_task(
|
||||
scan_id=self.scan_id,
|
||||
provider_id=self.provider_id,
|
||||
tenant_id=self.tenant_id,
|
||||
)
|
||||
|
||||
# The task must abort before generating/publishing any output.
|
||||
mock_gen_dir.assert_not_called()
|
||||
mock_compress.assert_not_called()
|
||||
mock_upload.assert_not_called()
|
||||
mock_scan_update.assert_not_called()
|
||||
|
||||
def test_generate_outputs_triggers_html_extra_update(self):
|
||||
mock_finding_output = MagicMock()
|
||||
mock_finding_output.compliance = {"cis": ["requirement-1", "requirement-2"]}
|
||||
|
||||
@@ -4762,7 +4762,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler-api"
|
||||
version = "1.37.0"
|
||||
version = "1.38.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "cartography" },
|
||||
|
||||
@@ -189,6 +189,11 @@ api:
|
||||
DJANGO_STALE_WHILE_REVALIDATE: "60"
|
||||
DJANGO_MANAGE_DB_PARTITIONS: "True"
|
||||
DJANGO_BROKER_VISIBILITY_TIMEOUT: "86400"
|
||||
# Caps the Celery prefork pool size on the worker pods. Without it, Celery
|
||||
# sizes the pool from the number of visible CPUs, so on large nodes the
|
||||
# worker spawns one child per CPU, each loading the full Prowler SDK, and
|
||||
# OOMKills under memory pressure. Raise it on bigger workers.
|
||||
DJANGO_CELERY_WORKER_CONCURRENCY: "2"
|
||||
|
||||
# Secret names to be used as env vars for api, worker, and worker_beat.
|
||||
secrets: []
|
||||
|
||||
@@ -0,0 +1,652 @@
|
||||
---
|
||||
title: "Changelog"
|
||||
description: "New features and improvements in each Prowler release"
|
||||
rss: true
|
||||
---
|
||||
|
||||
<Update label="v5.36.0" description="July 24, 2026">
|
||||
### 🎫 Finding Groups - Jira
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
Selected Findings, Finding Groups, and mixed selections can now be sent to Jira. When you select multiple findings, choose between one grouped issue or separate issues. Generated issues keep their Prowler context with deep links and filter details, while the UI provides clear dispatch and failure feedback.
|
||||
|
||||

|
||||
|
||||
Read more in the [Jira integration documentation](/user-guide/tutorials/prowler-app-jira-integration).
|
||||
|
||||
### 🕸️ Attack Paths - Queries
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
Prowler Cloud now records which built-in Attack Paths queries returned data at the end of each scan. The query selector hides confirmed-empty queries for the selected scan, so you can focus on paths that exist without opening blank graph views. Errored, unknown, and parameterized queries remain available when they still require investigation or input.
|
||||
|
||||
All Attack Paths queries are now published on [Prowler Hub](https://hub.prowler.com), where you can browse the full catalog.
|
||||
|
||||

|
||||
|
||||
Read more in the [Attack Paths documentation](/user-guide/tutorials/prowler-app-attack-paths).
|
||||
|
||||
### 🧑🏫 New Tutorials: Connect Your AI Agents to Prowler Cloud
|
||||
|
||||
<Note>
|
||||
This feature needs a Prowler Cloud API key, so it is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
New tutorials walk you through connecting your own AI agents to Prowler Cloud, so they can query your security posture and act on it programmatically.
|
||||
|
||||
Read more in the [AI agents documentation](/user-guide/ai-agents/index).
|
||||
|
||||
### ☁️ Region-less Oracle Cloud Infrastructure Setup
|
||||
|
||||
Oracle Cloud Infrastructure (OCI) provider credentials no longer require a region. Existing clients can still send the legacy `region` field for compatibility, but the API ignores it before storing credentials or starting a scan. This removes an unnecessary step from OCI onboarding.
|
||||
|
||||
Read more in the [OCI documentation](/user-guide/providers/oci/getting-started-oci).
|
||||
|
||||
### 🔍 Checks
|
||||
|
||||
#### AWS
|
||||
|
||||
- `sagemaker_notebook_instance_no_secrets` scans the `OnCreate` and `OnStart` lifecycle scripts of SageMaker notebook instances for hardcoded API keys, passwords, tokens, connection strings, and other secrets. Thanks to @kiranrajsg!
|
||||
|
||||
Read more in the [AWS documentation](/user-guide/providers/aws/getting-started-aws). Explore all AWS checks at [Prowler Hub](https://hub.prowler.com/check?provider=aws).
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- Integration responses and operations now respect provider visibility, preventing hidden-provider disclosure and blocking unauthorized attachment, connection checks, Jira dispatches, edits, and deletion.
|
||||
- Next.js was updated from 16.2.9 to 16.2.11, patching four high-severity and five medium-severity vulnerabilities.
|
||||
- The unused `npm` CLI was removed from the UI container image, eliminating the bundled `node-tar` CVE-2026-59873 and reducing exposure to future bundled npm vulnerabilities.
|
||||
- Vitest and its browser packages were updated from 4.1.8 to 4.1.10, resolving the critical `@vitest/browser` file-access permission bypass. These are development dependencies and have no runtime impact.
|
||||
- Kubernetes kubeconfig validation now blocks legacy `auth-provider.config.cmd-path` command authentication, closing a command-execution bypass.
|
||||
- `next-auth` was updated from 5.0.0-beta.30 to 5.0.0-beta.32, patching two critical Auth.js advisories: existence-based authorization checks that could fail open when a provider is misconfigured, and a homoglyph `@` bypass in email address normalization. The bump also pulls in the patched `@auth/core` 0.41.3 transitively.
|
||||
|
||||
### 🙌 External Contributors
|
||||
|
||||
Thank you to our community contributors for this release!
|
||||
|
||||
- @kiranrajsg: AWS `sagemaker_notebook_instance_no_secrets` check ([#11843](https://github.com/prowler-cloud/prowler/pull/11843))
|
||||
- @owenchenxy: Alibaba Cloud SSH and RDP security group checks now handle capitalized `Policy="Accept"` values correctly ([#12049](https://github.com/prowler-cloud/prowler/pull/12049))
|
||||
- @rsaladra: S3 bucket name validation no longer raises an invalid escape sequence `SyntaxWarning` at startup ([#12041](https://github.com/prowler-cloud/prowler/pull/12041))
|
||||
- @SujayKulkarni-2211: Updated the AWS check count in the README ([#12011](https://github.com/prowler-cloud/prowler/pull/12011))
|
||||
|
||||
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.36.0) for the complete list of changes.
|
||||
</Update>
|
||||
|
||||
<Update label="v5.35.0" description="July 17, 2026">
|
||||
### 💬 Lighthouse AI - Side Chat
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
Lighthouse AI now lives in a side panel you can open from anywhere in the app. Ask about the findings you are looking at without leaving the page, and expand to the full-page chat at any time: your draft, messages, and streaming response come along. Finding and resource details share the same panel, with tabs to switch between Details and Lighthouse AI.
|
||||
|
||||

|
||||
|
||||
Read more in the [Lighthouse AI documentation](/getting-started/products/prowler-cloud-lighthouse#side-panel).
|
||||
|
||||
### 🤖 Lighthouse AI - Take Action
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
Lighthouse AI is no longer read-only. Ask it to do things and it will: connect or remove providers, trigger a scan, schedule daily scans, update scan settings, and manage your mutelist and mute rules, straight from the chat. Every action is gated by RBAC: Lighthouse can only do what the user asking could do themselves.
|
||||
|
||||
Read more in the [Lighthouse AI capabilities](/getting-started/products/prowler-cloud-lighthouse#capabilities).
|
||||
|
||||
### ☁️ One-step AWS Organizations onboarding
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
Onboarding an entire AWS Organization is now a single step. One CloudFormation quick-create link deploys the management account role and a service-managed StackSet that rolls the role out to every member account, replacing the manual StackSet console setup. Target the whole organization or a specific Organizational Unit or Root ID, and deploy from the management account or a delegated administrator. The S3 integration quick-create link also pre-fills the bucket owner account ID, preventing a stack validation error.
|
||||
|
||||

|
||||
|
||||
Built on the full-organization CloudFormation template contributed by @jchrisfarris — thanks!
|
||||
|
||||
Read more in the [AWS Organizations documentation](/user-guide/tutorials/prowler-cloud-aws-organizations).
|
||||
|
||||
### 🎯 Scan configurations: exclude checks and services
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
Scan configurations now accept `excluded_checks` and `excluded_services` to narrow the execution scope. Skip individual checks or entire services per provider, and the scan does not run them at all: less noise, faster scans, and no findings you would mute anyway.
|
||||
|
||||
Read more in the [Scan Configuration documentation](/user-guide/tutorials/prowler-app-scan-configuration#limiting-the-scan-scope).
|
||||
|
||||
### 🧭 Redesigned sidebar navigation
|
||||
|
||||
The sidebar was redesigned around how you actually work: grouped sections for security, settings, and help, a Home/Chat switch at the top, collapsible configuration entries, clearer active states, and a responsive mobile overlay.
|
||||
|
||||

|
||||
|
||||
### 🔌 Prowler MCP tools renamed to `prowler_*`
|
||||
|
||||
Core Prowler tools in Prowler MCP moved from the `prowler_app_*` prefix to the shorter `prowler_*` namespace, and the MCP documentation was restructured around it. Legacy `prowler_app_*` names keep working in Lighthouse AI, so existing setups are not broken.
|
||||
|
||||
Read more in the [Prowler MCP tools reference](/getting-started/basic-usage/prowler-mcp-tools).
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- Jira integration credentials now only accept bare Atlassian site names (letters, numbers, and hyphens), and Jira tenant information requests validate site names and no longer follow redirects.
|
||||
- Social account linking now requires a verified matching email from both the identity provider and the existing user account, and account connection notification emails are disabled.
|
||||
- 13 advisories reported by `pnpm audit` on the UI (3 high, 9 moderate, 1 low) are resolved with patched versions of `hono`, `ws`, `vite`, `dompurify`, `js-yaml`, `@opentelemetry/core`, and `@babel/core`, including `hono` CVE-2026-59896.
|
||||
|
||||
### 🙌 External Contributors
|
||||
|
||||
No external contributors in this release.
|
||||
|
||||
Special mention to @jchrisfarris, whose full-organization CloudFormation template from v5.34.0 powers the new one-step AWS Organizations onboarding ([#10403](https://github.com/prowler-cloud/prowler/pull/10403)).
|
||||
|
||||
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.35.0) for the complete list of changes.
|
||||
</Update>
|
||||
|
||||
<Update label="v5.34.0" description="July 15, 2026">
|
||||
### 🏷️ New product names
|
||||
|
||||
The Prowler family has grown, and the names now say what each product is. Same products, clearer names:
|
||||
|
||||
**Prowler products:**
|
||||
|
||||
- **Prowler Cloud** — the managed cloud security platform operated by the Prowler team.
|
||||
- **Prowler Private Cloud** (formerly *Prowler Enterprise*) — the self-hosted deployment of Prowler Cloud in your own environment.
|
||||
- **Prowler Hub** — the free public library of versioned checks, cloud service artifacts, and compliance frameworks.
|
||||
- **Prowler Lighthouse AI** — The Agentic Cloud Defender in Prowler Cloud and Prowler Private Cloud.
|
||||
- **Prowler MCP** — the MCP server that connects AI assistants and agents to Prowler, including the IDE plugins.
|
||||
|
||||
**Open source projects:**
|
||||
|
||||
- **Prowler CLI** — the command-line scanner for all supported providers.
|
||||
- **Prowler Local Server** (formerly *Prowler App*) — the self-hosted web application and API to run scans, visualize findings, and manage providers.
|
||||
- **Prowler Local Dashboard** — the web dashboard for visualizing Prowler CLI scan results, distributed with the CLI.
|
||||
- **Prowler SDK** — the Python library behind Prowler CLI and Prowler Local Server.
|
||||
|
||||
See the full family in the [Prowler products documentation](/getting-started/products).
|
||||
|
||||
### 🧭 Cross-Provider Compliance
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
One framework, every cloud, a single answer. The new **Cross-provider** tab in Compliance takes the most recent completed scan of every compatible provider and rolls them up into a single compliance posture per framework, with a per-provider breakdown and a combined executive PDF report. Requirement status follows strict precedence (FAIL over PASS over MANUAL), so one failing provider is enough to flag a requirement across your whole estate.
|
||||
|
||||

|
||||
|
||||
Three universal frameworks support it today:
|
||||
|
||||
- **CIS Controls 8.1** — AWS, Azure, Google Cloud, Microsoft 365, Kubernetes, GitHub, Google Workspace, Okta, Oracle Cloud, Alibaba Cloud, Cloudflare, MongoDB Atlas, OpenStack, and Vercel.
|
||||
- **CSA CCM 4.0** — AWS, Azure, Google Cloud, Alibaba Cloud, and Oracle Cloud.
|
||||
- **DORA 2022/2554** — AWS, Azure, Google Cloud, Alibaba Cloud, and Cloudflare.
|
||||
|
||||
Filter by provider type, account, or provider group, drill into each framework's requirements, and export the combined PDF.
|
||||
|
||||

|
||||
|
||||
Read more in the [Cross-Provider Compliance documentation](/user-guide/compliance/tutorials/cross-provider-compliance).
|
||||
|
||||
### 🏢 New Provider — E2E Networks
|
||||
|
||||
Prowler now scans [**E2E Networks**](https://www.e2enetworks.com/), with **27 checks** spanning compute nodes, networking, security groups, load balancers, block and file storage, and managed databases. Thanks to @deepak7093 for their 1st provider in Prowler!
|
||||
|
||||
Available in the Prowler CLI:
|
||||
|
||||
```bash
|
||||
export E2E_NETWORKS_API_KEY="your-api-key"
|
||||
export E2E_NETWORKS_AUTH_TOKEN="your-auth-token"
|
||||
export E2E_NETWORKS_PROJECT_ID="your-project-id"
|
||||
prowler e2enetworks
|
||||
```
|
||||
|
||||
Read more in the [E2E Networks documentation](/user-guide/providers/e2enetworks/getting-started-e2enetworks). Explore all E2E Networks checks at [Prowler Hub](https://hub.prowler.com/check?provider=e2enetworks).
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
User role relationship updates in the API are now limited to the active tenant, preserving the role assignments the same user holds in other tenants.
|
||||
|
||||
### 🔍 Checks
|
||||
|
||||
#### AWS
|
||||
|
||||
- `ec2_ami_account_block_public_access` — verifies AMI block public access is enabled at the account level in each Region, so AMIs cannot be shared publicly. Thanks to @goutham-hari!
|
||||
- `datapipeline_pipeline_no_secrets_in_definition` — scans Data Pipeline object fields, parameter objects, and parameter values for hardcoded secrets with Kingfisher. Thanks to @YinkaMetrics!
|
||||
- `elbv2_listener_pqc_tls_enabled` — verifies ELBv2 HTTPS/TLS listeners use post-quantum TLS security policies with TLS 1.2 or higher, helping reduce harvest-now-decrypt-later exposure.
|
||||
- `amplify_app_no_secrets_in_environment` — scans Amplify app and branch environment variables and build settings (buildSpec) for hardcoded secrets with Kingfisher. Thanks to @Deep070203!
|
||||
|
||||
#### Azure
|
||||
|
||||
- `app_function_ensure_http_is_redirected_to_https` — verifies that Function Apps enforce HTTPS-only traffic. Thanks to @amandalal007!
|
||||
|
||||
#### Kubernetes
|
||||
|
||||
- `core_minimize_hostpath_volume_mounts` — detects Pods that use `hostPath` volumes. Thanks to @0xTaoZ!
|
||||
- `core_readonly_root_filesystem_enabled` — verifies that every container in each Pod explicitly sets `readOnlyRootFilesystem: true` in its security context. Thanks to @Weedle02!
|
||||
|
||||
#### STACKIT
|
||||
|
||||
- `iaas_server_public_ip_attached` — flags IaaS servers that have a public IP address directly attached to a network interface. Thanks to @johannes-engler-mw!
|
||||
|
||||
Explore all checks at [Prowler Hub](https://hub.prowler.com/check).
|
||||
|
||||
### 🙌 External Contributors
|
||||
|
||||
Thank you to our community contributors for this release!
|
||||
|
||||
- @jchrisfarris — Deploy AWS Organizations with the CloudFormation template in one step ([#10403](https://github.com/prowler-cloud/prowler/pull/10403))
|
||||
- @deepak7093 — New E2E Networks provider: 27 checks across compute nodes, networking, security groups, load balancers, block/file storage, and managed databases ([#11654](https://github.com/prowler-cloud/prowler/pull/11654))
|
||||
- @goutham-hari — AWS `ec2_ami_account_block_public_access` check ([#11828](https://github.com/prowler-cloud/prowler/pull/11828))
|
||||
- @YinkaMetrics — AWS `datapipeline_pipeline_no_secrets_in_definition` check ([#11821](https://github.com/prowler-cloud/prowler/pull/11821))
|
||||
- @amandalal007 — Azure `app_function_ensure_http_is_redirected_to_https` check ([#11929](https://github.com/prowler-cloud/prowler/pull/11929))
|
||||
- @0xTaoZ — Kubernetes `core_minimize_hostpath_volume_mounts` check ([#11837](https://github.com/prowler-cloud/prowler/pull/11837))
|
||||
- @Weedle02 — Kubernetes `core_readonly_root_filesystem_enabled` check ([#11835](https://github.com/prowler-cloud/prowler/pull/11835))
|
||||
- @johannes-engler-mw — STACKIT `iaas_server_public_ip_attached` check ([#11549](https://github.com/prowler-cloud/prowler/pull/11549))
|
||||
- @janderik — Trailing newlines added to compliance, region, and fixture data files for POSIX compliance ([#11765](https://github.com/prowler-cloud/prowler/pull/11765))
|
||||
- @Deep070203 — AWS `amplify_app_no_secrets_in_environment` check ([#11825](https://github.com/prowler-cloud/prowler/pull/11825))
|
||||
|
||||
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.34.0) for the complete list of changes.
|
||||
</Update>
|
||||
|
||||
<Update label="v5.33.0" description="July 7, 2026">
|
||||
### 🤖 Lighthouse AI — The Agentic Cloud Defender
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
Lighthouse AI is now a full agentic assistant wired to the Prowler Cloud backend. Ask it about your findings, your compliance posture, or your riskiest resources, and watch it work: the agent discovers and runs the Prowler tools it needs to answer, with every tool call visible in the new agentic view. It reads your security data through read-only tools, so it can never touch secrets or modify your tenant.
|
||||
|
||||

|
||||
|
||||
The chat experience is rebuilt around **persistent sessions**: conversations stream in real time, stay in your session history, can be archived, and a **sidebar chat mode** lets you ask questions from any page in the app without losing your place.
|
||||
|
||||

|
||||
|
||||
You control the brain behind it. Configure one or more LLM providers — **OpenAI**, **Amazon Bedrock**, or any **OpenAI-compatible** endpoint (OpenRouter, Ollama) — with connection testing built into the setup and per-provider model selection. Add a shared **business context** (your security goals, compliance needs, organizational priorities) and every session uses it to give answers that fit your environment.
|
||||
|
||||

|
||||
|
||||
Read more in the [Lighthouse AI documentation](/getting-started/products/prowler-cloud-lighthouse) and the [multiple LLM providers guide](/user-guide/tutorials/prowler-cloud-lighthouse-multi-llm).
|
||||
|
||||
### 📄 Compliance PDF Reports Without Credentials
|
||||
|
||||
Compliance PDF reports no longer require the provider's credentials to be present. Findings are now enriched from the provider metadata stored in the database, so a report still generates even after the provider secret has been deleted or its credentials have become invalid.
|
||||
|
||||
Read more in the [compliance documentation](/user-guide/compliance/tutorials/compliance).
|
||||
|
||||
### ⏳ Scan Queueing
|
||||
|
||||
Overlapping scans for the same provider now queue behind the active one instead of dispatching concurrent scan workers. Launch a manual scan while a scheduled one is running and it waits its turn. No more duplicated work or racing scans.
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
The Kubernetes provider credentials now reject kubeconfigs using `exec` authentication in Prowler Cloud, at the API and in the credential form, preventing user-supplied commands from running on Cloud workers.
|
||||
|
||||
Read more in the [Kubernetes provider authentication documentation](/user-guide/providers/kubernetes/getting-started-k8s#step-2-configure-kubernetes-authentication).
|
||||
|
||||
### 🙌 External Contributors
|
||||
|
||||
Thank you to our community contributors for this release!
|
||||
|
||||
- @kratos0718 — Azure `postgresql_flexible_server_log_retention_days_greater_3` Flexible Server log retention fix ([#11761](https://github.com/prowler-cloud/prowler/pull/11761))
|
||||
- @Sanjays2402 — `KeyError: 'MANUAL'` crash fix in the compliance summary table, shipped early in v5.32.1 ([#11823](https://github.com/prowler-cloud/prowler/pull/11823))
|
||||
|
||||
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.33.0) for the complete list of changes.
|
||||
</Update>
|
||||
|
||||
<Update label="v5.32.0" description="July 2, 2026">
|
||||
### 🔎 Findings Triage
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
Triage findings straight from the Findings view. Each finding gets a triage status you can move through its lifecycle:
|
||||
|
||||
**Open → Under Review → Remediating → Risk Accepted → False Positive → Resolved**
|
||||
|
||||
Add a triage note to record the decision, mute a finding, all from the row's actions menu. The current status shows inline on every finding row, so you keep track of what has been reviewed and stop re-checking the same issues scan after scan.
|
||||
|
||||

|
||||
|
||||
The status also follows the finding automatically across scans: when a finding flips from `FAIL` to `PASS` on the next scan it moves to **Resolved**, and when it flips from `PASS` back to `FAIL` it moves to **Reopened**. You always know whether an issue is genuinely fixed or has regressed, without touching it by hand.
|
||||
|
||||

|
||||
|
||||
Read more in the [Findings Triage documentation](/user-guide/tutorials/prowler-app-findings-triage).
|
||||
|
||||
### ⚙️ Scan Configuration
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
Create named, reusable scan configurations from a dedicated **Scans / Configuration** page. Each configuration is YAML that follows the structure of [`prowler/config/config.yaml`](https://github.com/prowler-cloud/prowler/blob/master/prowler/config/config.yaml), so you only include the keys you want to override; the rest fall back to the built-in defaults. Values are validated on save against a per-provider, type-safe configuration schema that range-checks each field and rejects unknown keys, so a malformed config is caught before it ever reaches a scan. Attach a configuration to one or more providers so it applies on their next scan, or save it now and attach providers later.
|
||||
|
||||

|
||||
|
||||
From the Providers view you can pick which configuration a provider uses (`Default` or any of your saved ones) without leaving the page. No more passing config files around by hand.
|
||||
|
||||

|
||||
|
||||
Read more in the [Scan Configuration documentation](/user-guide/tutorials/prowler-app-scan-configuration).
|
||||
|
||||
### ✅ Per-Requirement Configuration Validation
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
Compliance frameworks can now declare `ConfigRequirements` on a requirement, so it's reported as **FAIL** when its mapped checks ran under a configuration too loose to satisfy it. Even if every individual finding PASSed. This applies across all compliance outputs: CSV, OCSF, and console tables, and is the engine behind Scan Configuration's "marked as FAIL" behavior described above.
|
||||
|
||||

|
||||
|
||||
Read more in the [Configuration File documentation](/user-guide/cli/tutorials/configuration_file).
|
||||
|
||||
### ⏱️ Okta — Request Throttling & Retries
|
||||
|
||||
Prowler now proactively throttles Okta API requests to stay under rate limits, with reactive retries on HTTP 429 as a safety net. Both are set in the scan configuration (or their equivalent CLI flags):
|
||||
|
||||
- `okta_requests_per_second` (config file) / `--okta-requests-per-second` (CLI) — cap the request rate. Default: 4 req/s.
|
||||
- `okta_max_retries` (config file) / `--okta-retries-max-attempts` (CLI) — bound retry attempts. Default: 5.
|
||||
|
||||
This makes large Okta scans more reliable and less likely to be rate-limited.
|
||||
|
||||
Read more in the [Okta rate limit documentation](/user-guide/providers/okta/retry-configuration#request-throttling-requests-per-second).
|
||||
|
||||
### 📉 AWS — Cap Resources Scanned per Service
|
||||
|
||||
Large AWS accounts can now cap how many resources Prowler analyzes for the highest-volume services, keeping scan time and cost under control. Set a global limit with `max_scanned_resources_per_service`, or override it per service:
|
||||
|
||||
- EBS snapshots (`max_ebs_snapshots`)
|
||||
- Backup recovery points (`max_backup_recovery_points`)
|
||||
- CloudWatch log groups (`max_cloudwatch_log_groups`)
|
||||
- Lambda functions (`max_lambda_functions`)
|
||||
- ECS task definitions (`max_ecs_task_definitions`)
|
||||
- CodeArtifact packages (`max_codeartifact_packages`)
|
||||
|
||||
Limits are **disabled by default** (`0` = unlimited); only positive values cap the analyzed resources.
|
||||
|
||||
<Warning>
|
||||
When a positive limit is set, compliance results reflect only the sampled resources, not every matching resource in the account.
|
||||
</Warning>
|
||||
|
||||
Read more in the [configuration file documentation](/user-guide/cli/tutorials/configuration_file#supported-aws-resource-limits).
|
||||
|
||||
### 🏷️ Azure — Filter by Resource Group
|
||||
|
||||
Azure scans can now be scoped to one or more resource groups with the new `--azure-resource-group` / `--azure-resource-groups` option. This lets you run focused assessments against specific environments, teams, or workloads instead of scanning every accessible resource in the subscription. Thanks to @Legin-ML for contributing this feature!
|
||||
|
||||
```bash
|
||||
# Single resource group
|
||||
prowler azure --az-cli-auth --azure-resource-group rg-prod
|
||||
|
||||
# Multiple resource groups
|
||||
prowler azure --az-cli-auth --azure-resource-group rg-prod1 rg-prod2
|
||||
```
|
||||
|
||||
Read more in the [Azure Resource Groups documentation](/user-guide/providers/azure/resource-groups).
|
||||
|
||||
### 🧭 Provider Group Filter
|
||||
|
||||
Filter the **Overview, Findings, Resources, Scans, and Providers** views by provider group. Scope the whole app to a team, an environment, or a business unit in one click instead of filtering provider by provider.
|
||||
|
||||

|
||||
|
||||
Read more about managing provider groups in the [RBAC documentation](/user-guide/tutorials/prowler-app-rbac).
|
||||
|
||||
### 🔬 API — Timestamp Precision in Findings Filters
|
||||
|
||||
The `/api/v1/findings` endpoint now accepts full timestamps on the `inserted_at` and `updated_at` filters (`filter[inserted_at__gte]`, `filter[inserted_at__lte]`, and the `updated_at` variants), so you can query narrow time windows instead of whole days. Date-only filtering keeps working, so existing integrations are unaffected.
|
||||
|
||||
```bash
|
||||
# Findings inserted within a precise timestamp window
|
||||
curl --globoff \
|
||||
'http://localhost:8080/api/v1/findings?filter[inserted_at__gte]=2026-07-01T06:12:18Z&filter[inserted_at__lte]=2026-07-02T19:25:55Z' \
|
||||
-H 'Authorization: Bearer <YOUR_TOKEN>' \
|
||||
-H 'Accept: application/vnd.api+json'
|
||||
```
|
||||
|
||||
### 🕸️ Attack Paths — Neptune as a persistent sink
|
||||
|
||||
Attack Paths can now persist its graph in **AWS Neptune** in addition to Neo4j, selectable via `ATTACK_PATHS_SINK_DATABASE=neptune` (default `neo4j`). Cartography's per-scan ingest database stays on Neo4j. The scan task preflights the ingest database and the configured sink before ingestion, and provider graph cleanup now deletes relationships in directed batches before deleting nodes.
|
||||
|
||||
This is the groundwork for scale: a managed graph database lets Attack Paths hold much larger graphs, extend coverage to more providers, and link resources across them so an attack path can cross provider boundaries instead of stopping at one cloud's edge.
|
||||
|
||||
Read more in the [Attack Paths documentation](/user-guide/tutorials/prowler-app-attack-paths).
|
||||
|
||||
### 🔐 New Secret-Scanning Engine — Kingfisher
|
||||
|
||||
Prowler's secret-scanning checks now run on [Kingfisher](https://github.com/mongodb/kingfisher) instead of `detect-secrets`. Scans run **fully offline by default**, and obvious placeholder values (e.g. `password123`, `changeme`) are no longer reported, cutting down false positives.
|
||||
|
||||
Opt in to **live validation** with the new `--scan-secrets-validate` flag (or the `aws.secrets_validate` config option): Prowler checks discovered secrets against the provider APIs, and any secret confirmed to be **live is reported as critical**, so you can prioritize the credentials that actually work.
|
||||
|
||||
<Note>
|
||||
The `detect_secrets_plugins` configuration option has been removed, as it is no longer used by the new engine.
|
||||
</Note>
|
||||
|
||||
Read more in the [secret detection documentation](/user-guide/cli/tutorials/pentesting#detect-secrets).
|
||||
|
||||
### 🔍 Checks
|
||||
|
||||
#### AWS
|
||||
|
||||
- `stepfunctions_statemachine_encrypted_with_cmk` — Step Functions state machines use a customer-managed KMS key for encryption at rest instead of the default AWS-owned key. Thanks to @Sid-0602!
|
||||
- `waf_regional_webacl_logging_enabled` — AWS WAF Classic Regional Web ACLs have logging enabled to a Kinesis Data Firehose stream. Thanks to @Sid-0602!
|
||||
- **IAM privilege escalation** — the privesc checks now cover **AWS Bedrock AgentCore** paths across Runtime, Harness, Code Interpreter, and Custom Browser. Thanks to @MrCloudSec!
|
||||
- `apigateway_restapi_no_secrets_in_stage_variables` — scans API Gateway REST API stage variables for hardcoded passwords, API keys, and tokens. Thanks to @chirag1206!
|
||||
- `awslambda_function_no_secrets_in_code` — this check now supports a `secrets_ignore_files` audit-config option to skip files inside the deployment package by glob pattern (e.g. `*.deps.json`), suppressing .NET dependency-manifest false positives without masking real secrets.
|
||||
- `s3_bucket_object_public` — spot-checks a configurable sample of object ACLs in each bucket and flags objects granted to the `AllUsers` or `AuthenticatedUsers` groups. Disabled by default; opt in via the `s3_bucket_object_public_enabled` configuration option. Thanks to @Synchx00!
|
||||
|
||||
#### Microsoft 365
|
||||
|
||||
New **Conditional Access** hardening checks:
|
||||
|
||||
- `entra_conditional_access_policy_explicitly_targets_azure_devops` — at least one enabled policy explicitly includes the Azure DevOps cloud application, rather than relying on a broad "All cloud apps" policy. Thanks to @mzl2233!
|
||||
- `entra_conditional_access_policy_no_exclusion_gaps` — every user, group, role, or application excluded from an enabled policy stays in scope of another enabled policy. Thanks to @UTKARSH698 with @arieleli01212 as co-author!
|
||||
- `entra_conditional_access_policy_groups_management_restricted` — every security group referenced by an enabled or report-only policy is management-restricted or role-assignable. Thanks to @SAMurai-16!
|
||||
- `exchange_application_access_policy_restricts_mailbox_apps` — every service principal with Microsoft Graph application-level Exchange mailbox permissions is restricted by an Exchange Online Application Access Policy. Thanks to @VasistAcharya!
|
||||
|
||||
### 📚 Compliance
|
||||
|
||||
#### CIS Benchmark Refresh — Six New Versions
|
||||
|
||||
Prowler ships a coordinated refresh of the CIS Benchmarks across six providers:
|
||||
|
||||
- **AWS** — CIS Amazon Web Services Foundations Benchmark v7.0.0, adding the new Organizations section (2.1.1-2.1.6), resource policy (2.21), web front-end access logging (4.10), and VPC Endpoints (6.8) recommendations.
|
||||
- **Azure** — CIS Microsoft Azure Foundations Benchmark v6.0.0.
|
||||
- **GCP** — CIS Google Cloud Platform Foundation Benchmark v5.0.0.
|
||||
- **Kubernetes** — CIS Kubernetes Benchmark v2.0.1.
|
||||
- **GitHub** — CIS GitHub Benchmark v1.2.0.
|
||||
- **Microsoft 365** — CIS Microsoft 365 Foundations Benchmark v7.0.0.
|
||||
|
||||
#### CIS Controls v8.1 — Universal Framework
|
||||
|
||||
A new **universal** (cross-provider) compliance framework mapping existing checks across 18 providers — AWS, Azure, GCP, Kubernetes, M365, GitHub, AlibabaCloud, OracleCloud, GoogleWorkspace, Okta, Cloudflare, Vercel, MongoDB Atlas, OpenStack, Linode, StackIT, NHN, and Scaleway — to the 18 CIS Critical Security Controls and their Safeguards. Ships with a dedicated detail view and report mapping in the UI.
|
||||
|
||||
Read more in the [compliance documentation](/user-guide/compliance/tutorials/compliance). Explore the full compliance catalog at [Prowler Hub](https://hub.prowler.com/compliance).
|
||||
|
||||
### 🙌 External Contributors
|
||||
|
||||
Thank you to our community contributors for this release!
|
||||
|
||||
- @chirag1206 — `apigateway_restapi_no_secrets_in_stage_variables` check ([#11188](https://github.com/prowler-cloud/prowler/pull/11188))
|
||||
- @MrCloudSec — AWS Bedrock AgentCore privilege escalation paths in the IAM privesc checks ([#11726](https://github.com/prowler-cloud/prowler/pull/11726))
|
||||
- @Sid-0602 — `stepfunctions_statemachine_encrypted_with_cmk` ([#11538](https://github.com/prowler-cloud/prowler/pull/11538)) and `waf_regional_webacl_logging_enabled` ([#11539](https://github.com/prowler-cloud/prowler/pull/11539)) checks
|
||||
- @mzl2233 — `entra_conditional_access_policy_explicitly_targets_azure_devops` check ([#11182](https://github.com/prowler-cloud/prowler/pull/11182))
|
||||
- @UTKARSH698 with @arieleli01212 as co-author — `entra_conditional_access_policy_no_exclusion_gaps` check ([#11577](https://github.com/prowler-cloud/prowler/pull/11577))
|
||||
- @SAMurai-16 — `entra_conditional_access_policy_groups_management_restricted` check ([#11342](https://github.com/prowler-cloud/prowler/pull/11342))
|
||||
- @vahidg — Azure PostgreSQL flexible server collection resilience fix ([#11595](https://github.com/prowler-cloud/prowler/pull/11595))
|
||||
- @davletd — Azure `keyvault_logging_enabled` `AuditEvent` category fix ([#11660](https://github.com/prowler-cloud/prowler/pull/11660))
|
||||
- @VasistAcharya — `exchange_application_access_policy_restricts_mailbox_apps` ([#11247](https://github.com/prowler-cloud/prowler/pull/11247))
|
||||
- @Legin-ML — Filter scans at Resource Group level ([#10657](https://github.com/prowler-cloud/prowler/pull/10657))
|
||||
- @Synchx00 — `s3_bucket_object_public` check ([#9517](https://github.com/prowler-cloud/prowler/pull/9517))
|
||||
|
||||
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.32.0) for the complete list of changes.
|
||||
</Update>
|
||||
|
||||
<Update label="v5.31.0" description="June 23, 2026">
|
||||
### 🗓️ Flexible Scan Scheduling
|
||||
|
||||
<Note>
|
||||
Available exclusively in **Prowler Cloud**. Prowler Local Server supports daily scans only.
|
||||
</Note>
|
||||
|
||||

|
||||
|
||||
You can now set a per-provider scan schedule from the Providers page. Pick a **scan time** and a **repeat cadence**: Daily, Every 48 hours, Weekly (with a day-of-week selector), or Monthly. Schedules can be edited or removed at any time, and a new scan never interrupts access to existing data.
|
||||
|
||||

|
||||
|
||||
All schedules are listed in one place under the **Scheduled** tab in **Scan Jobs**, showing each provider's cadence, next scan, and last scan at a glance.
|
||||
|
||||

|
||||
|
||||
Read more in the [scan scheduling documentation](/user-guide/tutorials/prowler-scan-scheduling).
|
||||
|
||||
### 📚 DORA — Expanded Provider Coverage
|
||||
|
||||
Prowler extends [**DORA**](https://www.eiopa.europa.eu/digital-operational-resilience-act-dora_en) (Digital Operational Resilience Act, Regulation (EU) 2022/2554) coverage to **Azure**, **GCP**, **Cloudflare**, and **Alibaba Cloud**, mapping each provider's existing checks across the five DORA pillars.
|
||||
|
||||

|
||||
|
||||
<Note>
|
||||
The framework follows the `<name>_<version>` naming convention as `DORA_2022_2554`.
|
||||
</Note>
|
||||
|
||||
Read more in the [compliance documentation](/user-guide/compliance/tutorials/compliance).
|
||||
|
||||
### 🚀 Guided Onboarding
|
||||
|
||||
<Note>
|
||||
Available exclusively in **Prowler Cloud**.
|
||||
</Note>
|
||||
|
||||
New accounts now get a guided first-run experience. The Overview greets you with an **"Add your first provider"** prompt: connect a provider so Prowler has something to scan and assess, then get started in one click (or skip for now).
|
||||
|
||||

|
||||
|
||||
From there, contextual empty states across the product point you to the next action rather than leaving you stuck. Attack Paths, for example, explains that you need a completed scan before it can build a graph and links straight to **Scan Jobs**, with a **"See how it works"** affordance for first-timers.
|
||||
|
||||

|
||||
|
||||
### 🔐 Optional SAML SSO `userType`
|
||||
|
||||
The SAML `userType` attribute is now optional. If your IdP does not send it, or sends it blank, Prowler keeps the user's existing roles unchanged instead of replacing them with a fallback role.
|
||||
|
||||
When `userType` is provided, Prowler still maps the user to the matching role. If that role does not exist yet, Prowler creates it with read-only access: visibility over all providers, with no management permissions.
|
||||
|
||||
Read more in the [SAML SSO documentation](/user-guide/tutorials/prowler-app-sso).
|
||||
|
||||
### 🏢 New Provider — Linode
|
||||
|
||||
Prowler now scans [**Linode**](https://www.linode.com/) (Akamai Cloud), covering its administration, compute, and networking services. Thanks to @varunmamillapalli for their 1st provider in Prowler!
|
||||
|
||||
<Note>
|
||||
Linode is not officially supported. For more information, [contact us](https://prowler.com/contact).
|
||||
</Note>
|
||||
|
||||
Read more in the [Linode documentation](/user-guide/providers/linode/getting-started-linode). Explore all Linode checks at [Prowler Hub](https://hub.prowler.com/check?provider=linode).
|
||||
|
||||
### 🔍 Checks
|
||||
|
||||
#### AWS
|
||||
|
||||
**Post-Quantum Cryptography readiness** — get ahead of the migration to quantum-resistant cryptography:
|
||||
|
||||
- `cloudfront_distributions_pqc_tls_enabled` — CloudFront distributions enforce a post-quantum TLS 1.3 security policy.
|
||||
- `apigateway_domain_name_pqc_tls_enabled` — API Gateway custom domain names use a post-quantum TLS security policy.
|
||||
- `transfer_server_pqc_ssh_kex_enabled` — Transfer Family servers use a post-quantum hybrid SSH key exchange.
|
||||
- `acmpca_certificate_authority_pqc_key_algorithm` — Private CA authorities use a post-quantum (ML-DSA) key algorithm (new `acmpca` service).
|
||||
- `rolesanywhere_trust_anchor_pqc_pki` — IAM Roles Anywhere trust anchors are backed by a post-quantum (ML-DSA) PKI (new `rolesanywhere` service).
|
||||
|
||||
**Organization-wide governance:**
|
||||
|
||||
- `securityhub_delegated_admin_enabled_all_regions` — Security Hub has a delegated administrator, active in all opted-in regions, with organization auto-enable on. Thanks to @ernestprovo23!
|
||||
- `config_delegated_admin_and_org_aggregator_all_regions` — AWS Config has a delegated administrator and an organization aggregator covering all regions. Thanks to @ernestprovo23!
|
||||
|
||||
**Machine learning:**
|
||||
|
||||
- `sagemaker_clarify_exists` — verifies at least one SageMaker Clarify processing job exists per scanned region, so bias-detection and model-explainability controls are in place. Thanks to @AlexanderSanin!
|
||||
|
||||
#### Azure
|
||||
|
||||
A large batch of new Azure checks spanning data, compute, identity, and networking:
|
||||
|
||||
- **Cosmos DB** — automatic failover, continuous backup policy, minimum TLS 1.2, and public network access disabled.
|
||||
- **MySQL & PostgreSQL Flexible Servers** — geo-redundant backup and high availability.
|
||||
- **AKS** — auto-upgrade, Azure Monitor (Container Insights), local accounts disabled, and Microsoft Defender enabled.
|
||||
- **Databricks** — public network access disabled and secure cluster connectivity (no public IP).
|
||||
- **Defender** — CSPM on the Standard tier.
|
||||
- **Networking** — NSG association on subnets and DDoS Network Protection on VNets.
|
||||
- **Entra ID** — app registration credential expiry, users with recent sign-in and strong authentication enforcement.
|
||||
- **Recovery Services** — vaults with at least one protected backup item and vaults with adequate backup policy.
|
||||
|
||||
Thanks to @s1ns3nz0 for all these contributions!
|
||||
|
||||
#### GCP
|
||||
|
||||
New coverage for high availability and public-exposure detection:
|
||||
|
||||
- `cloudsql_instance_high_availability_enabled` — Cloud SQL primary instances use `REGIONAL` availability for automatic zone failover.
|
||||
- `cloudfunction_function_inside_vpc` — Cloud Functions use a Serverless VPC Access connector for private egress.
|
||||
- `cloudfunction_function_not_publicly_accessible` — detects `allUsers` / `allAuthenticatedUsers` IAM invocation bindings.
|
||||
- `secretmanager_secret_not_publicly_accessible` — detects Secret Manager secrets with public IAM bindings.
|
||||
- `secretmanager_secret_rotation_enabled` — verifies Secret Manager secrets have automatic rotation configured with a period of 90 days or less and no missed rotation.
|
||||
|
||||
Thanks to @s1ns3nz0 for all these contributions!
|
||||
|
||||
#### Kubernetes
|
||||
|
||||
New core checks for container resource governance and reliability: CPU limits, CPU requests, memory limits, memory requests, fixed image tags, liveness probes, and readiness probes. Thanks to @Nikhilkumar2311 for all these contributions!
|
||||
|
||||
#### Microsoft 365
|
||||
|
||||
- `entra_directory_sync_object_takeover_blocked` — hybrid Entra tenants block cloud object takeover through soft-match and hard-match directory synchronization. Thanks to @PrettyFox0 and @omobolajiadeyan!
|
||||
- `entra_conditional_access_policy_no_deleted_object_references` — flags Conditional Access policies that reference user, group, or role objects that no longer resolve in the directory. Thanks to @ernestprovo23!
|
||||
|
||||
#### Oracle Cloud Infrastructure
|
||||
|
||||
- `identity_storage_service_level_admins_scoped` — CIS 3.1 control 1.15, ensuring storage service-level administrators exclude delete permissions.
|
||||
|
||||
Explore all checks at [Prowler Hub](https://hub.prowler.com/check).
|
||||
|
||||
### 🐍 Python 3.13 Support
|
||||
|
||||
The Prowler SDK now supports **Python 3.13**. Thanks to @branchv!
|
||||
|
||||
### 🔐 Security Updates
|
||||
|
||||
- **SDK** — `pytest` 8.3.5 → 9.0.3, `black` 25.1.0 → 26.3.1, `microsoft-kiota-*` → 1.9.9, and `aiohttp` → 3.14.0, patching known CVEs.
|
||||
- **API** — `aiohttp` → 3.14.0 and `idna` → 3.15, patching known CVEs.
|
||||
- **UI** — bumped vulnerable `Next.js`, React, AI SDK, `postcss`, `hono`, `qs`, `esbuild`, and Alpine OpenSSL packages; `dompurify` 3.4.2 → 3.4.10, patching XSS sanitization bypass advisories.
|
||||
- **Containers** — base image bumped to `python:3.12.13-slim-bookworm` (patches `libgnutls30` CVE-2026-33845 and CVE-2026-42010) and `trivy` to 0.71.0 (patches embedded `golang.org/x/crypto` and Go stdlib CVEs).
|
||||
|
||||
### 🙌 External Contributors
|
||||
|
||||
Thank you to our community contributors for this release!
|
||||
|
||||
- @varunmamillapalli — New Linode provider: administration, compute, and networking services ([#11633](https://github.com/prowler-cloud/prowler/pull/11633))
|
||||
- @s1ns3nz0 — 20+ Azure & GCP checks across Cosmos DB, AKS, Databricks, Flexible Servers, Entra, networking, and GCP public-exposure
|
||||
- @Nikhilkumar2311 — Kubernetes resource limits, requests, image tag, and probe checks ([#11373](https://github.com/prowler-cloud/prowler/pull/11373))
|
||||
- @ernestprovo23 — AWS Security Hub/Config org-wide delegated admin checks ([#11259](https://github.com/prowler-cloud/prowler/pull/11259)) and M365 conditional access check ([#11236](https://github.com/prowler-cloud/prowler/pull/11236))
|
||||
- @AlexanderSanin — `sagemaker_clarify_exists` check ([#11211](https://github.com/prowler-cloud/prowler/pull/11211))
|
||||
- @PrettyFox0 with @omobolajiadeyan as co-author — M365 directory sync object takeover check ([#11098](https://github.com/prowler-cloud/prowler/pull/11098))
|
||||
- @branchv — Python 3.13 support ([#9293](https://github.com/prowler-cloud/prowler/pull/9293))
|
||||
- @alinealfa — GCP audit-filtered aggregated sinks fix ([#11575](https://github.com/prowler-cloud/prowler/pull/11575))
|
||||
- @b-abderrahmane — Configurable Celery worker concurrency ([#11075](https://github.com/prowler-cloud/prowler/pull/11075))
|
||||
|
||||
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.31.0) for the complete list of changes.
|
||||
</Update>
|
||||
|
||||
<Update label="Earlier releases">
|
||||
Release notes for v5.30.0 and earlier, along with every patch release, are on [GitHub Releases](https://github.com/prowler-cloud/prowler/releases).
|
||||
</Update>
|
||||
@@ -133,6 +133,7 @@ Only fields with a numeric range, a fixed value set, or a length cap are listed.
|
||||
| `max_unused_sagemaker_access_days` | `7..180` days | |
|
||||
| `max_security_group_rules` | `1..1000` | AWS hard limit is 1000 rules per security group |
|
||||
| `max_ec2_instance_age_in_days` | `1..1095` days | 3 years |
|
||||
| `max_ec2_instance_stopped_days` | `1..1095` days | 3 years |
|
||||
| `ec2_high_risk_ports` | each port `1..65535` | port 0 is reserved |
|
||||
| `max_idle_disconnect_timeout_in_seconds` | `60..1800` s | NIST AC-12: cap at 30 min |
|
||||
| `max_disconnect_timeout_in_seconds` | `60..3600` s | |
|
||||
|
||||
@@ -36,6 +36,9 @@ The former build-time variables map to the new runtime variables as follows:
|
||||
| `NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID` | `UI_GOOGLE_TAG_MANAGER_ID` |
|
||||
| `NEXT_PUBLIC_SENTRY_DSN`, `SENTRY_DSN` | `UI_SENTRY_DSN` |
|
||||
| `NEXT_PUBLIC_SENTRY_ENVIRONMENT`, `SENTRY_ENVIRONMENT` | `UI_SENTRY_ENVIRONMENT` |
|
||||
| `NEXT_PUBLIC_IS_CLOUD_ENV` | `UI_CLOUD_ENABLED` |
|
||||
|
||||
`UI_CLOUD_ENABLED` is a plain runtime boolean flag that enables Prowler Cloud behavior when set to the exact string `"true"` and defaults to off; unlike the other renamed variables it has no legacy fallback, so `NEXT_PUBLIC_IS_CLOUD_ENV` is no longer read.
|
||||
|
||||
The build-time-only Sentry variables used for source-map upload — `SENTRY_ORG`, `SENTRY_PROJECT`, `SENTRY_AUTH_TOKEN`, and `SENTRY_RELEASE` — keep their names, as they are not part of Prowler Local Server's runtime configuration.
|
||||
|
||||
|
||||
@@ -80,7 +80,24 @@
|
||||
{
|
||||
"group": "Prowler for AI Agents",
|
||||
"pages": [
|
||||
"getting-started/products/prowler-claude-code-plugin"
|
||||
"user-guide/ai-agents/index",
|
||||
"user-guide/ai-agents/claude-code",
|
||||
"user-guide/ai-agents/claude-desktop",
|
||||
"user-guide/ai-agents/codex",
|
||||
"user-guide/ai-agents/cursor",
|
||||
"user-guide/ai-agents/vscode"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Prowler for MSPs and MSSPs",
|
||||
"pages": [
|
||||
"getting-started/products/prowler-for-msps",
|
||||
"user-guide/tutorials/prowler-for-msps-sign-up",
|
||||
"user-guide/tutorials/prowler-for-msps-organization",
|
||||
"user-guide/tutorials/prowler-for-msps-team",
|
||||
"user-guide/tutorials/prowler-for-msps-customers",
|
||||
"user-guide/tutorials/prowler-for-msps-billing",
|
||||
"user-guide/tutorials/prowler-for-msps-branding"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -195,6 +212,17 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Prowler for MSPs and MSSPs",
|
||||
"pages": [
|
||||
"user-guide/tutorials/prowler-for-msps-sign-up",
|
||||
"user-guide/tutorials/prowler-for-msps-organization",
|
||||
"user-guide/tutorials/prowler-for-msps-team",
|
||||
"user-guide/tutorials/prowler-for-msps-customers",
|
||||
"user-guide/tutorials/prowler-for-msps-billing",
|
||||
"user-guide/tutorials/prowler-for-msps-branding"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Prowler Lighthouse AI",
|
||||
"pages": [
|
||||
@@ -217,7 +245,12 @@
|
||||
{
|
||||
"group": "Prowler for AI Agents",
|
||||
"pages": [
|
||||
"getting-started/products/prowler-claude-code-plugin"
|
||||
"user-guide/ai-agents/index",
|
||||
"user-guide/ai-agents/claude-code",
|
||||
"user-guide/ai-agents/claude-desktop",
|
||||
"user-guide/ai-agents/codex",
|
||||
"user-guide/ai-agents/cursor",
|
||||
"user-guide/ai-agents/vscode"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -333,6 +366,13 @@
|
||||
"user-guide/providers/googleworkspace/authentication"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Huawei Cloud",
|
||||
"pages": [
|
||||
"user-guide/providers/huaweicloud/getting-started-huaweicloud",
|
||||
"user-guide/providers/huaweicloud/authentication"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "IaC",
|
||||
"pages": [
|
||||
@@ -525,15 +565,16 @@
|
||||
"troubleshooting"
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "Changelog",
|
||||
"pages": [
|
||||
"changelog"
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "About Us",
|
||||
"icon": "/favicon.ico",
|
||||
"href": "https://prowler.com/about#team"
|
||||
},
|
||||
{
|
||||
"tab": "Changelog",
|
||||
"icon": "github",
|
||||
"href": "https://github.com/prowler-cloud/prowler/releases"
|
||||
}
|
||||
],
|
||||
"global": {
|
||||
@@ -570,7 +611,7 @@
|
||||
]
|
||||
},
|
||||
"banner": {
|
||||
"content": "Prowler App is now Prowler Local Server, and Prowler Enterprise is now Prowler Private Cloud. See [Prowler product families](/getting-started/products).",
|
||||
"content": "Prowler App is now Prowler Local Server, and Prowler Enterprise is now Prowler Private Cloud. See [Prowler product families](/getting-started/products). Check the [latest changes](/changelog).",
|
||||
"dismissible": false
|
||||
},
|
||||
"markdown": {
|
||||
@@ -660,6 +701,10 @@
|
||||
{
|
||||
"source": "/user-guide/tutorials/prowler-cloud-public-ips",
|
||||
"destination": "/security/networking"
|
||||
},
|
||||
{
|
||||
"source": "/getting-started/products/prowler-claude-code-plugin",
|
||||
"destination": "/user-guide/ai-agents/claude-code"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -23,6 +23,28 @@ Most users should use the **Cloud MCP Server** — it needs no installation and
|
||||
- **Cloud MCP Server (HTTP)**: the managed server at `https://mcp.prowler.com/mcp` (or your own self-hosted HTTP server).
|
||||
- **Local MCP Server (STDIO)**: local installation only (runs as a subprocess of your MCP client).
|
||||
|
||||
### Step-by-Step Guides Per Agent
|
||||
|
||||
The tabs below are a quick configuration reference. For a walkthrough with screenshots, troubleshooting, and client-specific caveats, follow the dedicated guide for your agent:
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Claude Code" icon="terminal" href="/user-guide/ai-agents/claude-code">
|
||||
Plugin vs. MCP-only, and which Claude surfaces work
|
||||
</Card>
|
||||
<Card title="Claude Desktop App (Chat)" icon="comment" href="/user-guide/ai-agents/claude-desktop">
|
||||
The Chat tab, via a local bridge
|
||||
</Card>
|
||||
<Card title="Codex" icon="code" href="/user-guide/ai-agents/codex">
|
||||
CLI and the VS Code extension
|
||||
</Card>
|
||||
<Card title="Cursor" icon="arrow-pointer" href="/user-guide/ai-agents/cursor">
|
||||
Global and project scopes
|
||||
</Card>
|
||||
<Card title="VS Code / Copilot" icon="microsoft" href="/user-guide/ai-agents/vscode">
|
||||
Agent mode with secure key prompts
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Cloud MCP Server Configuration (Recommended)
|
||||
|
||||
Connect to the **Cloud MCP Server** at `https://mcp.prowler.com/mcp` over HTTP. This is the recommended path — no installation, always up to date. The same configuration works for a self-hosted HTTP server: just swap the URL.
|
||||
@@ -76,67 +98,6 @@ Connect to the **Cloud MCP Server** at `https://mcp.prowler.com/mcp` over HTTP.
|
||||
The `mcp-remote` tool acts as a bridge for clients that don't support HTTP natively. Learn more at [mcp-remote on npm](https://www.npmjs.com/package/mcp-remote).
|
||||
</Info>
|
||||
</Tab>
|
||||
|
||||
<Tab title="Claude Desktop">
|
||||
1. Open Claude Desktop settings
|
||||
2. Go to "Developer" tab
|
||||
3. Click in "Edit Config" button
|
||||
4. Edit the `claude_desktop_config.json` file with your favorite editor
|
||||
5. Install a reviewed version of `mcp-remote` in a dedicated local workspace:
|
||||
```bash
|
||||
mkdir -p ~/.local/share/prowler-mcp-bridge
|
||||
cd ~/.local/share/prowler-mcp-bridge
|
||||
npm init -y
|
||||
npm install --save-exact mcp-remote@0.1.38
|
||||
```
|
||||
6. Add the following configuration:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"prowler": {
|
||||
"command": "/absolute/path/to/.local/share/prowler-mcp-bridge/node_modules/.bin/mcp-remote",
|
||||
"args": [
|
||||
"https://mcp.prowler.com/mcp",
|
||||
"--header",
|
||||
"Authorization: Bearer ${PROWLER_API_KEY}"
|
||||
],
|
||||
"env": {
|
||||
"PROWLER_API_KEY": "<your-api-key-here>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
|
||||
<Tab title="Claude Code">
|
||||
Run the following command:
|
||||
```bash
|
||||
export PROWLER_API_KEY="<your-api-key-here>"
|
||||
claude mcp add --transport http prowler https://mcp.prowler.com/mcp --header "Authorization: Bearer $PROWLER_API_KEY" --scope user
|
||||
```
|
||||
</Tab>
|
||||
|
||||
<Tab title="Cursor">
|
||||
1. Open Cursor settings
|
||||
2. Go to "Tools & MCP"
|
||||
3. Click in "New MCP Server" button
|
||||
4. Add to the JSON Configuration the following:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"prowler": {
|
||||
"url": "https://mcp.prowler.com/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer <your-api-key-here>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
|
||||
|
||||
</Tabs>
|
||||
|
||||
## Local MCP Server Configuration
|
||||
|
||||
@@ -128,8 +128,8 @@ To update the environment file:
|
||||
Edit the `.env` file and change version values:
|
||||
|
||||
```env
|
||||
PROWLER_UI_VERSION="5.35.0"
|
||||
PROWLER_API_VERSION="5.35.0"
|
||||
PROWLER_UI_VERSION="5.36.0"
|
||||
PROWLER_API_VERSION="5.36.0"
|
||||
```
|
||||
|
||||
<Note>
|
||||
|
||||
@@ -18,7 +18,7 @@ Read the [public announcement of the Prowler product families](https://prowler-w
|
||||
| Prowler Private Cloud | Prowler Cloud deployed in your own environment. Formerly Prowler Enterprise. See [pricing](https://prowler.com/pricing). |
|
||||
| [Prowler Hub](https://hub.prowler.com) | Free public library of versioned checks, cloud service artifacts, and compliance frameworks. |
|
||||
| [Prowler Lighthouse AI](/getting-started/products/prowler-cloud-lighthouse) | AI security analyst capabilities within Prowler Cloud and Prowler Private Cloud. |
|
||||
| [Prowler MCP](/getting-started/products/prowler-mcp) | MCP server that connects AI assistants and agents to Prowler, including IDE plugins such as [Prowler for Claude Code](/getting-started/products/prowler-claude-code-plugin). |
|
||||
| [Prowler MCP](/getting-started/products/prowler-mcp) | MCP server that connects AI assistants and agents to Prowler, including IDE plugins such as [Prowler for Claude Code](/user-guide/ai-agents/claude-code). |
|
||||
|
||||
{/* Unreleased products. Uncomment these rows in the Prowler Products table when announced:
|
||||
| Prowler Registry | Distribution service for Prowler content such as checks and compliance frameworks. Free and paid tiers. |
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
---
|
||||
title: 'Prowler for Claude Code'
|
||||
sidebarTitle: 'Claude Code'
|
||||
---
|
||||
|
||||
End-to-end cloud security and compliance from inside [Claude Code](https://www.claude.com/product/claude-code), powered by the [Prowler MCP server](/getting-started/products/prowler-mcp). The plugin lets Claude walk a Prowler Cloud-connected account through a compliance assessment and remediate findings until the chosen security or industry framework is compliant.
|
||||
|
||||
<Warning>
|
||||
**Preview**: this plugin is under active development. Please report issues on [GitHub](https://github.com/prowler-cloud/prowler/issues) or join the [Slack community](https://goto.prowler.com/slack) for feedback.
|
||||
</Warning>
|
||||
|
||||
## Requirements
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="Claude Code" icon="terminal">
|
||||
Installed and signed in. See the [official install guide](https://www.claude.com/product/claude-code).
|
||||
</Card>
|
||||
<Card title="Prowler Cloud account" icon="cloud">
|
||||
The free tier is enough to start. Sign up at [cloud.prowler.com](https://cloud.prowler.com).
|
||||
</Card>
|
||||
<Card title="Prowler API key" icon="key">
|
||||
Create one at [cloud.prowler.com/profile](https://cloud.prowler.com/profile).
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Installation
|
||||
|
||||
<Tabs>
|
||||
<Tab title="From GitHub (recommended)">
|
||||
Inside a Claude Code session:
|
||||
|
||||
```text
|
||||
/plugin marketplace add prowler-cloud/prowler
|
||||
/plugin install prowler@prowler-plugins
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="From a local clone">
|
||||
If you already have the repository checked out:
|
||||
|
||||
```text
|
||||
/plugin marketplace add /absolute/path/to/prowler
|
||||
/plugin install prowler@prowler-plugins
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## Configuration
|
||||
|
||||
On first install, Claude Code prompts for your **Prowler API key**. The value is stored securely (macOS keychain or `~/.claude/.credentials.json`) and used to authenticate against Prowler Cloud.
|
||||
|
||||
<Note>
|
||||
To rotate the key, uninstall and reinstall the plugin — Claude Code will prompt again.
|
||||
</Note>
|
||||
|
||||
## Verify the installation
|
||||
|
||||
In a Claude Code session:
|
||||
|
||||
```text
|
||||
/mcp → "prowler" appears as a connected server
|
||||
/plugin → "prowler" enabled, skill listed as prowler:framework-compliance-triage
|
||||
```
|
||||
|
||||
If `/mcp` reports the `prowler` server as failed, the most common cause is a rejected API key — re-issue one in Prowler Cloud and reinstall the plugin so it re-prompts.
|
||||
|
||||
## Usage
|
||||
|
||||
Open a conversation that mentions the framework you want to comply with. Examples:
|
||||
|
||||
- *"Make my AWS production account compliant with CIS 4.0."*
|
||||
- *"Make my current Terraform project compliant with Prowler ThreatScore Compliance Framework based on the latest scan results."*
|
||||
- *"Help me get to 100% on PCI-DSS for this GCP project."*
|
||||
|
||||
You pick a **primary tool** (Terraform, gh / az / aws CLI, web console, or mixed) and a **mode**:
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Claude-assisted (default)" icon="hand">
|
||||
Claude shows each fix — target resource, exact commands, side effects, reversibility — and waits for your go-ahead before applying.
|
||||
</Card>
|
||||
<Card title="Claude autonomous" icon="robot">
|
||||
Claude presents a single up-front plan grouped by shared fixes, waits for one confirmation, then proceeds. It pauses mid-loop if a fix has wide blast radius or a finding is not applicable.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
Claude tracks progress in a markdown report under `.prowler/` at your project root — one file per framework × account. Open it any time to see exactly where the flow is. When all findings are addressed, Claude proposes a fresh Prowler scan to verify everything end-to-end.
|
||||
|
||||
## Uninstalling
|
||||
|
||||
```text
|
||||
/plugin uninstall prowler@prowler-plugins
|
||||
/plugin marketplace remove prowler-plugins
|
||||
```
|
||||
|
||||
The stored API key is removed automatically.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
| --- | --- | --- |
|
||||
| `/mcp` shows `prowler` as failed | Rejected API key | Generate a new one in Prowler Cloud and reinstall the plugin to re-prompt. |
|
||||
| Skill not invoked when expected | The skill description didn't match the prompt | Mention the framework name plus "compliance" or "compliant" in your prompt. |
|
||||
| "Framework not supported" | Prowler Hub does not list the framework for that provider | Open an issue or PR at [github.com/prowler-cloud/prowler](https://github.com/prowler-cloud/prowler). |
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
title: "Prowler for MSPs and MSSPs"
|
||||
sidebarTitle: "Overview"
|
||||
---
|
||||
|
||||
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.
|
||||
|
||||
The console is available at [partners.prowler.com](https://partners.prowler.com).
|
||||
|
||||
<Card title="Sign Up for Prowler for MSPs and MSSPs" icon="rocket" href="https://partners.prowler.com/sign-up" />
|
||||
|
||||
## What You Get
|
||||
|
||||
* **Customer onboarding:** provision a Prowler Cloud tenant for each customer, with a billing plan selected up front.
|
||||
* **Delegated access:** open any customer's Prowler Cloud tenant from the console. Every action is attributed to you acting on behalf of that customer.
|
||||
* **Team and roles:** invite team members by email and assign a role that governs what they can do.
|
||||
* **Consolidated billing:** each customer carries its own plan, with month-to-date revenue reported across every customer.
|
||||
* **Branding:** upload your logo to appear alongside Prowler branding in the console.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
Three objects make up the model. Getting these straight makes the rest of the documentation easy to follow.
|
||||
|
||||
| Object | What it is |
|
||||
|---|---|
|
||||
| **Partner organization** | The provider's own company. The top-level container for everything below, created at sign-up. |
|
||||
| **Customer** | One of the provider's customers. Each customer maps to a Prowler Cloud tenant and carries its own billing plan. |
|
||||
| **Team member** | A user in the partner organization, holding a role that governs what they can do. |
|
||||
|
||||
## How It Relates to Prowler Cloud
|
||||
|
||||
| | Prowler Cloud | Prowler for MSPs and MSSPs |
|
||||
|---|---|---|
|
||||
| **Audience** | End customers | MSPs, MSSPs, resellers, consultants |
|
||||
| **Console** | [cloud.prowler.com](https://cloud.prowler.com) | [partners.prowler.com](https://partners.prowler.com) |
|
||||
| **Scope** | One organization's own cloud accounts | Many customer organizations |
|
||||
| **Billing** | Each organization pays for itself | The provider manages a plan per customer |
|
||||
| **Branding** | Prowler-branded | Your logo alongside Prowler branding |
|
||||
|
||||
Your customers keep signing in to Prowler Cloud with their own users. Provider-side access is **additive** — it does not replace or restrict customer-side users.
|
||||
|
||||
## The Console at a Glance
|
||||
|
||||
Signing in lands you on the **Dashboard**. The sidebar carries:
|
||||
|
||||
| Entry | What it does | Visible to |
|
||||
|---|---|---|
|
||||
| **Dashboard** | Partner Insights, a Billing Overview card and an Active Customers table | Everyone |
|
||||
| **Customers** | Add customers, review their posture and billing, and open their Prowler Cloud tenant | Everyone |
|
||||
| **Team** | Invite, re-invite, disable and remove team members | Roles with **Manage members** |
|
||||
| **Settings** | Profile, Partner Code, branding and security | Everyone; editing requires **Manage settings** |
|
||||
|
||||

|
||||
|
||||
**Partner Insights** is the top row: **Total Customers**, broken down into active and non-paid; **Cloud Accounts**, broken down by cloud provider; and **Monitored Resources**, with a note on organizations whose critical risk has grown. Each card carries a 30-day trend.
|
||||
|
||||
Below it, **Billing Overview** reports monthly expenses against the previous month and splits revenue for the period into annual, monthly and overage. **Active Customers** lists your customers with their provider count, resource count and last completed scan, and carries its own **Add Customer** button.
|
||||
|
||||
## Getting Access
|
||||
|
||||
Sign-up is self-service, approval is not. Register at [partners.prowler.com/sign-up](https://partners.prowler.com/sign-up), then verify your email address — the organization sits in **Pending email verification** until you do, and the Prowler team does not review it before that. Verifying moves the organization to **Pending approval**. Once approved, you can invite your team and start onboarding customers.
|
||||
|
||||
## Next Steps
|
||||
|
||||
<Columns cols={2}>
|
||||
<Card title="Sign Up and Sign In" icon="user-plus" href="/user-guide/tutorials/prowler-for-msps-sign-up">
|
||||
Register, verify your email, and get approved.
|
||||
</Card>
|
||||
<Card title="Your Partner Organization" icon="briefcase" href="/user-guide/tutorials/prowler-for-msps-organization">
|
||||
Lifecycle, settings, Partner Code and closing your organization.
|
||||
</Card>
|
||||
<Card title="Onboarding Customers" icon="building" href="/user-guide/tutorials/prowler-for-msps-customers">
|
||||
Add customers and open their Prowler Cloud tenants.
|
||||
</Card>
|
||||
<Card title="Managing Your Team" icon="users" href="/user-guide/tutorials/prowler-for-msps-team">
|
||||
Invite team members and assign roles.
|
||||
</Card>
|
||||
</Columns>
|
||||
@@ -26,7 +26,7 @@ The fastest way to get started is the **Cloud MCP Server** at `https://mcp.prowl
|
||||
```
|
||||
|
||||
<Card title="Connect Your MCP Client to the Cloud MCP Server" icon="cloud" href="/getting-started/basic-usage/prowler-mcp#cloud-mcp-server-configuration-recommended" horizontal>
|
||||
Step-by-step setup for Claude Desktop, Claude Code, Cursor, and other clients.
|
||||
Step-by-step setup for Claude Code, Codex, Cursor, VS Code, and other agents.
|
||||
</Card>
|
||||
|
||||
<Note>
|
||||
|
||||
|
After Width: | Height: | Size: 433 KiB |
|
After Width: | Height: | Size: 591 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 369 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 309 KiB |
|
After Width: | Height: | Size: 705 KiB |
|
After Width: | Height: | Size: 502 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 410 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 796 KiB |
|
After Width: | Height: | Size: 547 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 701 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 1019 KiB |
|
After Width: | Height: | Size: 665 KiB |
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 575 KiB |
|
Before Width: | Height: | Size: 422 KiB After Width: | Height: | Size: 557 KiB |
|
After Width: | Height: | Size: 469 KiB |
|
After Width: | Height: | Size: 661 KiB |
|
After Width: | Height: | Size: 609 KiB |
|
After Width: | Height: | Size: 711 KiB |
|
Before Width: | Height: | Size: 584 KiB After Width: | Height: | Size: 667 KiB |
|
After Width: | Height: | Size: 508 KiB |
|
After Width: | Height: | Size: 293 KiB |
|
After Width: | Height: | Size: 266 KiB |
|
After Width: | Height: | Size: 335 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 469 KiB |
|
After Width: | Height: | Size: 574 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 412 KiB |
|
After Width: | Height: | Size: 337 KiB |
|
After Width: | Height: | Size: 445 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 334 KiB |
|
After Width: | Height: | Size: 357 KiB |
|
After Width: | Height: | Size: 396 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 370 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 334 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 44 KiB |
@@ -48,6 +48,7 @@ Prowler supports a wide range of providers organized by category:
|
||||
| [Cloudflare](/user-guide/providers/cloudflare/getting-started-cloudflare) | Official | Accounts | UI, API, CLI |
|
||||
| [E2E Networks](/user-guide/providers/e2enetworks/getting-started-e2enetworks) | [Contact us](https://prowler.com/contact) | Projects | CLI |
|
||||
| [Google Cloud](/user-guide/providers/gcp/getting-started-gcp) | Official | Projects | UI, API, CLI |
|
||||
| [Huawei Cloud](/user-guide/providers/huaweicloud/getting-started-huaweicloud) | [Contact us](https://prowler.com/contact) | Accounts | CLI |
|
||||
| [Linode](/user-guide/providers/linode/getting-started-linode) | [Contact us](https://prowler.com/contact) | Accounts | CLI |
|
||||
| **NHN** | [Contact us](https://prowler.com/contact) | Tenants | CLI |
|
||||
| [OpenStack](/user-guide/providers/openstack/getting-started-openstack) | Official | Projects | UI, API, CLI |
|
||||
@@ -83,7 +84,7 @@ Prowler supports a wide range of providers organized by category:
|
||||
|
||||
| Provider | Support | Audit Scope/Entities | Interface |
|
||||
| ------------------------------------------------------------------- | -------- | -------------------- | --------- |
|
||||
| [Image](/user-guide/providers/image/getting-started-image) | Official | Container Images / Registries | CLI, API |
|
||||
| [Image](/user-guide/providers/image/getting-started-image) | Official | Container Images / Registries | UI, API, CLI |
|
||||
|
||||
### Custom Providers (Prowler Private Cloud Only)
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ li[data-title="Prowler Lighthouse AI"] > button span:first-child::after,
|
||||
li[data-title="Providers"] > button span:first-child::after,
|
||||
li[data-title="Scans"] > button span:first-child::after,
|
||||
li[data-title="Prowler MCP"] > button span:first-child::after,
|
||||
li[data-title="Prowler for AI Agents"] > button span:first-child::after,
|
||||
div:has(+ ul a[href="/security/encryption"]) h3 span::after,
|
||||
li[id="/user-guide/compliance/tutorials/cross-provider-compliance"] a > div > div > span:first-child::after,
|
||||
li[id="/user-guide/tutorials/prowler-alerts"] a > div > div > span:first-child::after,
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
---
|
||||
title: "Connect Claude Code to Prowler MCP Server"
|
||||
sidebarTitle: "Claude Code"
|
||||
---
|
||||
|
||||
Connect [Claude Code](https://www.claude.com/product/claude-code) to the Prowler Cloud MCP Server at `https://mcp.prowler.com/mcp`.
|
||||
|
||||
## Where Claude Code Runs
|
||||
|
||||
Claude Code runs in two places. Both read the same configuration file, so you set it up **once from a terminal** and it works in both.
|
||||
|
||||
| Surface | How you open it | Reads | Covered by |
|
||||
|---|---|---|---|
|
||||
| **Claude Code CLI** | `claude` in a terminal | `~/.claude.json` | This guide |
|
||||
| **Claude Code in the desktop app** | The **Code** tab inside the Claude app | `~/.claude.json` — the same file | This guide, [set up from a terminal](#claude-code-in-the-desktop-app-code-tab) |
|
||||
| **Claude app Chat** | The **Chat** tab inside the Claude app | `claude_desktop_config.json` | [Claude App Chat](/user-guide/ai-agents/claude-desktop) — a separate setup |
|
||||
|
||||
<Warning>
|
||||
**The Chat tab is not Claude Code.** It is a different product surface with its own configuration file and its own connection method (a local bridge). Nothing on this page applies to it. If you want Prowler in Chat, use the [Claude App Chat](/user-guide/ai-agents/claude-desktop) guide instead.
|
||||
</Warning>
|
||||
|
||||
## Choose Your Setup
|
||||
|
||||
There are two ways to connect. Both end with the same MCP Server connection, the difference is what comes with it.
|
||||
|
||||
| | 🔌 **Prowler Plugin** | ⚙️ **MCP Connection Only** |
|
||||
|---|---|---|
|
||||
| **What you get** | The MCP connection **plus** the official Prowler skills for cloud security tasks | The MCP connection |
|
||||
| **Setup** | Two slash commands, prompts for the API key | One `claude mcp add` command |
|
||||
| **Guided workflows** | ✅ Skills drive multi-step security work end to end | ❌ You drive the conversation |
|
||||
| **Best for** | Structured cloud security work, such as taking an account to compliance | Ad-hoc queries and your own workflows |
|
||||
| **Where to use it** | Claude Code CLI | Claude Code CLI, and the **recommended setup for the desktop app's [Code tab](#claude-code-in-the-desktop-app-code-tab)** |
|
||||
|
||||
<Note>
|
||||
**The plugin already includes the MCP connection.** If you install the plugin, do **not** also run `claude mcp add` — you would end up with the server configured twice.
|
||||
</Note>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Claude Code** installed and signed in. See the [official install guide](https://www.claude.com/product/claude-code).
|
||||
- **A Prowler Cloud account.** The free tier is enough to start. Sign up at [cloud.prowler.com](https://cloud.prowler.com).
|
||||
|
||||
## Get Your Prowler API Key
|
||||
|
||||
Create an API key in Prowler Cloud and copy it. The key begins with `pk_` and is shown only once. Check the [API Keys](/user-guide/tutorials/prowler-app-api-keys#creating-api-keys) guide for details.
|
||||
|
||||
---
|
||||
|
||||
# Option 1: Install the Prowler Plugin
|
||||
|
||||
<Warning>
|
||||
**Preview**: this plugin is under active development. Please report issues on [GitHub](https://github.com/prowler-cloud/prowler/issues) or join the [Slack community](https://goto.prowler.com/slack) for feedback.
|
||||
</Warning>
|
||||
|
||||
End-to-end cloud security from inside Claude Code, powered by the Prowler MCP server. The plugin bundles the official Prowler skills, task-specific workflows that let Claude carry out multi-step security work against a Prowler Cloud-connected account, rather than answering one question at a time.
|
||||
|
||||
### Included Skills
|
||||
|
||||
| Skill | What it does |
|
||||
| --- | --- |
|
||||
| `prowler:framework-compliance-triage` | Walks an account through a compliance assessment and remediates findings until the chosen security or industry framework is compliant. |
|
||||
|
||||
<Note>
|
||||
More skills are on the way. Installing the plugin keeps you current — new skills arrive with plugin updates, no extra configuration required.
|
||||
</Note>
|
||||
|
||||
## Installation (Claude Code CLI)
|
||||
|
||||
<Tabs>
|
||||
<Tab title="From GitHub (recommended)">
|
||||
Inside a Claude Code session:
|
||||
|
||||
```text
|
||||
/plugin marketplace add prowler-cloud/prowler
|
||||
/plugin install prowler@prowler-plugins
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="From a local clone">
|
||||
If you already have the repository checked out:
|
||||
|
||||
```text
|
||||
/plugin marketplace add /absolute/path/to/prowler
|
||||
/plugin install prowler@prowler-plugins
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
On first install, Claude Code prompts for your **Prowler API key**. The value is stored securely (macOS keychain or `~/.claude/.credentials.json`) and used to authenticate against Prowler Cloud.
|
||||
|
||||
## Verify the Installation
|
||||
|
||||
In a Claude Code session:
|
||||
|
||||
```text
|
||||
/mcp → "prowler" appears as a connected server
|
||||
/plugin → "prowler" enabled, with the bundled Prowler skills listed
|
||||
```
|
||||
|
||||
If `/mcp` reports the `prowler` server as failed, the most common cause is a rejected API key, re-issue one in Prowler Cloud and reinstall the plugin so it re-prompts.
|
||||
|
||||
## Usage
|
||||
|
||||
Describe the security task you want done and Claude selects the matching skill.
|
||||
|
||||
### Framework Compliance Triage
|
||||
|
||||
Mention the framework you want to comply with:
|
||||
|
||||
- *"Make my AWS production account compliant with CIS 4.0."*
|
||||
- *"Make my current Terraform project compliant with Prowler ThreatScore Compliance Framework based on the latest scan results."*
|
||||
- *"Help me get to 100% on PCI-DSS for this GCP project."*
|
||||
|
||||
You pick a **primary tool** (Terraform, gh / az / aws CLI, web console, or mixed) and a **mode**:
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Claude-assisted (default)" icon="hand">
|
||||
Claude shows each fix — target resource, exact commands, side effects, reversibility — and waits for your go-ahead before applying.
|
||||
</Card>
|
||||
<Card title="Claude autonomous" icon="robot">
|
||||
Claude presents a single up-front plan grouped by shared fixes, waits for one confirmation, then proceeds. It pauses mid-loop if a fix has wide blast radius or a finding is not applicable.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
Claude tracks progress in a markdown report under `.prowler/` at your project root — one file per framework × account. Open it any time to see exactly where the flow is. When all findings are addressed, Claude proposes a fresh Prowler scan to verify everything end-to-end.
|
||||
|
||||
## Uninstalling
|
||||
|
||||
```text
|
||||
/plugin uninstall prowler@prowler-plugins
|
||||
/plugin marketplace remove prowler-plugins
|
||||
```
|
||||
|
||||
The stored API key is removed automatically.
|
||||
|
||||
---
|
||||
|
||||
# Option 2: Connect the MCP Server Only
|
||||
|
||||
Choose this when you want Prowler's tools available without the Prowler skills.
|
||||
|
||||
## Add the Server
|
||||
|
||||
Claude Code connects to remote HTTP MCP servers natively and supports custom headers, so no bridge is required.
|
||||
|
||||
```bash
|
||||
export PROWLER_API_KEY="pk_your_api_key_here"
|
||||
|
||||
claude mcp add --transport http prowler https://mcp.prowler.com/mcp \
|
||||
--header "Authorization: Bearer $PROWLER_API_KEY" \
|
||||
--scope user
|
||||
```
|
||||
|
||||
<Frame>
|
||||
<img src="/images/prowler-mcp/claude/claude-code-mcp-add.png" alt="Terminal showing the claude mcp add command and its confirmation output" />
|
||||
</Frame>
|
||||
|
||||
<Warning>
|
||||
**Always pass `--scope user`.** The default scope is `local`, which binds the server to the single directory you ran the command in. A locally-scoped server does not load when you open Claude Code anywhere else — this is the most common reason Prowler tools appear to vanish.
|
||||
</Warning>
|
||||
|
||||
| Scope | Loads in | Shared | Stored in |
|
||||
|-------|----------|--------|-----------|
|
||||
| `user` | All your projects | No | `~/.claude.json`, top-level `mcpServers` |
|
||||
| `project` | Current project only | Yes, via version control | `.mcp.json` in the project root |
|
||||
| `local` (default) | Current project only | No | `~/.claude.json`, under that project's entry |
|
||||
|
||||
When the same server name exists in more than one scope, precedence is **local → project → user**. The winning entry is used whole; fields are not merged.
|
||||
|
||||
<Warning>
|
||||
Avoid `--scope project` for Prowler. That writes `.mcp.json` into your repository, and committing the file would publish your API key.
|
||||
</Warning>
|
||||
|
||||
<Note>
|
||||
**Local server:** Replace the URL with your own HTTP endpoint. Everything else stays the same.
|
||||
</Note>
|
||||
|
||||
## Verify the Connection
|
||||
|
||||
```bash
|
||||
claude mcp get prowler # shows which scope holds the definition
|
||||
claude mcp list # lists all servers and their status
|
||||
```
|
||||
|
||||
Inside a Claude Code session, run `/mcp` to see connected servers and their tools.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/prowler-mcp/claude/claude-code-mcp-command.png" alt="Claude Code session showing the /mcp command output with the Prowler server connected" />
|
||||
</Frame>
|
||||
|
||||
## Start Using Prowler MCP
|
||||
|
||||
- *"Show me all critical findings from my AWS accounts"*
|
||||
- *"What does the S3 bucket public access check do?"*
|
||||
- *"Onboard this new AWS account in my Prowler organization"*
|
||||
|
||||
<Frame>
|
||||
<img src="/images/prowler-mcp/claude/claude-code-prowler-query.png" alt="Claude Code answering a question about critical findings using Prowler MCP tools" />
|
||||
</Frame>
|
||||
|
||||
---
|
||||
|
||||
# Claude Code in the Desktop App (Code Tab)
|
||||
|
||||
The **Code** tab in the Claude desktop app runs the same Claude Code as the CLI, and reads the same `~/.claude.json`. There is no separate Prowler setup for it — you configure it **from a terminal** and the Code tab picks it up.
|
||||
|
||||
<Note>
|
||||
**Use [Option 2](#option-2-connect-the-mcp-server-only) with `--scope user` here.** It is the recommended setup for the Code tab. The Prowler plugin ([Option 1](#option-1-install-the-prowler-plugin)) is not the recommended route for the desktop app — install it in the Claude Code CLI instead.
|
||||
</Note>
|
||||
|
||||
<Warning>
|
||||
**You cannot do this from inside the app.** The desktop app has no interface for adding an MCP server to a Claude Code session. **Settings → Connectors** configures the **Chat** tab, not the **Code** tab, so anything added there never reaches Claude Code. Trying to configure it from the app is the main reason this appears not to work.
|
||||
</Warning>
|
||||
|
||||
<Steps>
|
||||
<Step title="Add the server at user scope from a terminal">
|
||||
In a normal terminal — not inside the app:
|
||||
|
||||
```bash
|
||||
export PROWLER_API_KEY="pk_your_api_key_here"
|
||||
|
||||
claude mcp add --transport http prowler https://mcp.prowler.com/mcp \
|
||||
--header "Authorization: Bearer $PROWLER_API_KEY" \
|
||||
--scope user
|
||||
```
|
||||
|
||||
`--scope user` is what makes this work. It writes to `~/.claude.json`, the file the Code tab reads.
|
||||
</Step>
|
||||
|
||||
<Step title="Confirm it landed at user scope">
|
||||
```bash
|
||||
claude mcp get prowler
|
||||
```
|
||||
|
||||
The scope must be `user`. A `local`-scoped server is bound to the directory you ran the command in and will not load in an app session opened elsewhere.
|
||||
</Step>
|
||||
|
||||
<Step title="Restart the Claude app">
|
||||
Quit the app completely and reopen it. Configuration is read at startup.
|
||||
</Step>
|
||||
|
||||
<Step title="Verify in the Code tab">
|
||||
Open a **Code** tab session and ask for a Prowler tool: "Do you have access to the Prowler MCP tools?", it should respond with a list of available tools or confirming that it has access.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
---
|
||||
|
||||
# Claude App Chat (Chat Tab)
|
||||
|
||||
Not covered by this page. The **Chat** tab is a separate surface: it does not read `~/.claude.json`, so a server added with `claude mcp add` appears in the CLI and in the Code tab but **never** in Chat. That is expected behavior, not a broken setup.
|
||||
|
||||
Chat reads `claude_desktop_config.json` and reaches the Prowler MCP Server through a local bridge.
|
||||
|
||||
<Card title="Connect the Claude App Chat" icon="comment" href="/user-guide/ai-agents/claude-desktop" horizontal>
|
||||
Separate guide: local bridge and its own configuration file
|
||||
</Card>
|
||||
|
||||
---
|
||||
|
||||
# Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
| --- | --- | --- |
|
||||
| `/mcp` shows `prowler` as failed | Rejected API key | Generate a new one in Prowler Cloud. With the plugin, reinstall it to re-prompt. |
|
||||
| No MCP servers configured | Server added at `local` scope from another directory | Run `claude mcp get prowler`, then re-add with `--scope user`. |
|
||||
| A stale entry overrides a working one | Precedence is local → project → user | `claude mcp remove prowler --scope local` |
|
||||
| Tools appear in the CLI but not in the app's **Code** tab | Server added at `local` scope, or the app was not restarted | Re-add with `--scope user`, then quit and reopen the app. See [Claude Code in the Desktop App](#claude-code-in-the-desktop-app-code-tab). |
|
||||
| Tools appear in the **Code** tab but not the **Chat** tab | Chat is a different surface with its own config file | Expected. Set Chat up separately, see [Claude App Chat](/user-guide/ai-agents/claude-desktop). |
|
||||
| No way to add the server from inside the app | The app has no MCP interface for Claude Code sessions | Configure it from a terminal with `--scope user`, then restart the app. See [Claude Code in the Desktop App](#claude-code-in-the-desktop-app-code-tab). |
|
||||
| Skill not invoked when expected | The prompt didn't match any skill's description | Name the task explicitly. For compliance triage, mention the framework plus "compliance" or "compliant". |
|
||||
| "Framework not supported" | Prowler Hub does not list the framework for that provider | Open an issue or PR at [github.com/prowler-cloud/prowler](https://github.com/prowler-cloud/prowler). |
|
||||
|
||||
### Authentication Fails With 401
|
||||
|
||||
- Confirm the header value includes the `Bearer ` prefix.
|
||||
- Check that `PROWLER_API_KEY` was set when you ran `claude mcp add` — the shell expands it at that moment and stores the resulting literal value. If the variable was empty, the stored header reads `Bearer ` with nothing after it. Verify with `claude mcp get prowler`.
|
||||
- Confirm the key has not been revoked in Prowler Cloud.
|
||||
|
||||
## Next Steps
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Tools Reference" icon="wrench" href="/getting-started/basic-usage/prowler-mcp-tools">
|
||||
Explore all available tools and capabilities
|
||||
</Card>
|
||||
<Card title="All MCP Clients" icon="plug" href="/getting-started/basic-usage/prowler-mcp">
|
||||
Configuration reference for every supported client
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Getting Help
|
||||
|
||||
- Search for existing [GitHub issues](https://github.com/prowler-cloud/prowler/issues)
|
||||
- Ask for help in our [Slack community](https://goto.prowler.com/slack)
|
||||
- Report a new issue on [GitHub](https://github.com/prowler-cloud/prowler/issues/new)
|
||||
@@ -0,0 +1,142 @@
|
||||
---
|
||||
title: "Connect the Claude App Chat to Prowler MCP Server"
|
||||
sidebarTitle: "Claude App (Chat)"
|
||||
---
|
||||
|
||||
Connect the **Chat** tab of the Claude desktop app to the Prowler Cloud MCP Server at `https://mcp.prowler.com/mcp`.
|
||||
|
||||
<Warning>
|
||||
**This page covers the Chat tab only.** Looking for **Claude Code** — either the CLI or the app's **Code** tab? Those are a different surface, with a different configuration file and a different connection method. See [Connect Claude Code](/user-guide/ai-agents/claude-code).
|
||||
</Warning>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Claude desktop app** installed and signed in.
|
||||
- **Node.js and npm**, to install the bridge.
|
||||
- **A Prowler Cloud account.** The free tier is enough to start. Sign up at [cloud.prowler.com](https://cloud.prowler.com).
|
||||
|
||||
## Why "Add Custom Connector" Does Not Work
|
||||
|
||||
The app's **Settings → Connectors → Add custom connector** dialog is the obvious place to paste an MCP URL, but it does not fit the Prowler Cloud MCP Server for two independent reasons:
|
||||
|
||||
1. **Connectors authenticate with OAuth.** Authenticating with a fixed API key sent as a request header is a separate mechanism that Anthropic documents as **beta**, rolled out on request. Without it, the dialog offers a URL and OAuth client credentials, with nowhere to supply `Authorization: Bearer pk_...`.
|
||||
2. **Connectors do not connect from your machine.** Claude reaches your MCP server from Anthropic's cloud infrastructure rather than your local device. A Prowler MCP Server on `localhost`, behind a VPN, or restricted by an IP allowlist is unreachable that way regardless of authentication.
|
||||
|
||||
Use a local bridge instead, as described below.
|
||||
|
||||
## Step 1: Get Your Prowler API Key
|
||||
|
||||
Create an API key in Prowler Cloud and copy it. The key begins with `pk_` and is shown only once. Check the [API Keys](/user-guide/tutorials/prowler-app-api-keys#creating-api-keys) guide for details.
|
||||
|
||||
## Step 2: Install the Bridge
|
||||
|
||||
`mcp-remote` presents the remote HTTP server to Claude as a local STDIO server and injects the `Authorization` header. Install a pinned version into a dedicated directory:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.local/share/prowler-mcp-bridge
|
||||
cd ~/.local/share/prowler-mcp-bridge
|
||||
npm init -y
|
||||
npm install --save-exact mcp-remote@0.1.38
|
||||
```
|
||||
|
||||
<Warning>
|
||||
Do not configure Claude to run `npx mcp-remote` directly. `npx` can fetch and execute a new version on every launch, which means unreviewed code runs with access to your API key. Install a pinned version and point Claude at the installed binary.
|
||||
</Warning>
|
||||
|
||||
<Note>
|
||||
`mcp-remote` is community-maintained and is not an Anthropic product. Review it before use.
|
||||
</Note>
|
||||
|
||||
## Step 3: Edit the Configuration File
|
||||
|
||||
In the Claude app, go to **Settings → Developer** and click **Edit Config**. This reveals `claude_desktop_config.json`:
|
||||
|
||||
- **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
|
||||
- **Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
|
||||
|
||||
<Frame>
|
||||
<img src="/images/prowler-mcp/claude/claude-desktop-developer-settings.png" alt="Claude app Settings Developer tab showing the Edit Config button" />
|
||||
</Frame>
|
||||
|
||||
Add the following, replacing the `command` path with the absolute path to the installed binary and the placeholder with your API key:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"prowler": {
|
||||
"command": "/absolute/path/to/.local/share/prowler-mcp-bridge/node_modules/.bin/mcp-remote",
|
||||
"args": [
|
||||
"https://mcp.prowler.com/mcp",
|
||||
"--header",
|
||||
"Authorization: Bearer ${PROWLER_API_KEY}"
|
||||
],
|
||||
"env": {
|
||||
"PROWLER_API_KEY": "pk_your_api_key_here"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<Note>
|
||||
**Local server:** Replace the URL with your own HTTP endpoint. Everything else stays the same.
|
||||
</Note>
|
||||
|
||||
## Step 4: Restart the App
|
||||
|
||||
Quit the Claude app completely and reopen it. Configuration is read at startup.
|
||||
|
||||
## Step 5: Start Using Prowler MCP
|
||||
|
||||
Open a Chat conversation and ask questions that use the Prowler tools:
|
||||
|
||||
- *"Show me all critical findings from my AWS accounts"*
|
||||
- *"What does the S3 bucket public access check do?"*
|
||||
- *"Summarize my CIS compliance status by provider"*
|
||||
|
||||
<Frame>
|
||||
<img src="/images/prowler-mcp/claude/claude-desktop-prowler-tools.png" alt="Claude app chat showing the Prowler MCP tools available" />
|
||||
</Frame>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Server Does Not Appear After Editing the Config
|
||||
|
||||
- Quit and reopen the app entirely — closing the window is not enough on macOS.
|
||||
- Confirm `claude_desktop_config.json` is valid JSON.
|
||||
- Confirm the `command` path points at a real executable. A wrong path surfaces as the server failing to start rather than as an auth error.
|
||||
|
||||
### Tools Appear in Claude Code but Not in Chat
|
||||
|
||||
Expected. The Chat tab does not read `~/.claude.json`, so servers added with `claude mcp add` never appear here. The Chat tab needs an entry in `claude_desktop_config.json`, which is what this guide sets up.
|
||||
|
||||
### Authentication Fails With 401
|
||||
|
||||
- Confirm the header value includes the `Bearer ` prefix.
|
||||
- Confirm the key has not been revoked in Prowler Cloud.
|
||||
|
||||
### Checking the Logs
|
||||
|
||||
- **macOS:** `~/Library/Logs/Claude/mcp*.log`
|
||||
- **Windows:** `%APPDATA%\Claude\logs\mcp*.log`
|
||||
|
||||
```bash
|
||||
tail -f ~/Library/Logs/Claude/mcp*.log
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Tools Reference" icon="wrench" href="/getting-started/basic-usage/prowler-mcp-tools">
|
||||
Explore all available tools and capabilities
|
||||
</Card>
|
||||
<Card title="All MCP Clients" icon="plug" href="/getting-started/basic-usage/prowler-mcp">
|
||||
Configuration reference for every supported client
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Getting Help
|
||||
|
||||
- Search for existing [GitHub issues](https://github.com/prowler-cloud/prowler/issues)
|
||||
- Ask for help in our [Slack community](https://goto.prowler.com/slack)
|
||||
- Report a new issue on [GitHub](https://github.com/prowler-cloud/prowler/issues/new)
|
||||
@@ -0,0 +1,188 @@
|
||||
---
|
||||
title: "Connect Codex / ChatGPT Desktop to Prowler MCP Server"
|
||||
sidebarTitle: "Codex / ChatGPT"
|
||||
---
|
||||
|
||||
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.
|
||||
|
||||
## Which Codex Surfaces Work
|
||||
|
||||
Codex keeps MCP servers in one file, `~/.codex/config.toml`. You can set it up from either the **Codex / ChatGPT desktop app** or the **Codex CLI** — both write to that same file, so pick whichever you already use.
|
||||
|
||||
| Surface | Set it up here | Notes |
|
||||
|---------|----------------|-------|
|
||||
| **[Codex / ChatGPT desktop app](https://learn.chatgpt.com/docs/app)** (macOS, Windows) | ✅ Yes | **Settings → MCP servers** |
|
||||
| **Codex CLI** (terminal) | ✅ Yes | `codex mcp` commands |
|
||||
| **Codex IDE extension** (VS Code) | Inherits | Works automatically once the app or CLI is configured |
|
||||
| **ChatGPT on the web** | ❌ No | Does not read local Codex configuration |
|
||||
|
||||
<Note>
|
||||
**Codex and ChatGPT share one desktop app.** Since July 2026 the standalone Codex app and the ChatGPT desktop app are the same application: Codex is a dedicated coding surface inside it, alongside Chat and Work. If you already had the Codex app, updating turns it into the new ChatGPT desktop app and it still opens in Codex. Either way, this guide applies.
|
||||
|
||||
Not to be confused with **ChatGPT Classic**, the name given to the previous-generation ChatGPT desktop app.
|
||||
</Note>
|
||||
|
||||
<Note>
|
||||
**Configure once, use everywhere.** The Codex documentation states that the ChatGPT desktop app, Codex CLI, and IDE extension "share this configuration. Once you configure your MCP servers, you can switch among those clients without redoing setup." Set the server up in the app or the CLI and the IDE extension picks it up with no extra work.
|
||||
</Note>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **The Codex / ChatGPT desktop app, or Codex CLI 0.46.0 or later.** Remote MCP servers over streamable HTTP were added to the CLI in 0.46.0 — check with `codex --version` and upgrade if needed.
|
||||
- **A Prowler Cloud account.** The free tier is enough to start. Sign up at [cloud.prowler.com](https://cloud.prowler.com).
|
||||
|
||||
## Step 1: Get Your Prowler API Key
|
||||
|
||||
Create an API key in Prowler Cloud and copy it. The key begins with `pk_` and is shown only once. Check the [API Keys](/user-guide/tutorials/prowler-app-api-keys#creating-api-keys) guide for details.
|
||||
|
||||
## Step 2: Add the Prowler MCP Server
|
||||
|
||||
The Prowler MCP Server needs two request headers: `Authorization` to authenticate you, and `User-Agent` because Codex does not send one by default.
|
||||
|
||||
Each tab below is a complete setup — follow the one that matches the surface you use.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Codex / ChatGPT desktop app">
|
||||
1. Open **Settings** and select **Plugins → MCPs**
|
||||
2. Click **Add server**
|
||||
3. Enter `prowler` as the name and choose type **Streamable HTTP**
|
||||
4. Enter the URL `https://mcp.prowler.com/mcp`
|
||||
5. Add two headers:
|
||||
|
||||
| Header | Value |
|
||||
|--------|-------|
|
||||
| `Authorization` | `Bearer pk_your_api_key_here` |
|
||||
| `User-Agent` | `codex` |
|
||||
|
||||
6. Save the server
|
||||
|
||||
<Frame>
|
||||
<img src="/images/prowler-mcp/codex/codex-app-mcp-servers.png" alt="Codex / ChatGPT desktop app Settings showing the MCP servers panel with the Add server dialog and both headers filled in" />
|
||||
</Frame>
|
||||
|
||||
<Note>
|
||||
**Enter the key directly here rather than using an environment variable.** Codex can read credentials from an environment variable, but desktop applications do not reliably inherit variables exported in a shell profile — on macOS an app launched from Finder or the Dock typically sees none of them. Pasting the key into the dialog is the approach that works consistently in the app.
|
||||
</Note>
|
||||
|
||||
<Warning>
|
||||
**This stores your API key in plain text** in `~/.codex/config.toml`. Treat that file accordingly: exclude it from dotfile repositories and config sync, and create the key from an account with the minimum permissions you need so its exposure is limited. Revoke and re-issue the key in Prowler Cloud if the file is ever shared.
|
||||
</Warning>
|
||||
</Tab>
|
||||
|
||||
<Tab title="Codex CLI">
|
||||
Register the server:
|
||||
|
||||
```bash
|
||||
codex mcp add prowler --url https://mcp.prowler.com/mcp
|
||||
```
|
||||
|
||||
Codex confirms with `Added global MCP server 'prowler'.`
|
||||
|
||||
Then add both headers by hand, since `codex mcp add` has no flag for headers. Open `~/.codex/config.toml` and complete the entry:
|
||||
|
||||
```toml
|
||||
[mcp_servers.prowler]
|
||||
url = "https://mcp.prowler.com/mcp"
|
||||
http_headers = { Authorization = "Bearer pk_your_api_key_here", "User-Agent" = "codex" }
|
||||
```
|
||||
|
||||
<Note>
|
||||
**Write the key literally rather than using an environment variable.** This is the form that works across every Codex surface. All of them read this same file, but only the CLI reliably sees variables exported in your shell profile — see the warning below.
|
||||
</Note>
|
||||
|
||||
<Warning>
|
||||
**This stores your API key in plain text** in `~/.codex/config.toml`. Treat that file accordingly: exclude it from dotfile repositories and config sync, and create the key from an account with the minimum permissions you need so its exposure is limited. Revoke and re-issue the key in Prowler Cloud if the file is ever shared.
|
||||
</Warning>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
Restart Codex once you are done.
|
||||
|
||||
<Note>
|
||||
**Local server:** Replace the URL with your own HTTP endpoint. Everything else stays the same.
|
||||
</Note>
|
||||
|
||||
## Step 3: Verify the Connection
|
||||
|
||||
Run `/mcp` in the app or in a CLI session to list connected servers and their tools.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/prowler-mcp/codex/codex-mcp-slash-command.png" alt="Codex composer showing the /mcp command output with Prowler tools listed" />
|
||||
</Frame>
|
||||
|
||||
From the CLI you can also inspect the stored entry directly:
|
||||
|
||||
```bash
|
||||
codex mcp list # one row per server, with status and auth
|
||||
codex mcp get prowler # full entry, header values masked
|
||||
```
|
||||
|
||||
<Warning>
|
||||
**Verify rather than assume.** Codex silently ignores unrecognized keys in `config.toml` — a misspelled key name produces no error at all, and the server simply never receives your credentials. Always confirm with `codex mcp get prowler` after editing the file by hand.
|
||||
</Warning>
|
||||
|
||||
## Step 4: Start Using Prowler MCP
|
||||
|
||||
Ask Codex questions that use the Prowler tools:
|
||||
|
||||
- *"Show me all critical findings from my AWS accounts"*
|
||||
- *"What does the S3 bucket public access check do?"*
|
||||
- *"List my connected Prowler providers and their last scan date"*
|
||||
|
||||
<Frame>
|
||||
<img src="/images/prowler-mcp/codex/codex-prowler-query.png" alt="Codex answering a question about critical findings using Prowler MCP tools" />
|
||||
</Frame>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Startup Fails With HTTP 403 Forbidden
|
||||
|
||||
Codex reports a handshake failure on startup, with an HTML error page rather than a JSON response:
|
||||
|
||||
```
|
||||
⚠ MCP client for `prowler` failed to start: MCP startup failed: handshaking with MCP server
|
||||
failed: ... unexpected server response: HTTP 403: <html>
|
||||
<head><title>403 Forbidden</title></head>
|
||||
```
|
||||
|
||||
The `User-Agent` header is missing. Codex's HTTP client does not send one, and requests without it are rejected before reaching the MCP server. Note this is a **403**, not a 401 — so it is not an API key problem. Add the header as shown in [Step 2](#step-2-add-the-prowler-mcp-server); the value itself does not matter, only that the header is present.
|
||||
|
||||
### Authentication Fails With 401
|
||||
|
||||
- Run `codex mcp get prowler` and confirm the entry has the headers you expect. Values are masked, but a missing header shows as `-`.
|
||||
- If you used a literal header, confirm the value starts with `Bearer ` and contains the full key.
|
||||
- **If it works in the CLI but fails in the desktop app or the VS Code extension, you are almost certainly using an environment variable.** Those surfaces do not inherit your shell profile. Switch that entry to a literal `Authorization` header as shown in [Step 2](#step-2-add-the-prowler-mcp-server).
|
||||
- If you use an environment variable, verify it is set in the environment Codex was launched from: `echo $PROWLER_API_KEY`.
|
||||
- With `env_http_headers` the variable must include the `Bearer ` prefix. With `bearer_token_env_var` it must **not** — Codex adds the prefix itself.
|
||||
- Confirm the key has not been revoked in Prowler Cloud.
|
||||
|
||||
### Server Not Listed
|
||||
|
||||
- Confirm your Codex CLI version is 0.46.0 or later with `codex --version`.
|
||||
- Run `codex mcp get prowler`. If it reports the server is not found, the entry was not written or the TOML table name is misspelled.
|
||||
- Check for a typo in the key names. Codex ignores unknown keys without warning.
|
||||
|
||||
### Project-Scoped Config Is Ignored
|
||||
|
||||
A `.codex/config.toml` inside a project is loaded **only when the project is trusted**. If your entry lives there and does nothing, trust the project or move the entry to `~/.codex/config.toml`.
|
||||
|
||||
### Tools Do Not Appear After Editing the Config
|
||||
|
||||
Restart Codex. Configuration is read at startup. In the app, quit completely and reopen it, sometimes just clous the window is not enough.
|
||||
|
||||
## Next Steps
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Tools Reference" icon="wrench" href="/getting-started/basic-usage/prowler-mcp-tools">
|
||||
Explore all available tools and capabilities
|
||||
</Card>
|
||||
<Card title="All MCP Clients" icon="plug" href="/getting-started/basic-usage/prowler-mcp">
|
||||
Configuration reference for every supported client
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Getting Help
|
||||
|
||||
- Search for existing [GitHub issues](https://github.com/prowler-cloud/prowler/issues)
|
||||
- Ask for help in our [Slack community](https://goto.prowler.com/slack)
|
||||
- Report a new issue on [GitHub](https://github.com/prowler-cloud/prowler/issues/new)
|
||||