Merge branch 'master' into PROWLER-2258-cross-account-compliance-view-and-pdf-report-ui
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Integration responses no longer disclose providers outside the visibility of the role, including the resources sideloaded through `?include=providers`
|
||||
@@ -0,0 +1 @@
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
Roles without unlimited visibility can no longer attach an integration to providers they cannot see, nor edit or delete an integration bound to them
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
@@ -2743,6 +2744,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 +2907,7 @@ class BaseWriteIntegrationSerializer(BaseWriteSerializer):
|
||||
)
|
||||
|
||||
|
||||
class IntegrationSerializer(RLSSerializer):
|
||||
class IntegrationSerializer(IntegrationProviderVisibilityMixin, RLSSerializer):
|
||||
"""
|
||||
Serializer for the Integration model.
|
||||
"""
|
||||
@@ -2904,15 +2936,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 +2946,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 +2999,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 +3055,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 +3073,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"]
|
||||
|
||||
|
||||
@@ -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,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"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -217,7 +222,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"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -660,6 +670,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
|
||||
|
||||
@@ -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). |
|
||||
@@ -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: 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: 164 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 44 KiB |
@@ -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)
|
||||
@@ -0,0 +1,171 @@
|
||||
---
|
||||
title: "Connect Cursor to Prowler MCP Server"
|
||||
sidebarTitle: "Cursor"
|
||||
---
|
||||
|
||||
Connect [Cursor](https://cursor.com/docs/mcp) to the Prowler Cloud MCP Server at `https://mcp.prowler.com/mcp` so the Cursor agent can query findings, inspect security checks, and manage your Prowler providers while you work.
|
||||
|
||||
Cursor supports remote MCP servers over HTTP natively, so no bridge or local installation is required.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Cursor** installed and authenticated. See the [official install guide](https://cursor.com/download).
|
||||
- **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
|
||||
|
||||
Cursor reads MCP servers from an `mcp.json` file. Choose the scope that fits your use case:
|
||||
|
||||
| Scope | File | Applies to |
|
||||
|-------|------|------------|
|
||||
| **Global** | `~/.cursor/mcp.json` | Every project you open in Cursor |
|
||||
| **Project** | `.cursor/mcp.json` in the project root | That project only |
|
||||
|
||||
Both files are merged. If the same server name appears in both, the project-level entry takes priority.
|
||||
|
||||
For Prowler, the **global** scope is usually the right choice — your findings are not tied to a single repository, and it keeps the API key out of any project directory that might be committed.
|
||||
|
||||
<Steps>
|
||||
<Step title="Open the MCP settings">
|
||||
From Agent Window open **Customize** in the Cursor sidebar, then select the MCP section.
|
||||
|
||||
On earlier versions, press `Cmd + Shift + J` (macOS) or `Ctrl + Shift + J` (Windows/Linux) to open Cursor Settings, then click **Tools & MCP** in the sidebar.
|
||||
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Add a new MCP server">
|
||||
Click **New MCP Server** (or **Add Custom MCP**). Cursor opens `mcp.json` in the editor.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/prowler-mcp/cursor/cursor-customize-page.png" alt="Cursor Customize page with the MCP section open" />
|
||||
</Frame>
|
||||
</Step>
|
||||
|
||||
<Step title="Add the Prowler configuration">
|
||||
Paste the following, replacing the placeholder with your API key:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"prowler": {
|
||||
"url": "https://mcp.prowler.com/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer <your-api-key-here>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Save the file. Cursor picks up the change and connects to the server.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/prowler-mcp/cursor/cursor-mcp-json.png" alt="Cursor editor showing the completed mcp.json with the Prowler server entry" />
|
||||
</Frame>
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Note>
|
||||
**Local server:** Replace the URL with your own HTTP endpoint. Everything else stays the same.
|
||||
</Note>
|
||||
|
||||
### Keeping the API Key Out of the File
|
||||
|
||||
Cursor resolves variables in the `command`, `args`, `env`, `url`, and `headers` fields, so you can reference an environment variable instead of writing the key into `mcp.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"prowler": {
|
||||
"url": "https://mcp.prowler.com/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${env:PROWLER_API_KEY}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Export the variable in your shell profile (`~/.zshrc`, `~/.bashrc`, or equivalent):
|
||||
|
||||
```bash
|
||||
export PROWLER_API_KEY="pk_your_api_key_here"
|
||||
```
|
||||
|
||||
<Note>
|
||||
The syntax is `${env:NAME}`, not a bare `${NAME}`. Restart Cursor after changing your shell profile so it inherits the new value.
|
||||
</Note>
|
||||
|
||||
<Warning>
|
||||
The `envFile` option does **not** work for remote servers — it is STDIO-only. Use `${env:...}` interpolation with variables set in your shell profile instead.
|
||||
</Warning>
|
||||
|
||||
This form is strongly recommended when using a **project-scoped** `.cursor/mcp.json`, since that file may be committed to version control.
|
||||
|
||||
## Step 3: Verify the Connection
|
||||
|
||||
Return to the MCP settings. The `prowler` server should be listed as enabled, with the Prowler tools shown beneath it.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/prowler-mcp/cursor/cursor-prowler-connected.png" alt="Cursor MCP settings showing the Prowler server connected with its tools listed" />
|
||||
</Frame>
|
||||
|
||||
## Step 4: Start Using Prowler MCP
|
||||
|
||||
Open the chat panel 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?"*
|
||||
- *"Which of my providers failed the most CIS checks in the last scan?"*
|
||||
|
||||
Cursor asks for approval before running an MCP tool the first time.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/prowler-mcp/cursor/cursor-prowler-query.png" alt="Cursor chat answering a question about critical findings using Prowler MCP tools" />
|
||||
</Frame>
|
||||
|
||||
You can toggle individual tools on or off from the tools list at the top of the chat panel, which is useful for keeping the active tool count down.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Server Does Not Connect
|
||||
|
||||
- Check that `mcp.json` is valid JSON. A trailing comma or missing brace prevents the whole file from loading.
|
||||
- Open **MCP Logs** in the Output panel for the specific error.
|
||||
- Confirm the URL is exactly `https://mcp.prowler.com/mcp`.
|
||||
|
||||
### Authentication Fails With 401
|
||||
|
||||
- Verify the header value includes the `Bearer ` prefix: `"Bearer pk_..."`, not just the key.
|
||||
- Confirm the key has not been revoked in Prowler Cloud.
|
||||
- If using `${env:PROWLER_API_KEY}`, check the variable is set in the environment Cursor inherits. Restart Cursor after editing your shell profile — a value exported only in an already-open terminal will not reach the app.
|
||||
|
||||
### The Entire `mcp.json` Is Ignored
|
||||
|
||||
Remove any `"type": "streamable-http"` field. One such entry causes the Cursor CLI to drop every server in the file silently.
|
||||
|
||||
### Some Prowler Tools Are Missing
|
||||
|
||||
Cursor limits how many tools it exposes to the agent at once. With several MCP servers enabled you may exceed it, and some tools become unavailable. Disable servers you are not using, or turn off individual tools from the chat panel's tools list.
|
||||
|
||||
## 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,49 @@
|
||||
---
|
||||
title: "Connect Your AI Agent to Prowler"
|
||||
sidebarTitle: "Overview"
|
||||
description: "Pick your AI agent and follow its guide to connect it to the Prowler Cloud MCP Server."
|
||||
---
|
||||
|
||||
Connect your AI agent to the Prowler Cloud MCP Server at `https://mcp.prowler.com/mcp` so it can query findings, inspect security checks, and manage your Prowler providers.
|
||||
|
||||
Pick your agent below. Each guide is a full walkthrough with screenshots, verification steps, and the caveats specific to that client.
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Claude Code" icon="terminal" href="/user-guide/ai-agents/claude-code">
|
||||
Plugin and MCP-only choices, 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 / ChatGPT Desktop App" icon="code" href="/user-guide/ai-agents/codex">
|
||||
ChatGPT Desktop App, Codex CLI, the VS Code extension through same config file
|
||||
</Card>
|
||||
<Card title="Cursor" icon="arrow-pointer" href="/user-guide/ai-agents/cursor">
|
||||
Agentic code editor. 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>
|
||||
|
||||
## Before You Start
|
||||
|
||||
All guides need the same two things:
|
||||
|
||||
- A **Prowler Cloud account** with at least one cloud provider connected. [Sign up](https://cloud.prowler.com) if you do not have one.
|
||||
- A **Prowler API key**, created in Prowler Cloud. The key begins with `pk_` and is shown only once. See the [API Keys](/user-guide/tutorials/prowler-app-api-keys#creating-api-keys) guide.
|
||||
|
||||
<Note>
|
||||
Using an agent that is not listed here? Any MCP-compatible client can connect. See the [generic configuration reference](/getting-started/basic-usage/prowler-mcp#cloud-mcp-server-configuration-recommended) for the raw connection details.
|
||||
</Note>
|
||||
|
||||
## Next Steps
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="What Is Prowler MCP" icon="cloud" href="/getting-started/products/prowler-mcp">
|
||||
How the MCP Server fits into Prowler
|
||||
</Card>
|
||||
<Card title="Configuration Reference" icon="gear" href="/getting-started/basic-usage/prowler-mcp">
|
||||
Cloud and local server options, all clients
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -0,0 +1,145 @@
|
||||
---
|
||||
title: "Connect VS Code and GitHub Copilot to Prowler MCP Server"
|
||||
sidebarTitle: "VS Code / Copilot"
|
||||
---
|
||||
|
||||
Connect [Visual Studio Code](https://code.visualstudio.com/docs/agents/reference/mcp-configuration) and GitHub Copilot agent mode to the Prowler Cloud MCP Server at `https://mcp.prowler.com/mcp` so Copilot can query findings, inspect security checks, and manage your Prowler providers.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **VS Code 1.102 or later.** MCP support became generally available in 1.102.
|
||||
- **GitHub Copilot** enabled, with access to agent mode.
|
||||
- **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
|
||||
|
||||
VS Code stores MCP servers in an `mcp.json` file. Choose the scope that fits your use case:
|
||||
|
||||
| Scope | How to open it | Applies to |
|
||||
|-------|----------------|------------|
|
||||
| **User** | Command palette → **MCP: Open User Configuration** | Every workspace |
|
||||
| **Workspace** | `.vscode/mcp.json` in the project root | That workspace only |
|
||||
|
||||
For Prowler, the **user** scope is usually the right choice — your findings are not tied to a single repository, and it keeps the API key out of any project directory that might be committed.
|
||||
|
||||
<Steps>
|
||||
<Step title="Open the MCP configuration">
|
||||
Open the command palette with `Cmd + Shift + P` (macOS) or `Ctrl + Shift + P` (Windows/Linux), then run **MCP: Open User Configuration**.
|
||||
|
||||
VS Code opens your user-level `mcp.json`. Use this command rather than navigating to the file by hand — the file lives inside your active profile folder, and the path differs per profile.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/prowler-mcp/vscode/vscode-command-palette.png" alt="VS Code command palette showing the MCP: Open User Configuration command" />
|
||||
</Frame>
|
||||
</Step>
|
||||
|
||||
<Step title="Add the Prowler configuration">
|
||||
Paste the following. This version prompts you for the API key on first use and stores it securely, so the key is never written into the file:
|
||||
|
||||
```json
|
||||
{
|
||||
"inputs": [
|
||||
{
|
||||
"type": "promptString",
|
||||
"id": "prowler-api-key",
|
||||
"description": "Prowler API Key",
|
||||
"password": true
|
||||
}
|
||||
],
|
||||
"servers": {
|
||||
"prowler": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.prowler.com/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${input:prowler-api-key}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Save the file.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/prowler-mcp/vscode/vscode-mcp-json.png" alt="VS Code editor showing the completed mcp.json with the Prowler server entry" />
|
||||
</Frame>
|
||||
</Step>
|
||||
|
||||
<Step title="Enter your API key">
|
||||
Start the server. VS Code prompts for the Prowler API key. Paste it and press Enter — VS Code stores it securely and does not ask again.
|
||||
</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
<Warning>
|
||||
**The root key is `servers`, not `mcpServers`.** VS Code uses a different schema from Cursor, Claude, and most other clients. Copying a `mcpServers` snippet from elsewhere silently fails to register the server.
|
||||
</Warning>
|
||||
|
||||
<Note>
|
||||
**Local server:** Replace the URL with your own HTTP endpoint. Everything else stays the same.
|
||||
</Note>
|
||||
|
||||
## Step 3: Verify the Connection
|
||||
|
||||
Run **MCP: List Servers** from the command palette. The `prowler` server should appear as running.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/prowler-mcp/vscode/vscode-list-servers.png" alt="VS Code MCP: List Servers output showing the Prowler server running" />
|
||||
</Frame>
|
||||
|
||||
Select the server to start, stop, or restart it, and to view its output log if the connection fails.
|
||||
|
||||
## Step 4: Start Using Prowler MCP
|
||||
|
||||
Open the Chat view and switch the mode selector to **Agent**. Click the tools icon to confirm the Prowler tools are available, then ask:
|
||||
|
||||
- *"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/vscode/vscode-agent-tools.png" alt="VS Code Copilot Chat in agent mode showing the Prowler tools in the tools picker" />
|
||||
</Frame>
|
||||
|
||||
Copilot asks for confirmation before running an MCP tool for the first time.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Server Does Not Appear
|
||||
|
||||
- Confirm the root key is `servers`, not `mcpServers`.
|
||||
- Confirm each server entry has `"type": "http"`.
|
||||
- Check that `mcp.json` is valid JSON.
|
||||
- Verify your VS Code version is 1.102 or later.
|
||||
|
||||
### Authentication Fails With 401
|
||||
|
||||
- Verify the header value includes the `Bearer ` prefix.
|
||||
- Confirm the key has not been revoked in Prowler Cloud.
|
||||
- If you mistyped the key at the prompt, run **MCP: List Servers**, select `prowler`, and restart it to be prompted again.
|
||||
|
||||
### Tools Do Not Appear in Chat
|
||||
|
||||
- Make sure the Chat view is in **Agent** mode. MCP tools are not available in Ask mode.
|
||||
- Open the tools picker and confirm the Prowler tools are enabled.
|
||||
|
||||
## 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)
|
||||
@@ -24,6 +24,12 @@ When enabled and configured:
|
||||
1. Security findings can be manually sent to Jira from the Findings table.
|
||||
2. Each finding creates a Jira work item with all the check's metadata, including guidance on how to remediate it.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
<VersionBadge version="5.36.0" />
|
||||
|
||||
Configuring and using the Jira integration requires the **Manage Integrations** permission. The Jira integration is tenant-wide, so it does not require **Unlimited Visibility** or any specific Provider Group. Findings sent to Jira are still limited to the providers the role can access.
|
||||
|
||||
## Configuration
|
||||
|
||||
To configure Jira integration in Prowler Cloud:
|
||||
|
||||
@@ -128,7 +128,7 @@ To resend the invitation to the user, it is necessary to explicitly **delete the
|
||||
|
||||
## Managing Groups and Roles
|
||||
|
||||
Roles combine administrative permissions with provider visibility. Administrative permissions control the actions a role can perform. Provider Groups and Unlimited Visibility control the providers, resources, findings, scans, and compliance results the role can access.
|
||||
Roles combine administrative permissions with provider visibility. Administrative permissions control the actions a role can perform. Provider Groups and Unlimited Visibility control the providers, resources, findings, scans, compliance results, and integrations the role can access.
|
||||
|
||||
<Note>
|
||||
**Only users that have the _Manage Account_ or _admin_ permission can access this section.**
|
||||
@@ -142,6 +142,12 @@ New roles have no provider visibility by default. Assign at least one Provider G
|
||||
|
||||
**Unlimited Visibility** grants organization-wide visibility across every provider, regardless of the Provider Groups assigned to the role. It does not grant administrative permissions.
|
||||
|
||||
#### Integration Visibility
|
||||
|
||||
<VersionBadge version="5.36.0" />
|
||||
|
||||
Integrations follow the visibility of the providers attached to them: a role can see an integration when it can access at least one of its providers, and only the providers visible to that role are listed on the integration. Editing or deleting an integration attached to providers outside the visibility of the role is not allowed. Integrations that are not attached to any provider, such as Jira, are tenant-wide and remain available to every role with the **Manage Integrations** permission.
|
||||
|
||||
#### Creating a Provider Group
|
||||
|
||||
Follow these steps to create a provider group in your account:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
`sagemaker_notebook_instance_no_secrets` check for AWS provider, scanning SageMaker notebook instance lifecycle configuration scripts (`OnCreate` and `OnStart`) for hardcoded secrets such as API keys, passwords, tokens, and connection strings
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "sagemaker_notebook_instance_no_secrets",
|
||||
"CheckTitle": "SageMaker notebook instance lifecycle configuration contains no hardcoded secrets",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices",
|
||||
"Sensitive Data Identifications/Passwords",
|
||||
"Effects/Data Exposure"
|
||||
],
|
||||
"ServiceName": "sagemaker",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "AwsSageMakerNotebookInstance",
|
||||
"ResourceGroup": "ai_ml",
|
||||
"Description": "**SageMaker notebook instance lifecycle configuration scripts** (`OnCreate` and `OnStart`) are analyzed for **embedded secrets**, detecting patterns like API keys, passwords, tokens, and connection strings. Findings reference the lifecycle hook and line numbers where potential secrets appear.",
|
||||
"Risk": "**Hardcoded secrets** in lifecycle configuration scripts can be read by anyone with SageMaker access to the notebook instance, letting attackers reuse the credentials to access databases, APIs, or cloud resources, enabling data exfiltration and unauthorized changes.\n\nRotation is harder, increasing dwell time and blast radius of compromises.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/sagemaker/latest/dg/notebook-lifecycle-config.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "aws sagemaker update-notebook-instance-lifecycle-config --notebook-instance-lifecycle-config-name <lifecycle-config-name> --on-start Content=<base64-script-without-secrets>",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Create a secret in AWS Secrets Manager for the hardcoded value.\n2. Update the notebook instance IAM role to allow secretsmanager:GetSecretValue on that secret.\n3. Edit the lifecycle script to fetch the secret at runtime instead of hardcoding it.\n4. Update the notebook instance lifecycle configuration.",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Use AWS Secrets Manager or Parameter Store to store secrets and retrieve them at runtime in lifecycle scripts; never hardcode them.",
|
||||
"Url": "https://hub.prowler.com/check/sagemaker_notebook_instance_no_secrets"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"secrets",
|
||||
"gen-ai"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
from prowler.lib.check.models import Check, Check_Report_AWS
|
||||
from prowler.lib.utils.utils import (
|
||||
SecretsScanError,
|
||||
annotate_verified_secrets,
|
||||
detect_secrets_scan_batch,
|
||||
)
|
||||
from prowler.providers.aws.services.sagemaker.sagemaker_client import (
|
||||
sagemaker_client,
|
||||
)
|
||||
|
||||
|
||||
class sagemaker_notebook_instance_no_secrets(Check):
|
||||
"""Check for hardcoded secrets in SageMaker notebook instance lifecycle scripts.
|
||||
|
||||
Scans the OnCreate and OnStart lifecycle configuration scripts of each
|
||||
SageMaker notebook instance for hardcoded secrets such as API keys,
|
||||
passwords, tokens, and connection strings. The scripts are fetched and
|
||||
decoded by the SageMaker service; this check only consumes that data.
|
||||
"""
|
||||
|
||||
def execute(self):
|
||||
"""Execute the sagemaker_notebook_instance_no_secrets check.
|
||||
|
||||
Returns:
|
||||
list[Check_Report_AWS]: One report per SageMaker notebook
|
||||
instance, with status PASS, FAIL, or MANUAL.
|
||||
"""
|
||||
findings = []
|
||||
notebook_instances = sagemaker_client.sagemaker_notebook_instances
|
||||
if not notebook_instances:
|
||||
return findings
|
||||
|
||||
secrets_ignore_patterns = sagemaker_client.audit_config.get(
|
||||
"secrets_ignore_patterns", []
|
||||
)
|
||||
validate = sagemaker_client.audit_config.get("secrets_validate", False)
|
||||
|
||||
# Instances that actually contribute a script to the batch. Only these
|
||||
# (plus instances whose describe/decode failed) may be marked MANUAL on
|
||||
# a batch scan failure; instances with nothing to scan must PASS.
|
||||
scanned_resources = {
|
||||
notebook_instance.arn
|
||||
for notebook_instance in notebook_instances
|
||||
if notebook_instance.lifecycle_scripts
|
||||
}
|
||||
|
||||
def payloads():
|
||||
for notebook_instance in notebook_instances:
|
||||
for fragment, script in notebook_instance.lifecycle_scripts.items():
|
||||
yield (notebook_instance.arn, fragment), script
|
||||
|
||||
scan_error = None
|
||||
try:
|
||||
batch_results = detect_secrets_scan_batch(
|
||||
payloads(),
|
||||
excluded_secrets=secrets_ignore_patterns,
|
||||
validate=validate,
|
||||
)
|
||||
except SecretsScanError as error:
|
||||
batch_results = {}
|
||||
scan_error = error
|
||||
|
||||
findings_by_instance = {}
|
||||
for (
|
||||
resource_id,
|
||||
fragment,
|
||||
), fragment_findings in batch_results.items():
|
||||
findings_by_instance.setdefault(resource_id, {})[
|
||||
fragment
|
||||
] = fragment_findings
|
||||
|
||||
for notebook_instance in notebook_instances:
|
||||
report = Check_Report_AWS(
|
||||
metadata=self.metadata(), resource=notebook_instance
|
||||
)
|
||||
|
||||
# MANUAL when the instance could not be fully scanned: either the
|
||||
# lifecycle config describe/decode failed, or the batch scan failed
|
||||
# for an instance that actually had scripts queued for scanning.
|
||||
batch_failed = (
|
||||
scan_error is not None and notebook_instance.arn in scanned_resources
|
||||
)
|
||||
if notebook_instance.lifecycle_scan_failed or batch_failed:
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
f"Could not fully scan SageMaker notebook instance "
|
||||
f"{notebook_instance.name} lifecycle configuration for "
|
||||
f"secrets; manual review is required."
|
||||
)
|
||||
findings.append(report)
|
||||
continue
|
||||
|
||||
report.status = "PASS"
|
||||
if not notebook_instance.lifecycle_config_name:
|
||||
report.status_extended = (
|
||||
f"SageMaker notebook instance {notebook_instance.name} "
|
||||
f"does not have a lifecycle configuration."
|
||||
)
|
||||
else:
|
||||
report.status_extended = (
|
||||
f"No secrets found in SageMaker notebook instance "
|
||||
f"{notebook_instance.name} lifecycle configuration."
|
||||
)
|
||||
|
||||
fragments_with_secrets = findings_by_instance.get(notebook_instance.arn)
|
||||
|
||||
if fragments_with_secrets:
|
||||
all_secrets = []
|
||||
secrets_findings = []
|
||||
|
||||
for fragment, fragment_findings in fragments_with_secrets.items():
|
||||
all_secrets.extend(fragment_findings)
|
||||
secrets_string = ", ".join(
|
||||
f"{secret['type']} on line {secret['line_number']}"
|
||||
for secret in fragment_findings
|
||||
)
|
||||
secrets_findings.append(f"{fragment}: {secrets_string}")
|
||||
|
||||
final_output_string = "; ".join(secrets_findings)
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Potential {'secrets' if len(secrets_findings) > 1 else 'secret'} "
|
||||
f"found in SageMaker notebook instance "
|
||||
f"{notebook_instance.name} lifecycle configuration -> "
|
||||
f"{final_output_string}."
|
||||
)
|
||||
annotate_verified_secrets(report, all_secrets)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -1,3 +1,4 @@
|
||||
import base64
|
||||
from typing import Optional
|
||||
|
||||
from botocore.client import ClientError
|
||||
@@ -37,6 +38,11 @@ class SageMaker(AWSService):
|
||||
self.__threading_call__(
|
||||
self._describe_notebook_instance, self.sagemaker_notebook_instances
|
||||
)
|
||||
# Runs after _describe_notebook_instance so lifecycle_config_name is set.
|
||||
self.__threading_call__(
|
||||
self._describe_notebook_instance_lifecycle_config,
|
||||
self.sagemaker_notebook_instances,
|
||||
)
|
||||
self.__threading_call__(
|
||||
self._describe_training_job, self.sagemaker_training_jobs
|
||||
)
|
||||
@@ -224,11 +230,61 @@ class SageMaker(AWSService):
|
||||
notebook_instance.direct_internet_access = True
|
||||
if "KmsKeyId" in describe_notebook_instance:
|
||||
notebook_instance.kms_key_id = describe_notebook_instance["KmsKeyId"]
|
||||
if "NotebookInstanceLifecycleConfigName" in describe_notebook_instance:
|
||||
notebook_instance.lifecycle_config_name = describe_notebook_instance[
|
||||
"NotebookInstanceLifecycleConfigName"
|
||||
]
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _describe_notebook_instance_lifecycle_config(self, notebook_instance):
|
||||
"""Fetch and decode a notebook instance's lifecycle scripts.
|
||||
|
||||
Reads the ``OnCreate`` and ``OnStart`` scripts from
|
||||
``DescribeNotebookInstanceLifecycleConfig`` and stores the base64-decoded
|
||||
content on ``notebook_instance.lifecycle_scripts`` keyed by
|
||||
``"<hook>[<index>]"``. Instances without a lifecycle configuration are
|
||||
skipped. Any describe or decode failure sets
|
||||
``notebook_instance.lifecycle_scan_failed`` to True so the consuming
|
||||
check can report ``MANUAL`` instead of a false ``PASS``.
|
||||
|
||||
Args:
|
||||
notebook_instance: NotebookInstance model to enrich in-place.
|
||||
"""
|
||||
if not notebook_instance.lifecycle_config_name:
|
||||
return
|
||||
logger.info("SageMaker - describing notebook instance lifecycle config...")
|
||||
try:
|
||||
regional_client = self.regional_clients[notebook_instance.region]
|
||||
lifecycle_config = regional_client.describe_notebook_instance_lifecycle_config(
|
||||
NotebookInstanceLifecycleConfigName=notebook_instance.lifecycle_config_name
|
||||
)
|
||||
except Exception as error:
|
||||
notebook_instance.lifecycle_scan_failed = True
|
||||
logger.error(
|
||||
f"{notebook_instance.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
return
|
||||
|
||||
scripts = {}
|
||||
for hook_name in ("OnCreate", "OnStart"):
|
||||
for script_index, script in enumerate(lifecycle_config.get(hook_name, [])):
|
||||
content_b64 = script.get("Content")
|
||||
if not content_b64:
|
||||
continue
|
||||
try:
|
||||
scripts[f"{hook_name}[{script_index}]"] = base64.b64decode(
|
||||
content_b64
|
||||
).decode("utf-8", errors="ignore")
|
||||
except Exception as error:
|
||||
notebook_instance.lifecycle_scan_failed = True
|
||||
logger.error(
|
||||
f"{notebook_instance.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
notebook_instance.lifecycle_scripts = scripts
|
||||
|
||||
def _describe_model(self, model):
|
||||
logger.info("SageMaker - describing models...")
|
||||
try:
|
||||
@@ -497,6 +553,13 @@ class NotebookInstance(BaseModel):
|
||||
subnet_id: str = None
|
||||
direct_internet_access: bool = None
|
||||
kms_key_id: str = None
|
||||
lifecycle_config_name: str = None
|
||||
# Decoded lifecycle scripts keyed by "<hook>[<index>]" (e.g. "OnStart[0]"),
|
||||
# populated by _describe_notebook_instance_lifecycle_config.
|
||||
lifecycle_scripts: dict = {}
|
||||
# True if the lifecycle configuration could not be fully described/decoded,
|
||||
# so the secrets check reports MANUAL instead of a false PASS.
|
||||
lifecycle_scan_failed: bool = False
|
||||
tags: Optional[list] = []
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Audit Reference: Requirement Text → Prowler Checks
|
||||
|
||||
Built from a real audit of 172 CCC ARs × 3 providers (April 2026). Use it to map
|
||||
CCC-style / NIST-style / ISO-style requirement text to the checks that actually
|
||||
verify them. Always re-validate every check id against the current inventory
|
||||
(`assets/build_inventory.py` + `assets/query_checks.py`) before using a row —
|
||||
checks get renamed and added over time.
|
||||
|
||||
**Entries containing `*` are glob patterns, NOT literal check ids** (e.g.
|
||||
`iam_*_no_administrative_privileges`, `cloudwatch_log_metric_filter_*`,
|
||||
`*_minimum_tls_version_12`). Copied verbatim into a compliance JSON they map
|
||||
nothing — expand each pattern to the concrete check ids via
|
||||
`python skills/prowler-compliance/assets/query_checks.py <provider> <keywords>`
|
||||
before writing any mapping.
|
||||
|
||||
| Requirement text | AWS checks | Azure checks | GCP checks |
|
||||
|---|---|---|---|
|
||||
| **TLS in transit enforced** | `cloudfront_distributions_https_enabled`, `s3_bucket_secure_transport_policy`, `elbv2_ssl_listeners`, `elbv2_insecure_ssl_ciphers`, `elb_ssl_listeners`, `elb_insecure_ssl_ciphers`, `opensearch_service_domains_https_communications_enforced`, `rds_instance_transport_encrypted`, `redshift_cluster_in_transit_encryption_enabled`, `elasticache_redis_cluster_in_transit_encryption_enabled`, `dynamodb_accelerator_cluster_in_transit_encryption_enabled`, `dms_endpoint_ssl_enabled`, `kafka_cluster_in_transit_encryption_enabled`, `transfer_server_in_transit_encryption_enabled`, `glue_database_connections_ssl_enabled`, `sns_subscription_not_using_http_endpoints` | `storage_secure_transfer_required_is_enabled`, `storage_ensure_minimum_tls_version_12`, `postgresql_flexible_server_enforce_ssl_enabled`, `mysql_flexible_server_ssl_connection_enabled`, `mysql_flexible_server_minimum_tls_version_12`, `sqlserver_recommended_minimal_tls_version`, `app_minimum_tls_version_12`, `app_ensure_http_is_redirected_to_https`, `app_ftp_deployment_disabled` | `cloudsql_instance_ssl_connections` (almost only option) |
|
||||
| **TLS 1.3 specifically** | Partial: `cloudfront_distributions_using_deprecated_ssl_protocols`, `elb*_insecure_ssl_ciphers`, `*_minimum_tls_version_12` | Partial: `*_minimum_tls_version_12` checks | None — accept as MANUAL |
|
||||
| **SSH / port 22 hardening** | `ec2_instance_port_ssh_exposed_to_internet`, `ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22`, `ec2_networkacl_allow_ingress_tcp_port_22` | `network_ssh_internet_access_restricted`, `vm_linux_enforce_ssh_authentication` | `compute_firewall_ssh_access_from_the_internet_allowed`, `compute_instance_block_project_wide_ssh_keys_disabled`, `compute_project_os_login_enabled`, `compute_project_os_login_2fa_enabled` |
|
||||
| **mTLS (mutual TLS)** | `kafka_cluster_mutual_tls_authentication_enabled`, `apigateway_restapi_client_certificate_enabled` | `app_client_certificates_on` | None — MANUAL |
|
||||
| **Data at rest encrypted** | `s3_bucket_default_encryption`, `s3_bucket_kms_encryption`, `ec2_ebs_default_encryption`, `ec2_ebs_volume_encryption`, `rds_instance_storage_encrypted`, `rds_cluster_storage_encrypted`, `rds_snapshots_encrypted`, `dynamodb_tables_kms_cmk_encryption_enabled`, `redshift_cluster_encrypted_at_rest`, `neptune_cluster_storage_encrypted`, `documentdb_cluster_storage_encrypted`, `opensearch_service_domains_encryption_at_rest_enabled`, `kinesis_stream_encrypted_at_rest`, `firehose_stream_encrypted_at_rest`, `sns_topics_kms_encryption_at_rest_enabled`, `sqs_queues_server_side_encryption_enabled`, `efs_encryption_at_rest_enabled`, `athena_workgroup_encryption`, `glue_data_catalogs_metadata_encryption_enabled`, `backup_vaults_encrypted`, `backup_recovery_point_encrypted`, `cloudtrail_kms_encryption_enabled`, `cloudwatch_log_group_kms_encryption_enabled`, `eks_cluster_kms_cmk_encryption_in_secrets_enabled`, `sagemaker_notebook_instance_encryption_enabled`, `apigateway_restapi_cache_encrypted`, `kafka_cluster_encryption_at_rest_uses_cmk`, `dynamodb_accelerator_cluster_encryption_enabled`, `storagegateway_fileshare_encryption_enabled` | `storage_infrastructure_encryption_is_enabled`, `storage_ensure_encryption_with_customer_managed_keys`, `vm_ensure_attached_disks_encrypted_with_cmk`, `vm_ensure_unattached_disks_encrypted_with_cmk`, `sqlserver_tde_encryption_enabled`, `sqlserver_tde_encrypted_with_cmk`, `databricks_workspace_cmk_encryption_enabled`, `monitor_storage_account_with_activity_logs_cmk_encrypted` | `compute_instance_encryption_with_csek_enabled`, `dataproc_encrypted_with_cmks_disabled`, `bigquery_dataset_cmk_encryption`, `bigquery_table_cmk_encryption` |
|
||||
| **CMEK required (customer-managed keys)** | `kms_cmk_are_used` | `storage_ensure_encryption_with_customer_managed_keys`, `vm_ensure_attached_disks_encrypted_with_cmk`, `vm_ensure_unattached_disks_encrypted_with_cmk`, `sqlserver_tde_encrypted_with_cmk`, `databricks_workspace_cmk_encryption_enabled` | `bigquery_dataset_cmk_encryption`, `bigquery_table_cmk_encryption`, `dataproc_encrypted_with_cmks_disabled`, `compute_instance_encryption_with_csek_enabled` |
|
||||
| **Key rotation enabled** | `kms_cmk_rotation_enabled` | `keyvault_key_rotation_enabled`, `storage_key_rotation_90_days` | `kms_key_rotation_enabled` |
|
||||
| **MFA for UI access** | `iam_root_mfa_enabled`, `iam_root_hardware_mfa_enabled`, `iam_user_mfa_enabled_console_access`, `iam_user_hardware_mfa_enabled`, `iam_administrator_access_with_mfa`, `cognito_user_pool_mfa_enabled` | `entra_privileged_user_has_mfa`, `entra_non_privileged_user_has_mfa`, `entra_user_with_vm_access_has_mfa`, `entra_security_defaults_enabled` | `compute_project_os_login_2fa_enabled` |
|
||||
| **API access / credentials** | `iam_no_root_access_key`, `iam_user_no_setup_initial_access_key`, `apigateway_restapi_authorizers_enabled`, `apigateway_restapi_public_with_authorizer`, `apigatewayv2_api_authorizers_enabled` | `entra_conditional_access_policy_require_mfa_for_management_api`, `app_function_access_keys_configured`, `app_function_identity_is_configured` | `apikeys_api_restrictions_configured`, `apikeys_key_exists`, `apikeys_key_rotated_in_90_days` |
|
||||
| **Log all admin/config changes** | `cloudtrail_multi_region_enabled`, `cloudtrail_multi_region_enabled_logging_management_events`, `cloudtrail_cloudwatch_logging_enabled`, `cloudtrail_log_file_validation_enabled`, `cloudwatch_log_metric_filter_*`, `cloudwatch_changes_to_*_alarm_configured`, `config_recorder_all_regions_enabled` | `monitor_diagnostic_settings_exists`, `monitor_diagnostic_setting_with_appropriate_categories`, `monitor_alert_*` | `iam_audit_logs_enabled`, `logging_log_metric_filter_and_alert_for_*`, `logging_sink_created` |
|
||||
| **Log integrity (digital signatures)** | `cloudtrail_log_file_validation_enabled` (exact) | None | None |
|
||||
| **Public access denied** | `s3_bucket_public_access`, `s3_bucket_public_list_acl`, `s3_bucket_public_write_acl`, `s3_account_level_public_access_blocks`, `apigateway_restapi_public`, `awslambda_function_url_public`, `awslambda_function_not_publicly_accessible`, `rds_instance_no_public_access`, `rds_snapshots_public_access`, `ec2_securitygroup_allow_ingress_from_internet_to_all_ports`, `sns_topics_not_publicly_accessible`, `sqs_queues_not_publicly_accessible` | `storage_blob_public_access_level_is_disabled`, `storage_ensure_private_endpoints_in_storage_accounts`, `containerregistry_not_publicly_accessible`, `keyvault_private_endpoints`, `app_function_not_publicly_accessible`, `aks_clusters_public_access_disabled`, `network_http_internet_access_restricted` | `cloudstorage_bucket_public_access`, `compute_instance_public_ip`, `cloudsql_instance_public_ip`, `compute_firewall_*_access_from_the_internet_allowed` |
|
||||
| **IAM least privilege** | `iam_*_no_administrative_privileges`, `iam_policy_allows_privilege_escalation`, `iam_inline_policy_allows_privilege_escalation`, `iam_role_administratoraccess_policy`, `iam_group_administrator_access_policy`, `iam_user_administrator_access_policy`, `iam_policy_attached_only_to_group_or_roles`, `iam_role_cross_service_confused_deputy_prevention` | `iam_role_user_access_admin_restricted`, `iam_subscription_roles_owner_custom_not_created`, `iam_custom_role_has_permissions_to_administer_resource_locks` | `iam_sa_no_administrative_privileges`, `iam_no_service_roles_at_project_level`, `iam_role_kms_enforce_separation_of_duties`, `iam_role_sa_enforce_separation_of_duties` |
|
||||
| **Password policy** | `iam_password_policy_minimum_length_14`, `iam_password_policy_uppercase`, `iam_password_policy_lowercase`, `iam_password_policy_symbol`, `iam_password_policy_number`, `iam_password_policy_expires_passwords_within_90_days_or_less`, `iam_password_policy_reuse_24` | None | None |
|
||||
| **Credential rotation / unused** | `iam_rotate_access_key_90_days`, `iam_user_accesskey_unused`, `iam_user_console_access_unused` | None | `iam_sa_user_managed_key_rotate_90_days`, `iam_sa_user_managed_key_unused`, `iam_service_account_unused` |
|
||||
| **VPC / flow logs** | `vpc_flow_logs_enabled` | `network_flow_log_captured_sent`, `network_watcher_enabled`, `network_flow_log_more_than_90_days` | `compute_subnet_flow_logs_enabled` |
|
||||
| **Backup / DR / Multi-AZ** | `backup_vaults_exist`, `backup_plans_exist`, `backup_reportplans_exist`, `rds_instance_backup_enabled`, `rds_*_protected_by_backup_plan`, `rds_cluster_multi_az`, `neptune_cluster_backup_enabled`, `documentdb_cluster_backup_enabled`, `efs_have_backup_enabled`, `s3_bucket_cross_region_replication`, `dynamodb_table_protected_by_backup_plan` | `vm_backup_enabled`, `vm_sufficient_daily_backup_retention_period`, `storage_geo_redundant_enabled` | `cloudsql_instance_automated_backups`, `cloudstorage_bucket_log_retention_policy_lock`, `cloudstorage_bucket_sufficient_retention_period` |
|
||||
| **Access analysis / discovery** | `accessanalyzer_enabled`, `accessanalyzer_enabled_without_findings` | None specific | `iam_account_access_approval_enabled`, `iam_cloud_asset_inventory_enabled` |
|
||||
| **Object lock / retention** | `s3_bucket_object_lock`, `s3_bucket_object_versioning`, `s3_bucket_lifecycle_enabled`, `cloudtrail_bucket_requires_mfa_delete`, `s3_bucket_no_mfa_delete` | `storage_ensure_soft_delete_is_enabled`, `storage_blob_versioning_is_enabled`, `storage_ensure_file_shares_soft_delete_is_enabled` | `cloudstorage_bucket_log_retention_policy_lock`, `cloudstorage_bucket_soft_delete_enabled`, `cloudstorage_bucket_versioning_enabled`, `cloudstorage_bucket_sufficient_retention_period` |
|
||||
| **Uniform bucket-level access** | `s3_bucket_acl_prohibited` | `storage_account_key_access_disabled`, `storage_default_to_entra_authorization_enabled` | `cloudstorage_bucket_uniform_bucket_level_access` |
|
||||
| **Container vulnerability scanning** | `ecr_registry_scan_images_on_push_enabled`, `ecr_repositories_scan_vulnerabilities_in_latest_image` | `defender_container_images_scan_enabled`, `defender_container_images_resolved_vulnerabilities` | `artifacts_container_analysis_enabled`, `gcr_container_scanning_enabled` |
|
||||
| **WAF / rate limiting** | `wafv2_webacl_with_rules`, `waf_*_webacl_with_rules`, `wafv2_webacl_logging_enabled`, `waf_global_webacl_logging_enabled` | None | None |
|
||||
| **Deployment region restriction** | `organizations_scp_check_deny_regions` | None | None |
|
||||
| **Secrets automatic rotation** | `secretsmanager_automatic_rotation_enabled`, `secretsmanager_secret_rotated_periodically` | `keyvault_rbac_secret_expiration_set`, `keyvault_non_rbac_secret_expiration_set` | None |
|
||||
| **Certificate management** | `acm_certificates_expiration_check`, `acm_certificates_with_secure_key_algorithms`, `acm_certificates_transparency_logs_enabled` | `keyvault_key_expiration_set_in_non_rbac`, `keyvault_rbac_key_expiration_set`, `keyvault_non_rbac_secret_expiration_set` | None |
|
||||
| **GenAI guardrails / input/output filtering** | `bedrock_guardrail_prompt_attack_filter_enabled`, `bedrock_guardrail_sensitive_information_filter_enabled`, `bedrock_agent_guardrail_enabled`, `bedrock_model_invocation_logging_enabled`, `bedrock_api_key_no_administrative_privileges`, `bedrock_api_key_no_long_term_credentials` | None | None |
|
||||
| **ML dev environment security** | `sagemaker_notebook_instance_root_access_disabled`, `sagemaker_notebook_instance_without_direct_internet_access_configured`, `sagemaker_notebook_instance_vpc_settings_configured`, `sagemaker_models_vpc_settings_configured`, `sagemaker_training_jobs_vpc_settings_configured`, `sagemaker_training_jobs_network_isolation_enabled`, `sagemaker_training_jobs_volume_and_output_encryption_enabled` | None | None |
|
||||
| **Threat detection / anomalous behavior** | `cloudtrail_threat_detection_enumeration`, `cloudtrail_threat_detection_privilege_escalation`, `cloudtrail_threat_detection_llm_jacking`, `guardduty_is_enabled`, `guardduty_no_high_severity_findings` | None | None |
|
||||
| **Serverless private access** | `awslambda_function_inside_vpc`, `awslambda_function_not_publicly_accessible`, `awslambda_function_url_public` | `app_function_not_publicly_accessible` | None |
|
||||
|
||||
## What Prowler Does NOT Cover (accept MANUAL honestly)
|
||||
|
||||
Don't pad mappings for these — mark the requirement's checks empty and move on:
|
||||
|
||||
- **TLS 1.3 version specifically** — Prowler verifies TLS is enforced, not always the exact version
|
||||
- **IANA port-protocol consistency** — no check for "protocol running on its assigned port"
|
||||
- **mTLS on most Azure/GCP services** — limited to App Service client certs on Azure, nothing on GCP
|
||||
- **Rate limiting** on monitoring endpoints, load balancers, serverless invocations, vector ingestion
|
||||
- **Session cookie expiry** (LB stickiness)
|
||||
- **HTTP header scrubbing** (Server, X-Powered-By)
|
||||
- **Certificate transparency verification for imports**
|
||||
- **Model version pinning, red teaming, AI quality review**
|
||||
- **Vector embedding validation, dimensional constraints, ANN vs exact search**
|
||||
- **Secret region replication** (cross-region residency)
|
||||
- **Lifecycle cleanup policies on container registries**
|
||||
- **Row-level / column-level security in data warehouses**
|
||||
- **Deployment region restriction on Azure/GCP** (AWS has `organizations_scp_check_deny_regions`, others don't)
|
||||
- **Cross-tenant alert silencing permissions**
|
||||
- **Field-level masking in logs**
|
||||
- **Managed view enforcement for database access**
|
||||
- **Automatic MFA delete on all S3 buckets** (only CloudTrail bucket variant exists for some frameworks — AWS has the generic `s3_bucket_no_mfa_delete` though)
|
||||
|
||||
## Provider coverage asymmetry
|
||||
|
||||
AWS has dense coverage (in-transit encryption, IAM, database encryption, backup,
|
||||
GenAI). Azure and GCP are thinner, especially for in-transit encryption, mTLS,
|
||||
and ML/AI. Accept the asymmetry in mappings — don't force GCP parity where
|
||||
Prowler genuinely can't verify. Newer providers (alibabacloud, oraclecloud,
|
||||
googleworkspace, okta, cloudflare, linode...) have far smaller inventories:
|
||||
always rebuild the inventory with `assets/build_inventory.py` before assuming
|
||||
a mapping exists.
|
||||
@@ -1,137 +1,154 @@
|
||||
# Compliance Framework Documentation
|
||||
# Compliance Framework Quick Reference
|
||||
|
||||
## Code References
|
||||
|
||||
Key files for understanding and modifying compliance frameworks:
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `prowler/lib/check/compliance_models.py` | Pydantic models defining attribute structures for each framework type |
|
||||
| `prowler/lib/check/compliance.py` | Core compliance processing logic |
|
||||
| `prowler/lib/check/utils.py` | Utility functions including `list_compliance_modules()` |
|
||||
| `prowler/lib/outputs/compliance/` | Framework-specific output generators |
|
||||
| `prowler/compliance/{provider}/` | JSON compliance framework definitions |
|
||||
| `prowler/lib/check/compliance_models.py` | Legacy + universal Pydantic (v1) model trees, config-constraint model, loaders, legacy→universal adapter |
|
||||
| `prowler/lib/check/compliance.py` | `update_checks_metadata_with_compliance()` (only) |
|
||||
| `prowler/lib/check/compliance_config_eval.py` | Shared `ConfigRequirements` guardrail evaluation (SDK outputs + API) |
|
||||
| `prowler/lib/outputs/compliance/compliance_check.py` | `get_check_compliance()` — per-finding `{Framework}-{Version}` → requirement ids |
|
||||
| `prowler/lib/check/utils.py` | `list_compliance_modules()` |
|
||||
| `prowler/lib/outputs/compliance/` | Output formatters (legacy per-framework + `universal/`) |
|
||||
| `prowler/compliance/*.json` | Universal multi-provider framework definitions |
|
||||
| `prowler/compliance/{provider}/` | Legacy per-provider framework definitions |
|
||||
|
||||
## Attribute Model Classes
|
||||
## Attribute Model Classes (legacy schema)
|
||||
|
||||
Each framework type has a specific Pydantic model in `compliance_models.py`:
|
||||
Registered in the `Compliance_Requirement.Attributes` Union, in this order
|
||||
(order is load-bearing; Generic must stay last):
|
||||
|
||||
| Framework | Model Class |
|
||||
| Framework family | Model Class |
|
||||
|-----------|-------------|
|
||||
| ASD Essential Eight | `ASDEssentialEight_Requirement_Attribute` |
|
||||
| CIS | `CIS_Requirement_Attribute` |
|
||||
| ISO 27001 | `ISO27001_2013_Requirement_Attribute` |
|
||||
| ENS | `ENS_Requirement_Attribute` |
|
||||
| MITRE ATT&CK | `Mitre_Requirement` (uses different structure) |
|
||||
| ISO 27001 | `ISO27001_2013_Requirement_Attribute` |
|
||||
| AWS Well-Architected | `AWS_Well_Architected_Requirement_Attribute` |
|
||||
| KISA ISMS-P | `KISA_ISMSP_Requirement_Attribute` |
|
||||
| Prowler ThreatScore | `Prowler_ThreatScore_Requirement_Attribute` |
|
||||
| CCC | `CCC_Requirement_Attribute` |
|
||||
| C5 Germany | `C5Germany_Requirement_Attribute` |
|
||||
| Generic/Fallback | `Generic_Compliance_Requirement_Attribute` |
|
||||
| CSA CCM (legacy shape) | `CSA_CCM_Requirement_Attribute` |
|
||||
| DISA STIG (Okta IDaaS) | `STIG_Requirement_Attribute` |
|
||||
| Generic/Fallback (NIST, PCI, GDPR, HIPAA, SOC2, FedRAMP, ...) | `Generic_Compliance_Requirement_Attribute` |
|
||||
|
||||
## How Compliance Frameworks are Loaded
|
||||
MITRE ATT&CK uses the separate `Mitre_Requirement` model with per-provider
|
||||
`Mitre_Requirement_Attribute_{AWS,Azure,GCP}` attribute classes.
|
||||
|
||||
1. `Compliance.get_bulk(provider)` is called at startup
|
||||
2. Scans `prowler/compliance/{provider}/` for `.json` files
|
||||
3. Each file is parsed using `load_compliance_framework()`
|
||||
4. Pydantic validates against `Compliance` model
|
||||
5. Framework is stored in dictionary with filename (without `.json`) as key
|
||||
`Compliance_Requirement_ConfigConstraint` models each `ConfigRequirements` /
|
||||
`config_requirements` entry (`Check`, `ConfigKey`, `Operator`, `Value`,
|
||||
optional `Provider`) with load-time operator/value type validation.
|
||||
|
||||
## Universal Schema Models
|
||||
|
||||
| Model | Purpose |
|
||||
|-------|---------|
|
||||
| `ComplianceFramework` | Top-level container (`framework`, `name`, `version`, `requirements`, `attributes_metadata`, `outputs`); validates attributes against metadata at load |
|
||||
| `UniversalComplianceRequirement` | Flat `attributes: dict`, `checks: dict[provider, list]`, `config_requirements`, MITRE extras |
|
||||
| `AttributeMetadata` | Per-attribute schema descriptor (key/label/type/enum/required/`enum_display`/`enum_order`/`output_formats`) |
|
||||
| `OutputsConfig` → `TableConfig` | CLI table rendering (`group_by`, `split_by`, `scoring`, `labels`) — consumed by `universal_table.py` |
|
||||
| `OutputsConfig` → `PDFConfig` (+ `ChartConfig`, `ScoringFormula`, `I18nLabels`, ...) | Declarative PDF config — modeled but **not yet consumed** by the API PDF pipeline (it uses its own `FRAMEWORK_REGISTRY`) |
|
||||
|
||||
## How Frameworks Are Loaded
|
||||
|
||||
Two entry points — they see different files:
|
||||
|
||||
1. **Legacy**: `Compliance.get_bulk(provider)` scans only
|
||||
`prowler/compliance/{provider}/` (exact provider-segment match) plus
|
||||
external JSONs from the `prowler.compliance` entry-point group. Invalid
|
||||
built-in file → `logger.critical` + `sys.exit(1)`
|
||||
(`load_compliance_framework`, `fatal=True`).
|
||||
2. **Universal**: `get_bulk_compliance_frameworks_universal(provider)` scans
|
||||
the top-level `prowler/compliance/` **and** every provider subdirectory,
|
||||
plus the `prowler.compliance.universal` entry-point group (built-ins win
|
||||
collisions). Legacy files are adapted via `adapt_legacy_to_universal()`
|
||||
(flattens `Attributes[0]` into a dict, wraps `Checks` as
|
||||
`{provider: [...]}`, infers `attributes_metadata` from the matched Pydantic
|
||||
class). Invalid file → logged and **skipped**
|
||||
(`load_compliance_framework_universal` returns `None`).
|
||||
|
||||
The framework key in both bulk dicts is the JSON basename without `.json` —
|
||||
that's also the `--compliance` CLI key.
|
||||
|
||||
## How Checks Map to Compliance
|
||||
|
||||
1. After loading, `update_checks_metadata_with_compliance()` is called
|
||||
2. For each check, it finds all compliance requirements that reference it
|
||||
3. Compliance info is attached to `CheckMetadata.Compliance` list
|
||||
4. During output, `get_check_compliance()` retrieves mappings per finding
|
||||
1. `update_checks_metadata_with_compliance()` attaches, per check, every
|
||||
framework requirement that references it (`CheckMetadata.Compliance`).
|
||||
2. During output, `get_check_compliance()`
|
||||
(`prowler/lib/outputs/compliance/compliance_check.py`) returns the
|
||||
per-finding dict `{"{Framework}-{Version}": [requirement_ids]}` — the
|
||||
`-{Version}` suffix only exists when `Version` is non-empty.
|
||||
3. `ConfigRequirements` guardrails are evaluated by
|
||||
`evaluate_config_constraints()` (`compliance_config_eval.py`); a violated
|
||||
constraint forces FAIL and prepends
|
||||
`Configuration not valid for this requirement.` to `status_extended` in
|
||||
every output format.
|
||||
|
||||
## File Naming Convention
|
||||
## File Naming Conventions
|
||||
|
||||
```text
|
||||
{framework}_{version}_{provider}.json
|
||||
prowler/compliance/{framework}_{version}.json # universal
|
||||
prowler/compliance/{provider}/{framework}_{version}_{provider}.json # legacy
|
||||
```
|
||||
|
||||
Examples:
|
||||
- `cis_5.0_aws.json`
|
||||
- `iso27001_2022_azure.json`
|
||||
- `mitre_attack_gcp.json`
|
||||
- `ens_rd2022_aws.json`
|
||||
- `nist_800_53_revision_5_aws.json`
|
||||
Examples: `dora_2022_2554.json`, `cis_controls_8.1.json`, `cis_7.0_aws.json`,
|
||||
`iso27001_2022_azure.json`, `okta_idaas_stig_v1r2_okta.json`,
|
||||
`cisa_scuba_0.6_googleworkspace.json`, `ccc_aws.json` (unversioned only when
|
||||
the framework has no versioning). For legacy files the version substring in
|
||||
the filename must equal `Version`.
|
||||
|
||||
## Validation
|
||||
## Validation Summary
|
||||
|
||||
Prowler validates compliance JSON at startup. Invalid files cause:
|
||||
- `ValidationError` logged with details
|
||||
- Application exit with error code
|
||||
- **Load time (universal)**: `attributes_metadata` root validator — required
|
||||
keys, unknown-key drift guard, enums, int/float/bool types. Omit the
|
||||
metadata and nothing is validated.
|
||||
- **Load time (legacy)**: Pydantic attribute-class matching; a shape matching
|
||||
no specific class silently falls through to Generic.
|
||||
- **Never validated at load**: check-id existence. Cross-check manually
|
||||
(see SKILL.md → Validation).
|
||||
- **Test suite**: `tests/lib/check/universal_compliance_models_test.py::test_loads_as_universal`
|
||||
is parametrized over every shipped JSON (top-level + per-provider).
|
||||
- **CI**: `.github/workflows/pr-check-compliance-mapping.yml` flags new checks
|
||||
not mapped in any framework (`needs-compliance-review` label; opt out with
|
||||
`no-compliance-check`).
|
||||
- **Pre-commit**: `check-json` + `pretty-format-json` only (syntax/format, no
|
||||
semantics).
|
||||
- **Manual**: `skills/prowler-compliance-review/assets/validate_compliance.py`
|
||||
(legacy schema only).
|
||||
|
||||
Common validation errors:
|
||||
- Missing required fields (`Id`, `Description`, `Checks`, `Attributes`)
|
||||
- Invalid enum values (e.g., `Profile` must be "Level 1" or "Level 2" for CIS)
|
||||
- Type mismatches (e.g., `Checks` must be array of strings)
|
||||
## Repo Tooling (`util/compliance/`)
|
||||
|
||||
## Adding a New Framework
|
||||
|
||||
1. Create JSON file in `prowler/compliance/{provider}/`
|
||||
2. Use appropriate attribute model (see table above)
|
||||
3. Map existing checks to requirements via `Checks` array
|
||||
4. Use empty `Checks: []` for manual-only requirements
|
||||
5. Test with `prowler {provider} --list-compliance` to verify loading
|
||||
6. Run `prowler {provider} --compliance {framework_name}` to test execution
|
||||
|
||||
## Templates
|
||||
|
||||
See `assets/` directory for example templates:
|
||||
- `cis_framework.json` - CIS Benchmark template
|
||||
- `iso27001_framework.json` - ISO 27001 template
|
||||
- `ens_framework.json` - ENS (Spain) template
|
||||
- `mitre_attack_framework.json` - MITRE ATT&CK template
|
||||
- `prowler_threatscore_framework.json` - Prowler ThreatScore template
|
||||
- `generic_framework.json` - Generic/custom framework template
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| `util/compliance/generate_json_from_csv/*.py` | CSV→JSON generators (CIS 1.5, CIS 2.0 GCP, CIS 1.0 GitHub, CIS 4.0 M365, ENS, ThreatScore) |
|
||||
| `util/compliance/ccc/from_yaml_to_json.py` | FINOS CCC YAML→JSON converter |
|
||||
| `util/compliance/compliance_mapper/` | Compliance mapper (see its README) |
|
||||
| `util/compliance/threatscore/get_prowler_threatscore_from_generic_output.py` | Derive ThreatScore from generic output |
|
||||
|
||||
## Prowler ThreatScore Details
|
||||
|
||||
Prowler ThreatScore is a custom security scoring framework that calculates an overall security posture score based on:
|
||||
Custom Prowler scoring framework. Pillars / ID prefixes: `1.x.x` IAM, `2.x.x`
|
||||
Attack Surface, `3.x.x` Logging and Monitoring, `4.x.x` Encryption.
|
||||
|
||||
### Four Pillars
|
||||
1. **IAM (Identity and Access Management)**
|
||||
- SubSections: Authentication, Authorization, Credentials Management
|
||||
|
||||
2. **Attack Surface**
|
||||
- SubSections: Network Exposure, Storage Exposure, Service Exposure
|
||||
|
||||
3. **Logging and Monitoring**
|
||||
- SubSections: Audit Logging, Threat Detection, Alerting
|
||||
|
||||
4. **Encryption**
|
||||
- SubSections: Data at Rest, Data in Transit
|
||||
|
||||
### Scoring Algorithm
|
||||
The ThreatScore uses `LevelOfRisk` and `Weight` to calculate severity:
|
||||
|
||||
| LevelOfRisk | Weight | Example Controls |
|
||||
|-------------|--------|------------------|
|
||||
| 5 (Critical) | 1000 | Root MFA, No root access keys, Public S3 buckets |
|
||||
| 4 (High) | 100 | User MFA, Public EC2, GuardDuty enabled |
|
||||
| 3 (Medium) | 10 | Password policies, EBS encryption, CloudTrail |
|
||||
| 2 (Low) | 1-10 | Best practice recommendations |
|
||||
| 1 (Info) | 1 | Informational controls |
|
||||
|
||||
### ID Numbering Convention
|
||||
- `1.x.x` - IAM controls
|
||||
- `2.x.x` - Attack Surface controls
|
||||
- `3.x.x` - Logging and Monitoring controls
|
||||
- `4.x.x` - Encryption controls
|
||||
Scoring: `LevelOfRisk` 1–5 (5=critical) × `Weight` (values in the shipped
|
||||
catalogs: 1000 critical / 100 high / 8–10 standard / 1 low). Available for
|
||||
aws, azure, gcp, kubernetes, m365, alibabacloud.
|
||||
|
||||
## External Resources
|
||||
|
||||
### Official Framework Documentation
|
||||
- [CIS Benchmarks](https://www.cisecurity.org/cis-benchmarks)
|
||||
- [ISO 27001:2022](https://www.iso.org/standard/27001)
|
||||
- [CIS Critical Security Controls](https://www.cisecurity.org/controls)
|
||||
- [ISO 27001](https://www.iso.org/standard/27001)
|
||||
- [NIST 800-53](https://csrc.nist.gov/publications/detail/sp/800-53/rev-5/final)
|
||||
- [NIST CSF](https://www.nist.gov/cyberframework)
|
||||
- [PCI DSS](https://www.pcisecuritystandards.org/)
|
||||
- [MITRE ATT&CK](https://attack.mitre.org/)
|
||||
- [ENS (Spain)](https://www.ccn-cert.cni.es/es/ens.html)
|
||||
|
||||
### Prowler Documentation
|
||||
- [Prowler Docs - Compliance](https://docs.prowler.com/projects/prowler-open-source/en/latest/)
|
||||
- [Prowler GitHub](https://github.com/prowler-cloud/prowler)
|
||||
- [FINOS CCC](https://github.com/finos/common-cloud-controls)
|
||||
- [CSA CCM](https://cloudsecurityalliance.org/research/cloud-controls-matrix)
|
||||
- [DORA (EU 2022/2554)](https://eur-lex.europa.eu/eli/reg/2022/2554/oj)
|
||||
- [ASD Essential Eight](https://www.cyber.gov.au/resources-business-and-government/essential-cybersecurity/essential-eight)
|
||||
- [CISA SCuBA](https://www.cisa.gov/resources-tools/services/secure-cloud-business-applications-scuba-project)
|
||||
- [DISA STIGs](https://public.cyber.mil/stigs/)
|
||||
- [Prowler Docs — Compliance developer guide](https://docs.prowler.com/developer-guide/security-compliance-framework)
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
from unittest import mock
|
||||
|
||||
from prowler.lib.utils.utils import SecretsScanError
|
||||
from prowler.providers.aws.services.sagemaker.sagemaker_service import (
|
||||
NotebookInstance,
|
||||
)
|
||||
from tests.providers.aws.utils import (
|
||||
AWS_ACCOUNT_NUMBER,
|
||||
AWS_REGION_EU_WEST_1,
|
||||
set_mocked_aws_provider,
|
||||
)
|
||||
|
||||
test_notebook_instance = "test-notebook-instance"
|
||||
notebook_instance_arn = (
|
||||
f"arn:aws:sagemaker:{AWS_REGION_EU_WEST_1}:"
|
||||
f"{AWS_ACCOUNT_NUMBER}:notebook-instance/{test_notebook_instance}"
|
||||
)
|
||||
|
||||
other_notebook_instance = "other-notebook-instance"
|
||||
other_notebook_instance_arn = (
|
||||
f"arn:aws:sagemaker:{AWS_REGION_EU_WEST_1}:"
|
||||
f"{AWS_ACCOUNT_NUMBER}:notebook-instance/{other_notebook_instance}"
|
||||
)
|
||||
|
||||
CHECK_MODULE = "prowler.providers.aws.services.sagemaker.sagemaker_notebook_instance_no_secrets.sagemaker_notebook_instance_no_secrets"
|
||||
|
||||
|
||||
class Test_sagemaker_notebook_instance_no_secrets:
|
||||
def test_no_instances(self):
|
||||
sagemaker_client = mock.MagicMock
|
||||
sagemaker_client.sagemaker_notebook_instances = []
|
||||
sagemaker_client.audit_config = {}
|
||||
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(f"{CHECK_MODULE}.sagemaker_client", sagemaker_client),
|
||||
):
|
||||
from prowler.providers.aws.services.sagemaker.sagemaker_notebook_instance_no_secrets.sagemaker_notebook_instance_no_secrets import (
|
||||
sagemaker_notebook_instance_no_secrets,
|
||||
)
|
||||
|
||||
check = sagemaker_notebook_instance_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
def test_pass_no_lifecycle_config(self):
|
||||
sagemaker_client = mock.MagicMock
|
||||
sagemaker_client.audit_config = {}
|
||||
sagemaker_client.sagemaker_notebook_instances = [
|
||||
NotebookInstance(
|
||||
name=test_notebook_instance,
|
||||
arn=notebook_instance_arn,
|
||||
region=AWS_REGION_EU_WEST_1,
|
||||
lifecycle_config_name=None,
|
||||
)
|
||||
]
|
||||
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(f"{CHECK_MODULE}.sagemaker_client", sagemaker_client),
|
||||
mock.patch(
|
||||
f"{CHECK_MODULE}.detect_secrets_scan_batch",
|
||||
return_value={},
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.sagemaker.sagemaker_notebook_instance_no_secrets.sagemaker_notebook_instance_no_secrets import (
|
||||
sagemaker_notebook_instance_no_secrets,
|
||||
)
|
||||
|
||||
check = sagemaker_notebook_instance_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert (
|
||||
"does not have a lifecycle configuration" in result[0].status_extended
|
||||
)
|
||||
assert result[0].resource_id == test_notebook_instance
|
||||
assert result[0].resource_arn == notebook_instance_arn
|
||||
|
||||
def test_pass_lifecycle_config_scanned_clean(self):
|
||||
sagemaker_client = mock.MagicMock
|
||||
sagemaker_client.audit_config = {}
|
||||
sagemaker_client.sagemaker_notebook_instances = [
|
||||
NotebookInstance(
|
||||
name=test_notebook_instance,
|
||||
arn=notebook_instance_arn,
|
||||
region=AWS_REGION_EU_WEST_1,
|
||||
lifecycle_config_name="test-lifecycle-config",
|
||||
lifecycle_scripts={"OnCreate[0]": "echo hello"},
|
||||
)
|
||||
]
|
||||
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(f"{CHECK_MODULE}.sagemaker_client", sagemaker_client),
|
||||
mock.patch(
|
||||
f"{CHECK_MODULE}.detect_secrets_scan_batch",
|
||||
return_value={},
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.sagemaker.sagemaker_notebook_instance_no_secrets.sagemaker_notebook_instance_no_secrets import (
|
||||
sagemaker_notebook_instance_no_secrets,
|
||||
)
|
||||
|
||||
check = sagemaker_notebook_instance_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert "No secrets found" in result[0].status_extended
|
||||
assert result[0].resource_id == test_notebook_instance
|
||||
assert result[0].resource_arn == notebook_instance_arn
|
||||
|
||||
def test_fail_secret_found(self):
|
||||
notebook_instance = NotebookInstance(
|
||||
name=test_notebook_instance,
|
||||
arn=notebook_instance_arn,
|
||||
region=AWS_REGION_EU_WEST_1,
|
||||
lifecycle_config_name="test-lifecycle-config",
|
||||
lifecycle_scripts={"OnCreate[0]": "echo API_KEY=12345"},
|
||||
)
|
||||
|
||||
sagemaker_client = mock.MagicMock
|
||||
sagemaker_client.audit_config = {}
|
||||
sagemaker_client.sagemaker_notebook_instances = [notebook_instance]
|
||||
|
||||
fake_secret = {"type": "Secret Keyword", "line_number": 1}
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(f"{CHECK_MODULE}.sagemaker_client", sagemaker_client),
|
||||
mock.patch(
|
||||
f"{CHECK_MODULE}.detect_secrets_scan_batch",
|
||||
return_value={(notebook_instance_arn, "OnCreate[0]"): [fake_secret]},
|
||||
),
|
||||
mock.patch(
|
||||
f"{CHECK_MODULE}.annotate_verified_secrets",
|
||||
lambda *_: None,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.sagemaker.sagemaker_notebook_instance_no_secrets.sagemaker_notebook_instance_no_secrets import (
|
||||
sagemaker_notebook_instance_no_secrets,
|
||||
)
|
||||
|
||||
check = sagemaker_notebook_instance_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "Secret Keyword" in result[0].status_extended
|
||||
assert "OnCreate[0]" in result[0].status_extended
|
||||
assert result[0].resource_id == test_notebook_instance
|
||||
assert result[0].resource_arn == notebook_instance_arn
|
||||
|
||||
def test_manual_lifecycle_describe_failed(self):
|
||||
# Service could not fully describe/decode the lifecycle config.
|
||||
notebook_instance = NotebookInstance(
|
||||
name=test_notebook_instance,
|
||||
arn=notebook_instance_arn,
|
||||
region=AWS_REGION_EU_WEST_1,
|
||||
lifecycle_config_name="test-lifecycle-config",
|
||||
lifecycle_scripts={},
|
||||
lifecycle_scan_failed=True,
|
||||
)
|
||||
|
||||
sagemaker_client = mock.MagicMock
|
||||
sagemaker_client.audit_config = {}
|
||||
sagemaker_client.sagemaker_notebook_instances = [notebook_instance]
|
||||
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(f"{CHECK_MODULE}.sagemaker_client", sagemaker_client),
|
||||
mock.patch(
|
||||
f"{CHECK_MODULE}.detect_secrets_scan_batch",
|
||||
return_value={},
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.sagemaker.sagemaker_notebook_instance_no_secrets.sagemaker_notebook_instance_no_secrets import (
|
||||
sagemaker_notebook_instance_no_secrets,
|
||||
)
|
||||
|
||||
check = sagemaker_notebook_instance_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
assert result[0].resource_id == test_notebook_instance
|
||||
assert result[0].resource_arn == notebook_instance_arn
|
||||
|
||||
def test_manual_scan_error_only_scanned_instances(self):
|
||||
# Batch scan fails. The instance with scripts must be MANUAL; the
|
||||
# instance without a lifecycle config (nothing to scan) must PASS.
|
||||
scanned_instance = NotebookInstance(
|
||||
name=test_notebook_instance,
|
||||
arn=notebook_instance_arn,
|
||||
region=AWS_REGION_EU_WEST_1,
|
||||
lifecycle_config_name="test-lifecycle-config",
|
||||
lifecycle_scripts={"OnStart[0]": "echo hello"},
|
||||
)
|
||||
unscanned_instance = NotebookInstance(
|
||||
name=other_notebook_instance,
|
||||
arn=other_notebook_instance_arn,
|
||||
region=AWS_REGION_EU_WEST_1,
|
||||
lifecycle_config_name=None,
|
||||
lifecycle_scripts={},
|
||||
)
|
||||
|
||||
sagemaker_client = mock.MagicMock
|
||||
sagemaker_client.audit_config = {}
|
||||
sagemaker_client.sagemaker_notebook_instances = [
|
||||
scanned_instance,
|
||||
unscanned_instance,
|
||||
]
|
||||
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(f"{CHECK_MODULE}.sagemaker_client", sagemaker_client),
|
||||
mock.patch(
|
||||
f"{CHECK_MODULE}.detect_secrets_scan_batch",
|
||||
side_effect=SecretsScanError("scan failed"),
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.sagemaker.sagemaker_notebook_instance_no_secrets.sagemaker_notebook_instance_no_secrets import (
|
||||
sagemaker_notebook_instance_no_secrets,
|
||||
)
|
||||
|
||||
check = sagemaker_notebook_instance_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 2
|
||||
results_by_id = {report.resource_id: report for report in result}
|
||||
|
||||
assert results_by_id[test_notebook_instance].status == "MANUAL"
|
||||
assert results_by_id[other_notebook_instance].status == "PASS"
|
||||
assert (
|
||||
"does not have a lifecycle configuration"
|
||||
in results_by_id[other_notebook_instance].status_extended
|
||||
)
|
||||
@@ -28,6 +28,10 @@ test_training_job = "test-training-job"
|
||||
test_arn_training_job = f"arn:aws:sagemaker:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:training-job/{test_model}"
|
||||
subnet_id = "subnet-" + str(uuid4())
|
||||
kms_key_id = str(uuid4())
|
||||
lifecycle_config_name = "test-lifecycle-config"
|
||||
# base64 of "echo OnCreate" / "echo OnStart"
|
||||
lifecycle_on_create_b64 = "ZWNobyBPbkNyZWF0ZQ=="
|
||||
lifecycle_on_start_b64 = "ZWNobyBPblN0YXJ0"
|
||||
endpoint_config_name = "endpoint-config-test"
|
||||
endpoint_config_arn = f"arn:aws:sagemaker:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:endpoint-config/{endpoint_config_name}"
|
||||
prod_variant_name = "Variant1"
|
||||
@@ -76,6 +80,12 @@ def mock_make_api_call(self, operation_name, kwarg):
|
||||
"KmsKeyId": kms_key_id,
|
||||
"DirectInternetAccess": "Enabled",
|
||||
"RootAccess": "Enabled",
|
||||
"NotebookInstanceLifecycleConfigName": lifecycle_config_name,
|
||||
}
|
||||
if operation_name == "DescribeNotebookInstanceLifecycleConfig":
|
||||
return {
|
||||
"OnCreate": [{"Content": lifecycle_on_create_b64}],
|
||||
"OnStart": [{"Content": lifecycle_on_start_b64}],
|
||||
}
|
||||
if operation_name == "DescribeModel":
|
||||
return {
|
||||
@@ -247,6 +257,21 @@ class Test_SageMaker_Service:
|
||||
assert sagemaker.sagemaker_notebook_instances[0].subnet_id == subnet_id
|
||||
assert sagemaker.sagemaker_notebook_instances[0].direct_internet_access
|
||||
assert sagemaker.sagemaker_notebook_instances[0].kms_key_id == kms_key_id
|
||||
assert (
|
||||
sagemaker.sagemaker_notebook_instances[0].lifecycle_config_name
|
||||
== lifecycle_config_name
|
||||
)
|
||||
|
||||
# Test SageMaker describe notebook instance lifecycle config
|
||||
def test_describe_notebook_instance_lifecycle_config(self):
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
|
||||
sagemaker = SageMaker(aws_provider)
|
||||
notebook_instance = sagemaker.sagemaker_notebook_instances[0]
|
||||
assert notebook_instance.lifecycle_scan_failed is False
|
||||
assert notebook_instance.lifecycle_scripts == {
|
||||
"OnCreate[0]": "echo OnCreate",
|
||||
"OnStart[0]": "echo OnStart",
|
||||
}
|
||||
|
||||
# Test SageMaker describe model
|
||||
def test_describe_model(self):
|
||||
|
||||
@@ -4,7 +4,9 @@ FROM node:24.13.0-alpine@sha256:cd6fb7efa6490f039f3471a189214d5f548c11df1ff9e5b1
|
||||
LABEL maintainer="https://github.com/prowler-cloud"
|
||||
|
||||
# Patch Alpine OpenSSL runtime packages before all stages inherit the base image.
|
||||
RUN apk upgrade --no-cache libcrypto3 libssl3 && corepack enable
|
||||
# The build uses pnpm via corepack, so npm is unused — remove it (and npx) to drop
|
||||
# the bundled-npm CVE surface (node-tar CVE-2026-59873) from every stage, incl. prod.
|
||||
RUN apk upgrade --no-cache libcrypto3 libssl3 && corepack enable && rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
@@ -79,9 +81,10 @@ ENV HOSTNAME="0.0.0.0"
|
||||
# Helm/K8s):
|
||||
# - required: UI_API_BASE_URL, AUTH_URL, AUTH_SECRET (missing ⇒ fail fast at boot)
|
||||
# - optional: UI_API_DOCS_URL
|
||||
# - optional: UI_CLOUD_ENABLED ("true" only in Prowler Cloud deployments)
|
||||
# - gated integrations (load only when *_ENABLED="true"; the value is then
|
||||
# required or boot fails). Legacy names (NEXT_PUBLIC_*, POSTHOG_KEY/HOST)
|
||||
# still activate without the flag:
|
||||
# required or boot fails). Their legacy names (NEXT_PUBLIC_SENTRY_*,
|
||||
# NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID, POSTHOG_KEY/HOST) still work:
|
||||
# UI_SENTRY_ENABLED + UI_SENTRY_DSN (+ optional UI_SENTRY_ENVIRONMENT)
|
||||
# UI_GOOGLE_TAG_MANAGER_ENABLED + UI_GOOGLE_TAG_MANAGER_ID
|
||||
# UI_POSTHOG_ENABLED + UI_POSTHOG_KEY + UI_POSTHOG_HOST (no consumer yet)
|
||||
|
||||
@@ -235,7 +235,7 @@ describe("adaptFindingGroupResourcesResponse — malformed input", () => {
|
||||
|
||||
it("should attach adapter-produced triage DTOs to finding-level resource rows", () => {
|
||||
// Given
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "true");
|
||||
const input = {
|
||||
data: [
|
||||
{
|
||||
@@ -291,7 +291,7 @@ describe("adaptFindingGroupResourcesResponse — malformed input", () => {
|
||||
|
||||
it("should leave triage editing disabled until a real capability is provided", () => {
|
||||
// Given
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "false");
|
||||
const input = {
|
||||
data: [
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import {
|
||||
FINDING_TRIAGE_DISABLED_REASON,
|
||||
type FindingTriageDisabledReason,
|
||||
@@ -9,7 +10,7 @@ interface FindingTriageAdapterOptions {
|
||||
}
|
||||
|
||||
export function getFindingTriageAdapterOptions(): FindingTriageAdapterOptions {
|
||||
const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
|
||||
const isCloudEnvironment = isCloud();
|
||||
|
||||
return {
|
||||
canEdit: isCloudEnvironment,
|
||||
|
||||
@@ -55,7 +55,7 @@ describe("findings actions triage projection", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "false");
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
|
||||
handleApiResponseMock.mockResolvedValue(findingsResponse);
|
||||
@@ -83,7 +83,7 @@ describe("findings actions triage projection", () => {
|
||||
|
||||
it("should attach domain triage DTOs to latest findings responses", async () => {
|
||||
// Given
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "true");
|
||||
|
||||
// When
|
||||
const result = await getLatestFindings({ page: 1, pageSize: 10 });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
LucideIcon,
|
||||
Activity,
|
||||
BarChart3,
|
||||
Bot,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "@/lib";
|
||||
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { OrganizationResource } from "@/types/organizations";
|
||||
|
||||
export const getResources = async ({
|
||||
@@ -287,7 +288,7 @@ export const getResourceDrawerData = async ({
|
||||
pageSize?: number;
|
||||
query?: string;
|
||||
}) => {
|
||||
const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
|
||||
const isCloudEnv = isCloud();
|
||||
|
||||
const [resourceData, findingsResponse, organizationsResponse] =
|
||||
await Promise.all([
|
||||
|
||||
@@ -74,7 +74,7 @@ describe("role actions", () => {
|
||||
|
||||
it("includes manage_alerts when creating a role in Prowler Cloud", async () => {
|
||||
// Given
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "true");
|
||||
|
||||
// When
|
||||
await addRole(makeRoleFormData());
|
||||
@@ -85,7 +85,7 @@ describe("role actions", () => {
|
||||
|
||||
it("omits manage_alerts when creating a role outside Prowler Cloud", async () => {
|
||||
// Given
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "false");
|
||||
|
||||
// When
|
||||
await addRole(makeRoleFormData());
|
||||
@@ -98,7 +98,7 @@ describe("role actions", () => {
|
||||
|
||||
it("includes manage_alerts when updating a role in Prowler Cloud", async () => {
|
||||
// Given
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "true");
|
||||
|
||||
// When
|
||||
await updateRole(makeRoleFormData(), "role-1");
|
||||
|
||||
@@ -5,6 +5,7 @@ import { redirect } from "next/navigation";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
|
||||
export const getRoles = async ({
|
||||
page = 1,
|
||||
@@ -108,7 +109,7 @@ export const addRole = async (formData: FormData) => {
|
||||
};
|
||||
|
||||
// Conditionally include Prowler Cloud permissions.
|
||||
if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") {
|
||||
if (isCloud()) {
|
||||
payload.data.attributes.manage_billing =
|
||||
formData.get("manage_billing") === "true";
|
||||
payload.data.attributes.manage_alerts =
|
||||
@@ -165,7 +166,7 @@ export const updateRole = async (formData: FormData, roleId: string) => {
|
||||
};
|
||||
|
||||
// Conditionally include Prowler Cloud permissions.
|
||||
if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") {
|
||||
if (isCloud()) {
|
||||
payload.data.attributes.manage_billing =
|
||||
formData.get("manage_billing") === "true";
|
||||
payload.data.attributes.manage_alerts =
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { AuthForm } from "@/components/auth/oss";
|
||||
import { getAuthUrl, isGithubOAuthEnabled } from "@/lib/helper";
|
||||
import { isGoogleOAuthEnabled } from "@/lib/helper";
|
||||
import {
|
||||
getAuthUrl,
|
||||
isGithubOAuthEnabled,
|
||||
isGoogleOAuthEnabled,
|
||||
} from "@/lib/helper";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
|
||||
const SignUp = async ({
|
||||
@@ -13,7 +17,7 @@ const SignUp = async ({
|
||||
typeof resolvedSearchParams?.invitation_token === "string"
|
||||
? resolvedSearchParams.invitation_token
|
||||
: null;
|
||||
const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
|
||||
const isCloudEnv = isCloud();
|
||||
|
||||
const GOOGLE_AUTH_URL = getAuthUrl("google");
|
||||
const GITHUB_AUTH_URL = getAuthUrl("github");
|
||||
|
||||
@@ -2,6 +2,7 @@ import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { LIGHTHOUSE_OVERVIEW_BANNER_HREF } from "../_lib/lighthouse-banner";
|
||||
|
||||
import { LighthouseOverviewBanner } from "./lighthouse-overview-banner";
|
||||
|
||||
describe("LighthouseOverviewBanner", () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
|
||||
import { pickFilterParams } from "../_lib/filter-params";
|
||||
import { SSRComponentProps } from "../_types";
|
||||
|
||||
import { AttackSurface } from "./_components/attack-surface";
|
||||
|
||||
export const AttackSurfaceSSR = async ({ searchParams }: SSRComponentProps) => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
filterProvidersByScope,
|
||||
parseFilterIds,
|
||||
} from "../../_lib/provider-scope";
|
||||
|
||||
import { RiskPlotClient } from "./risk-plot-client";
|
||||
|
||||
export async function RiskPlotSSR({
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { SearchParamsProps } from "@/types";
|
||||
|
||||
import { pickFilterParams } from "../../_lib/filter-params";
|
||||
|
||||
import { RiskRadarViewClient } from "./risk-radar-view-client";
|
||||
|
||||
export async function RiskRadarViewSSR({
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
|
||||
import { pickFilterParams } from "../_lib/filter-params";
|
||||
import { SSRComponentProps } from "../_types";
|
||||
|
||||
import { ResourcesInventory } from "./_components/resources-inventory";
|
||||
|
||||
export const ResourcesInventorySSR = async ({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getFindingsBySeverity } from "@/actions/overview";
|
||||
|
||||
import { pickFilterParams } from "../_lib/filter-params";
|
||||
import { SSRComponentProps } from "../_types";
|
||||
|
||||
import { RiskSeverityChart } from "./_components/risk-severity-chart";
|
||||
|
||||
export const RiskSeverityChartSSR = async ({
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "@/types/severities";
|
||||
|
||||
import { DEFAULT_TIME_RANGE } from "../_constants/time-range.constants";
|
||||
|
||||
import { type TimeRange, TimeRangeSelector } from "./time-range-selector";
|
||||
|
||||
interface FindingSeverityOverTimeProps {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
|
||||
|
||||
import { pickFilterParams } from "../_lib/filter-params";
|
||||
import { SSRComponentProps } from "../_types";
|
||||
|
||||
import { FindingSeverityOverTime } from "./_components/finding-severity-over-time";
|
||||
import { FindingSeverityOverTimeSkeleton } from "./_components/finding-severity-over-time.skeleton";
|
||||
import { DEFAULT_TIME_RANGE } from "./_constants/time-range.constants";
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getThreatScore } from "@/actions/overview";
|
||||
|
||||
import { pickFilterParams } from "../_lib/filter-params";
|
||||
import { SSRComponentProps } from "../_types";
|
||||
|
||||
import { ThreatScore } from "./_components/threat-score";
|
||||
|
||||
export const ThreatScoreSSR = async ({ searchParams }: SSRComponentProps) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
|
||||
import { pickFilterParams } from "../_lib/filter-params";
|
||||
import { SSRComponentProps } from "../_types";
|
||||
|
||||
import { ComplianceWatchlist } from "./_components/compliance-watchlist";
|
||||
|
||||
export const ComplianceWatchlistSSR = async ({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getServicesOverview, ServiceOverview } from "@/actions/overview";
|
||||
|
||||
import { pickFilterParams } from "../_lib/filter-params";
|
||||
import { SSRComponentProps } from "../_types";
|
||||
|
||||
import { ServiceWatchlist } from "./_components/service-watchlist";
|
||||
|
||||
export const ServiceWatchlistSSR = async ({
|
||||
|
||||
@@ -25,6 +25,7 @@ vi.mock("@/lib/server-actions-helper", () => ({
|
||||
}));
|
||||
|
||||
import { ALERT_AGGREGATE_OPS, ALERT_TRIGGER_KINDS } from "../_types";
|
||||
|
||||
import {
|
||||
createAlert,
|
||||
deleteAlert,
|
||||
|
||||
@@ -15,12 +15,10 @@ import {
|
||||
ALERT_TRIGGER_KINDS,
|
||||
type AlertRule,
|
||||
} from "@/app/(prowler)/alerts/_types";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { useToast } from "@/components/shadcn";
|
||||
import { Button, useToast } from "@/components/shadcn";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { DOCS_URLS } from "@/lib/external-urls";
|
||||
import type { MetaDataProps } from "@/types";
|
||||
import type { ScanEntity } from "@/types";
|
||||
import type { MetaDataProps, ScanEntity } from "@/types";
|
||||
import type { ProviderProps } from "@/types/providers";
|
||||
|
||||
import { toAlertPayload } from "../_lib/alert-adapter";
|
||||
@@ -29,6 +27,7 @@ import type {
|
||||
AlertFormSubmitResult,
|
||||
AlertFormValues,
|
||||
} from "../_types/alert-form";
|
||||
|
||||
import { AlertFormModal } from "./alert-form-modal";
|
||||
import { AlertsEmptyState } from "./alerts-empty-state";
|
||||
import { AlertsTable } from "./alerts-table";
|
||||
|
||||
@@ -28,8 +28,9 @@ import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
ToastAction,
|
||||
useToast,
|
||||
} from "@/components/shadcn";
|
||||
import { ToastAction, useToast } from "@/components/shadcn";
|
||||
import { useCloudUpgradeStore } from "@/store";
|
||||
import type { ScanEntity } from "@/types";
|
||||
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
|
||||
|
||||
@@ -2,6 +2,7 @@ import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ATTACK_PATHS_VIEW_STATES } from "../_lib/get-attack-paths-view-state";
|
||||
|
||||
import { AttackPathsStatusPanel } from "./attack-paths-status-panel";
|
||||
|
||||
describe("AttackPathsStatusPanel", () => {
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
resolveHiddenFindingIds,
|
||||
} from "../../_lib";
|
||||
import { isFindingNode, layoutWithDagre } from "../../_lib/layout";
|
||||
|
||||
import { FindingNode } from "./nodes/finding-node";
|
||||
import { InternetNode } from "./nodes/internet-node";
|
||||
import { ResourceNode } from "./nodes/resource-node";
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { GraphNode } from "@/types/attack-paths";
|
||||
import { resolveNodeColors, resolveNodeVisual } from "../../../_lib";
|
||||
import { FINDING_NODE_DIMENSIONS } from "../../../_lib/node-dimensions";
|
||||
import { getNodeLabelDisplay } from "../../../_lib/node-label-lines";
|
||||
|
||||
import { HiddenHandles } from "./hidden-handles";
|
||||
|
||||
interface FindingNodeData {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { type NodeProps } from "@xyflow/react";
|
||||
import type { GraphNode } from "@/types/attack-paths";
|
||||
|
||||
import { resolveNodeColors } from "../../../_lib";
|
||||
|
||||
import { HiddenHandles } from "./hidden-handles";
|
||||
|
||||
interface InternetNodeData {
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { GraphNode } from "@/types/attack-paths";
|
||||
import { resolveNodeColors, resolveNodeVisual } from "../../../_lib";
|
||||
import { RESOURCE_NODE_DIMENSIONS } from "../../../_lib/node-dimensions";
|
||||
import { getNodeLabelDisplay } from "../../../_lib/node-label-lines";
|
||||
|
||||
import { HiddenHandles } from "./hidden-handles";
|
||||
|
||||
interface ResourceNodeData {
|
||||
|
||||
@@ -28,13 +28,13 @@ import {
|
||||
import { StatusAlert } from "@/components/shared/status-alert";
|
||||
import { useMountEffect } from "@/hooks/use-mount-effect";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { attackPathsEmptyTour } from "@/lib/tours/attack-paths-empty.tour";
|
||||
import {
|
||||
attackPathsTour,
|
||||
type AttackPathsTourTarget,
|
||||
pickDemoQuery,
|
||||
pickDemoScan,
|
||||
} from "@/lib/tours/attack-paths.tour";
|
||||
import { attackPathsEmptyTour } from "@/lib/tours/attack-paths-empty.tour";
|
||||
import { advanceActiveTour, useDriverTour } from "@/lib/tours/use-driver-tour";
|
||||
import type {
|
||||
AttackPathQuery,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useCloudUpgradeStore } from "@/store";
|
||||
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
|
||||
|
||||
import { COMPLIANCE_TAB } from "../_types";
|
||||
|
||||
import { CompliancePageTabs } from "./compliance-page-tabs";
|
||||
import { getComplianceTab } from "./compliance-page-tabs.shared";
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
parseCrossProviderFilters,
|
||||
} from "../_lib/cross-provider-frameworks";
|
||||
import { CROSS_PROVIDER_OVERVIEW_RESULT_STATUS } from "../_types";
|
||||
|
||||
import { CrossProviderErrorAlert } from "./cross-provider-error-alert";
|
||||
import type {
|
||||
CrossProviderAccountOption,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
CROSS_PROVIDER_OVERVIEW_RESULT_STATUS,
|
||||
CROSS_PROVIDER_OVERVIEW_TYPE,
|
||||
} from "../_types";
|
||||
|
||||
import { CrossProviderOverview } from "./cross-provider-overview";
|
||||
|
||||
vi.mock("../_actions/cross-provider", () => ({
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import type { CrossProviderFrameworkSummary } from "../_types";
|
||||
import { CROSS_PROVIDER_OVERVIEW_RESULT_STATUS } from "../_types";
|
||||
import { ComplianceSectionHeader } from "./compliance-section-header";
|
||||
|
||||
import { CrossProviderErrorAlert } from "./cross-provider-error-alert";
|
||||
import type {
|
||||
CrossProviderAccountOption,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import type { CheckProviderTypesMap, Requirement } from "@/types/compliance";
|
||||
|
||||
import type { CrossProviderRequirementExtras } from "../_types";
|
||||
|
||||
import { CrossProviderRequirementContent } from "./cross-provider-requirement-content";
|
||||
|
||||
const { clientAccordionContentMock } = vi.hoisted(() => ({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ProviderBreakdownEntry } from "../_types";
|
||||
|
||||
import { ProviderCoverageCard } from "./provider-coverage-card";
|
||||
|
||||
vi.mock("@/components/icons/providers-badge/provider-type-icon", () => ({
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { getRoles } from "@/actions/roles";
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
|
||||
import { ProviderIcon } from "../config/provider-icon";
|
||||
|
||||
import { ChatComposerPanel } from "./composer";
|
||||
import { ChatEmptyState } from "./empty-state";
|
||||
import { useLighthouseChatStore } from "./lighthouse-chat-store-provider";
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
LIGHTHOUSE_CHAT_SURFACE,
|
||||
LighthouseV2ChatView,
|
||||
} from "../chat/lighthouse-v2-chat-view";
|
||||
|
||||
import { LighthousePanelChatSkeleton } from "./lighthouse-panel-chat-skeleton";
|
||||
|
||||
const PANEL_CHAT_STATUS = {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Suspense } from "react";
|
||||
import { ConnectLLMProvider } from "@/components/lighthouse-v1/connect-llm-provider";
|
||||
import { SelectBedrockAuthMethod } from "@/components/lighthouse-v1/select-bedrock-auth-method";
|
||||
import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import type { LighthouseProvider } from "@/types/lighthouse-v1";
|
||||
|
||||
export const BEDROCK_AUTH_MODES = {
|
||||
@@ -44,7 +45,7 @@ function ConnectContent() {
|
||||
}
|
||||
|
||||
export default function ConnectLLMProviderPage() {
|
||||
if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") {
|
||||
if (isCloud()) {
|
||||
redirect(LIGHTHOUSE_ROUTE.SETTINGS);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,7 @@ import {
|
||||
} from "@/actions/lighthouse-v1/lighthouse";
|
||||
import { DeleteLLMProviderForm } from "@/components/lighthouse-v1/forms/delete-llm-provider-form";
|
||||
import { WorkflowConnectLLM } from "@/components/lighthouse-v1/workflow";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { NavigationHeader } from "@/components/shadcn";
|
||||
import { Button, NavigationHeader } from "@/components/shadcn";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes";
|
||||
import type { LighthouseProvider } from "@/types/lighthouse-v1";
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Suspense } from "react";
|
||||
|
||||
import { SelectModel } from "@/components/lighthouse-v1/select-model";
|
||||
import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import type { LighthouseProvider } from "@/types/lighthouse-v1";
|
||||
|
||||
function SelectModelContent() {
|
||||
@@ -27,7 +28,7 @@ function SelectModelContent() {
|
||||
}
|
||||
|
||||
export default function SelectModelPage() {
|
||||
if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") {
|
||||
if (isCloud()) {
|
||||
redirect(LIGHTHOUSE_ROUTE.SETTINGS);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ import {
|
||||
FieldError,
|
||||
Skeleton,
|
||||
Textarea,
|
||||
useToast,
|
||||
} from "@/components/shadcn";
|
||||
import { useToast } from "@/components/shadcn";
|
||||
import { CustomLink } from "@/components/shadcn/custom/custom-link";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { fontMono } from "@/config/fonts";
|
||||
|
||||
@@ -4,8 +4,7 @@ import { useState } from "react";
|
||||
|
||||
import { toggleMuteRule } from "@/actions/mute-rules";
|
||||
import { MuteRuleData } from "@/actions/mute-rules/types";
|
||||
import { Switch } from "@/components/shadcn";
|
||||
import { useToast } from "@/components/shadcn";
|
||||
import { Switch, useToast } from "@/components/shadcn";
|
||||
|
||||
interface MuteRuleEnabledToggleProps {
|
||||
muteRule: MuteRuleData;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { type MuteRuleTableData } from "./mute-rule-target-previews";
|
||||
import { MuteRuleTargetsModal } from "./mute-rule-targets-modal";
|
||||
|
||||
vi.mock("@/components/shadcn/modal", () => ({
|
||||
Modal: ({
|
||||
@@ -21,8 +22,6 @@ vi.mock("@/components/shadcn/modal", () => ({
|
||||
) : null,
|
||||
}));
|
||||
|
||||
import { MuteRuleTargetsModal } from "./mute-rule-targets-modal";
|
||||
|
||||
const longMuteRule: MuteRuleTableData = {
|
||||
type: "mute-rules",
|
||||
id: "mute-rule-1",
|
||||
|
||||