Compare commits

...
Author SHA1 Message Date
Prowler BotandJosema Camacho 851c8dbed3 fix(api): limit mute rules to current and future scans (#12685)
Co-authored-by: Josema Camacho <josema@prowler.com>
2026-09-01 09:53:41 +02:00
Prowler BotandAlejandro Bailo 14ddd9641d fix(ui): refresh cached permissions after token rotation (#12674)
Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
2026-08-31 11:09:08 +02:00
1b1d118e7d fix(github): paginate repository discovery (#12673)
Co-authored-by: ye11oc4t <138095093+ye11oc4t@users.noreply.github.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-31 10:39:06 +02:00
Prowler Botandprowler-bot f4459ff7c8 chore(release): Bump versions to v5.40.1 (#12648)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-08-28 13:15:55 +02:00
Prowler Botandprowler-bot 49104f39df chore(api): Update prowler dependency to v5.40 for release 5.40.0 (#12643)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-08-28 12:01:55 +02:00
Prowler Botandprowler-bot 4d13e8432e chore(changelog): v5.40.0 (#12642)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-08-28 11:51:30 +02:00
Pedro Martín 5f24bec9fe fix(compliance): correct Cyber Essentials mappings and remediation text (#12596) 2026-08-28 11:08:15 +02:00
César Arroba afefb8f333 fix(api): apply findings partition max age in months, not days (#12580) 2026-08-28 10:34:12 +02:00
Rubén De la Torre Vico 2b81fdcc04 fix(mcp): call the Mintlify search endpoint the documentation moved to (#12578) 2026-08-27 16:48:38 +02:00
Pablo Fernandez Guerra (PFE) db298c1d48 fix(ui): redirect the Slack OAuth callback relative to the browser's origin (#12577) 2026-08-27 16:13:36 +02:00
2877c3d6c0 fix(slack): handle scans that produce no findings (#12229)
Co-authored-by: Juhef <117518034+juheff@users.noreply.github.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-27 14:03:33 +02:00
ee64c17108 fix(kubernetes): return empty list when resource gather fails (#12225)
Co-authored-by: Juhef <117518034+juheff@users.noreply.github.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-27 13:55:46 +02:00
Pablo Fernandez Guerra (PFE)andalejandrobailo a610314eba fix(ui): complete Slack OAuth callback server-side to avoid router race (#12572)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-08-27 13:54:15 +02:00
Muhammad Ibrahimandpedrooot 301edea7ce feat(compliance): add Cyber Essentials 3.3 for Azure (#11588)
Co-authored-by: pedrooot <pedromarting3@gmail.com>
2026-08-27 13:31:18 +02:00
c89d900aae fix(iac): raise typed exceptions instead of sys.exit on provider failures (#12227)
Co-authored-by: Juhef <117518034+juheff@users.noreply.github.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-27 12:15:50 +02:00
Hugo Pereira Brito 4cfb4eeb96 fix(sdk): use system trust store for push-to-cloud (#12485) 2026-08-27 11:07:14 +01:00
Pedro Martínandalejandrobailo f19478f2f6 fix(compliance): discover universal frameworks from entry point (#12536)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-08-27 09:17:39 +02:00
Chethas DileepandDaniel Barranquero 2f11b16299 feat(github): add repository_default_workflow_permissions_read_only check (#12143)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-27 09:00:42 +02:00
Pablo Fernandez Guerra (PFE) 654d2c9f17 docs: document Slack channel destinations for alerts (#12496) 2026-08-27 08:53:07 +02:00
Chethas DileepandDaniel Barranquero 2721d42594 feat(github): add organization_actions_pull_request_approval_disabled check (#12394)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-27 08:25:32 +02:00
Daniel Barranquero bd1956446d fix(alibabacloud): read OSS bucket sub-resource configs via the SDK execute path (#12546) 2026-08-26 16:41:13 +02:00
Pablo Fernandez Guerra (PFE) d26d7cf91a feat(ui): show a rule's destinations in the alerts list (#12493) 2026-08-26 13:36:31 +02:00
Pablo Fernandez Guerra (PFE) 4c20bf1fac feat(ui): let alert rules target authorized Slack channels (#12492) 2026-08-26 12:55:49 +02:00
Chethas DileepandDaniel Barranquero 9dc53ffc60 feat(github): add organization_default_workflow_permissions_read_only check (#12122)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-26 12:21:49 +02:00
Alan Buscagliaandalejandrobailo 95642fb220 feat(ui): add Lighthouse request outcome feedback (#12419)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-08-26 11:34:40 +02:00
Pedro Martín 6449f3a592 fix(container): patch the high OpenSSL CVEs for container img (#12549) 2026-08-26 11:10:55 +02:00
Rubén De la Torre Vico 91e6cb798d feat(mcp): classify shared tool failures and stop relaying upstream bodies (#12531) 2026-08-26 11:06:07 +02:00
Rubén De la Torre Vico bcd37988b5 fix(mcp): patch the high openssl CVE in the container image (#12547) 2026-08-26 10:45:41 +02:00
Alejandro Bailo 7a64e1a1d1 feat(ui): add cancelled subscription variant to the trial sidebar banner (#12538) 2026-08-26 08:59:00 +02:00
Pablo Fernandez Guerra (PFE) 39c85ffb77 feat(ui): authorize multiple Slack destination channels (#12491) 2026-08-26 08:52:56 +02:00
Alex ChenandDaniel Barranquero 301ca50541 feat(alibabacloud): add oss_bucket_server_side_encryption_enabled check (#11981)
Signed-off-by: Alex Chen <l46983284@gmail.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-25 17:41:52 +02:00
Rubén De la Torre Vico f299e1d9ac fix(mcp): patch the two high sqlite CVEs in the container image (#12537) 2026-08-25 17:41:44 +02:00
João Mesquitaandpedrooot 465e35bf54 fix(compliance): correct AWS FSBP check mapping for IAM.9 and EKS.1 (#12372)
Co-authored-by: pedrooot <pedromarting3@gmail.com>
2026-08-25 17:38:33 +02:00
Pablo Fernandez Guerra (PFE) 829af2e3f7 docs: add Slack integration guide (#12438) 2026-08-25 15:44:34 +02:00
Hugo Pereira Brito 51c65bdc69 docs: restructure organization documentation (#12521) 2026-08-25 12:49:23 +01:00
Hugo Pereira Brito d0c088dd0a docs: explain AWS Organization account membership updates (#12512) 2026-08-25 12:47:23 +01:00
abidedavanaandDaniel Barranquero 411d112165 feat(alibabacloud): add oss_bucket_versioning_enabled check (#11913)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-25 13:03:25 +02:00
Gabrielandpedrooot 8a4cc8780d feat(kubernetes): include cluster name in compliance reports (#12506)
Co-authored-by: pedrooot <pedromarting3@gmail.com>
2026-08-25 11:08:04 +02:00
Pepe Fagoaga cd4d2a27e3 chore(lighthouse): rename not tested to not connected (#12523) 2026-08-25 09:21:18 +02:00
9898cf7364 feat(m365): add defender_domain_dmarc_records_published check (#11936)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
Co-authored-by: hdy2001 <56308320+hdy2001@users.noreply.github.com>
2026-08-24 16:49:36 +02:00
83d8cfa829 fix(aws): treat security groups on Batch compute environments as used (#12458)
Co-authored-by: hackertwinten <193916571+hackertwinten@users.noreply.github.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-24 16:18:56 +02:00
246 changed files with 17153 additions and 3044 deletions
+1 -1
View File
@@ -158,7 +158,7 @@ SENTRY_RELEASE=local
# REO_DEV_CLIENT_ID=
#### Prowler release version ####
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.40.0
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.40.1
# Social login credentials
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
@@ -114,6 +114,8 @@ jobs:
egress-policy: block
allowed-endpoints: >
auth.docker.io:443
dl-cdn.alpinelinux.org:443
dualstack.j.sni.global.fastly.net:443
files.pythonhosted.org:443
ghcr.io:443
github.com:443
@@ -81,6 +81,8 @@ jobs:
pkg-containers.githubusercontent.com:443
files.pythonhosted.org:443
pypi.org:443
dl-cdn.alpinelinux.org:443
dualstack.j.sni.global.fastly.net:443
api.github.com:443
mirror.gcr.io:443
check.trivy.dev:443
+10 -1
View File
@@ -22,11 +22,20 @@ ARG POWERSHELL_SHA256_ARM64=2503b71da3e83635592b092df59a0aca4c3606b4d9b068217bb0
ARG ZIZMOR_SHA256_AMD64=a8000f3c683319a523d3b20df0e75457ba591f049cfcbfa98966631b56733c03
ARG ZIZMOR_SHA256_ARM64=d66e37ef8a375fb07939c630ebf9709a6e0f20242bdc3faf672a7ed97e0b768d
# High CVEs fixed in Debian trixie-security but not yet in the pinned base image:
# openssl/libssl3t64/openssl-provider-legacy 3.5.7-1~deb13u2 CVE-2026-14456,
# -14457, -18798, -54874, -63072, -63073, -63074, -63075, -63076, -75803
# (image ships 3.5.6-1~deb13u2)
# Taken as a targeted --only-upgrade rather than by moving the digest: the newest
# published python:3.12-slim-trixie carries the same vulnerable version. The three
# packages are all built from openssl and are flagged separately, so all are named.
# Drop them once the base image ships 3.5.7-1~deb13u2 or later.
# hadolint ignore=DL3008
RUN apt-get update && apt-get install -y --no-install-recommends \
wget libicu76 libunwind8 libssl3 libcurl4 ca-certificates apt-transport-https gnupg \
build-essential pkg-config libzstd-dev zlib1g-dev \
&& apt-get install -y --no-install-recommends --only-upgrade util-linux \
&& apt-get install -y --no-install-recommends --only-upgrade \
util-linux libssl3t64 openssl openssl-provider-legacy \
&& rm -rf /var/lib/apt/lists/*
# Install PowerShell
+13
View File
@@ -4,6 +4,19 @@ All notable changes to the **Prowler API** are documented in this file.
<!-- changelog: release notes start -->
## [1.41.0] (Prowler v5.40.0)
### 🐞 Fixed
- `FINDINGS_TABLE_PARTITION_MAX_AGE_MONTHS` is now applied in months instead of days, and negative values are rejected [(#12580)](https://github.com/prowler-cloud/prowler/pull/12580)
### 🔐 Security
- `sqlparse` upgraded to 0.6.0, patching CVE-2026-54284, CVE-2026-59893, and CVE-2026-71491 [(#12509)](https://github.com/prowler-cloud/prowler/pull/12509)
- `openssl`, `libssl3t64` and `openssl-provider-legacy` upgraded to 3.5.7-1~deb13u2 in the API container image, patching ten high OpenSSL CVEs [(#12549)](https://github.com/prowler-cloud/prowler/pull/12549)
---
## [1.40.1] (Prowler v5.39.1)
### 🔄 Changed
+10 -1
View File
@@ -21,6 +21,14 @@ ARG POWERSHELL_SHA256_ARM64=2503b71da3e83635592b092df59a0aca4c3606b4d9b068217bb0
ARG ZIZMOR_SHA256_AMD64=a8000f3c683319a523d3b20df0e75457ba591f049cfcbfa98966631b56733c03
ARG ZIZMOR_SHA256_ARM64=d66e37ef8a375fb07939c630ebf9709a6e0f20242bdc3faf672a7ed97e0b768d
# High CVEs fixed in Debian trixie-security but not yet in the pinned base image:
# openssl/libssl3t64/openssl-provider-legacy 3.5.7-1~deb13u2 CVE-2026-14456,
# -14457, -18798, -54874, -63072, -63073, -63074, -63075, -63076, -75803
# (image ships 3.5.6-1~deb13u2)
# Taken as a targeted --only-upgrade rather than by moving the digest: the newest
# published python:3.12-slim-trixie carries the same vulnerable version. The three
# packages are all built from openssl and are flagged separately, so all are named.
# Drop them once the base image ships 3.5.7-1~deb13u2 or later.
# hadolint ignore=DL3008
RUN apt-get update && apt-get install -y --no-install-recommends \
wget \
@@ -36,7 +44,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libtool \
libxslt1-dev \
python3-dev \
&& apt-get install -y --no-install-recommends --only-upgrade util-linux \
&& apt-get install -y --no-install-recommends --only-upgrade \
util-linux libssl3t64 openssl openssl-provider-legacy \
&& rm -rf /var/lib/apt/lists/*
# Install PowerShell
@@ -0,0 +1 @@
`POST /api/v1/mute-rules` now updates only each affected provider's latest completed scan and future scans, preventing historical reaggregation from flooding Celery queues
-1
View File
@@ -1 +0,0 @@
`sqlparse` upgraded to 0.6.0, patching CVE-2026-54284, CVE-2026-59893, and CVE-2026-71491
+2 -2
View File
@@ -45,7 +45,7 @@ dependencies = [
"gunicorn==26.0.0",
"uvloop==0.22.1",
"lxml==6.1.0",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.40",
"psycopg2-binary==2.9.9",
"pytest-celery[redis] (==1.3.0)",
"sentry-sdk[django] (==2.56.0)",
@@ -71,7 +71,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.41.0"
version = "1.41.1"
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
# target-version tracks this project's lowest supported Python.
+13 -5
View File
@@ -6,6 +6,7 @@ from api.rls import RowLevelSecurityConstraint
from api.uuid_utils import datetime_to_uuid7
from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from psqlextra.partitioning import (
PostgresPartitioningError,
PostgresPartitioningManager,
@@ -153,10 +154,17 @@ class PostgresUUIDv7PartitioningStrategy(PostgresRangePartitioningStrategy):
)
def relative_days_or_none(value):
if value is None:
def relative_months_or_none(value):
# A negative value would set the cutoff in the future and delete every
# partition, so it is rejected rather than silently ignored.
if value is not None and value < 0:
raise ImproperlyConfigured(
"FINDINGS_TABLE_PARTITION_MAX_AGE_MONTHS must not be negative; "
"leave it unset or use 0 to keep partitions indefinitely"
)
if not value:
return None
return relativedelta(days=value)
return relativedelta(months=value)
#
@@ -173,7 +181,7 @@ manager = PostgresPartitioningManager(
months=settings.FINDINGS_TABLE_PARTITION_MONTHS
),
count=settings.FINDINGS_TABLE_PARTITION_COUNT,
max_age=relative_days_or_none(
max_age=relative_months_or_none(
settings.FINDINGS_TABLE_PARTITION_MAX_AGE_MONTHS
),
name_format="%Y_%b",
@@ -189,7 +197,7 @@ manager = PostgresPartitioningManager(
months=settings.FINDINGS_TABLE_PARTITION_MONTHS
),
count=settings.FINDINGS_TABLE_PARTITION_COUNT,
max_age=relative_days_or_none(
max_age=relative_months_or_none(
settings.FINDINGS_TABLE_PARTITION_MAX_AGE_MONTHS
),
name_format="%Y_%b",
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
version: 1.41.0
version: 1.41.1
description: |-
Prowler API specification.
@@ -0,0 +1,63 @@
from datetime import UTC, datetime
from itertools import islice
import pytest
from api.partitions import (
PostgresUUIDv7PartitioningStrategy,
relative_months_or_none,
)
from dateutil.relativedelta import relativedelta
from django.core.exceptions import ImproperlyConfigured
from psqlextra.partitioning import PostgresTimePartitionSize
def build_strategy(max_age):
return PostgresUUIDv7PartitioningStrategy(
size=PostgresTimePartitionSize(months=1),
count=1,
start_date=datetime.now(UTC),
max_age=max_age,
name_format="%Y_%b",
)
class TestRelativeMonthsOrNone:
@pytest.mark.parametrize("value", [None, 0])
def test_unset_or_zero_keeps_partitions_indefinitely(self, value):
assert relative_months_or_none(value) is None
@pytest.mark.parametrize("months", [1, 3, 12])
def test_value_is_interpreted_as_months(self, months):
assert relative_months_or_none(months) == relativedelta(months=months)
def test_value_is_not_interpreted_as_days(self):
assert relative_months_or_none(12) != relativedelta(days=12)
def test_negative_is_rejected(self):
with pytest.raises(ImproperlyConfigured):
relative_months_or_none(-12)
class TestToDelete:
@pytest.mark.parametrize("max_age", [None, relative_months_or_none(0)])
def test_nothing_is_deleted_without_max_age(self, max_age):
strategy = build_strategy(max_age)
assert list(islice(strategy.to_delete(), 5)) == []
def test_first_deleted_partition_is_max_age_old(self):
months = 3
strategy = build_strategy(relative_months_or_none(months))
first = next(strategy.to_delete())
expected = strategy.get_start_datetime() - relativedelta(months=months)
assert first.name() == expected.strftime("%Y_%b").lower()
def test_deleted_partitions_go_further_back_in_time(self):
strategy = build_strategy(relative_months_or_none(3))
names = [p.name() for p in islice(strategy.to_delete(), 3)]
starts = [datetime.strptime(n, "%Y_%b") for n in names]
assert starts == sorted(starts, reverse=True)
+18 -27
View File
@@ -18333,19 +18333,14 @@ class TestMuteRuleViewSet:
assert len(data) == 2
assert data[0]["id"] == str(mute_rules_fixture[first_index].id)
@patch("api.v1.views.chain")
@patch("api.v1.views.reaggregate_all_finding_group_summaries_task.si")
@patch("api.v1.views.mute_historical_findings_task.si")
@patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async")
@patch("api.v1.views.transaction.on_commit", side_effect=lambda fn: fn())
def test_mute_rules_create_valid(
self,
_mock_on_commit,
mock_mute_signature,
mock_reaggregate_signature,
mock_chain,
mock_mute_task,
authenticated_client,
findings_fixture,
create_test_user,
):
"""Test creating a valid mute rule."""
finding_ids = [str(findings_fixture[0].id)]
@@ -18372,24 +18367,20 @@ class TestMuteRuleViewSet:
assert response_data["attributes"]["name"] == "New Mute Rule"
assert response_data["attributes"]["reason"] == "Security exception approved"
# Verify the finding was immediately muted
from api.models import Finding
finding = Finding.objects.get(id=findings_fixture[0].id)
assert finding.muted is True
assert finding.muted_at is not None
assert finding.muted_reason == "Security exception approved"
assert finding.muted is False
assert finding.muted_at is None
assert finding.muted_reason is None
# Verify background task chain was called: mute → reaggregate all
mock_mute_signature.assert_called_once()
mock_reaggregate_signature.assert_called_once()
mock_chain.assert_called_once_with(
mock_mute_signature.return_value,
mock_reaggregate_signature.return_value,
mock_mute_task.assert_called_once_with(
kwargs={
"tenant_id": str(finding.tenant_id),
"mute_rule_id": response_data["id"],
"provider_ids": [str(finding.scan.provider_id)],
}
)
mock_chain.return_value.apply_async.assert_called_once()
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
@patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async")
def test_mute_rules_create_converts_finding_ids_to_uids(
self,
mock_task,
@@ -18425,7 +18416,7 @@ class TestMuteRuleViewSet:
]
assert set(mute_rule.finding_uids) == set(expected_uids)
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
@patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async")
def test_mute_rules_deduplicates_uids(
self,
mock_task,
@@ -18492,10 +18483,10 @@ class TestMuteRuleViewSet:
finding1.refresh_from_db()
finding2.refresh_from_db()
assert finding1.muted is True
assert finding2.muted is True
assert finding1.muted is False
assert finding2.muted is False
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
@patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async")
def test_mute_rules_create_overlap_detection_active(
self,
mock_task,
@@ -18528,7 +18519,7 @@ class TestMuteRuleViewSet:
"already muted" in error_detail.lower() or "overlap" in error_detail.lower()
)
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
@patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async")
def test_mute_rules_create_no_overlap_with_inactive(
self,
mock_task,
@@ -18584,7 +18575,7 @@ class TestMuteRuleViewSet:
== "/data/attributes/finding_ids"
)
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
@patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async")
def test_mute_rules_create_invalid_finding_ids(
self, mock_task, authenticated_client
):
+18 -27
View File
@@ -244,7 +244,6 @@ from api.v1.serializers import (
UserUpdateSerializer,
)
from botocore.exceptions import ClientError, NoCredentialsError, ParamValidationError
from celery import chain
from celery.result import AsyncResult
from config.custom_logging import BackendLogger
from config.env import env
@@ -342,8 +341,7 @@ from tasks.tasks import (
enqueue_scan_execution_on_commit,
get_active_provider_scan,
jira_integration_task,
mute_historical_findings_task,
reaggregate_all_finding_group_summaries_task,
mute_findings_in_latest_scans_task,
refresh_lighthouse_provider_models_task,
)
@@ -7551,35 +7549,28 @@ class MuteRuleViewSet(BaseRLSViewSet):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
# Create the mute rule
tenant_id = str(request.tenant_id)
finding_ids = serializer.validated_data["finding_ids"]
provider_ids = list(
dict.fromkeys(
Finding.all_objects.filter(
id__in=finding_ids, tenant_id=tenant_id
).values_list("scan__provider_id", flat=True)
)
)
mute_rule = serializer.save()
tenant_id = str(request.tenant_id)
finding_ids = request.data.get("finding_ids", [])
# Immediately mute the selected findings
Finding.all_objects.filter(
id__in=finding_ids, tenant_id=tenant_id, muted=False
).update(
muted=True,
muted_at=mute_rule.inserted_at,
muted_reason=mute_rule.reason,
)
# Launch background task for historical muting + reaggregation
transaction.on_commit(
lambda: chain(
mute_historical_findings_task.si(
tenant_id=tenant_id,
mute_rule_id=str(mute_rule.id),
),
reaggregate_all_finding_group_summaries_task.si(
tenant_id=tenant_id,
),
).apply_async()
lambda: mute_findings_in_latest_scans_task.apply_async(
kwargs={
"tenant_id": tenant_id,
"mute_rule_id": str(mute_rule.id),
"provider_ids": [str(provider_id) for provider_id in provider_ids],
}
)
)
# Return the created mute rule
serializer = self.get_serializer(mute_rule)
return Response(
data=serializer.data,
+86 -45
View File
@@ -1,63 +1,104 @@
from collections.abc import Iterable
from api.db_utils import rls_transaction
from api.models import Finding, MuteRule
from api.models import Finding, MuteRule, Scan, StateChoices
from celery.utils.log import get_task_logger
from config.django.base import DJANGO_FINDINGS_BATCH_SIZE
from tasks.utils import batched
logger = get_task_logger(__name__)
def mute_historical_findings(tenant_id: str, mute_rule_id: str):
"""
Mute historical findings that match the given mute rule.
def _mute_findings_for_rule(
*,
tenant_id: str,
scan_id: str,
finding_uids: Iterable[str],
muted_at,
muted_reason: str,
) -> int:
finding_uids = list(finding_uids)
if not finding_uids:
return 0
This function processes findings in batches, updating their muted status
and adding the mute reason.
return Finding.all_objects.filter(
tenant_id=tenant_id,
scan_id=scan_id,
uid__in=finding_uids,
muted=False,
).update(
muted=True,
muted_at=muted_at,
muted_reason=muted_reason,
)
Args:
tenant_id (str): The tenant ID for RLS context
mute_rule_id (str): The ID of the mute rule to apply
Returns:
dict: Summary of the muting operation with findings_muted count
"""
findings_muted_count = 0
def mute_findings_in_latest_scans(
tenant_id: str, mute_rule_id: str, provider_ids: list[str]
) -> dict:
"""Apply a mute rule to the latest completed scan of each provider."""
provider_ids = list(dict.fromkeys(provider_ids))
# Get the list of UIDs to mute and the reason
with rls_transaction(tenant_id):
mute_rule = MuteRule.objects.get(id=mute_rule_id, tenant_id=tenant_id)
finding_uids = mute_rule.finding_uids
mute_reason = mute_rule.reason
muted_at = mute_rule.inserted_at
# Query findings that match the UIDs and are not already muted
with rls_transaction(tenant_id):
findings_to_mute = Finding.objects.filter(
tenant_id=tenant_id, uid__in=finding_uids, muted=False
)
total_findings = findings_to_mute.count()
logger.info(
f"Processing {total_findings} findings for mute rule {mute_rule_id}"
latest_scans = list(
Scan.objects.filter(
tenant_id=tenant_id,
provider_id__in=provider_ids,
state=StateChoices.COMPLETED,
completed_at__isnull=False,
)
.order_by("provider_id", "-completed_at", "-inserted_at", "-id")
.distinct("provider_id")
.values_list("id", flat=True)
)
if total_findings > 0:
for batch, is_last in batched(
findings_to_mute.iterator(), DJANGO_FINDINGS_BATCH_SIZE
):
batch_ids = [f.id for f in batch]
updated_count = Finding.all_objects.filter(
id__in=batch_ids, tenant_id=tenant_id
).update(
muted=True,
muted_at=muted_at,
muted_reason=mute_reason,
)
findings_muted_count += updated_count
logger.info(f"Muted {findings_muted_count} findings for rule {mute_rule_id}")
changed_scan_ids = []
findings_muted = 0
for scan_id in latest_scans:
updated = _mute_findings_for_rule(
tenant_id=tenant_id,
scan_id=str(scan_id),
finding_uids=mute_rule.finding_uids,
muted_at=mute_rule.inserted_at,
muted_reason=mute_rule.reason,
)
if updated:
findings_muted += updated
changed_scan_ids.append(str(scan_id))
logger.info(
"Muted %d findings in %d latest scans for rule %s",
findings_muted,
len(changed_scan_ids),
mute_rule_id,
)
return {
"findings_muted": findings_muted_count,
"findings_muted": findings_muted,
"rule_id": mute_rule_id,
"scan_ids": changed_scan_ids,
}
def reconcile_scan_mute_rules(tenant_id: str, scan_id: str) -> dict:
"""Apply the current enabled mute rules to one completed scan."""
findings_muted = 0
with rls_transaction(tenant_id):
mute_rules = MuteRule.objects.filter(tenant_id=tenant_id, enabled=True).values(
"finding_uids", "reason", "inserted_at"
)
for mute_rule in mute_rules:
findings_muted += _mute_findings_for_rule(
tenant_id=tenant_id,
scan_id=scan_id,
finding_uids=mute_rule["finding_uids"],
muted_at=mute_rule["inserted_at"],
muted_reason=mute_rule["reason"],
)
logger.info(
"Reconciled mute rules for scan %s; muted %d findings",
scan_id,
findings_muted,
)
return {"findings_muted": findings_muted, "scan_id": str(scan_id)}
+45 -99
View File
@@ -73,7 +73,10 @@ from tasks.jobs.lighthouse_providers import (
check_lighthouse_provider_connection,
refresh_lighthouse_provider_models,
)
from tasks.jobs.muting import mute_historical_findings
from tasks.jobs.muting import (
mute_findings_in_latest_scans,
reconcile_scan_mute_rules,
)
from tasks.jobs.orphan_recovery import reconcile_orphans
from tasks.jobs.report import (
STALE_TMP_OUTPUT_MAX_AGE_HOURS,
@@ -526,6 +529,7 @@ def perform_scan_task(
provider_id=provider_id,
checks_to_execute=checks_to_execute,
)
reconcile_scan_mute_rules(tenant_id, scan_id)
_perform_scan_complete_tasks(tenant_id, scan_id, provider_id)
return result
finally:
@@ -635,6 +639,7 @@ def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str):
scan_id=str(scan_instance.id),
provider_id=provider_id,
)
reconcile_scan_mute_rules(tenant_id, str(scan_instance.id))
_perform_scan_complete_tasks(tenant_id, str(scan_instance.id), provider_id)
return result
finally:
@@ -1188,85 +1193,48 @@ def aggregate_finding_group_summaries_task(tenant_id: str, scan_id: str):
return aggregate_finding_group_summaries(tenant_id=tenant_id, scan_id=scan_id)
@shared_task(
base=RLSTask, name="reaggregate-all-finding-group-summaries", queue="overview"
)
@set_tenant(keep_tenant=True)
def reaggregate_all_finding_group_summaries_task(tenant_id: str):
"""Reaggregate every pre-aggregated summary table for this tenant.
def _dispatch_scan_summary_reaggregation(tenant_id: str, scan_ids: list[str]) -> None:
if not scan_ids:
return
Mirrors the unbounded scope of `mute_historical_findings_task`: that task
rewrites every Finding row whose UID matches a mute rule, with no time
limit. To keep the pre-aggregated tables consistent with that update,
this task re-runs the same per-scan aggregation pipeline that scan
completion runs on the latest completed scan of every (provider, day)
pair, rebuilding the tables that power the read endpoints:
- `ScanSummary` and `DailySeveritySummary` -> `/overviews/findings`,
`/overviews/findings-severity`, `/overviews/services`.
- `FindingGroupDailySummary` -> `/finding-groups` and
`/finding-groups/latest`.
- `ScanGroupSummary` -> `/overviews/resource-groups` (resource
inventory).
- `ScanCategorySummary` -> `/overviews/categories`.
- `AttackSurfaceOverview` -> `/overviews/attack-surfaces`.
Per-scan pipelines are dispatched in parallel via a Celery group so
wallclock scales with the worker pool.
"""
completed_scans = list(
Scan.objects.filter(
tenant_id=tenant_id,
state=StateChoices.COMPLETED,
completed_at__isnull=False,
)
.order_by("-completed_at")
.values("id", "completed_at", "provider_id")
logger.info(
"Reaggregating overview/finding summaries for %d latest scans",
len(scan_ids),
)
# Keep the latest scan per (provider, day) pair so the daily summary row
# the aggregator writes is the most recent snapshot of that day for that
# provider. Iterating from most recent to oldest means the first scan we
# see for a given key wins.
latest_scans: dict[tuple, str] = {}
for scan in completed_scans:
key = (scan["provider_id"], scan["completed_at"].date())
if key not in latest_scans:
latest_scans[key] = str(scan["id"])
scan_ids = list(latest_scans.values())
if scan_ids:
logger.info(
"Reaggregating overview/finding summaries for %d scans (provider x day)",
len(scan_ids),
)
# DailySeveritySummary reads from ScanSummary, so ScanSummary must be
# recomputed first; the other aggregators read Finding directly and
# can run in parallel with the severity step.
group(
chain(
perform_scan_summary_task.si(tenant_id=tenant_id, scan_id=scan_id),
group(
aggregate_daily_severity_task.si(
tenant_id=tenant_id, scan_id=scan_id
),
aggregate_finding_group_summaries_task.si(
tenant_id=tenant_id, scan_id=scan_id
),
aggregate_scan_resource_group_summaries_task.si(
tenant_id=tenant_id, scan_id=scan_id
),
aggregate_scan_category_summaries_task.si(
tenant_id=tenant_id, scan_id=scan_id
),
aggregate_attack_surface_task.si(
tenant_id=tenant_id, scan_id=scan_id
),
group(
chain(
perform_scan_summary_task.si(tenant_id=tenant_id, scan_id=scan_id),
group(
aggregate_daily_severity_task.si(tenant_id=tenant_id, scan_id=scan_id),
aggregate_finding_group_summaries_task.si(
tenant_id=tenant_id, scan_id=scan_id
),
)
for scan_id in scan_ids
).apply_async()
return {"scans_reaggregated": len(scan_ids)}
aggregate_scan_resource_group_summaries_task.si(
tenant_id=tenant_id, scan_id=scan_id
),
aggregate_scan_category_summaries_task.si(
tenant_id=tenant_id, scan_id=scan_id
),
aggregate_attack_surface_task.si(tenant_id=tenant_id, scan_id=scan_id),
),
)
for scan_id in scan_ids
).apply_async()
@shared_task(base=RLSTask, name="findings-mute-latest-scans", queue="overview")
@set_tenant(keep_tenant=True)
def mute_findings_in_latest_scans_task(
tenant_id: str, mute_rule_id: str, provider_ids: list[str]
):
"""Apply a mute rule to current scans and rebuild only changed summaries."""
result = mute_findings_in_latest_scans(
tenant_id=tenant_id,
mute_rule_id=mute_rule_id,
provider_ids=provider_ids,
)
_dispatch_scan_summary_reaggregation(tenant_id, result["scan_ids"])
return result
@shared_task(base=RLSTask, name="lighthouse-connection-check")
@@ -1467,25 +1435,3 @@ def generate_compliance_reports_task(tenant_id: str, scan_id: str, provider_id:
generate_csa=True,
generate_cis=True,
)
@shared_task(name="findings-mute-historical")
def mute_historical_findings_task(tenant_id: str, mute_rule_id: str):
"""
Background task to mute all historical findings matching a mute rule.
This task processes findings in batches to avoid memory issues with large datasets.
It updates the Finding.muted, Finding.muted_at, and Finding.muted_reason fields
for all findings whose UID is in the mute rule's finding_uids list.
Args:
tenant_id (str): The tenant ID for RLS context.
mute_rule_id (str): The primary key of the MuteRule to apply.
Returns:
dict: A dictionary containing:
- 'findings_muted' (int): Total number of findings muted.
- 'rule_id' (str): The mute rule ID.
- 'status' (str): Final status ('completed').
"""
return mute_historical_findings(tenant_id, mute_rule_id)
+176 -502
View File
@@ -1,531 +1,205 @@
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from uuid import uuid4
import pytest
from api.models import Finding, MuteRule
from django.core.exceptions import ObjectDoesNotExist
from api.models import Finding, MuteRule, Scan, StateChoices
from prowler.lib.check.models import Severity
from prowler.lib.outputs.finding import Status
from tasks.jobs.muting import mute_historical_findings
from tasks.jobs.muting import (
mute_findings_in_latest_scans,
reconcile_scan_mute_rules,
)
def _create_finding(scan: Scan, uid: str) -> Finding:
return Finding.objects.create(
tenant_id=scan.tenant_id,
uid=uid,
scan=scan,
status=Status.FAIL,
status_extended="Test finding",
impact=Severity.high,
severity=Severity.high,
raw_result={},
check_id="test_check",
check_metadata={"CheckId": "test_check"},
muted=False,
)
def _create_mute_rule(tenant_id, user, finding_uids, *, enabled=True) -> MuteRule:
return MuteRule.objects.create(
tenant_id=tenant_id,
name=f"Mute rule {uuid4()}",
reason="Approved exception",
enabled=enabled,
created_by=user,
finding_uids=finding_uids,
)
@pytest.mark.django_db
class TestMuteHistoricalFindings:
"""
Test suite for the mute_historical_findings function.
class TestMuteFindingsInLatestScans:
def test_mutes_latest_scan_and_leaves_older_scan_unchanged(
self, scans_fixture, create_test_user
):
latest_scan = scans_fixture[0]
older_scan = Scan.objects.create(
tenant_id=latest_scan.tenant_id,
provider=latest_scan.provider,
name="Older scan",
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
started_at=datetime.now(UTC) - timedelta(days=1),
completed_at=datetime.now(UTC) - timedelta(days=1),
)
uid = "latest-scan-only"
older_finding = _create_finding(older_scan, uid)
latest_finding = _create_finding(latest_scan, uid)
mute_rule = _create_mute_rule(latest_scan.tenant_id, create_test_user, [uid])
This class tests the batch processing of findings to update their muted status
based on MuteRule criteria.
"""
result = mute_findings_in_latest_scans(
str(latest_scan.tenant_id),
str(mute_rule.id),
[str(latest_scan.provider_id)],
)
@pytest.fixture(scope="function")
def test_user(self, create_test_user):
"""Create a test user for mute rule creation."""
return create_test_user
older_finding.refresh_from_db()
latest_finding.refresh_from_db()
assert older_finding.muted is False
assert latest_finding.muted is True
assert latest_finding.muted_at == mute_rule.inserted_at
assert latest_finding.muted_reason == mute_rule.reason
assert result == {
"findings_muted": 1,
"rule_id": str(mute_rule.id),
"scan_ids": [str(latest_scan.id)],
}
@pytest.fixture(scope="function")
def mute_rule_with_findings(self, tenants_fixture, findings_fixture, test_user):
"""
Create a mute rule that targets the first finding in the fixture.
"""
def test_mutes_one_latest_scan_per_provider(self, scans_fixture, create_test_user):
first_scan, second_scan, _ = scans_fixture
uid = "shared-selected-uid"
first_finding = _create_finding(first_scan, uid)
second_finding = _create_finding(second_scan, uid)
mute_rule = _create_mute_rule(first_scan.tenant_id, create_test_user, [uid])
result = mute_findings_in_latest_scans(
str(first_scan.tenant_id),
str(mute_rule.id),
[str(first_scan.provider_id), str(second_scan.provider_id)],
)
first_finding.refresh_from_db()
second_finding.refresh_from_db()
assert first_finding.muted is True
assert second_finding.muted is True
assert result["findings_muted"] == 2
assert set(result["scan_ids"]) == {str(first_scan.id), str(second_scan.id)}
def test_provider_without_completed_scan_does_nothing(
self, tenants_fixture, provider_factory, create_test_user
):
tenant = tenants_fixture[0]
finding = findings_fixture[0]
mute_rule = MuteRule.objects.create(
tenant_id=tenant.id,
name="Test Mute Rule",
reason="Testing mute functionality",
enabled=True,
created_by=test_user,
finding_uids=[finding.uid],
provider = provider_factory()
mute_rule = _create_mute_rule(
tenant.id, create_test_user, ["future-scan-finding"]
)
return mute_rule
result = mute_findings_in_latest_scans(
str(tenant.id), str(mute_rule.id), [str(provider.id)]
)
@pytest.fixture(scope="function")
def mute_rule_multiple_findings(self, scans_fixture, test_user):
"""
Create multiple unmuted findings and a mute rule targeting all of them.
"""
assert result == {
"findings_muted": 0,
"rule_id": str(mute_rule.id),
"scan_ids": [],
}
def test_retry_does_not_report_changed_scans_twice(
self, scans_fixture, create_test_user
):
scan = scans_fixture[0]
tenant_id = scan.tenant_id
# Create 5 unmuted findings
finding_uids = []
for i in range(5):
finding = Finding.objects.create(
tenant_id=tenant_id,
uid=f"test_finding_uid_mute_{i}",
scan=scan,
status=Status.FAIL,
status_extended=f"Test status {i}",
impact=Severity.high,
severity=Severity.high,
raw_result={
"status": Status.FAIL,
"impact": Severity.high,
"severity": Severity.high,
},
check_id=f"test_check_id_{i}",
check_metadata={
"CheckId": f"test_check_id_{i}",
"Description": f"Test description {i}",
},
muted=False,
)
finding_uids.append(finding.uid)
# Create mute rule targeting all findings
mute_rule = MuteRule.objects.create(
tenant_id=tenant_id,
name="Test Multiple Findings Mute Rule",
reason="Testing batch muting",
enabled=True,
created_by=test_user,
finding_uids=finding_uids,
finding = _create_finding(scan, "idempotent-mute")
mute_rule = _create_mute_rule(scan.tenant_id, create_test_user, [finding.uid])
args = (
str(scan.tenant_id),
str(mute_rule.id),
[str(scan.provider_id)],
)
return mute_rule, finding_uids
first_result = mute_findings_in_latest_scans(*args)
second_result = mute_findings_in_latest_scans(*args)
@pytest.fixture(scope="function")
def mute_rule_already_muted(self, findings_fixture, test_user):
"""
Create a mute rule that targets an already-muted finding.
"""
tenant_id = findings_fixture[1].tenant_id
already_muted_finding = findings_fixture[1]
assert first_result["scan_ids"] == [str(scan.id)]
assert second_result["findings_muted"] == 0
assert second_result["scan_ids"] == []
mute_rule = MuteRule.objects.create(
tenant_id=tenant_id,
name="Test Already Muted Rule",
reason="Testing already muted findings",
enabled=True,
created_by=test_user,
finding_uids=[already_muted_finding.uid],
def test_does_not_cross_tenant_boundary(
self, tenants_fixture, provider_factory, create_test_user
):
tenant = tenants_fixture[0]
other_tenant = tenants_fixture[2]
other_provider = provider_factory(tenant=other_tenant)
other_scan = Scan.objects.create(
tenant_id=other_tenant.id,
provider=other_provider,
name="Other tenant scan",
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
started_at=datetime.now(UTC),
completed_at=datetime.now(UTC),
)
other_finding = _create_finding(other_scan, "tenant-isolated-uid")
mute_rule = _create_mute_rule(tenant.id, create_test_user, [other_finding.uid])
result = mute_findings_in_latest_scans(
str(tenant.id), str(mute_rule.id), [str(other_provider.id)]
)
return mute_rule
other_finding.refresh_from_db()
assert other_finding.muted is False
assert result["scan_ids"] == []
@pytest.fixture(scope="function")
def mute_rule_mixed_findings(self, scans_fixture, test_user):
"""
Create a mute rule with a mix of muted and unmuted findings.
"""
def test_nonexistent_rule_raises(self, tenants_fixture):
with pytest.raises(MuteRule.DoesNotExist):
mute_findings_in_latest_scans(str(tenants_fixture[0].id), str(uuid4()), [])
@pytest.mark.django_db
class TestReconcileScanMuteRules:
def test_applies_only_enabled_rules_to_requested_scan(
self, scans_fixture, create_test_user
):
scan = scans_fixture[0]
tenant_id = scan.tenant_id
# Create 3 unmuted findings
unmuted_uids = []
for i in range(3):
finding = Finding.objects.create(
tenant_id=tenant_id,
uid=f"unmuted_finding_{i}",
scan=scan,
status=Status.FAIL,
status_extended=f"Unmuted status {i}",
impact=Severity.medium,
severity=Severity.medium,
raw_result={
"status": Status.FAIL,
"impact": Severity.medium,
"severity": Severity.medium,
},
check_id=f"unmuted_check_{i}",
check_metadata={
"CheckId": f"unmuted_check_{i}",
"Description": f"Unmuted description {i}",
},
muted=False,
)
unmuted_uids.append(finding.uid)
# Create 2 already muted findings
muted_uids = []
for i in range(2):
finding = Finding.objects.create(
tenant_id=tenant_id,
uid=f"muted_finding_{i}",
scan=scan,
status=Status.FAIL,
status_extended=f"Muted status {i}",
impact=Severity.low,
severity=Severity.low,
raw_result={
"status": Status.FAIL,
"impact": Severity.low,
"severity": Severity.low,
},
check_id=f"muted_check_{i}",
check_metadata={
"CheckId": f"muted_check_{i}",
"Description": f"Muted description {i}",
},
muted=True,
muted_at=datetime.now(UTC),
muted_reason="Already muted",
)
muted_uids.append(finding.uid)
# Create mute rule targeting all findings
all_uids = unmuted_uids + muted_uids
mute_rule = MuteRule.objects.create(
tenant_id=tenant_id,
name="Test Mixed Findings Rule",
reason="Testing mixed muted/unmuted findings",
enabled=True,
created_by=test_user,
finding_uids=all_uids,
active_finding = _create_finding(scan, "active-rule-uid")
disabled_finding = _create_finding(scan, "disabled-rule-uid")
active_rule = _create_mute_rule(
scan.tenant_id, create_test_user, [active_finding.uid]
)
return mute_rule, unmuted_uids, muted_uids
@pytest.fixture(scope="function")
def mute_rule_batch_test(self, scans_fixture, test_user):
"""
Create enough findings to test batch processing (>1000 for default batch size).
"""
scan = scans_fixture[0]
tenant_id = scan.tenant_id
# Create 1500 findings to exceed default batch size of 1000
finding_uids = []
for i in range(1500):
finding = Finding.objects.create(
tenant_id=tenant_id,
uid=f"batch_test_finding_{i}",
scan=scan,
status=Status.FAIL,
status_extended=f"Batch test status {i}",
impact=Severity.critical,
severity=Severity.critical,
raw_result={
"status": Status.FAIL,
"impact": Severity.critical,
"severity": Severity.critical,
},
check_id=f"batch_test_check_{i}",
check_metadata={
"CheckId": f"batch_test_check_{i}",
"Description": f"Batch test description {i}",
},
muted=False,
)
finding_uids.append(finding.uid)
# Create mute rule targeting all findings
mute_rule = MuteRule.objects.create(
tenant_id=tenant_id,
name="Test Batch Processing Rule",
reason="Testing batch processing functionality",
enabled=True,
created_by=test_user,
finding_uids=finding_uids,
_create_mute_rule(
scan.tenant_id,
create_test_user,
[disabled_finding.uid],
enabled=False,
)
return mute_rule, finding_uids
def test_mute_historical_findings_single_finding(
self, mute_rule_with_findings, findings_fixture
):
"""
Test muting a single historical finding.
"""
mute_rule = mute_rule_with_findings
tenant_id = str(mute_rule.tenant_id)
finding = findings_fixture[0]
# Ensure the finding is not muted before execution
finding.refresh_from_db()
assert finding.muted is False
assert finding.muted_at is None
assert finding.muted_reason is None
# Execute the muting function
result = mute_historical_findings(tenant_id, str(mute_rule.id))
# Verify return value
assert result["findings_muted"] == 1
assert result["rule_id"] == str(mute_rule.id)
# Verify the finding was muted
finding.refresh_from_db()
assert finding.muted is True
assert finding.muted_at == mute_rule.inserted_at
assert finding.muted_reason == mute_rule.reason
def test_mute_historical_findings_multiple_findings(
self, mute_rule_multiple_findings
):
"""
Test muting multiple historical findings.
"""
mute_rule, finding_uids = mute_rule_multiple_findings
tenant_id = str(mute_rule.tenant_id)
# Verify all findings are unmuted
findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=finding_uids)
assert findings.count() == 5
for finding in findings:
assert finding.muted is False
# Execute the muting function
result = mute_historical_findings(tenant_id, str(mute_rule.id))
# Verify return value
assert result["findings_muted"] == 5
assert result["rule_id"] == str(mute_rule.id)
# Verify all findings were muted
findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=finding_uids)
for finding in findings:
assert finding.muted is True
assert finding.muted_at == mute_rule.inserted_at
assert finding.muted_reason == mute_rule.reason
def test_mute_historical_findings_already_muted(
self, mute_rule_already_muted, findings_fixture
):
"""
Test that already-muted findings are not counted or updated.
"""
mute_rule = mute_rule_already_muted
tenant_id = str(mute_rule.tenant_id)
finding = findings_fixture[1]
# Verify the finding is already muted
finding.refresh_from_db()
assert finding.muted is True
original_muted_at = finding.muted_at
original_muted_reason = finding.muted_reason
# Execute the muting function
result = mute_historical_findings(tenant_id, str(mute_rule.id))
# Verify no findings were muted
assert result["findings_muted"] == 0
assert result["rule_id"] == str(mute_rule.id)
# Verify the finding's mute status did not change
finding.refresh_from_db()
assert finding.muted is True
assert finding.muted_at == original_muted_at
assert finding.muted_reason == original_muted_reason
def test_mute_historical_findings_mixed_status(self, mute_rule_mixed_findings):
"""
Test muting when some findings are already muted and others are not.
"""
mute_rule, unmuted_uids, muted_uids = mute_rule_mixed_findings
tenant_id = str(mute_rule.tenant_id)
# Execute the muting function
result = mute_historical_findings(tenant_id, str(mute_rule.id))
# Verify only unmuted findings were counted
assert result["findings_muted"] == 3
assert result["rule_id"] == str(mute_rule.id)
# Verify unmuted findings are now muted
unmuted_findings = Finding.objects.filter(
tenant_id=tenant_id, uid__in=unmuted_uids
older_scan = Scan.objects.create(
tenant_id=scan.tenant_id,
provider=scan.provider,
name="Older matching scan",
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
started_at=datetime.now(UTC) - timedelta(days=1),
completed_at=datetime.now(UTC) - timedelta(days=1),
)
for finding in unmuted_findings:
assert finding.muted is True
assert finding.muted_at == mute_rule.inserted_at
assert finding.muted_reason == mute_rule.reason
older_finding = _create_finding(older_scan, active_finding.uid)
# Verify already-muted findings remained unchanged
already_muted_findings = Finding.objects.filter(
tenant_id=tenant_id, uid__in=muted_uids
)
for finding in already_muted_findings:
assert finding.muted is True
assert finding.muted_reason == "Already muted"
result = reconcile_scan_mute_rules(str(scan.tenant_id), str(scan.id))
def test_mute_historical_findings_nonexistent_rule(self, tenants_fixture):
"""
Test that a nonexistent mute rule raises ObjectDoesNotExist.
"""
tenant_id = str(tenants_fixture[0].id)
nonexistent_rule_id = str(uuid4())
with pytest.raises(ObjectDoesNotExist):
mute_historical_findings(tenant_id, nonexistent_rule_id)
def test_mute_historical_findings_no_matching_findings(
self, tenants_fixture, test_user
):
"""
Test muting when no findings match the rule's UIDs.
"""
tenant_id = str(tenants_fixture[0].id)
# Create a mute rule with non-existent finding UIDs
mute_rule = MuteRule.objects.create(
tenant_id=tenant_id,
name="Test No Match Rule",
reason="Testing no matching findings",
enabled=True,
created_by=test_user,
finding_uids=[
"nonexistent_uid_1",
"nonexistent_uid_2",
"nonexistent_uid_3",
],
)
# Execute the muting function
result = mute_historical_findings(tenant_id, str(mute_rule.id))
# Verify no findings were muted
assert result["findings_muted"] == 0
assert result["rule_id"] == str(mute_rule.id)
def test_mute_historical_findings_batch_processing(self, mute_rule_batch_test):
"""
Test that large numbers of findings are processed in batches correctly.
"""
mute_rule, finding_uids = mute_rule_batch_test
tenant_id = str(mute_rule.tenant_id)
# Verify all findings exist and are unmuted
findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=finding_uids)
assert findings.count() == 1500
for finding in findings:
assert finding.muted is False
# Execute the muting function
result = mute_historical_findings(tenant_id, str(mute_rule.id))
# Verify return value
assert result["findings_muted"] == 1500
assert result["rule_id"] == str(mute_rule.id)
# Verify all findings were muted
findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=finding_uids)
for finding in findings:
assert finding.muted is True
assert finding.muted_at == mute_rule.inserted_at
assert finding.muted_reason == mute_rule.reason
def test_mute_historical_findings_preserves_muted_at_timestamp(
self, mute_rule_with_findings, findings_fixture
):
"""
Test that muted_at is set to the rule's inserted_at, not the current time.
"""
mute_rule = mute_rule_with_findings
tenant_id = str(mute_rule.tenant_id)
finding = findings_fixture[0]
# Execute the muting function
result = mute_historical_findings(tenant_id, str(mute_rule.id))
# Verify the finding was muted
assert result["findings_muted"] == 1
# Verify muted_at matches the rule's inserted_at timestamp
finding.refresh_from_db()
assert finding.muted_at == mute_rule.inserted_at
assert finding.muted_at is not None
def test_mute_historical_findings_partial_match(self, scans_fixture, test_user):
"""
Test muting when only some of the rule's UIDs exist as findings.
"""
scan = scans_fixture[0]
tenant_id = str(scan.tenant_id)
# Create 3 findings
existing_uids = []
for i in range(3):
finding = Finding.objects.create(
tenant_id=tenant_id,
uid=f"partial_match_finding_{i}",
scan=scan,
status=Status.FAIL,
status_extended=f"Partial match status {i}",
impact=Severity.high,
severity=Severity.high,
raw_result={
"status": Status.FAIL,
"impact": Severity.high,
"severity": Severity.high,
},
check_id=f"partial_match_check_{i}",
check_metadata={
"CheckId": f"partial_match_check_{i}",
"Description": f"Partial match description {i}",
},
muted=False,
)
existing_uids.append(finding.uid)
# Create a mute rule with both existing and non-existing UIDs
all_uids = existing_uids + [
"nonexistent_uid_1",
"nonexistent_uid_2",
]
mute_rule = MuteRule.objects.create(
tenant_id=tenant_id,
name="Test Partial Match Rule",
reason="Testing partial matching",
enabled=True,
created_by=test_user,
finding_uids=all_uids,
)
# Execute the muting function
result = mute_historical_findings(tenant_id, str(mute_rule.id))
# Verify only existing findings were muted
assert result["findings_muted"] == 3
assert result["rule_id"] == str(mute_rule.id)
# Verify the existing findings were muted
findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=existing_uids)
assert findings.count() == 3
for finding in findings:
assert finding.muted is True
assert finding.muted_at == mute_rule.inserted_at
assert finding.muted_reason == mute_rule.reason
def test_mute_historical_findings_empty_uids(self, tenants_fixture, test_user):
"""
Test muting when the rule has an empty finding_uids array.
"""
tenant_id = str(tenants_fixture[0].id)
# Create a mute rule with empty finding_uids
mute_rule = MuteRule.objects.create(
tenant_id=tenant_id,
name="Test Empty UIDs Rule",
reason="Testing empty UIDs",
enabled=True,
created_by=test_user,
finding_uids=[],
)
# Execute the muting function
result = mute_historical_findings(tenant_id, str(mute_rule.id))
# Verify no findings were muted
assert result["findings_muted"] == 0
assert result["rule_id"] == str(mute_rule.id)
def test_mute_historical_findings_return_format(self, mute_rule_with_findings):
"""
Test that the return value has the correct format and fields.
"""
mute_rule = mute_rule_with_findings
tenant_id = str(mute_rule.tenant_id)
result = mute_historical_findings(tenant_id, str(mute_rule.id))
# Verify return value structure
assert isinstance(result, dict)
assert "findings_muted" in result
assert "rule_id" in result
assert isinstance(result["findings_muted"], int)
assert isinstance(result["rule_id"], str)
assert result["rule_id"] == str(mute_rule.id)
active_finding.refresh_from_db()
disabled_finding.refresh_from_db()
older_finding.refresh_from_db()
assert active_finding.muted is True
assert active_finding.muted_at == active_rule.inserted_at
assert disabled_finding.muted is False
assert older_finding.muted is False
assert result == {"findings_muted": 1, "scan_id": str(scan.id)}
+69 -124
View File
@@ -1,6 +1,6 @@
import uuid
from contextlib import contextmanager
from datetime import UTC, datetime, timedelta
from datetime import UTC, datetime
from unittest.mock import MagicMock, patch
import httpx
@@ -33,10 +33,10 @@ from tasks.tasks import (
check_integrations_task,
check_lighthouse_provider_connection_task,
generate_outputs_task,
mute_findings_in_latest_scans_task,
perform_attack_paths_scan_task,
perform_scan_task,
perform_scheduled_scan_task,
reaggregate_all_finding_group_summaries_task,
refresh_lighthouse_provider_models_task,
s3_integration_task,
security_hub_integration_task,
@@ -2959,6 +2959,7 @@ class TestPerformScheduledScanTask:
with (
patch("tasks.tasks.perform_prowler_scan", side_effect=_complete_scan),
patch("tasks.tasks._perform_scan_complete_tasks"),
patch("tasks.tasks.reconcile_scan_mute_rules") as mock_reconcile,
self._override_task_request(perform_scheduled_scan_task, id=task_id),
):
perform_scheduled_scan_task.run(
@@ -2982,6 +2983,13 @@ class TestPerformScheduledScanTask:
).count()
== 1
)
completed_scan = Scan.objects.get(
tenant_id=tenant.id,
provider=provider,
trigger=Scan.TriggerChoices.SCHEDULED,
state=StateChoices.COMPLETED,
)
mock_reconcile.assert_called_once_with(str(tenant.id), str(completed_scan.id))
assert (
Scan.objects.filter(
tenant_id=tenant.id,
@@ -3176,7 +3184,10 @@ class TestPerformScanTask:
task=queued_task,
)
events = []
def _complete_scan(tenant_id, scan_id, provider_id, checks_to_execute=None):
events.append("scan")
scan_instance = Scan.objects.get(id=scan_id)
scan_instance.state = StateChoices.COMPLETED
scan_instance.save()
@@ -3184,7 +3195,14 @@ class TestPerformScanTask:
with (
patch("tasks.tasks.perform_prowler_scan", side_effect=_complete_scan),
patch("tasks.tasks._perform_scan_complete_tasks"),
patch(
"tasks.tasks.reconcile_scan_mute_rules",
side_effect=lambda *_args: events.append("reconcile"),
),
patch(
"tasks.tasks._perform_scan_complete_tasks",
side_effect=lambda *_args: events.append("summaries"),
),
patch("tasks.tasks.perform_scan_task.apply_async") as mock_apply_async,
):
with django_capture_on_commit_callbacks(execute=True):
@@ -3196,6 +3214,7 @@ class TestPerformScanTask:
queued_task_result.refresh_from_db()
assert result == {"status": "ok"}
assert events == ["scan", "reconcile", "summaries"]
assert queued_task_result.status == states.PENDING
mock_apply_async.assert_called_once_with(
kwargs={
@@ -3241,10 +3260,7 @@ class TestPerformScanTask:
@pytest.mark.django_db
class TestReaggregateAllFindingGroupSummaries:
def setup_method(self):
self.tenant_id = str(uuid.uuid4())
class TestMuteFindingsInLatestScansTask:
@patch("tasks.tasks.chain")
@patch("tasks.tasks.group")
@patch("tasks.tasks.aggregate_attack_surface_task")
@@ -3253,10 +3269,10 @@ class TestReaggregateAllFindingGroupSummaries:
@patch("tasks.tasks.aggregate_finding_group_summaries_task")
@patch("tasks.tasks.aggregate_daily_severity_task")
@patch("tasks.tasks.perform_scan_summary_task")
@patch("tasks.tasks.Scan.objects.filter")
def test_dispatches_subtasks_for_each_provider_per_day(
@patch("tasks.tasks.mute_findings_in_latest_scans")
def test_reaggregates_only_changed_scans(
self,
mock_scan_filter,
mock_mute_findings,
mock_scan_summary_task,
mock_daily_severity_task,
mock_finding_group_task,
@@ -3265,119 +3281,36 @@ class TestReaggregateAllFindingGroupSummaries:
mock_attack_surface_task,
mock_group,
mock_chain,
tenants_fixture,
):
provider_id_1 = uuid.uuid4()
provider_id_2 = uuid.uuid4()
scan_id_today_p1 = uuid.uuid4()
scan_id_yesterday_p1 = uuid.uuid4()
scan_id_today_p2 = uuid.uuid4()
today = datetime.now(tz=UTC)
yesterday = today - timedelta(days=1)
mock_outer_group_result = MagicMock()
# The first `group()` call wraps the inner parallel step; subsequent
# calls wrap the outer per-scan generator.
mock_group.side_effect = lambda *args, **kwargs: (
list(args[0]) if args and hasattr(args[0], "__iter__") else None,
mock_outer_group_result,
)[1]
mock_scan_filter.return_value.order_by.return_value.values.return_value = [
{
"id": scan_id_today_p1,
"completed_at": today,
"provider_id": provider_id_1,
},
{
"id": scan_id_today_p2,
"completed_at": today,
"provider_id": provider_id_2,
},
{
"id": scan_id_yesterday_p1,
"completed_at": yesterday,
"provider_id": provider_id_1,
},
]
result = reaggregate_all_finding_group_summaries_task(tenant_id=self.tenant_id)
assert result == {"scans_reaggregated": 3}
expected_scan_ids = {
str(scan_id_today_p1),
str(scan_id_today_p2),
str(scan_id_yesterday_p1),
tenant_id = str(tenants_fixture[0].id)
mute_rule_id = str(uuid.uuid4())
provider_ids = [str(uuid.uuid4()), str(uuid.uuid4())]
scan_ids = [str(uuid.uuid4()), str(uuid.uuid4())]
result = {
"findings_muted": 2,
"rule_id": mute_rule_id,
"scan_ids": scan_ids,
}
for task_mock in (
mock_scan_summary_task,
mock_daily_severity_task,
mock_finding_group_task,
mock_resource_group_task,
mock_category_task,
mock_attack_surface_task,
):
assert task_mock.si.call_count == 3
dispatched = {
call.kwargs["scan_id"] for call in task_mock.si.call_args_list
}
assert dispatched == expected_scan_ids
for call in task_mock.si.call_args_list:
assert call.kwargs["tenant_id"] == self.tenant_id
assert mock_chain.call_count == 3
mock_outer_group_result.apply_async.assert_called_once()
@patch("tasks.tasks.chain")
@patch("tasks.tasks.group")
@patch("tasks.tasks.aggregate_attack_surface_task")
@patch("tasks.tasks.aggregate_scan_category_summaries_task")
@patch("tasks.tasks.aggregate_scan_resource_group_summaries_task")
@patch("tasks.tasks.aggregate_finding_group_summaries_task")
@patch("tasks.tasks.aggregate_daily_severity_task")
@patch("tasks.tasks.perform_scan_summary_task")
@patch("tasks.tasks.Scan.objects.filter")
def test_dedupes_scans_to_latest_per_provider_per_day(
self,
mock_scan_filter,
mock_scan_summary_task,
mock_daily_severity_task,
mock_finding_group_task,
mock_resource_group_task,
mock_category_task,
mock_attack_surface_task,
mock_group,
mock_chain,
):
"""When several scans run on the same day for the same provider, only
the latest one is dispatched (matching the daily summary unique key)."""
provider_id = uuid.uuid4()
latest_scan_today = uuid.uuid4()
earlier_scan_today = uuid.uuid4()
today_late = datetime.now(tz=UTC)
today_early = today_late - timedelta(hours=4)
mock_mute_findings.return_value = result
mock_outer_group_result = MagicMock()
mock_group.side_effect = lambda *args, **kwargs: (
list(args[0]) if args and hasattr(args[0], "__iter__") else None,
mock_outer_group_result,
)[1]
# Returned ordered by `-completed_at`, so the most recent comes first.
mock_scan_filter.return_value.order_by.return_value.values.return_value = [
{
"id": latest_scan_today,
"completed_at": today_late,
"provider_id": provider_id,
},
{
"id": earlier_scan_today,
"completed_at": today_early,
"provider_id": provider_id,
},
]
task_result = mute_findings_in_latest_scans_task(
tenant_id=tenant_id,
mute_rule_id=mute_rule_id,
provider_ids=provider_ids,
)
result = reaggregate_all_finding_group_summaries_task(tenant_id=self.tenant_id)
assert result == {"scans_reaggregated": 1}
assert task_result == result
mock_mute_findings.assert_called_once_with(
tenant_id=tenant_id,
mute_rule_id=mute_rule_id,
provider_ids=provider_ids,
)
for task_mock in (
mock_scan_summary_task,
mock_daily_severity_task,
@@ -3386,23 +3319,35 @@ class TestReaggregateAllFindingGroupSummaries:
mock_category_task,
mock_attack_surface_task,
):
task_mock.si.assert_called_once_with(
tenant_id=self.tenant_id, scan_id=str(latest_scan_today)
)
mock_chain.assert_called_once()
assert task_mock.si.call_count == 2
assert {
call.kwargs["scan_id"] for call in task_mock.si.call_args_list
} == set(scan_ids)
assert mock_chain.call_count == 2
mock_outer_group_result.apply_async.assert_called_once()
@patch("tasks.tasks.chain")
@patch("tasks.tasks.group")
@patch("tasks.tasks.Scan.objects.filter")
def test_no_completed_scans_skips_dispatch(
self, mock_scan_filter, mock_group, mock_chain
@patch("tasks.tasks.mute_findings_in_latest_scans")
def test_skips_reaggregation_when_no_scan_changed(
self, mock_mute_findings, mock_group, mock_chain, tenants_fixture
):
mock_scan_filter.return_value.order_by.return_value.values.return_value = []
tenant_id = str(tenants_fixture[0].id)
mute_rule_id = str(uuid.uuid4())
result = {
"findings_muted": 0,
"rule_id": mute_rule_id,
"scan_ids": [],
}
mock_mute_findings.return_value = result
result = reaggregate_all_finding_group_summaries_task(tenant_id=self.tenant_id)
task_result = mute_findings_in_latest_scans_task(
tenant_id=tenant_id,
mute_rule_id=mute_rule_id,
provider_ids=[],
)
assert result == {"scans_reaggregated": 0}
assert task_result == result
mock_group.assert_not_called()
mock_chain.assert_not_called()
Generated
+72 -3
View File
@@ -4836,7 +4836,7 @@ wheels = [
[[package]]
name = "prowler"
version = "5.40.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#b6e9967da6bebd6c7b8b237317a2a95e2e0c65bc" }
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=v5.40#4d13e8432e57289a188c2a12200dcd8437f35543" }
dependencies = [
{ name = "alibabacloud-actiontrail20200706" },
{ name = "alibabacloud-credentials" },
@@ -4928,14 +4928,17 @@ dependencies = [
{ name = "stackit-iaas" },
{ name = "stackit-objectstorage" },
{ name = "stackit-resourcemanager" },
{ name = "stackit-ske" },
{ name = "tabulate" },
{ name = "truststore" },
{ name = "tzlocal" },
{ name = "uuid6" },
{ name = "zstandard" },
]
[[package]]
name = "prowler-api"
version = "1.41.0"
version = "1.41.1"
source = { virtual = "." }
dependencies = [
{ name = "cartography" },
@@ -5035,7 +5038,7 @@ requires-dist = [
{ name = "matplotlib", specifier = "==3.10.8" },
{ name = "neo4j", specifier = "==6.1.0" },
{ name = "openai", specifier = "==1.109.1" },
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=master" },
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=v5.40" },
{ name = "psycopg2-binary", specifier = "==2.9.9" },
{ name = "pytest-celery", extras = ["redis"], specifier = "==1.3.0" },
{ name = "reportlab", specifier = "==4.4.10" },
@@ -6117,6 +6120,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/9c/38a74d0f7a89b4320f6d2366fb660638bda8860daa08748b12c713d84381/stackit_resourcemanager-0.8.0-py3-none-any.whl", hash = "sha256:dd04bb8353d041a137c4dcba190beabded7acfaff1bc98b218fce20a99389ebc", size = 81288, upload-time = "2026-05-13T09:43:07.81Z" },
]
[[package]]
name = "stackit-ske"
version = "1.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dateutil" },
{ name = "requests" },
{ name = "stackit-core" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cd/9e/df3ad585cb96d028354f4253568e9879d81bb9395d5ebfa268fa9350e2df/stackit_ske-1.12.0.tar.gz", hash = "sha256:62814279f3b7fb2387648f92d14453a8905ad60115c07579f2741ddb7d1fcc94", size = 37239, upload-time = "2026-06-30T11:18:49.39Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/37/dc54fb7185a2d4da37308322ea1a7b992312030b2e37262de4eb4003f5c7/stackit_ske-1.12.0-py3-none-any.whl", hash = "sha256:45bd8084d87f14f818b3d7e824450248c8784ed204ca1b2dc108f491dcbdb1a3", size = 93142, upload-time = "2026-06-30T11:18:48.233Z" },
]
[[package]]
name = "statsd"
version = "4.0.1"
@@ -6225,6 +6243,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" },
]
[[package]]
name = "truststore"
version = "0.10.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
]
[[package]]
name = "typer"
version = "0.21.1"
@@ -6621,6 +6648,48 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/81/2f171fbc4222066957e6b9220c4fb9146792540102c37e6d94e5d14aad97/zope_interface-8.2-cp312-cp312-win_amd64.whl", hash = "sha256:845d14e580220ae4544bd4d7eb800f0b6034fe5585fc2536806e0a26c2ee6640", size = 212444, upload-time = "2026-01-09T08:05:25.148Z" },
]
[[package]]
name = "zstandard"
version = "0.25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" },
{ url = "https://files.pythonhosted.org/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559, upload-time = "2025-09-14T22:16:27.973Z" },
{ url = "https://files.pythonhosted.org/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020, upload-time = "2025-09-14T22:16:29.523Z" },
{ url = "https://files.pythonhosted.org/packages/3b/13/2b7ed68bd85e69a2069bcc72141d378f22cae5a0f3b353a2c8f50ef30c1b/zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", size = 5058126, upload-time = "2025-09-14T22:16:31.811Z" },
{ url = "https://files.pythonhosted.org/packages/c9/dd/fdaf0674f4b10d92cb120ccff58bbb6626bf8368f00ebfd2a41ba4a0dc99/zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", size = 5405390, upload-time = "2025-09-14T22:16:33.486Z" },
{ url = "https://files.pythonhosted.org/packages/0f/67/354d1555575bc2490435f90d67ca4dd65238ff2f119f30f72d5cde09c2ad/zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", size = 5452914, upload-time = "2025-09-14T22:16:35.277Z" },
{ url = "https://files.pythonhosted.org/packages/bb/1f/e9cfd801a3f9190bf3e759c422bbfd2247db9d7f3d54a56ecde70137791a/zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", size = 5559635, upload-time = "2025-09-14T22:16:37.141Z" },
{ url = "https://files.pythonhosted.org/packages/21/88/5ba550f797ca953a52d708c8e4f380959e7e3280af029e38fbf47b55916e/zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", size = 5048277, upload-time = "2025-09-14T22:16:38.807Z" },
{ url = "https://files.pythonhosted.org/packages/46/c0/ca3e533b4fa03112facbe7fbe7779cb1ebec215688e5df576fe5429172e0/zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", size = 5574377, upload-time = "2025-09-14T22:16:40.523Z" },
{ url = "https://files.pythonhosted.org/packages/12/9b/3fb626390113f272abd0799fd677ea33d5fc3ec185e62e6be534493c4b60/zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", size = 4961493, upload-time = "2025-09-14T22:16:43.3Z" },
{ url = "https://files.pythonhosted.org/packages/cb/d3/23094a6b6a4b1343b27ae68249daa17ae0651fcfec9ed4de09d14b940285/zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", size = 5269018, upload-time = "2025-09-14T22:16:45.292Z" },
{ url = "https://files.pythonhosted.org/packages/8c/a7/bb5a0c1c0f3f4b5e9d5b55198e39de91e04ba7c205cc46fcb0f95f0383c1/zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", size = 5443672, upload-time = "2025-09-14T22:16:47.076Z" },
{ url = "https://files.pythonhosted.org/packages/27/22/503347aa08d073993f25109c36c8d9f029c7d5949198050962cb568dfa5e/zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", size = 5822753, upload-time = "2025-09-14T22:16:49.316Z" },
{ url = "https://files.pythonhosted.org/packages/e2/be/94267dc6ee64f0f8ba2b2ae7c7a2df934a816baaa7291db9e1aa77394c3c/zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", size = 5366047, upload-time = "2025-09-14T22:16:51.328Z" },
{ url = "https://files.pythonhosted.org/packages/7b/a3/732893eab0a3a7aecff8b99052fecf9f605cf0fb5fb6d0290e36beee47a4/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", size = 436484, upload-time = "2025-09-14T22:16:55.005Z" },
{ url = "https://files.pythonhosted.org/packages/43/a3/c6155f5c1cce691cb80dfd38627046e50af3ee9ddc5d0b45b9b063bfb8c9/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", size = 506183, upload-time = "2025-09-14T22:16:52.753Z" },
{ url = "https://files.pythonhosted.org/packages/8c/3e/8945ab86a0820cc0e0cdbf38086a92868a9172020fdab8a03ac19662b0e5/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", size = 462533, upload-time = "2025-09-14T22:16:53.878Z" },
{ url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" },
{ url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" },
{ url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" },
{ url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" },
{ url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" },
{ url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" },
{ url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" },
{ url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" },
{ url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" },
{ url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" },
{ url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" },
{ url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" },
{ url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" },
{ url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" },
{ url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" },
{ url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" },
{ url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" },
]
[[package]]
name = "zstd"
version = "1.5.7.2"
+57 -13
View File
@@ -120,14 +120,14 @@ class NewFeatureTools(BaseTool):
Returns complete feature details including configuration and metadata.
"""
try:
response = await self.api_client.get(f"/api/v1/features/{feature_id}")
return DetailedFeature.from_api_response(response["data"]).model_dump()
except Exception as e:
self.logger.error(f"Failed to get feature {feature_id}: {e}")
return {"error": str(e), "status": "failed"}
response = await self.api_client.get(f"/api/v1/features/{feature_id}")
return DetailedFeature.from_api_response(response["data"]).model_dump()
```
There is no `try`/`except` here on purpose. A failed request raises, and
[Error Handling](#error-handling) explains what turns that raise into a message
the agent can act on.
### Step 2: Create the Models
Create corresponding models in `prowler_app/models/`:
@@ -369,18 +369,62 @@ async def search_items(self, status: str = Field(...)) -> dict:
### Error Handling
Return structured error responses instead of raising exceptions:
**Raise, never return.** A returned `{"error": ...}` dict is reported to the
client as `isError: false` -- a *successful* tool call whose payload happens to
mention a failure. Clients and models read that as success. A raised exception
becomes a spec-correct tool execution error instead.
The common case therefore needs no handler at all:
```python
async def get_item(self, item_id: str) -> dict:
try:
response = await self.api_client.get(f"/api/v1/items/{item_id}")
return DetailedItem.from_api_response(response["data"]).model_dump()
except Exception as e:
self.logger.error(f"Failed to get item {item_id}: {e}")
return {"error": str(e), "status": "failed"}
response = await self.api_client.get(f"/api/v1/items/{item_id}")
return DetailedItem.from_api_response(response["data"]).model_dump()
```
`prowler_mcp_server/lib/errors.py` classifies the failures every tool shares --
a rejected credential, a missing permission, a rate limit, an outage, an
unreachable API, a bad argument -- and gives each one a message that says what
went wrong and what to do about it. Anything it does not recognise is masked,
because `mask_error_details=True` is set on every sub-server and upstream
response bodies must never be replayed into a model's context.
Three ways to raise, in the order to reach for them:
```python
from fastmcp.exceptions import ToolError
from prowler_mcp_server.lib.errors import InvalidArgument
# 1. An argument this server rejected before any request went out. The message
# is repeated to the agent verbatim, so write it for one to read.
if not 1 <= page_size <= 1000:
raise InvalidArgument("page_size must be between 1 and 1000.")
# 2. A request the API answered or never answered: let it propagate untouched.
# `ProwlerAPIError` and `ProwlerAPIUnreachable` are what the classifier keys
# on, and the second one is what stops a retry from duplicating a write.
response = await self.api_client.get(f"/api/v1/items/{item_id}")
data = response["data"]
# 3. A sentence the classifier cannot know -- a resource name, a precondition,
# the next tool to call. NOTE the absent `from` clause: it is what marks the
# message as already final. With `from e` the classifier would replace it.
if not data:
raise ToolError(
f"No item with the ID {item_id!r} exists. Use prowler_list_items to "
"find a valid one."
)
```
The one thing that still *returns* rather than raises is a write whose outcome is
genuinely unknown. `prowler_send_findings_to_jira` is the worked example: work
items are created one at a time and Prowler cannot delete them, so a dispatch
that stopped halfway answers with a result object carrying
`safe_to_retry: false`. "This may have been applied" is a fact about the world,
not an error, and squashing it into one loses the only thing that stops a retry
from duplicating the write.
### Parameter Descriptions
Use Pydantic `Field()` with clear descriptions. This also helps LLMs understand
+12 -1
View File
@@ -182,7 +182,8 @@
"pages": [
"user-guide/tutorials/prowler-app-s3-integration",
"user-guide/tutorials/prowler-app-security-hub-integration",
"user-guide/tutorials/prowler-app-jira-integration"
"user-guide/tutorials/prowler-app-jira-integration",
"user-guide/tutorials/prowler-app-slack-integration"
]
},
{
@@ -301,6 +302,15 @@
{
"group": "Providers",
"pages": [
{
"group": "Organizations",
"pages": [
"user-guide/organizations",
"user-guide/providers/aws/organizations",
"user-guide/providers/gcp/organization",
"user-guide/providers/azure/management-groups"
]
},
{
"group": "Alibaba Cloud",
"pages": [
@@ -330,6 +340,7 @@
"user-guide/providers/azure/getting-started-azure",
"user-guide/providers/azure/authentication",
"user-guide/providers/azure/use-non-default-cloud",
"user-guide/providers/azure/management-groups",
"user-guide/providers/azure/subscriptions",
"user-guide/providers/azure/resource-groups",
"user-guide/providers/azure/create-prowler-service-principal"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

+1
View File
@@ -92,6 +92,7 @@ li[id="/user-guide/tutorials/prowler-alerts"] a > div > div > span:first-child::
li[id="/user-guide/tutorials/prowler-app-attack-paths-active-queries"] a > div > div > span:first-child::after,
li[id="/user-guide/tutorials/prowler-app-findings-triage"] a > div > div > span:first-child::after,
li[id="/user-guide/tutorials/prowler-app-scan-configuration"] a > div > div > span:first-child::after,
li[id="/user-guide/tutorials/prowler-app-slack-integration"] a > div > div > span:first-child::after,
li[id="/user-guide/tutorials/prowler-cloud-aws-organizations"] a > div > div > span:first-child::after,
li[id="/user-guide/tutorials/prowler-cloud-azure-management-groups"] a > div > div > span:first-child::after,
li[id="/user-guide/tutorials/prowler-cloud-gcp-organizations"] a > div > div > span:first-child::after,
+58
View File
@@ -0,0 +1,58 @@
---
title: 'Organizations Across Cloud Providers'
description: 'Understand organization hierarchies and onboarding across AWS, Google Cloud, and Azure'
---
Cloud providers use organization-level hierarchies to group accounts, projects, or subscriptions and apply access and governance consistently. Prowler uses these hierarchies to discover cloud targets and help configure multi-account or multi-project scanning.
This guide explains the shared lifecycle and the differences between AWS Organizations, Google Cloud organizations, and Azure Management Groups. Use the provider-specific guides for commands, permissions, and limitations.
## Organization Lifecycle
Organization-level onboarding generally follows these steps:
1. **Identify the hierarchy:** Locate the organization, management account, management group, folder, organizational unit, or equivalent parent node in the cloud provider.
2. **Grant access:** Assign the provider permissions required to enumerate the hierarchy and read the resources that Prowler scans.
3. **Discover members:** Use Prowler to retrieve accounts, projects, or subscriptions under the selected hierarchy.
4. **Select scan targets:** Choose the cloud targets to connect or scan. Discovery does not necessarily make every discovered target a Prowler provider.
5. **Test access:** Confirm that Prowler can authenticate to each selected target and read its resources.
6. **Scan and maintain:** Run scans, review findings, and repeat discovery when the provider hierarchy changes.
<Note>
Organization membership changes are not automatically synchronized in every Prowler workflow. Follow the provider-specific guide to learn when manual rediscovery is required.
</Note>
## Capability Matrix
| Capability | AWS Organizations | Google Cloud organization | Azure Management Groups |
| --- | --- | --- | --- |
| Hierarchy members | AWS accounts grouped in organizational units (OUs) | Projects grouped in folders and nested folders | Subscriptions grouped in management groups |
| Organization-level discovery | Supported through AWS Organizations APIs | Supported through the Cloud Asset API | Supported through Azure management-group and subscription APIs |
| Primary scan target | AWS account | Google Cloud project | Azure subscription |
| Common organization-level permission | IAM role in the management or delegated administrator account | Cloud Asset Viewer or Cloud Asset Owner at the organization node | Appropriate Azure role assignment at the management-group or subscription scope |
| Provider-specific onboarding | AWS account discovery and optional StackSet role deployment | Project discovery under an organization ID | Subscription discovery under a management group; role assignments inherit to subscriptions |
| Membership maintenance | Repeat the discovery flow when accounts are added or removed | Re-run organization discovery when projects or folders change | Refresh discovery when subscriptions move between management groups |
## Provider Guides
### AWS Organizations
The [AWS Organizations guide](/user-guide/providers/aws/organizations) covers account details, delegated administration, IAM roles, CloudFormation StackSets, and CLI scanning. For Prowler Cloud onboarding, see [AWS Organizations in Prowler Cloud](/user-guide/tutorials/prowler-cloud-aws-organizations).
### Google Cloud Organization
The [Google Cloud organization guide](/user-guide/providers/gcp/organization) covers scanning projects under an organization ID, organization-level permissions, and Cloud Asset API requirements. For Prowler Cloud onboarding, see [Google Cloud organizations in Prowler Cloud](/user-guide/tutorials/prowler-cloud-gcp-organizations).
### Azure Management Groups
The [Azure Management Groups guide](/user-guide/providers/azure/management-groups) covers hierarchy setup, role assignment, subscription scope, and Azure-specific limitations. For Prowler Cloud onboarding, see [Azure Management Groups in Prowler Cloud](/user-guide/tutorials/prowler-cloud-azure-management-groups).
## Scope Boundaries
The organization concepts in this guide refer only to cloud-provider resource hierarchies:
- **GitHub organizations** group repositories and GitHub resources. They are a separate provider concept and are not part of AWS, Google Cloud, or Azure organization discovery.
- **MongoDB Atlas organizations** group Atlas projects and teams. They use a separate provider API and authentication model.
- **Prowler Cloud organizations** are internal tenants that isolate providers, scans, findings, users, and permissions. They are not the same as a cloud-provider organization and do not replace one.
Choose the guide that matches the hierarchy being configured, then use the relevant Prowler Cloud or CLI workflow for the scan targets.
@@ -12,6 +12,8 @@ See [AWS Organizations](/user-guide/tutorials/prowler-cloud-aws-organizations) i
Prowler can integrate with AWS Organizations to manage the visibility and onboarding of accounts centrally.
For the cross-provider organization lifecycle and capability comparison, see [Organizations Across Cloud Providers](/user-guide/organizations).
When trusted access is enabled with the Organization, Prowler can discover accounts as they are created and even automate deployment of the Prowler Scan IAM Role.
> ℹ️ Trusted access can be enabled in the Management Account from the AWS Console under **AWS Organizations → Settings → Trusted access for AWS CloudFormation StackSets**.
@@ -0,0 +1,53 @@
---
title: 'Azure Management Groups in Prowler'
---
Azure Management Groups provide a hierarchy above subscriptions. They allow Azure role assignments and governance policies to apply to multiple subscriptions through a shared scope.
For the cross-provider concepts and lifecycle, see [Organizations Across Cloud Providers](/user-guide/organizations).
## Azure Hierarchy
Azure resources are organized in the following order:
1. Tenant
2. Management groups
3. Subscriptions
4. Resource groups
5. Resources
Prowler scans Azure subscriptions. Management groups help organize those subscriptions and provide a scope where permissions can be assigned, but a management group is not itself a scan target.
## Create a Management Group
To create a management group, follow the [official Azure guide](https://learn.microsoft.com/en-us/azure/governance/management-groups/create-management-group-portal).
![Create management group](/images/create-management-group.gif)
After creating the management group, add the subscriptions that Prowler should access and scan.
![Add Subscription to Management Group](/images/add-sub-to-management-group.gif)
## Assign Roles
Assign the roles required by Prowler at the management-group scope instead of assigning them separately to every subscription. Role assignments at a management group can inherit to its child subscriptions, subject to Azure role-assignment and inheritance rules.
Use the [subscription scope permissions](/user-guide/providers/azure/authentication#subscription-scope-permissions) guide to identify the permissions required for scans. The identity used by Prowler must be able to read the management-group hierarchy and access each subscription selected for scanning.
## Subscription Scope
Management groups organize subscriptions, but Azure scan results remain scoped to individual subscriptions:
- Prowler Cloud scans one subscription per scan.
- Prowler CLI can scan multiple subscriptions by using the `--subscription-ids` option.
- A subscription must be accessible to the configured identity before Prowler can scan it.
- Moving a subscription between management groups can change the permissions it inherits and may require a connection test or rediscovery.
See [Azure Subscription Scope](/user-guide/providers/azure/subscriptions) for subscription selection and CLI options.
## Limitations
- Management groups do not replace subscription providers in Prowler.
- Azure role inheritance depends on the management-group hierarchy and the scope of each assignment; verify access on every subscription selected for scanning.
- The Prowler Cloud workflow is designed around Azure management-group discovery and subscription onboarding. The Prowler CLI workflow still requires explicit subscription selection when restricting scans.
- Changes to management-group membership or role assignments may not be reflected until the hierarchy is refreshed and access is tested again.
@@ -25,14 +25,4 @@ Check the [Authentication > Subscription Scope Permissions](/user-guide/provider
## Recommendation for Managing Multiple Subscriptions
Scanning multiple subscriptions requires creating and assigning roles for each, which can be a time-consuming process. To streamline subscription management and auditing, use management groups in Azure. This approach allows Prowler to efficiently organize and audit multiple subscriptions collectively.
1. **Create a Management Group**: Follow the [official guide](https://learn.microsoft.com/en-us/azure/governance/management-groups/create-management-group-portal) to create a new management group.
![Create management group](/images/create-management-group.gif)
2. **Assign Roles**: Assign necessary roles to the management group, similar to the [role assignment process](#assigning-permissions-for-subscription-scans).
Role assignment should be done at the management group level instead of per subscription.
3. **Add Subscriptions**: Add all subscriptions you want to audit to the newly created management group. ![Add Subscription to Management Group](/images/add-sub-to-management-group.gif)
Scanning multiple subscriptions requires creating and assigning roles for each, which can be a time-consuming process. To streamline subscription management and auditing, use [Azure Management Groups](/user-guide/providers/azure/management-groups) to organize subscriptions and assign permissions collectively.
@@ -4,6 +4,8 @@ title: 'Scanning a Specific GCP Organization'
By default, Prowler scans all Google Cloud projects accessible to the authenticated user.
For the cross-provider organization lifecycle and capability comparison, see [Organizations Across Cloud Providers](/user-guide/organizations).
To limit the scan to projects within a specific Google Cloud organization, use the `--organization-id` option with the GCP organization’s ID:
```console
@@ -42,7 +42,7 @@ Required for scanning repository security settings:
| Permission | Access Level | Purpose | Checks Enabled |
|------------|-------------|---------|----------------|
| **Administration** | Read | Branch protection, security settings | All branch protection checks, secret scanning status |
| **Administration** | Read | Branch protection, security and Actions settings | All branch protection checks, secret scanning status, `repository_default_workflow_permissions_read_only` |
| **Contents** | Read | File existence checks | `repository_public_has_securitymd_file`, `repository_has_codeowners_file` |
| **Metadata** | Read | Basic repository information | All checks (automatically granted) |
| **Dependabot alerts** | Read | Dependency vulnerability scanning | `repository_dependency_scanning_enabled` |
@@ -63,7 +63,7 @@ Required for scanning organization-level security settings:
| Permission | Access Level | Purpose | Checks Enabled |
|------------|-------------|---------|----------------|
| **Administration** | Read | Organization security policies | `organization_members_mfa_required`, `organization_repository_creation_limited`, `organization_default_repository_permission_strict` |
| **Administration** | Read | Organization security policies and Actions settings | `organization_members_mfa_required`, `organization_repository_creation_limited`, `organization_default_repository_permission_strict`, `organization_default_workflow_permissions_read_only`, `organization_actions_pull_request_approval_disabled` |
| **Members** | Read | Member access reviews | Organization membership auditing |
#### Account Permissions (Fine-Grained PAT only)
@@ -83,8 +83,8 @@ With the **Read-only permissions** listed above, Prowler can run:
| Check Category | Coverage | Notes |
|----------------|----------|-------|
| Branch protection checks (12 checks) | ✅ Full | Signed commits, status checks, PR reviews, etc. |
| Repository security checks | ✅ Full | Secret scanning, Dependabot, SECURITY.md, CODEOWNERS |
| Organization checks (3 checks) | ✅ Full | MFA, repo creation policies, default permissions |
| Repository security checks | ✅ Full | Secret scanning, Dependabot, SECURITY.md, CODEOWNERS, default workflow permissions |
| Organization checks (5 checks) | ✅ Full | MFA, repo creation policies, default permissions, Actions workflow permissions |
| Compliance frameworks | ✅ Full | CIS GitHub Benchmark and others |
| Merge settings (`delete_branch_on_merge`) | ⚠️ MANUAL | Requires write permission (see below) |
@@ -171,6 +171,7 @@ Use OAuth App Tokens when building applications that need delegated user permiss
- `repo`: Full control of repositories
- `read:org`: Read organization and team membership
- `admin:org`: Required by `organization_default_workflow_permissions_read_only` and `organization_actions_pull_request_approval_disabled` to read the organization Actions workflow permissions
- `read:user`: Read user profile data
**Create an OAuth App:**
@@ -214,7 +215,7 @@ If a GitHub App is required:
| Permission | Access Level | Purpose | Checks Enabled |
|------------|-------------|---------|----------------|
| **Administration** | Read | Branch protection, security settings | All branch protection checks, `repository_secret_scanning_enabled` |
| **Administration** | Read | Branch protection, security and Actions settings | All branch protection checks, `repository_secret_scanning_enabled`, `repository_default_workflow_permissions_read_only` |
| **Contents** | Read | File existence checks | `repository_public_has_securitymd_file`, `repository_has_codeowners_file` |
| **Metadata** | Read | Basic repository information | All checks (automatically granted) |
| **Dependabot alerts** | Read | Dependency vulnerability scanning | `repository_dependency_scanning_enabled` |
@@ -223,7 +224,7 @@ If a GitHub App is required:
| Permission | Access Level | Purpose | Checks Enabled |
|------------|-------------|---------|----------------|
| **Administration** | Read | Organization security policies | `organization_members_mfa_required`, `organization_repository_creation_limited`, `organization_default_repository_permission_strict` |
| **Administration** | Read | Organization security policies and Actions settings | `organization_members_mfa_required`, `organization_repository_creation_limited`, `organization_default_repository_permission_strict`, `organization_default_workflow_permissions_read_only`, `organization_actions_pull_request_approval_disabled` |
| **Members** | Read | Member access reviews | Organization membership auditing |
**Create a GitHub App:**
@@ -4,6 +4,8 @@ title: 'AWS Organizations Bulk Provisioning in Prowler'
Prowler offers an automated tool to discover and provision all AWS accounts within an AWS Organization. This streamlines onboarding for organizations managing multiple AWS accounts by automatically generating the configuration needed for bulk provisioning.
For the cross-provider organization lifecycle and terminology, see [Organizations Across Cloud Providers](/user-guide/organizations).
The tool, `aws_org_generator.py`‎, complements the [Bulk Provider Provisioning](./bulk-provider-provisioning) tool and is available in the Prowler repository at: [util/prowler-bulk-provisioning](https://github.com/prowler-cloud/prowler/tree/master/util/prowler-bulk-provisioning)
<Note>
+46 -6
View File
@@ -1,7 +1,7 @@
---
title: 'Alerts'
sidebarTitle: 'Alerts'
description: 'Create email alerts from Prowler Cloud findings to monitor relevant security changes after scans or in daily digests.'
description: 'Create alerts from Prowler Cloud findings, deliver them to email recipients and Slack channels, and monitor relevant security changes after scans or in daily digests.'
---
import { VersionBadge } from "/snippets/version-badge.mdx"
@@ -9,7 +9,7 @@ import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
<VersionBadge version="5.26.0" />
Alerts notify recipients by email when security findings match saved filter conditions. Use Alerts to track high-priority findings, monitor specific providers or services, and keep teams informed about scan results that match defined criteria.
Alerts notify their destinations — email recipients, Slack channels, or both — when security findings match saved filter conditions. Use Alerts to track high-priority findings, monitor specific providers or services, and keep teams informed about scan results that match defined criteria.
<SubscriptionBanner />
@@ -19,12 +19,13 @@ Before creating Alerts, ensure that:
* At least one scan has completed and produced findings.
* The user role includes the `manage_alerts` permission.
* To deliver Alerts to Slack channels, a Slack workspace is connected, at least one channel is authorized on it, and the integration's connection check has confirmed that channel. See [Slack Integration](/user-guide/tutorials/prowler-app-slack-integration).
The `manage_alerts` permission is required to create, edit, test, enable, disable, and delete Alerts. See [RBAC Administrative Permissions](/user-guide/tutorials/prowler-app-rbac#rbac-administrative-permissions) for details.
## How Alerts Work
Alerts are created from Findings filters. When an Alert runs, Prowler Cloud evaluates the saved conditions against findings and sends an email digest when matching findings exist.
Alerts are created from Findings filters. When an Alert runs, Prowler Cloud evaluates the saved conditions against findings and notifies the Alert's destinations when matching findings exist: an email digest to each recipient, a message to each Slack channel, or both. Destination kinds are independent — neither requires the other, and neither displaces the other.
<Note>
Alerts evaluate findings with status `FAIL` only. Findings with status `PASS` or `MANUAL`, and muted findings, never trigger an Alert regardless of the saved filters.
@@ -53,6 +54,7 @@ To create an Alert:
* **Description:** Add optional context for the Alert.
* **Frequency:** Select when Prowler Cloud should evaluate the Alert.
* **Recipients:** Select the recipients who should receive the email digest.
* **Destination channels:** Select the Slack channels that should receive the Alert. See [Slack Channel Destinations](#slack-channel-destinations).
![Create Alert Modal](/images/prowler-app/alerts/create-alert-modal.png)
@@ -86,11 +88,18 @@ Navigate to **Alerts** to review and manage existing Alerts.
![Alerts List](/images/prowler-app/alerts/alerts-list.png)
The **Destinations** column summarizes where each Alert delivers, without the Alert being opened:
* **Email recipients:** The first address, plus a count of the rest, such as `security@example.com +2 more`.
* **Slack channels:** The first channel, plus a count of the rest, such as `#sec-alerts +1 more`.
Each summary is omitted when that destination kind is empty, and the column reads **No destinations** when an Alert has neither.
Each Alert provides these actions:
| Action | Description |
|--------|-------------|
| Edit | Update name, description, recipients, frequency, or filters. |
| Edit | Update name, description, recipients, Slack channels, frequency, or filters. |
| Enable/Disable | Start or stop Alert evaluation without deleting the Alert. |
| Delete | Permanently remove the Alert. |
@@ -125,6 +134,37 @@ By default, the **organization owner** receives a **daily digest** for **critica
If a recipient unsubscribes from Alerts, that address stops receiving digests until it is reconfirmed.
An Alert does not require email recipients: an Alert that targets Slack channels only is accepted with those channels as its sole destinations. An Alert with no destinations at all stays valid and keeps evaluating its filters, but it delivers nothing.
## Slack Channel Destinations
<VersionBadge version="5.40.0" />
An Alert can post to Slack channels alongside its email recipients, or instead of them. The **Destination channels** field sits directly below **Recipients** in the Alert form, both when creating an Alert and when editing one. When the Alert matches findings, Prowler Cloud posts a message to each of its channels and sends the email digest to each of its recipients, independently of each other.
The channels offered are the confirmed channels of the connected Slack integration, never the whole Slack workspace. Widening the pool takes two steps on the integration: authorize the channel there, then run its connection check, which confirms the channel by posting a one-time confirmation message to it. Once confirmed, the channel is selectable on every Alert. See [Slack Integration](/user-guide/tutorials/prowler-app-slack-integration) for connecting a workspace, authorizing its channels, and confirming them.
A channel that was authorized a moment ago but does not appear in the Alert form has not been confirmed yet. Run **Test connection** on the Slack integration, then reopen the Alert form.
Private channels are identified as **Private** both in the open channel list and on the selected channels once the list is closed, so a private destination is never mistaken for a public one.
### When Slack Channels Cannot Be Selected
The field is always present, so channel delivery is never silently missing. It reports why it cannot be used:
| State | What the Alert form shows |
|-------|---------------------------|
| No Slack workspace connected | The field is visible but cannot be edited, explaining that posting Alerts to Slack channels needs a connected Slack workspace, with a link to the Slack integration. |
| Workspace connected, no confirmed channels | A notice that no channels are available yet and that they are authorized and confirmed on the Slack integration, with the same link. |
In both states the rest of the Alert is unaffected: it can still be created or saved with its filters, frequency, and email recipients.
<Note>
Slack destinations stay in step with the integration. Removing a channel from the integration's authorized set — or disconnecting the Slack integration altogether — removes that channel from every Alert that targeted it, so an Alert never keeps a destination Prowler can no longer deliver to. The Alert keeps its filters, frequency, and email recipients, and future delivery to that channel stops: nothing is posted to announce the removal, and the notifications already delivered stay in the channel. Restoring delivery means authorizing and confirming the channel again on the integration, then selecting it again on the Alert.
</Note>
Saving an Alert that names a channel which is not a confirmed channel of a connected Slack integration is refused, and the reason is reported on the Alert form. Authorize and confirm the channel on the Slack integration, or remove it from the Alert, and save again.
## Email Notifications
When an Alert matches findings, Prowler Cloud sends a security alert email that summarizes the matching findings. The email includes:
@@ -141,6 +181,6 @@ When an Alert matches findings, Prowler Cloud sends a security alert email that
* **Start with focused filters:** Create Alerts for specific high-priority scopes, such as critical findings, production providers, or important services.
* **Use clear names:** Choose names that explain the intent of the Alert.
* **Review recipients regularly:** Keep recipient lists aligned with current ownership.
* **Review destinations regularly:** Keep recipient lists and channel selections aligned with current ownership.
* **Test before saving edits:** Use **Test** after changing filters to confirm that the Alert matches the expected findings.
* **Disable instead of deleting during tuning:** Disable Alerts temporarily when adjusting filters or recipients.
* **Disable instead of deleting during tuning:** Disable Alerts temporarily when adjusting filters or destinations.
@@ -0,0 +1,195 @@
---
title: "Slack Integration"
sidebarTitle: 'Slack'
description: 'Connect a Slack workspace to Prowler Cloud or Prowler Private Cloud, authorize the channels Prowler posts to, and verify the connection.'
---
import { VersionBadge } from "/snippets/version-badge.mdx"
import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
<VersionBadge version="5.40.0" />
<SubscriptionBanner />
Prowler Cloud and Prowler Private Cloud connect to a Slack workspace so security updates arrive where teams already work. Connecting takes one approval in Slack — there is no bot token to create, copy, or store by hand — and Prowler records the set of channels it is authorized to post to.
Integrating Prowler Cloud or Prowler Private Cloud with Slack provides:
* **Approval-based setup:** Approve Prowler once in Slack instead of building a Slack app and pasting a token.
* **Confirmed destinations:** The connection check verifies every authorized channel and confirms each new one in the channel itself, so a channel Prowler cannot reach is reported before anything depends on it.
* **Controlled reach:** Prowler posts only to the channels authorized on the integration, and private channels stay invisible until the Prowler app is invited to them.
<Note>
This guide covers the Slack integration in Prowler Cloud and Prowler Private Cloud. It is unrelated to the Prowler CLI `--slack` flag, which posts a scan summary from the command line using a self-created Slack app and the `SLACK_API_TOKEN` and `SLACK_CHANNEL_NAME` environment variables — see [CLI Integrations](/user-guide/cli/tutorials/integrations) for that feature.
</Note>
## How the Slack Integration Works
When connected and configured:
1. A Slack workspace is approved once through Slack's app install flow, and Prowler stores the resulting credential encrypted.
2. Prowler reads the channels it can post to: the workspace's public channels, plus the private channels the Prowler app has been invited to.
3. Several of those channels are selected and saved as the integration's authorized channels.
4. The connection check verifies the credential and every authorized channel, and posts a one-time confirmation message to each channel it has not confirmed yet.
5. Features that deliver to Slack, such as [Alerts](/user-guide/tutorials/prowler-alerts), choose their destinations from the confirmed channels.
6. Disconnecting removes the integration from Prowler and attempts to revoke Prowler's access at Slack.
## Prerequisites
The Slack integration is available only in **Prowler Cloud** and **Prowler Private Cloud**. Prowler Local Server does not serve the Slack endpoints at all, so the Slack card does not appear on the Integrations page and the management page redirects away.
Configuring and using the Slack integration requires the **Manage Integrations** permission. The integration is tenant-wide, so it does not require **Unlimited Visibility** or any specific Provider Group.
One Slack workspace connects per tenant. Approving Prowler again in the same workspace refreshes the stored credential and keeps the authorized channels, but it resets their confirmations and the connection state — the connection check has to be run again. Approving Prowler in a *different* workspace is refused until the current workspace is disconnected: a workspace is never swapped out silently.
## Permissions Prowler Requests in Slack
Slack shows a consent screen listing everything the Prowler app asks for. Prowler requests exactly four bot scopes:
| Scope | Why Prowler Requests It |
|-------|-------------------------|
| `chat:write` | Post the confirmation message, and any later notification, to the authorized channels. |
| `chat:write.public` | Post to a public channel without first inviting the Prowler app to it. |
| `channels:read` | List public channels for the channel selection and resolve the chosen ones. |
| `groups:read` | List the private channels the Prowler app has been invited to, so they appear in the channel selection. |
Two of these read more broadly than they behave, and both are worth understanding before approving the app.
### What `chat:write.public` Does Not Grant
On the consent screen, `chat:write.public` reads as permission to post in any public channel. Prowler never uses it that way: **Prowler only ever posts to the channels authorized on the integration.** The scope exists so that authorizing a public channel does not also require someone to invite the Prowler app to it first.
### Why a Private Channel Is Missing From the Channel List
`groups:read` reveals only the private channels the Prowler app is already a member of. A private channel therefore appears in the channel list only after someone invites `@Prowler` to it in Slack:
```text
/invite @Prowler
```
That invite is issued in Slack, by that channel's own members, and **the invite itself is the permission grant** — no scope bypasses it. Prowler ships no in-product flow to get the app invited, because the decision belongs to the channel's members. After inviting the app, click **Refresh channels** to re-read the list.
## Connecting a Slack Workspace
To connect a Slack workspace to Prowler Cloud or Prowler Private Cloud:
1. In either product, navigate to **Integrations**.
2. Locate the **Slack** card and click **Manage**.
![Slack card on the Integrations page in Prowler Cloud or Prowler Private Cloud](/images/prowler-app/slack/integrations-tab.png)
3. Click **Add to Slack**.
![Slack management page before a workspace is connected, showing the Add to Slack action](/images/prowler-app/slack/no-workspace-connected.png)
4. In Slack, select the workspace to connect and approve the permissions listed on the consent screen.
5. Slack returns to Prowler Cloud or Prowler Private Cloud, which completes the install and shows the connected workspace.
![Connected Slack workspace with no channels authorized yet](/images/prowler-app/slack/connected-workspace.png)
The connected card reports the workspace name and a **Not checked yet** status: the connection is checked against the authorized channels, and none are authorized at this point. Authorizing them is the next step. Once at least one channel is authorized, **Test connection** verifies the credential and every authorized channel, and confirms the ones not confirmed yet.
<Note>
Declining the consent screen creates nothing. Prowler reports that the workspace was not connected and offers to start again.
</Note>
## Authorizing Destination Channels
Prowler posts to the channels authorized on the integration. Several channels can be authorized at once, and once the connection check has confirmed them they are the pool every consumer of the integration draws from: an [Alert](/user-guide/tutorials/prowler-alerts) picks its Slack destinations from the confirmed channels, never from the whole workspace.
1. Open the **Destination channels** selection. It lists the workspace's public channels, plus the private channels the Prowler app has been invited to, each marked **Private**.
![Destination channels selection listing public channels and an invited private channel marked Private](/images/prowler-app/slack/channel-picker.png)
2. Select one or more channels. A selected private channel keeps its lock and **Private** identification with the list closed, so the authorized set stays readable at a glance.
3. Click **Save channels**.
Prowler validates the selection against Slack and derives each channel name itself, so a recorded name can never drift from the channel it belongs to. Once the set is saved, the page reports where Prowler posts and runs the connection check over it.
If the selection reports that no channels are available, the workspace exposes nothing Prowler can see. Create a public channel, or invite `@Prowler` to a private one, then click **Refresh channels**.
A workspace can hold more channels than Prowler reads in one go. When that happens, the selection says so and lists what was read: every listed channel is usable, and a channel missing from a partial list is not necessarily one `@Prowler` has to be invited to. Only listed channels can be selected: **Refresh channels** repeats the same bounded read rather than reading further, and the selection's search filters what was already read, so neither surfaces a channel the read left out.
Saving a new selection replaces the authorized set: channels left out of it stop being authorized, and channels added to it are authorized but not yet confirmed. Changing which channels are in the set also resets the integration's connection state, so the check runs again over the new set — reordering the same channels does not. Saving an empty selection leaves the integration with no authorized channels, and **Test connection** cannot be run again until at least one channel is authorized.
<Warning>
Removing a channel from the authorized set also removes it from every Alert that targeted it. Those Alerts keep their filters, frequency, and email recipients, and future delivery to that channel simply stops: nothing is posted to announce the removal, and the notifications already delivered stay in the channel. Disconnecting the integration has the same effect on every channel it had authorized. Restoring delivery means authorizing and confirming the channel again here, then selecting it again on each Alert.
</Warning>
### Confirming the Authorized Channels
A channel becomes usable as a destination once the connection check has confirmed it. Click **Test connection**: it verifies the stored credential and every authorized channel, and posts a one-time message to each channel it has not confirmed yet.
```text
✅ Prowler connection verified. Notifications will be delivered to this channel.
```
Later checks never post that message again to a channel that is already confirmed, so it arrives once per channel. The integration reports as connected only when every check and every required confirmation succeeded; a failure names the channel that failed. The check needs at least one authorized channel — with none authorized, it cannot be run yet.
Confirmation is what makes a channel selectable elsewhere in Prowler Cloud. A channel authorized a moment ago is missing from an Alert's channel list until a connection check confirms it.
## Disconnecting a Slack Workspace
Disconnecting removes the integration from Prowler **and** attempts to revoke Prowler's access at Slack.
1. On the Slack management page, click **Disconnect**.
2. Review the confirmation, then click **Disconnect workspace**.
![Disconnect Slack workspace confirmation dialog](/images/prowler-app/slack/disconnect-confirmation.png)
The page returns to its unconnected state, ready for a new install.
### What Revocation Means
Revocation is attempted at Slack, and it is best-effort:
* **Revocation succeeded:** The stored credential no longer grants Prowler anything, and the integration is gone from Prowler.
* **Revocation failed:** The integration and the stored credential are gone from Prowler either way, so there is nothing to retry. Slack did not confirm the revocation, which means the Prowler app may still be installed in the workspace. Remove it from that workspace's Slack app settings.
* **Revocation unreported:** Slack's answer carried no outcome either way. The integration is gone from Prowler, and the disconnect is reported without any claim about revocation. When certainty matters, check the workspace's Slack app settings and remove the Prowler app if it is still installed.
Prowler reports the outcome it received: a failed revocation always names the manual cleanup step, and an unreported one is never presented as revoked.
<Warning>
Disconnecting cannot be undone, and it removes the Slack channels from every Alert that targeted them. Reconnecting means approving Prowler in Slack again, authorizing the destination channels again, confirming them with a connection check, and selecting them again on each Alert that posts to Slack.
</Warning>
## Integration Status
The Slack management page reports the state of the connection and offers these actions:
| Button | Purpose | Notes |
|--------|---------|-------|
| **Test connection** | Verify the credential and every authorized channel, and confirm the ones not confirmed yet | Posts the confirmation message once per channel and updates the last-checked time. Cannot be run until at least one channel is authorized |
| **Refresh channels** | Re-read the workspace's channel list | Use after inviting `@Prowler` to a private channel |
| **Save channels** | Record the selected channels as the integration's authorized set | Enabled once the selection differs from the authorized set |
| **Disconnect** | Remove the integration and attempt to revoke access at Slack | ⚠️ **Cannot be undone** — confirm before disconnecting |
## Troubleshooting
### Slack Is Not Available in This Environment Yet
The Prowler Slack app is not configured for the deployment being used, so no workspace can be connected. This resolves without any action on the tenant's side — the page starts working as soon as the app is configured.
### A Private Channel Does Not Appear in the Channel List
The Prowler app has not been invited to it. In Slack, run `/invite @Prowler` in that channel, then click **Refresh channels**. Membership is the permission: no scope reveals a private channel the app is not in.
### Connection Test Fails
* Confirm every authorized channel still exists and has not been archived. A failure names the channel Slack refused, and the integration reports as connected only when every authorized channel passes.
* For a private authorized channel, confirm the Prowler app is still a member of it.
* Confirm the Prowler app is still installed in the workspace.
### A Channel Is Missing From an Alert's Channel List
The channel is authorized here but not confirmed yet. Click **Test connection**: it confirms every authorized channel it has not confirmed, and confirmed channels become selectable on Alerts.
### Prowler's Access Has Been Revoked
When Slack stops accepting the stored credential — because a workspace administrator revoked it, or the app was removed from the workspace — Prowler reports the workspace as disconnected and offers **Reconnect to Slack**. Approving Prowler in Slack again restores access.
### The Connection Check Fails on a Channel
* Check the outcome reported on the page: it names the channel Slack refused and the reason Slack gave — an archived or deleted channel surfaces here rather than failing silently.
* Confirm that channel is still one of the intended destinations, and that it has not been archived or deleted in Slack.
* For a private channel, confirm the Prowler app is still a member of it.
* One unreachable channel is enough to report the integration as not connected, so removing a retired channel from the authorized set clears the failure — bearing in mind that removing it also removes it from every Alert that targeted it.
@@ -10,6 +10,8 @@ import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
Prowler Cloud onboards every AWS account in your Organization through a single guided wizard. Instead of connecting accounts one by one, you can discover every account in your AWS Organization, select the ones you want to monitor, test connectivity, and launch scans — all from the Prowler Cloud UI.
For the cross-provider organization lifecycle and terminology, see [Organizations Across Cloud Providers](/user-guide/organizations).
<SubscriptionBanner>
For CLI-based multi-account scanning, see [AWS Organizations in Prowler CLI](/user-guide/providers/aws/organizations).
</SubscriptionBanner>
@@ -266,7 +268,7 @@ Click **Save**, **Save and launch scan**, or **Launch scan**, depending on the s
After launching:
- Scans appear in the **Scans** page as they start and complete.
- Results populate the **Overview** and **Findings** pages.
- Prowler runs an **automatic sync every 6 hours** to detect accounts added to or removed from your Organization. New accounts under the targeted OU or root are onboarded automatically.
- To detect accounts added to or removed from the AWS Organization, repeat the discovery flow described in [Add or Remove Organization Accounts](#add-or-remove-organization-accounts).
## Manage Your Organization After Onboarding
@@ -288,6 +290,24 @@ Open the row actions menu on the organization row on the **Providers** page.
Organizational unit rows carry the same **Test Connections** and **Delete Organizational Unit** actions, scoped to the accounts beneath them.
### Add or Remove Organization Accounts
To refresh the account membership of an existing AWS Organization, repeat the same discovery flow used during onboarding:
1. Navigate to **Providers**, click **Add Provider**, and select **Amazon Web Services**.
2. Choose **Add Multiple Accounts With AWS Organizations**.
3. Enter the existing **Organization ID**, proceed to **Authentication Details**, and use the existing deployment account **Role ARN**.
4. Confirm that the stack is deployed and click **Authenticate**. Prowler reuses the existing organization and starts a new discovery instead of creating a duplicate.
The refreshed tree shows the accounts currently returned by AWS Organizations:
- **New accounts** appear in the tree. Select them, test their connections, and save the configuration to connect them as providers. Existing providers and their historical data are preserved.
- **Accounts that left the Organization** no longer appear in the tree. Discovery does not automatically delete their existing providers. To remove one, return to the **Providers** page, open the account provider actions, and select **Delete Provider**.
<Danger>
Deleting a provider permanently removes its scans, findings, resources, and other stored data. Confirm that the account has left the AWS Organization and that its historical data is no longer required before deleting it.
</Danger>
### Update Organization Credentials
Choosing **Update Credentials** re-enters the Authentication Details step. Because the organization already holds a credential, Prowler warns before overwriting it and names how many providers re-authenticate with the new one:
@@ -458,16 +478,17 @@ Deploy the ProwlerScan role to every member account with a [CloudFormation Stack
</Note>
1. In your management account, navigate to **CloudFormation > StackSets > Create StackSet** ([open directly](https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacksets/create)).
2. Choose **Service-managed permissions** so AWS Organizations deploys the role automatically across current and future member accounts.
3. Select **Amazon S3 URL** as the template source and paste:
2. Choose **Service-managed permissions**.
3. Enable **Automatic deployment** so CloudFormation deploys the role to accounts added to the targeted root or OUs. Configure the account removal behavior based on whether the stack and its resources should be retained when an account leaves the target.
4. Select **Amazon S3 URL** as the template source and paste:
```
https://prowler-cloud-public.s3.eu-west-1.amazonaws.com/permissions/templates/aws/cloudformation/prowler-scan-role.yml
```
4. Set the **ExternalId** parameter to the External ID shown in the Prowler wizard.
5. Choose your deployment targets (entire organization or specific OUs) and regions, then click **Create StackSet**.
6. Open the **Stack instances** tab and confirm every instance shows **Status: CURRENT** and **Stack status: CREATE_COMPLETE**. Deployment typically takes **2–5 minutes**; large organizations (500+ accounts) may take longer.
5. Set the **ExternalId** parameter to the External ID shown in the Prowler wizard.
6. Choose your deployment targets (entire organization or specific OUs) and regions, then click **Create StackSet**.
7. Open the **Stack instances** tab and confirm every instance shows **Status: CURRENT** and **Stack status: CREATE_COMPLETE**. Deployment typically takes **2–5 minutes**; large organizations (500+ accounts) may take longer.
The StackSet role uses read-only access only (`SecurityAudit`, `ViewOnlyAccess`, plus a small set of additional read-only permissions). Prowler makes no changes to your accounts. See the [CloudFormation template](https://prowler-cloud-public.s3.eu-west-1.amazonaws.com/permissions/templates/aws/cloudformation/prowler-scan-role.yml) for the full list. When you add new accounts under the targeted OU or root, the StackSet deploys the role automatically, and Prowler's 6-hour sync onboards them end-to-end.
The StackSet role uses read-only access only (`SecurityAudit`, `ViewOnlyAccess`, plus a small set of additional read-only permissions). Prowler makes no changes to your accounts. See the [CloudFormation template](https://prowler-cloud-public.s3.eu-west-1.amazonaws.com/permissions/templates/aws/cloudformation/prowler-scan-role.yml) for the full list. When **Automatic deployment** is enabled, the StackSet deploys the role to new accounts under the targeted OU or root. Repeat the [organization discovery flow](#add-or-remove-organization-accounts) to connect those accounts in Prowler Cloud.
## Key Concepts
@@ -153,6 +153,18 @@ export PROWLER_CLOUD_API_KEY="pk_your_api_key_here"
prowler aws --push-to-cloud
```
### TLS Certificate Trust
For `--push-to-cloud` uploads, Prowler CLI creates one ingestion-scoped TLS context and validates HTTPS certificates with one handshake and one POST request. The upload does not retry or fall back to another TLS configuration. Redirect responses are rejected.
The ingestion context combines the operating system roots with the default certificate authority (CA) roots bundled with Requests. If `REQUESTS_CA_BUNDLE` is configured, Prowler CLI also loads that file or directory into the ingestion context. Otherwise, Prowler CLI loads `CURL_CA_BUNDLE` when configured.
`REQUESTS_CA_BUNDLE` and `CURL_CA_BUNDLE` can also affect other Requests-based connections throughout the Prowler CLI process, including provider authentication. A bundle containing only a private CA can cause connections to public services to fail before the upload starts. Installing the private CA in the operating system or container trust store is recommended. If a custom bundle is required, it must include both the public CA roots, such as the certifi bundle, and the required private CA certificates.
For Prowler Private Cloud deployments that use an organization CA or a TLS-intercepting corporate proxy, installing the required root CA in the operating system or container store remains the recommended approach. Containers have an isolated system CA store, so add the organization or proxy CA to the container image or runtime, then run the operating system's CA update command, such as `update-ca-certificates`, before starting Prowler CLI. Installing a CA on the container host does not automatically install it inside the container.
Prowler CLI does not create, modify, or remove these environment variables. The custom TLS context created by `--push-to-cloud` applies only to the temporary ingestion session and does not change API, provider, integration, global SSL, or unrelated Requests session behavior.
### Combining with Output Formats
When using `--push-to-cloud` with custom output formats that exclude OCSF, Prowler generates a temporary OCSF file for upload:
+2 -1
View File
@@ -113,7 +113,8 @@ make test-mcp # Run the MCP test suite exactly as CI does
- [ ] Models use `MinimalSerializerMixin`
- [ ] API responses transformed to simplified models
- [ ] No hardcoded secrets
- [ ] Error handling returns structured responses
- [ ] Failures are raised, not returned (see `prowler_mcp_server/lib/errors.py`);
a returned error dict is reported to the client as a success
- [ ] Parameter descriptions use Pydantic `Field()`
- [ ] Tests added under `mcp_server/tests/`, mirroring the source path below the
package root (`prowler_mcp_server/prowler_app/tools/` -> `tests/prowler_app/tools/`),
+18
View File
@@ -4,6 +4,24 @@ All notable changes to the **Prowler MCP Server** are documented in this file.
<!-- changelog: release notes start -->
## [0.11.0] (Prowler v5.40.0)
### 🚀 Added
- Failures shared by every tool - a rejected credential, a missing permission, a rate limit, an outage, an unreachable API, a bad argument - are now explained with a message that says what went wrong and what to do about it [(#12531)](https://github.com/prowler-cloud/prowler/pull/12531)
### 🐞 Fixed
- `prowler_docs_search` returns results again: it calls the search endpoint docs.prowler.com moved to, since the one it used no longer exists, and each result now names the page's title, the section it matched and a URL anchored at that section [(#12578)](https://github.com/prowler-cloud/prowler/pull/12578)
### 🔐 Security
- Stop relaying upstream response bodies to agents: a failed request now reaches the caller as a sentence this server wrote, with the full body kept to the logs, so a gateway error page or a debug traceback can no longer be replayed into a model's context [(#12531)](https://github.com/prowler-cloud/prowler/pull/12531)
- `sqlite-libs` upgraded to 3.53.4-r0 in the container image, patching CVE-2026-11822 and CVE-2026-11824 [(#12537)](https://github.com/prowler-cloud/prowler/pull/12537)
- `libcrypto3` and `libssl3` upgraded to 3.5.8-r0 in the container image, patching CVE-2026-14456 [(#12547)](https://github.com/prowler-cloud/prowler/pull/12547)
---
## [0.10.0] (Prowler v5.38.0)
### 🚀 Added
+16
View File
@@ -29,6 +29,22 @@ FROM python:3.13.14-alpine3.23@sha256:9fdbf2e3e82628351513560b121e2ee6ce31cac212
LABEL maintainer="https://github.com/prowler-cloud"
# High CVEs fixed in Alpine 3.23 but not yet in the pinned base image:
# sqlite-libs 3.53.4-r0 CVE-2026-11822, CVE-2026-11824 (image ships 3.51.2-r0)
# libcrypto3/libssl3 3.5.8-r0 CVE-2026-14456 (image ships 3.5.7-r0)
# The base image pins python 3.13.14, which has not been rebuilt since those
# packages were published, so the upgrade is taken here rather than by moving
# the pin -- the newest published python:3.13-alpine3.23 carries the same
# vulnerable versions. libcrypto3 and libssl3 are both built from openssl and
# are flagged separately, so both are named.
# `>=` rather than `=`: Alpine keeps only the newest build of a package in a
# branch's index, so an exact pin breaks this build the day one of these is
# superseded. Drop an entry once the base image ships that version or later.
RUN apk add --no-cache --upgrade \
"sqlite-libs>=3.53.4-r0" \
"libcrypto3>=3.5.8-r0" \
"libssl3>=3.5.8-r0"
# Create non-root user for security
# Using specific UID/GID for consistency across environments
RUN addgroup -g 1001 prowler && \
+238
View File
@@ -0,0 +1,238 @@
"""Shared failure classification for every tool in this server."""
import json
from typing import Any
import httpx
from fastmcp.exceptions import McpError, NotFoundError, ToolError
from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext
from pydantic import ValidationError
from prowler_mcp_server.lib.logger import logger
# ------------------------------------------------------------- failure types
class ProwlerAPIError(Exception):
"""An error response returned by the Prowler API.
Attributes:
status_code: HTTP status the API answered with
detail: JSON:API `errors[0].detail`, None when there is none to trust
"""
def __init__(
self, message: str, status_code: int, *, detail: str | None = None
) -> None:
super().__init__(message)
self.status_code: int = status_code
# Prowler's own JSON:API `errors[0].detail`, which our API writes for a
# caller and we therefore trust. None when the body was not JSON:API --
# a gateway HTML page or a debug traceback, which is exactly the case
# that must never be repeated to a model -- and None for a 5xx, see
# `jsonapi_detail`.
self.detail: str | None = detail
class ProwlerAPIUnreachable(Exception):
"""The request never got an answer, so whether it was applied is unknown."""
class ProwlerAPIInvalidResponse(Exception):
"""The API answered, but with a body this server could not read as JSON."""
def jsonapi_detail(response: httpx.Response) -> str | None:
"""Return the API's own JSON:API error detail, when there is one to trust.
Args:
response: Error response returned by the Prowler API
Returns:
`errors[0].detail`, or None if the status is 5xx or the body is not
JSON:API
"""
if response.status_code >= 500:
return None
try:
errors = response.json().get("errors")
except Exception:
return None
if not isinstance(errors, list) or not errors:
return None
detail = errors[0].get("detail") if isinstance(errors[0], dict) else None
return detail if isinstance(detail, str) and detail.strip() else None
class InvalidArgument(ValueError):
"""An argument this server rejected before any request went out."""
# ------------------------------------------------------------------- messages
def _describe_prowler_api_error(exc: ProwlerAPIError) -> str:
"""Describe a request the Prowler API answered with an error status."""
status = exc.status_code
# The fallback: a status this server has nothing specific to say about.
message = (
f"Prowler rejected the request with status {status} and gave no reason. "
"Check the arguments against the tool description."
)
if status == 401:
message = (
"Prowler rejected this server's credential: it is missing, malformed "
"or expired. In HTTP mode the request needs an 'Authorization: Bearer "
"<token>' header; in STDIO mode PROWLER_API_KEY must hold a valid key."
)
elif status == 403:
message = (
"The credential is valid but not allowed to do this. Use "
"prowler_get_current_user to see which role it holds."
)
elif status == 429:
message = (
"Prowler is rate limiting this credential. Wait before retrying, and "
"narrow the request with tighter filters or a smaller page_size."
)
elif status >= 500:
message = (
f"Prowler answered {status}: the request failed on Prowler's side, "
"not because of anything in the call."
)
elif exc.detail:
# Written by the Prowler API for a caller to read, so it is ours to relay.
message = f"Prowler rejected the request ({status}): {exc.detail}"
return message
def _describe_upstream_http_error(exc: httpx.HTTPError) -> str:
"""Describe a failure from an upstream this server reads directly."""
# `.request` raises rather than returning None when it was never set, and
# this runs inside an exception handler, so it is read defensively.
request = getattr(exc, "_request", None)
host = request.url.host if request is not None else "the upstream service"
if not isinstance(exc, httpx.HTTPStatusError):
return f"{host} could not be reached: {type(exc).__name__}."
status = exc.response.status_code
if status == 429:
return f"{host} is rate limiting this server. Wait before retrying."
if status >= 500:
return (
f"{host} answered {status}: the request failed on its side, not "
"because of anything in the call."
)
return (
f"{host} rejected the request with status {status}. Check the arguments "
"against the tool description."
)
def _describe_failure(exc: BaseException) -> str | None:
"""Describe a failure for a model to read, or return None to leave it masked."""
# InvalidArgument first: it is the only ValueError here whose message this
# server wrote. The ones below quote the input they rejected, so they are
# matched by type and answered with a message of our own.
if isinstance(exc, InvalidArgument):
return str(exc)
if isinstance(exc, ProwlerAPIError):
return _describe_prowler_api_error(exc)
if isinstance(exc, ProwlerAPIInvalidResponse):
return (
"Prowler answered with a body this server could not read, so the "
"outcome of the call is unknown. If it changes anything, check the "
"current state before sending it again."
)
if isinstance(exc, ProwlerAPIUnreachable):
# The only failure a model can turn into a duplicate write by repeating.
return (
f"Prowler could not be reached: {exc}. Whether the request was "
"applied is unknown, so check the current state before sending it again."
)
if isinstance(exc, ValidationError):
problems = [
f"{'.'.join(str(part) for part in error['loc']) or '(argument)'}: {error['msg']}"
for error in exc.errors(include_url=False)
]
# Field and expectation only: pydantic quotes the rejected value back.
return f"Invalid arguments -- {'; '.join(problems)}."
if isinstance(exc, json.JSONDecodeError):
return (
"An argument that had to be a JSON object could not be parsed. Send "
"it as a real object rather than as a quoted or escaped string."
)
# Only the Hub and documentation tools reach here: the Prowler API client
# converts its own httpx failures into the two types matched above.
if isinstance(exc, (httpx.HTTPStatusError, httpx.RequestError)):
return _describe_upstream_http_error(exc)
return None
# ----------------------------------------------------------------- middleware
class SharedFailureMiddleware(Middleware):
"""Replace the failures many tools share with a message a model can act on."""
async def on_call_tool(
self,
context: MiddlewareContext[Any],
call_next: CallNext[Any, Any],
) -> Any:
"""Replace a shared tool failure with the message that describes it.
Args:
context: Tool call being handled
call_next: Rest of the middleware chain
Returns:
The tool result when the call succeeded
Raises:
ToolError: With the classified message when the failure is one this
module recognises
"""
try:
return await call_next(context)
except (NotFoundError, McpError):
# Protocol-level, not a tool failure. Must stay exactly as it is.
raise
except Exception as exc:
# FastMCP wraps whatever the tool raised and records it as __cause__.
# An absent cause means the message is already the final word: a
# ToolError raised deliberately without a `from` clause.
original = exc.__cause__
message = _describe_failure(original) if original is not None else None
if message is None:
if original is not None:
logger.warning(
"Tool %s failed with an unclassified error: %s: %s",
getattr(context.message, "name", "<unknown>"),
type(original).__name__,
original,
)
raise
logger.warning(
"Tool %s failed: %s: %s",
getattr(context.message, "name", "<unknown>"),
type(original).__name__,
original,
)
raise ToolError(message) from exc
@@ -7,28 +7,22 @@ from typing import Any
from urllib.parse import urlparse
import httpx
from fastmcp.exceptions import ToolError
from prowler_mcp_server import __version__
from prowler_mcp_server.lib.errors import (
InvalidArgument,
ProwlerAPIError,
ProwlerAPIInvalidResponse,
ProwlerAPIUnreachable,
jsonapi_detail,
)
from prowler_mcp_server.lib.logger import logger
from prowler_mcp_server.prowler_app.utils.auth import ProwlerAppAuth
ALLOWED_EXTERNAL_DOMAINS: frozenset[str] = frozenset({"raw.githubusercontent.com"})
class ProwlerAPIError(Exception):
"""An error response returned by the Prowler API.
Raised only when the API answered with an error status, which tells a caller
something no plain exception can: the request reached Prowler and was
rejected, so it changed nothing. A timeout or a dropped connection stays a
bare exception because the request may well have been processed.
"""
def __init__(self, message: str, status_code: int) -> None:
super().__init__(message)
self.status_code: int = status_code
class HTTPMethod(StrEnum):
"""HTTP methods enum."""
@@ -88,7 +82,8 @@ class ProwlerAPIClient(metaclass=SingletonMeta):
Raises:
ProwlerAPIError: If the API answered with an error status
Exception: If the request could not be completed
ProwlerAPIUnreachable: If the request got no answer
ProwlerAPIInvalidResponse: If the answer was not readable as JSON
"""
try:
token: str = await self.auth_manager.get_valid_token()
@@ -103,31 +98,61 @@ class ProwlerAPIClient(metaclass=SingletonMeta):
json=json_data,
)
response.raise_for_status()
if not response.content:
return {
"success": True,
"status_code": response.status_code,
}
else:
return response.json()
except httpx.HTTPStatusError as e:
logger.error(f"HTTP error during {method.value} {path}: {e}")
error_detail: str = ""
try:
error_data: dict[str, any] = e.response.json()
error_detail = error_data.get("errors", [{}])[0].get("detail", "")
except Exception:
error_detail = e.response.text
raise ProwlerAPIError(
f"API request failed: {e.response.status_code} - {error_detail}",
e.response.status_code,
status: int = e.response.status_code
# `jsonapi_detail` returns nothing for a 5xx, so a server error never
# puts upstream text into the exception message either.
detail: str | None = jsonapi_detail(e.response)
# The full body goes to the log and nowhere else. A body that is not
# JSON:API is upstream text of unknown provenance, and the exception
# message is read by a model.
logger.error(
"HTTP error during %s %s: %s %s",
method.value,
path,
status,
(e.response.text or "")[:500],
)
message = f"API request failed: {status}"
if detail:
message = f"{message} - {detail}"
raise ProwlerAPIError(message, status, detail=detail) from e
except httpx.RequestError as e:
# No answer came back, so whether the request was applied is unknown.
logger.error(f"Error during {method.value} {path}: {e}")
raise ProwlerAPIUnreachable(
f"{method.value} {path} got no answer: {type(e).__name__}"
) from e
except Exception as e:
logger.error(f"Error during {method.value} {path}: {e}")
raise
if not response.content:
return {
"success": True,
"status_code": response.status_code,
}
# Parsed outside the block above so that a body we cannot read is told
# apart from an argument a tool could not parse: both are a
# `JSONDecodeError`, and only the second one is the caller's doing.
try:
return response.json()
except ValueError as e:
logger.error(
"Unreadable response body during %s %s: %s %s",
method.value,
path,
response.status_code,
(response.text or "")[:500],
)
raise ProwlerAPIInvalidResponse(
f"{method.value} {path} answered {response.status_code} with a "
"body that is not JSON"
) from e
async def get(
self, path: str, params: dict[str, any] | None = None
) -> dict[str, any]:
@@ -229,14 +254,14 @@ class ProwlerAPIClient(metaclass=SingletonMeta):
Raw text content from the URL
Raises:
ValueError: If the URL domain is not in the allowlist
Exception: If the HTTP request fails
InvalidArgument: If the URL scheme or domain is not allowed
ToolError: If the fetch failed
"""
parsed = urlparse(url)
if parsed.scheme != "https":
raise ValueError(f"Only HTTPS URLs are allowed, got '{parsed.scheme}'")
raise InvalidArgument(f"Only HTTPS URLs are allowed, got '{parsed.scheme}'")
if parsed.hostname not in ALLOWED_EXTERNAL_DOMAINS:
raise ValueError(
raise InvalidArgument(
f"Domain '{parsed.hostname}' is not allowed. "
f"Allowed domains: {', '.join(sorted(ALLOWED_EXTERNAL_DOMAINS))}"
)
@@ -249,13 +274,20 @@ class ProwlerAPIClient(metaclass=SingletonMeta):
response.raise_for_status()
return response.text
except httpx.HTTPStatusError as e:
logger.error(f"HTTP error fetching external URL {url}: {e}")
raise Exception(
f"Failed to fetch external URL: {e.response.status_code}"
) from e
except Exception as e:
# The status is ours to report; the body is upstream text and stays
# in the log. No `from` clause: this sentence is the final word.
logger.error(
"HTTP error fetching external URL %s: %s %s",
url,
e.response.status_code,
(e.response.text or "")[:500],
)
raise ToolError(
f"Fetching {url} failed with status {e.response.status_code}."
)
except httpx.RequestError as e:
logger.error(f"Error fetching external URL {url}: {e}")
raise
raise ToolError(f"Fetching {url} got no answer: {type(e).__name__}.")
async def poll_task_until_complete(
self,
@@ -278,7 +310,7 @@ class ProwlerAPIClient(metaclass=SingletonMeta):
The complete task response when terminal state is reached
Raises:
Exception: If task fails, is cancelled, or timeout is exceeded
ToolError: If the task fails, is cancelled, or the timeout is exceeded
"""
terminal_states = {"completed", "failed", "cancelled"}
start_time = asyncio.get_event_loop().time()
@@ -292,7 +324,7 @@ class ProwlerAPIClient(metaclass=SingletonMeta):
# Check if we've exceeded the timeout
current_time = asyncio.get_event_loop().time()
if current_time >= max_time:
raise Exception(
raise ToolError(
f"Task {task_id} polling timed out after {timeout} seconds. "
f"The task may still be running. Try increasing the timeout or check task status manually."
)
@@ -311,10 +343,14 @@ class ProwlerAPIClient(metaclass=SingletonMeta):
logger.info(f"Task {task_id} completed successfully")
return response
elif state == "failed":
error_msg = task_attrs.get("error", "Unknown error")
raise Exception(f"Task {task_id} failed: {error_msg}")
# The task's own failure text is an upstream body: a celery
# traceback, a provider message. Log it, never relay it.
logger.error(
f"Task {task_id} failed: {task_attrs.get('error', 'no error reported')}"
)
raise ToolError(f"Task {task_id} failed.")
elif state == "cancelled":
raise Exception(f"Task {task_id} was cancelled")
raise ToolError(f"Task {task_id} was cancelled")
# Wait before next poll
await asyncio.sleep(poll_interval)
@@ -330,12 +366,12 @@ class ProwlerAPIClient(metaclass=SingletonMeta):
Parsed datetime object
Raises:
ValueError: If date format is invalid
InvalidArgument: If date format is invalid
"""
try:
return datetime.strptime(date_str, "%Y-%m-%d")
except ValueError:
raise ValueError(
raise InvalidArgument(
f"Invalid date format for {param_name}. Expected YYYY-MM-DD (e.g., '2025-01-15'), got '{date_str}'. "
f"Full date required - partial dates like '2025' or '2025-01' are not accepted."
)
@@ -347,10 +383,10 @@ class ProwlerAPIClient(metaclass=SingletonMeta):
page_size: Page size to validate
Raises:
ValueError: If page size is out of valid range (1-1000)
InvalidArgument: If page size is out of valid range (1-1000)
"""
if page_size < 1 or page_size > 1000:
raise ValueError(
raise InvalidArgument(
f"Invalid page_size: {page_size}. Must be between 1 and 1000 (inclusive)."
)
@@ -373,7 +409,7 @@ class ProwlerAPIClient(metaclass=SingletonMeta):
None if no dates provided, otherwise tuple of (date_from, date_to) as strings
Raises:
ValueError: If date range exceeds max_days or date format is invalid
InvalidArgument: If date range exceeds max_days or date format is invalid
"""
if not date_from and not date_to:
return None
@@ -394,7 +430,7 @@ class ProwlerAPIClient(metaclass=SingletonMeta):
# Validate that date_from is before or equal to date_to
if from_date > to_date:
raise ValueError(
raise InvalidArgument(
f"Invalid date range: date_from must be before or equal to date_to. "
f"Got date_from='{from_date.date()}' and date_to='{to_date.date()}'. "
f"Please swap the dates or use the correct order."
@@ -403,7 +439,7 @@ class ProwlerAPIClient(metaclass=SingletonMeta):
# Validate range doesn't exceed max_days
delta: int = (to_date - from_date).days + 1
if delta > max_days:
raise ValueError(
raise InvalidArgument(
f"Date range cannot exceed {max_days} days. "
f"Requested range: {from_date.date()} to {to_date.date()} ({delta} days)"
)
@@ -8,12 +8,21 @@ class SearchResult(BaseModel):
"""Search result model."""
path: str = Field(description="Document path")
title: str = Field(description="Document title")
url: str = Field(description="Documentation URL")
highlights: list[str] = Field(
description="Highlighted content snippets showing query matches with <mark><b> tags",
title: str = Field(description="Title of the page the match is on")
section: str = Field(
description="Heading of the section the match is in", default=""
)
breadcrumbs: list[str] = Field(
description="Where the page sits in the documentation, from the top-level group down to the page itself",
default_factory=list,
)
url: str = Field(
description="Documentation URL, anchored at the matching section when it has an anchor"
)
excerpt: str = Field(
description="Text of the matching section, which is a part of the page and not the whole of it",
default="",
)
score: float = Field(
description="Relevance score for the search result", default=0.0
)
@@ -24,12 +33,10 @@ class ProwlerDocsSearchEngine:
def __init__(self):
"""Initialize the search engine."""
self.api_base_url = (
"https://api.mintlifytrieve.com/api/chunk_group/group_oriented_autocomplete"
)
self.dataset_id = "0096ba11-3f72-463b-9d95-b788495ac392"
self.api_key = "tr-T6JLeTkFXeNbNPyhijtI9XhIncydQQ3O"
self.docs_base_url = "https://prowler.mintlify.app"
# The endpoint docs.prowler.com itself calls, with the site's Mintlify
# project name as the last segment.
self.api_base_url = "https://leaves.mintlify.com/api/search/prowler"
self.docs_base_url = "https://docs.prowler.com"
# HTTP client for Mintlify API
self.mintlify_client = httpx.Client(
@@ -38,9 +45,6 @@ class ProwlerDocsSearchEngine:
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": f"prowler-mcp-server/{__version__}",
"TR-Dataset": self.dataset_id,
"Authorization": self.api_key,
"X-API-Version": "V2",
},
)
@@ -59,75 +63,51 @@ class ProwlerDocsSearchEngine:
Args:
query: Search query string
page_size: Maximum number of results to return
page_size: Maximum number of results to return. The API decides how
many matches it answers with and takes no size of its own, so
this only trims the list it returned.
Returns:
list of search results
"""
try:
# Construct request body
payload = {
"query": query,
"search_type": "fulltext",
"extend_results": True,
"highlight_options": {
"highlight_window": 10,
"highlight_max_num": 1,
"highlight_max_length": 2,
"highlight_strategy": "exactmatch",
"highlight_delimiters": ["?", ",", ".", "!", "\n"],
},
"score_threshold": 0.2,
"filters": {"must_not": [{"field": "tag_set", "match": ["code"]}]},
"page_size": page_size,
"group_size": 3,
}
# Make request to Mintlify API
response = self.mintlify_client.post(
self.api_base_url,
json=payload,
json={"query": query, "filters": {}},
)
response.raise_for_status()
data = response.json()
# Parse results
results = []
for result in data.get("results", []):
group = result.get("group", {})
chunks = result.get("chunks", [])
for match in data.get("results", [])[:page_size]:
metadata = match.get("metadata", {})
breadcrumbs = metadata.get("breadcrumbs", [])
doc_path = match.get("page", "")
# Get document path and title from group
doc_path = group.get("name", "")
group_title = group.get("name", "").replace("/", " / ").title()
# A match is one section of a page rather than the page: the
# heading it was found under is its header, and the page's own
# title is the last step of its breadcrumb trail.
section = match.get("header", "")
title = breadcrumbs[-1] if breadcrumbs else section
# If chunks exist, use the first chunk's title from metadata
title = group_title
if chunks:
first_chunk = chunks[0].get("chunk", {})
metadata = first_chunk.get("metadata", {})
title = metadata.get("title", group_title)
# Construct full URL to docs
full_url = f"{self.docs_base_url}/{doc_path}"
# Extract highlights and scores from chunks
highlights = []
max_score = 0.0
for chunk_data in chunks:
chunk_highlights = chunk_data.get("highlights", [])
highlights.extend(chunk_highlights)
# Track the highest score among all chunks in this group
chunk_score = chunk_data.get("score", 0.0)
max_score = max(max_score, chunk_score)
# Sent as "" for the section a page opens with and as null for
# the pages that have no anchors at all; both mean the page.
anchor = metadata.get("hash")
url = f"{self.docs_base_url}/{doc_path}"
if anchor:
url = f"{url}#{anchor}"
results.append(
SearchResult(
path=doc_path,
title=title,
url=full_url,
highlights=highlights,
score=max_score,
section=section,
breadcrumbs=breadcrumbs,
url=url,
excerpt=match.get("content", ""),
score=match.get("score", 0.0),
)
)
@@ -17,9 +17,9 @@ def search(
term: str = Field(description="The term to search for in the documentation"),
page_size: int = Field(
5,
description="Number of top results to return to return. It must be between 1 and 20.",
gt=1,
lt=20,
description="Number of top results to return. It must be between 1 and 20.",
ge=1,
le=20,
),
) -> list[dict[str, Any]]:
"""Search in Prowler documentation.
@@ -27,11 +27,12 @@ def search(
This tool searches through the official Prowler documentation
to find relevant information about everything related to Prowler.
Uses fulltext search to find the most relevant documentation pages
based on your query.
A result is one section of a documentation page, not the page itself: its
'excerpt' is that section alone. Read the whole page with
`prowler_docs_get_document`, passing the result's 'path'.
Returns:
List of search results with highlights showing matched terms (in <mark><b> tags)
List of matching documentation sections, most relevant first
"""
return prowler_docs_search_engine.search(term, page_size) # type: ignore In the hint we cannot put SearchResult type because JSON API MCP Generator cannot handle Pydantic models yet
+9 -1
View File
@@ -2,9 +2,17 @@ from fastmcp import FastMCP
from starlette.responses import JSONResponse
from prowler_mcp_server import __version__
from prowler_mcp_server.lib.errors import SharedFailureMiddleware
from prowler_mcp_server.lib.logger import logger
prowler_mcp_server = FastMCP("prowler-mcp-server")
# `mask_error_details` keeps an unhandled failure from relaying text this server
# does not control. It is set on every sub-server as well, because it does not
# reach mounted children -- FastMCP warns about exactly that at mount time.
prowler_mcp_server = FastMCP("prowler-mcp-server", mask_error_details=True)
# Middleware, unlike masking, does reach mounted children, so the classifier that
# gives the masked failures a sentence back is wired here once.
prowler_mcp_server.add_middleware(SharedFailureMiddleware())
def setup_main_server():
View File
+197
View File
@@ -0,0 +1,197 @@
"""Tests for the shared failure classifier.
Two properties are pinned here: a failed tool call answers with ``isError: true``
rather than a result object the client reads as a success, and the only text that
reaches a model is text this server produced.
"""
import json
import pytest
from fastmcp import Client
from pydantic import BaseModel, ValidationError
from prowler_mcp_server.lib.errors import InvalidArgument, _describe_failure
from prowler_mcp_server.prowler_app.utils.api_client import (
ProwlerAPIError,
ProwlerAPIInvalidResponse,
ProwlerAPIUnreachable,
)
from tests.helpers.jsonapi import jsonapi_error
LATEST = "/api/v1/findings/latest"
# ------------------------------------------------------------ classification
@pytest.mark.parametrize(
("status", "expected"),
[
(401, "missing, malformed or expired"),
(403, "prowler_get_current_user"),
(429, "rate limiting"),
(503, "failed on Prowler's side"),
],
ids=["unauthorized", "forbidden", "rate-limited", "unavailable"],
)
def test_the_failures_every_authenticated_tool_shares_get_one_message(status, expected):
"""These four mean the same thing whichever tool hit them."""
message = _describe_failure(ProwlerAPIError("failed", status))
assert expected in message
def test_a_rejection_relays_the_apis_own_reason():
"""`errors[].detail` is written by Prowler for a caller, so it is ours to relay."""
message = _describe_failure(
ProwlerAPIError("failed", 400, detail="scan_id is not a valid UUID.")
)
assert "scan_id is not a valid UUID." in message
def test_a_rejection_with_no_trustworthy_reason_says_so_instead_of_guessing():
"""A body that was not JSON:API leaves `detail` unset, and it stays unrelayed."""
message = _describe_failure(ProwlerAPIError("failed", 400))
assert "gave no reason" in message
def test_a_request_that_got_no_answer_says_the_outcome_is_unknown():
"""An unanswered write may well have landed, so repeating it can duplicate it."""
message = _describe_failure(ProwlerAPIUnreachable("POST /providers got no answer"))
assert "could not be reached" in message
assert "unknown" in message
def test_an_unreadable_api_answer_is_not_blamed_on_the_arguments():
"""The same `JSONDecodeError` means opposite things on the two sides."""
message = _describe_failure(
ProwlerAPIInvalidResponse(
"GET /findings answered 200 with a body that is not JSON"
)
)
assert "could not read" in message
assert "argument" not in message
def test_an_unreadable_api_answer_is_never_called_safe_to_repeat():
"""`post`, `patch` and `delete` reach this too, and a write may have landed."""
message = _describe_failure(
ProwlerAPIInvalidResponse(
"POST /providers answered 201 with a body that is not JSON"
)
)
assert "unknown" in message
assert "check the current state" in message
def test_an_argument_this_server_rejected_is_repeated_verbatim():
"""`InvalidArgument` exists to mark a message as one we wrote."""
message = _describe_failure(
InvalidArgument("page_size must be between 1 and 1000.")
)
assert message == "page_size must be between 1 and 1000."
def test_a_pydantic_rejection_names_the_field_without_echoing_the_value():
"""Pydantic quotes `input_value` back, and these tools take credentials."""
class Credentials(BaseModel):
api_token: int
with pytest.raises(ValidationError) as raised:
Credentials(api_token="hunter2-the-real-secret")
message = _describe_failure(raised.value)
assert "api_token" in message
assert "hunter2-the-real-secret" not in message
def test_unparseable_json_is_reported_without_quoting_the_input():
"""`JSONDecodeError` is a ValueError whose message quotes what it was given."""
with pytest.raises(json.JSONDecodeError) as raised:
json.loads('{"api_token": "hunter2-the-real-secret"')
message = _describe_failure(raised.value)
assert "could not be parsed" in message
assert "hunter2" not in message
def test_an_unrecognised_failure_is_left_masked():
"""Saying nothing is the safe default; the alternative is relaying anything."""
assert (
_describe_failure(RuntimeError("connection pool exhausted at 10.0.0.4:5432"))
is None
)
# --------------------------------------------------------- through the server
async def test_a_failing_tool_answers_with_is_error_not_a_result(
mcp_root_server, mock_api_client, mock_router
):
"""An error dict would arrive as `isError: false` and read as a success."""
mock_router.add("GET", LATEST, status=403, json=jsonapi_error(403, "Denied."))
async with Client(mcp_root_server) as client:
result = await client.call_tool_mcp("prowler_search_security_findings", {})
assert result.isError is True
assert result.structuredContent is None
assert "prowler_get_current_user" in result.content[0].text
async def test_an_upstream_body_never_reaches_the_agent(
mcp_root_server, mock_api_client, mock_router
):
"""A body this server did not write is logged and replaced, never relayed."""
mock_router.add(
"GET",
LATEST,
status=500,
text="Traceback: psycopg2 could not connect to internal-db:5432",
)
async with Client(mcp_root_server) as client:
result = await client.call_tool_mcp("prowler_search_security_findings", {})
assert result.isError is True
assert "internal-db" not in result.content[0].text
assert "failed on Prowler's side" in result.content[0].text
async def test_a_bad_argument_is_rejected_before_any_request_goes_out(
mcp_root_server, mock_api_client, mock_router
):
"""Local validation saves a round trip, and its message is safe to repeat."""
async with Client(mcp_root_server) as client:
result = await client.call_tool_mcp(
"prowler_search_security_findings", {"page_size": 5000}
)
assert result.isError is True
assert "Must be between 1 and 1000" in result.content[0].text
assert mock_router.requests == []
async def test_an_unreadable_api_answer_does_not_reach_the_agent_as_a_bad_argument(
mcp_root_server, mock_api_client, mock_router
):
"""Told apart by type, so the agent is not sent to fix an argument that is fine."""
mock_router.add("GET", LATEST, text="<html>gateway timeout</html>")
async with Client(mcp_root_server) as client:
result = await client.call_tool_mcp("prowler_search_security_findings", {})
assert result.isError is True
assert "gateway timeout" not in result.content[0].text
assert "argument" not in result.content[0].text
@@ -7,9 +7,19 @@ query encoding and header assembly stay covered.
import httpx
import pytest
from fastmcp.exceptions import ToolError
from prowler_mcp_server.prowler_app.utils.api_client import ProwlerAPIError
from tests.helpers.jsonapi import jsonapi_collection, jsonapi_error, jsonapi_resource
from prowler_mcp_server.prowler_app.utils.api_client import (
ProwlerAPIError,
ProwlerAPIInvalidResponse,
ProwlerAPIUnreachable,
)
from tests.helpers.jsonapi import (
jsonapi_collection,
jsonapi_error,
jsonapi_resource,
task_document,
)
from tests.helpers.tokens import FAKE_API_KEY
@@ -46,11 +56,7 @@ async def test_get_forwards_query_parameters(mock_api_client, mock_router):
async def test_error_response_surfaces_the_jsonapi_detail(mock_api_client, mock_router):
"""A failed request is raised with the API's own `errors[].detail` message.
Tools relay this text straight to the model, so losing it turns an actionable
error into an opaque one.
"""
"""A failed request carries the API's own `errors[].detail`."""
mock_router.add(
"GET",
"/api/v1/findings/nope",
@@ -64,26 +70,112 @@ async def test_error_response_surfaces_the_jsonapi_detail(mock_api_client, mock_
await mock_api_client.get("/findings/nope")
assert raised.value.status_code == 404
assert raised.value.detail == "Not found."
async def test_a_body_that_is_not_jsonapi_is_never_repeated(
mock_api_client, mock_router
):
"""A body that is not JSON:API leaves `detail` unset, so nothing is relayed."""
mock_router.add(
"GET",
"/api/v1/findings",
status=502,
text="<html><body>Traceback: secret-internal-host:5432</body></html>",
)
with pytest.raises(ProwlerAPIError) as raised:
await mock_api_client.get("/findings")
assert raised.value.detail is None
assert "secret-internal-host" not in str(raised.value)
async def test_a_server_error_detail_never_reaches_the_exception_text(
mock_api_client, mock_router
):
"""On a 5xx `errors[].detail` carries the failure, not a reason for a caller.
Tools that answer with `str(exc)` bypass the shared classifier, so the check
is on the exception itself rather than on the message the classifier builds.
"""
mock_router.add(
"GET",
"/api/v1/findings",
status=500,
json=jsonapi_error(
500, "OperationalError: could not connect to secret-internal-host:5432"
),
)
with pytest.raises(ProwlerAPIError) as raised:
await mock_api_client.get("/findings")
assert raised.value.detail is None
assert "secret-internal-host" not in str(raised.value)
async def test_an_unreadable_body_is_not_an_argument_failure(
mock_api_client, mock_router
):
"""A `JSONDecodeError` here is the API's doing, and must not read as ours."""
mock_router.add("GET", "/api/v1/findings", text="<html>gateway timeout</html>")
with pytest.raises(ProwlerAPIInvalidResponse) as raised:
await mock_api_client.get("/findings")
assert not isinstance(raised.value, ValueError)
assert "gateway timeout" not in str(raised.value)
async def test_a_mutation_with_an_unreadable_answer_still_raises(
mock_api_client, mock_router
):
"""`post` shares the parse, so a write cannot answer with an unread body."""
mock_router.add(
"POST", "/api/v1/providers", status=201, text="<html>accepted</html>"
)
with pytest.raises(ProwlerAPIInvalidResponse):
await mock_api_client.post("/providers", json_data={"data": {}})
async def test_a_request_that_got_no_answer_is_not_an_api_error(
mock_api_client, mock_router
):
"""`ProwlerAPIError` means the API answered, and callers act on that.
A write tool tells a rejected request -- which changed nothing -- from one
that may have been processed by the type of the failure, so a timeout must
not be dressed up as a rejection.
"""
"""`ProwlerAPIError` means the API answered, so a timeout must not use it."""
def timed_out(request):
raise httpx.ReadTimeout("Timed out reading the response", request=request)
mock_router.add_handler("GET", "/api/v1/findings", timed_out)
with pytest.raises(httpx.ReadTimeout):
with pytest.raises(ProwlerAPIUnreachable) as raised:
await mock_api_client.get("/findings")
assert not isinstance(raised.value, ProwlerAPIError)
async def test_a_failed_task_does_not_relay_its_own_error_text(
mock_api_client, mock_router
):
"""A failed task's error is an upstream body, so polling must not repeat it."""
mock_router.add(
"GET",
"/api/v1/tasks/t1",
json=task_document(
"t1",
"failed",
error="Traceback: connection to secret-internal-host:5432 refused",
),
)
with pytest.raises(ToolError) as raised:
await mock_api_client.poll_task_until_complete(task_id="t1", timeout=5)
assert "secret-internal-host" not in str(raised.value)
assert "t1" in str(raised.value)
def test_build_filter_params_normalises_types_for_the_api(mock_api_client):
"""Booleans become lowercase strings, sequences become CSV, `None` is dropped."""
@@ -0,0 +1,109 @@
"""Tests for the Prowler documentation search tool.
Mintlify moved the docs search to a new endpoint that answers with page
sections, so a result is a part of a page and has to read as one.
"""
import json
from fastmcp import Client
SEARCH = "/api/search/prowler"
def search_match(
path: str = "getting-started/installation",
*,
header: str = "Requirements",
breadcrumbs: tuple[str, ...] = ("Get Started", "Installation"),
anchor: str | None = "requirements",
score: float = 4.9,
):
"""One match as Mintlify answers with it: a section of a page, not the page."""
return {
"page": path,
"header": header,
"content": "Prowler runs on Python 3.9 or later.",
"metadata": {
"title": header,
"breadcrumbs": list(breadcrumbs),
"icon": "",
"hash": anchor,
"openapi": "",
},
"score": score,
}
def stub_search_hit(docs_router, *matches):
"""Serve the search endpoint, with one default match when none are given."""
if not matches:
matches = (search_match(),)
return docs_router.add("POST", SEARCH, json={"results": list(matches)})
async def test_search_returns_the_matching_sections(mcp_root_server, docs_router):
"""Every field of a result, since the shape of one changed with the endpoint."""
stub_search_hit(docs_router)
async with Client(mcp_root_server) as client:
result = await client.call_tool("prowler_docs_search", {"term": "install"})
match = result.data[0]
assert match["path"] == "getting-started/installation"
# The page's title, so a result reads as more than the heading it matched.
assert match["title"] == "Installation"
assert match["section"] == "Requirements"
assert match["breadcrumbs"] == ["Get Started", "Installation"]
assert match["excerpt"] == "Prowler runs on Python 3.9 or later."
assert match["score"] == 4.9
# Anchored: a match is a section, and the page it is on can be a long one.
assert match["url"] == (
"https://docs.prowler.com/getting-started/installation#requirements"
)
async def test_the_search_query_is_sent_as_the_api_expects_it(
mcp_root_server, docs_router
):
"""The endpoint takes a POST body, not the payload the old one took."""
stub_search_hit(docs_router)
async with Client(mcp_root_server) as client:
await client.call_tool("prowler_docs_search", {"term": "install"})
request = docs_router.request_for("POST", SEARCH)
assert json.loads(request.content) == {"query": "install", "filters": {}}
async def test_a_section_with_no_anchor_links_to_the_page(mcp_root_server, docs_router):
"""The API sends "" for a page's first section and null for pages without anchors."""
stub_search_hit(
docs_router,
search_match(anchor=""),
search_match(path="getting-started/requirements", anchor=None),
)
async with Client(mcp_root_server) as client:
result = await client.call_tool("prowler_docs_search", {"term": "install"})
assert result.data[0]["url"] == (
"https://docs.prowler.com/getting-started/installation"
)
assert result.data[1]["url"] == (
"https://docs.prowler.com/getting-started/requirements"
)
async def test_page_size_caps_a_response_the_api_did_not_size(
mcp_root_server, docs_router
):
"""The endpoint takes no size argument, so the cap has to be applied here."""
stub_search_hit(docs_router, *(search_match() for _ in range(6)))
async with Client(mcp_root_server) as client:
result = await client.call_tool(
"prowler_docs_search", {"term": "install", "page_size": 2}
)
assert len(result.data) == 2
+37
View File
@@ -4,6 +4,43 @@ All notable changes to the **Prowler SDK** are documented in this file.
<!-- changelog: release notes start -->
## [5.40.0] (Prowler v5.40.0)
### 🚀 Added
- NCSC Cyber Essentials 3.3 compliance framework with Azure provider coverage across the five Cyber Essentials themes [(#11588)](https://github.com/prowler-cloud/prowler/pull/11588)
- `oss_bucket_versioning_enabled` check for Alibaba Cloud provider, verifying that OSS buckets have versioning enabled to allow recovery from accidental or malicious object overwrite and deletion [(#11913)](https://github.com/prowler-cloud/prowler/pull/11913)
- `defender_domain_dmarc_records_published` checks that every Exchange Online domain publishes a DMARC record with an enforcing policy (`p=quarantine` or `p=reject`) [(#11936)](https://github.com/prowler-cloud/prowler/pull/11936)
- `ske_cluster_no_public_endpoint` check for STACKIT provider, flagging SKE clusters whose Kubernetes API endpoint is reachable from the whole internet because the ACL extension is disabled or its allowed CIDR list contains `0.0.0.0/0` or `::/0` [(#11943)](https://github.com/prowler-cloud/prowler/pull/11943)
- `oss_bucket_server_side_encryption_enabled` check for Alibaba Cloud provider, verifying that OSS buckets have a default server-side encryption rule (AES256 or KMS) [(#11981)](https://github.com/prowler-cloud/prowler/pull/11981)
- `organization_default_workflow_permissions_read_only` check for GitHub provider, verifying that organizations grant GitHub Actions workflows a read-only default `GITHUB_TOKEN` [(#12122)](https://github.com/prowler-cloud/prowler/pull/12122)
- `ecr_repository_image_no_secrets` check for AWS provider, scanning the latest ECR repository image's configuration and filesystem layers for hardcoded secrets [(#12123)](https://github.com/prowler-cloud/prowler/pull/12123)
- `repository_default_workflow_permissions_read_only` check for GitHub provider, verifying that repositories grant GitHub Actions workflows a read-only default `GITHUB_TOKEN` [(#12143)](https://github.com/prowler-cloud/prowler/pull/12143)
- `vpc_security_group_open_egress` check for Huawei Cloud provider: VPC security groups do not allow open egress to the internet [(#12209)](https://github.com/prowler-cloud/prowler/pull/12209)
- `organization_actions_pull_request_approval_disabled` check for GitHub provider, verifying that organizations prevent GitHub Actions from creating and approving pull requests [(#12394)](https://github.com/prowler-cloud/prowler/pull/12394)
- Add the `iam_workload_identity_pool_provider_attribute_condition` check to flag GCP Workload Identity Federation providers that trust a multi-tenant issuer without an attribute condition restricting which external identities can impersonate federated principals [(#12416)](https://github.com/prowler-cloud/prowler/pull/12416)
- Add the `rolesanywhere_profile_restricts_session_permissions` check to flag AWS IAM Roles Anywhere profiles that reference an administrative role without scoping down the vended session with a session policy or managed policies [(#12416)](https://github.com/prowler-cloud/prowler/pull/12416)
- `bedrock_guardrail_contextual_grounding_filter_enabled`, `bedrock_custom_model_encrypted_with_cmk`, `bedrock_knowledge_base_encrypted_with_cmk` and `bedrock_agent_role_not_shared_across_agents` are four new AWS Bedrock checks covering guardrail contextual grounding, custom model encryption, knowledge-base data-source encryption, and non-shared agent execution roles. [(#12459)](https://github.com/prowler-cloud/prowler/pull/12459)
- `Cluster` column in Kubernetes CIS, ISO27001, Prowler ThreatScore, and universal compliance outputs, populated with the resolved cluster name so multi-cluster scans can be told apart in the output [(#12506)](https://github.com/prowler-cloud/prowler/pull/12506)
### 🐞 Fixed
- Kubernetes `kubelet` checks no longer disappear from the scan with `TypeError: 'NoneType' object is not iterable` when a `kubelet-config` ConfigMap is broken: one with malformed YAML is logged and skipped while the valid ones are still evaluated, one without kubelet data is evaluated with an empty configuration instead of crashing the checks, and the `apiserver`, `controllermanager`, `etcd` and `scheduler` pod gatherers now always return a list [(#12225)](https://github.com/prowler-cloud/prowler/pull/12225)
- IaC provider now raises typed `IacBaseException` errors (repository clone, Trivy missing, scan and output processing failures) instead of calling `sys.exit(1)`; the CLI still stops with the logged message, and API scans fail as regular task errors instead of a `SystemExit` escaping the worker [(#12227)](https://github.com/prowler-cloud/prowler/pull/12227)
- CLI Slack integration (`--slack`) no longer fails when a scan produces no findings: the pass and fail percentages are guarded against a `findings_count` of 0, which previously raised `ZeroDivisionError` and sent `blocks=None` to Slack instead of the summary [(#12229)](https://github.com/prowler-cloud/prowler/pull/12229)
- AWS FSBP compliance mapping for `IAM.9` and `EKS.1` referenced missing/renamed checks; both now point to their real, existing check IDs [(#12372)](https://github.com/prowler-cloud/prowler/pull/12372)
- `ec2_securitygroup_not_used` no longer reports a false positive for security groups attached only to an AWS Batch compute environment, which holds them in configuration without creating a network interface while scaled down to zero instances [(#12458)](https://github.com/prowler-cloud/prowler/pull/12458)
- Bedrock Agent ARNs are now built from the audited partition instead of a hardcoded `arn:aws:`, so findings in GovCloud and China carry a resolvable ARN and `--resource-arn` scoping matches agents in those partitions. [(#12459)](https://github.com/prowler-cloud/prowler/pull/12459)
- `push-to-cloud` now validates Private Cloud TLS certificates with the operating system trust store without changing provider HTTP clients [(#12485)](https://github.com/prowler-cloud/prowler/pull/12485)
- `prowler.compliance.universal` entry point directories are resolved through a single shared helper and deduplicated by resolved path, so a directory reached through two entry points is parsed once and a package that fails to import no longer hides the rest [(#12536)](https://github.com/prowler-cloud/prowler/pull/12536)
- OSS bucket logging, versioning, default encryption and ACL configurations are now read correctly from the Alibaba Cloud SDK, so `oss_bucket_logging_enabled`, `oss_bucket_versioning_enabled`, `oss_bucket_server_side_encryption_enabled` and `oss_bucket_not_publicly_accessible` no longer report every bucket as unconfigured [(#12546)](https://github.com/prowler-cloud/prowler/pull/12546)
### 🔐 Security
- `openssl`, `libssl3t64` and `openssl-provider-legacy` upgraded to 3.5.7-1~deb13u2 in the SDK container image, patching ten high OpenSSL CVEs [(#12549)](https://github.com/prowler-cloud/prowler/pull/12549)
---
## [5.39.1] (Prowler v5.39.1)
### 🐞 Fixed
+85 -39
View File
@@ -126,7 +126,10 @@ from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_m365
from prowler.lib.outputs.csv.csv import CSV
from prowler.lib.outputs.finding import Finding
from prowler.lib.outputs.html.html import HTML
from prowler.lib.outputs.ocsf.ingestion import send_ocsf_to_api
from prowler.lib.outputs.ocsf.ingestion import (
SystemTrustStoreError,
send_ocsf_to_api,
)
from prowler.lib.outputs.ocsf.ocsf import OCSF
from prowler.lib.outputs.outputs import extract_findings_statistics, report
from prowler.lib.outputs.sarif.sarif import SARIF
@@ -145,6 +148,7 @@ from prowler.providers.gcp.models import GCPOutputOptions
from prowler.providers.github.models import GithubOutputOptions
from prowler.providers.googleworkspace.models import GoogleWorkspaceOutputOptions
from prowler.providers.huaweicloud.models import HuaweiCloudOutputOptions
from prowler.providers.iac.exceptions.exceptions import IacBaseException
from prowler.providers.iac.models import IACOutputOptions
from prowler.providers.image.exceptions.exceptions import ImageBaseException
from prowler.providers.image.models import ImageOutputOptions
@@ -162,6 +166,75 @@ from prowler.providers.stackit.models import StackITOutputOptions
from prowler.providers.vercel.models import VercelOutputOptions
def _send_ocsf_to_cloud(file_path: str) -> dict | None:
"""Upload OCSF findings and report safe, actionable CLI failures."""
try:
return send_ocsf_to_api(file_path)
except requests.exceptions.JSONDecodeError:
print(
f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: "
"the API returned an invalid JSON response. "
f"Scan results were saved to {file_path}{Style.RESET_ALL}"
)
except ValueError:
print(
f"{Style.BRIGHT}{Fore.YELLOW}\nPush to Prowler Cloud skipped: no API key configured. "
"Set the PROWLER_CLOUD_API_KEY environment variable to enable it. "
f"Scan results were saved to {file_path}{Style.RESET_ALL}"
)
except SystemTrustStoreError:
print(
f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: the operating system trust store "
"could not be initialized. Check the configured CA bundle paths, verify the host certificate "
"hostname, validity period, and certificate chain, and install the organization or TLS-intercepting "
"proxy CA in the operating system trust store. In containers, configure and update the container "
"system CA store. "
f"Scan results were saved to {file_path}{Style.RESET_ALL}"
)
except requests.exceptions.SSLError:
print(
f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: TLS certificate validation failed. "
"Verify the hostname, validity period, and certificate chain, and install the organization or "
"TLS-intercepting proxy CA in the operating system trust store. In containers, configure and update "
"the container system CA store. "
f"Scan results were saved to {file_path}{Style.RESET_ALL}"
)
except requests.ConnectionError:
print(
f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: could not reach the Prowler Cloud API at "
f"{cloud_api_base_url}. Check the URL and your network connection. "
f"Scan results were saved to {file_path}{Style.RESET_ALL}"
)
except requests.HTTPError as http_err:
status_code = (
http_err.response.status_code if http_err.response is not None else None
)
if status_code == 402:
print(
f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: "
"this feature is only available with a Prowler Cloud subscription. "
f"Scan results were saved to {file_path}{Style.RESET_ALL}"
)
elif status_code is None:
print(
f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: "
"the API request failed without a response status. "
f"Scan results were saved to {file_path}{Style.RESET_ALL}"
)
else:
print(
f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: the API returned HTTP "
f"{status_code}. Verify your API key is valid and has the right permissions. "
f"Scan results were saved to {file_path}{Style.RESET_ALL}"
)
except Exception:
print(
f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed unexpectedly. "
f"Scan results were saved to {file_path}{Style.RESET_ALL}"
)
return None
def prowler():
# Parse Arguments
# Refactor(CLI)
@@ -493,12 +566,16 @@ def prowler():
except ImageBaseException as error:
logger.critical(f"{error}")
sys.exit(1)
elif provider == "iac":
try:
findings = global_provider.run()
except IacBaseException as error:
logger.critical(f"{error}")
sys.exit(1)
else:
# IAC and external tool-wrapper providers registered via entry
# points. Unexpected failures propagate to the outer except
# Exception backstop further down in this file — keeping the
# branch free of an Image-specific catch that would otherwise
# mislead plug-in authors reading this code.
# External tool-wrapper providers registered via entry points.
# Unexpected failures propagate to the outer except Exception
# backstop further down in this file.
findings = global_provider.run()
# Note: External tool providers don't support granular progress tracking since
# they run external tools as a black box and return all findings at once.
@@ -641,39 +718,8 @@ def prowler():
print(
f"{Style.BRIGHT}\nPushing findings to Prowler Cloud, please wait...{Style.RESET_ALL}"
)
try:
response = send_ocsf_to_api(ocsf_output.file_path)
except ValueError:
print(
f"{Style.BRIGHT}{Fore.YELLOW}\nPush to Prowler Cloud skipped: no API key configured. "
"Set the PROWLER_CLOUD_API_KEY environment variable to enable it. "
f"Scan results were saved to {ocsf_output.file_path}{Style.RESET_ALL}"
)
except requests.ConnectionError:
print(
f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: could not reach the Prowler Cloud API at "
f"{cloud_api_base_url}. Check the URL and your network connection. "
f"Scan results were saved to {ocsf_output.file_path}{Style.RESET_ALL}"
)
except requests.HTTPError as http_err:
if http_err.response.status_code == 402:
print(
f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: "
"this feature is only available with a Prowler Cloud subscription. "
f"Scan results were saved to {ocsf_output.file_path}{Style.RESET_ALL}"
)
else:
print(
f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: the API returned HTTP {http_err.response.status_code}. "
"Verify your API key is valid and has the right permissions. "
f"Scan results were saved to {ocsf_output.file_path}{Style.RESET_ALL}"
)
except Exception as error:
print(
f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed unexpectedly: {error}. "
f"Scan results were saved to {ocsf_output.file_path}{Style.RESET_ALL}"
)
else:
response = _send_ocsf_to_cloud(ocsf_output.file_path)
if response is not None:
job_id = response.get("data", {}).get("id") if response else None
if job_id:
print(
@@ -1 +0,0 @@
Bedrock Agent ARNs are now built from the audited partition instead of a hardcoded `arn:aws:`, so findings in GovCloud and China carry a resolvable ARN and `--resource-arn` scoping matches agents in those partitions.
@@ -1 +0,0 @@
`bedrock_guardrail_contextual_grounding_filter_enabled`, `bedrock_custom_model_encrypted_with_cmk`, `bedrock_knowledge_base_encrypted_with_cmk` and `bedrock_agent_role_not_shared_across_agents` are four new AWS Bedrock checks covering guardrail contextual grounding, custom model encryption, knowledge-base data-source encryption, and non-shared agent execution roles.
@@ -1 +0,0 @@
`ecr_repository_image_no_secrets` check for AWS provider, scanning the latest ECR repository image's configuration and filesystem layers for hardcoded secrets
@@ -1 +0,0 @@
Add the `iam_workload_identity_pool_provider_attribute_condition` check to flag GCP Workload Identity Federation providers that trust a multi-tenant issuer without an attribute condition restricting which external identities can impersonate federated principals
@@ -0,0 +1 @@
GitHub repository discovery for unscoped scans now paginates beyond the first 100 accessible repositories instead of silently scanning only the first page
@@ -1 +0,0 @@
Add the `rolesanywhere_profile_restricts_session_permissions` check to flag AWS IAM Roles Anywhere profiles that reference an administrative role without scoping down the vended session with a session policy or managed policies
@@ -1 +0,0 @@
`ske_cluster_no_public_endpoint` check for STACKIT provider, flagging SKE clusters whose Kubernetes API endpoint is reachable from the whole internet because the ACL extension is disabled or its allowed CIDR list contains `0.0.0.0/0` or `::/0`
@@ -1 +0,0 @@
`vpc_security_group_open_egress` check for Huawei Cloud provider: VPC security groups do not allow open egress to the internet
@@ -1782,7 +1782,7 @@
"Name": "EKS cluster endpoints should not be publicly accessible",
"Description": "This control checks whether an Amazon EKS cluster endpoint is publicly accessible. The control fails if an EKS cluster has an endpoint that is publicly accessible.",
"Checks": [
"eks_endpoints_not_publicly_accessible"
"eks_cluster_not_publicly_accessible"
],
"Attributes": [
{
@@ -2634,7 +2634,9 @@
"Id": "IAM.9",
"Name": "MFA should be enabled for the root user",
"Description": "The root user has complete access to all the services and resources in an AWS account. MFA adds an extra layer of protection on top of a user name and password. With MFA enabled, when a user signs in to the AWS Management Console, they're prompted for their user name and password and for an authentication code from their AWS MFA device.",
"Checks": [],
"Checks": [
"iam_root_mfa_enabled"
],
"Attributes": [
{
"ItemId": "IAM.9",
@@ -0,0 +1,601 @@
{
"framework": "Cyber-Essentials",
"name": "NCSC Cyber Essentials: Requirements for IT Infrastructure",
"version": "3.3",
"description": "The UK National Cyber Security Centre (NCSC) Cyber Essentials scheme - Requirements for IT Infrastructure v3.3 (April 2026). Cyber Essentials organises its technical requirements into five control themes: Firewalls, Secure Configuration, Security Update Management, User Access Control, and Malware Protection. Cloud services are explicitly in scope for Cyber Essentials and cannot be excluded, so under the shared responsibility model this framework covers the controls the applicant organisation is responsible for implementing on its own cloud infrastructure (Infrastructure/Platform as a Service). Requirements that apply to end-user devices, on-premises network appliances, or organisational process and are not observable from cloud control-plane evidence are included for completeness with an empty check list and 'non-applicable' or 'Manual' attributes.",
"icon": "cyber-essentials",
"attributes_metadata": [
{
"key": "Theme",
"label": "Technical Control Theme",
"type": "str",
"required": true,
"enum": [
"Firewalls",
"Secure Configuration",
"Security Update Management",
"User Access Control",
"Malware Protection"
],
"output_formats": {
"csv": true,
"ocsf": true
}
},
{
"key": "AssessmentStatus",
"label": "Assessment Status",
"type": "str",
"required": true,
"enum": [
"Automated",
"Manual"
],
"output_formats": {
"csv": true,
"ocsf": true
}
},
{
"key": "CloudApplicability",
"label": "Cloud Applicability",
"type": "str",
"required": true,
"enum": [
"full",
"partial",
"non-applicable"
],
"output_formats": {
"csv": true,
"ocsf": true
}
},
{
"key": "RemediationProcedure",
"label": "Remediation Procedure",
"type": "str",
"required": true,
"output_formats": {
"csv": true,
"ocsf": false
}
},
{
"key": "References",
"label": "References",
"type": "str",
"required": true,
"output_formats": {
"csv": true,
"ocsf": false
}
}
],
"outputs": {
"table_config": {
"group_by": "Theme"
},
"pdf_config": {
"language": "en",
"primary_color": "#003D54",
"secondary_color": "#0072CE",
"bg_color": "#F0F4FA",
"group_by_field": "Theme",
"sections": [
"Firewalls",
"Secure Configuration",
"Security Update Management",
"User Access Control",
"Malware Protection"
],
"section_short_names": {
"Firewalls": "Firewalls",
"Secure Configuration": "Secure Config",
"Security Update Management": "Update Mgmt",
"User Access Control": "Access Control",
"Malware Protection": "Malware"
},
"charts": [
{
"id": "theme_compliance",
"type": "horizontal_bar",
"group_by": "Theme",
"title": "Compliance Score by Cyber Essentials Theme",
"y_label": "Theme",
"x_label": "Compliance %",
"value_source": "compliance_percent",
"color_mode": "by_value"
}
],
"filter": {
"only_failed": true,
"include_manual": false
}
}
},
"requirements": [
{
"id": "CE-FW-01",
"name": "Boundary firewall on every in-scope device",
"description": "Every device in scope must be protected by a correctly configured firewall or network device with firewall functionality, restricting inbound and outbound network services to those that are secure and necessary.",
"attributes": {
"Theme": "Firewalls",
"AssessmentStatus": "Automated",
"CloudApplicability": "partial",
"RemediationProcedure": "Restrict inbound access from the internet to management and data services (RDP, SSH, database ports) using the provider's network firewall, security group or access-control-list controls, and remove any rule that allows unrestricted inbound access.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": [
"network_rdp_internet_access_restricted",
"network_ssh_internet_access_restricted",
"network_udp_internet_access_restricted",
"network_http_internet_access_restricted"
]
}
},
{
"id": "CE-FW-02",
"name": "Change default administrative passwords or disable remote admin access",
"description": "Default administrative passwords on firewalls and network devices must be changed to a strong, unique password, or remote administrative access must be disabled entirely.",
"attributes": {
"Theme": "Firewalls",
"AssessmentStatus": "Manual",
"CloudApplicability": "non-applicable",
"RemediationProcedure": "This requirement applies to on-premises network appliances and home/remote routers, which are outside the scope of cloud control-plane evidence. Manage and document this control as part of your organisation's device estate.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": []
}
},
{
"id": "CE-FW-03",
"name": "Restrict access to firewall/network device administrative interfaces",
"description": "Access to the administrative interface used to manage firewall or network device configuration must not be possible from the internet, unless there is a documented business need and the interface is protected by MFA or an IP allow list combined with a managed password approach.",
"attributes": {
"Theme": "Firewalls",
"AssessmentStatus": "Automated",
"CloudApplicability": "partial",
"RemediationProcedure": "Disable public network access on management-plane resources (object storage, secret and key management services) or restrict access to trusted networks and IP ranges, and require MFA for any administrative access exposed to the internet.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": [
"storage_account_public_network_access_disabled",
"storage_default_network_access_rule_is_denied",
"keyvault_access_only_through_private_endpoints",
"network_rdp_internet_access_restricted",
"network_ssh_internet_access_restricted"
]
}
},
{
"id": "CE-FW-04",
"name": "Block unauthenticated inbound connections by default",
"description": "Firewalls and network devices must block unauthenticated inbound connections by default.",
"attributes": {
"Theme": "Firewalls",
"AssessmentStatus": "Automated",
"CloudApplicability": "full",
"RemediationProcedure": "Configure network access-control rules with a default-deny inbound posture and only allow specific, documented inbound services. Disable public network access on managed services that do not require it.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": [
"network_rdp_internet_access_restricted",
"network_ssh_internet_access_restricted",
"network_udp_internet_access_restricted",
"network_http_internet_access_restricted",
"storage_account_public_network_access_disabled"
]
}
},
{
"id": "CE-FW-05",
"name": "Inbound firewall rules approved and documented",
"description": "Inbound firewall rules must be approved and documented by an authorised person, including the business need for the rule.",
"attributes": {
"Theme": "Firewalls",
"AssessmentStatus": "Manual",
"CloudApplicability": "non-applicable",
"RemediationProcedure": "Maintain a change-approval record (e.g. change tickets or a network rule register) for every inbound network access-control rule, including the business justification and approver.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": []
}
},
{
"id": "CE-FW-06",
"name": "Remove or disable unnecessary firewall rules",
"description": "Firewall rules that are no longer needed must be removed or disabled.",
"attributes": {
"Theme": "Firewalls",
"AssessmentStatus": "Manual",
"CloudApplicability": "non-applicable",
"RemediationProcedure": "Periodically review firewall and network access-control rules, removing or disabling any rule that no longer has a documented business need.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": []
}
},
{
"id": "CE-FW-07",
"name": "Software firewall on devices used on untrusted networks",
"description": "Devices that connect to untrusted networks, such as public Wi-Fi hotspots, must use a software firewall.",
"attributes": {
"Theme": "Firewalls",
"AssessmentStatus": "Manual",
"CloudApplicability": "non-applicable",
"RemediationProcedure": "This is an end-user device control (host-based firewall) and has no cloud control-plane equivalent. Enforce via endpoint management policy.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": []
}
},
{
"id": "CE-SC-01",
"name": "Remove or disable unnecessary user accounts",
"description": "Unnecessary user accounts, such as guest accounts and unused administrative accounts, must be removed or disabled.",
"attributes": {
"Theme": "Secure Configuration",
"AssessmentStatus": "Automated",
"CloudApplicability": "partial",
"RemediationProcedure": "Restrict guest and external user invitations and access, and review identity-provider and cloud role assignments to remove unused guest or administrative accounts.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": [
"entra_policy_guest_users_access_restrictions",
"entra_policy_guest_invite_only_for_admin_roles"
]
}
},
{
"id": "CE-SC-02",
"name": "Change default or guessable account passwords",
"description": "Default or guessable account passwords must be changed before a device or service is used.",
"attributes": {
"Theme": "Secure Configuration",
"AssessmentStatus": "Automated",
"CloudApplicability": "partial",
"RemediationProcedure": "Enable identity-provider security defaults (or an equivalent sign-in protection baseline) so that default or weak credentials cannot be used for sign-in.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": [
"entra_security_defaults_enabled"
]
}
},
{
"id": "CE-SC-03",
"name": "Remove or disable unnecessary software",
"description": "Unnecessary software, including applications, system utilities and network services, must be removed or disabled.",
"attributes": {
"Theme": "Secure Configuration",
"AssessmentStatus": "Manual",
"CloudApplicability": "non-applicable",
"RemediationProcedure": "This is an operating-system level control for devices and servers and has no direct cloud control-plane equivalent. Maintain an approved software baseline and image hardening process for VM images.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": []
}
},
{
"id": "CE-SC-04",
"name": "Disable auto-run features",
"description": "Auto-run features that allow file execution without user authorisation must be disabled.",
"attributes": {
"Theme": "Secure Configuration",
"AssessmentStatus": "Manual",
"CloudApplicability": "non-applicable",
"RemediationProcedure": "This is an operating-system level control and has no cloud control-plane equivalent. Disable AutoRun/AutoPlay via OS configuration or group policy on VM images.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": []
}
},
{
"id": "CE-SC-05",
"name": "Authenticate users before granting access to organisational data or services",
"description": "Users must be authenticated before being allowed access to organisational data or services, including cloud services.",
"attributes": {
"Theme": "Secure Configuration",
"AssessmentStatus": "Automated",
"CloudApplicability": "full",
"RemediationProcedure": "Disable anonymous or public access to object storage and require authenticated, encrypted (TLS 1.2+) access. Use role-based access control for secret and key management data-plane access instead of policies that allow unauthenticated retrieval.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": [
"storage_secure_transfer_required_is_enabled",
"storage_blob_public_access_level_is_disabled",
"storage_account_public_network_access_disabled",
"storage_ensure_minimum_tls_version_12",
"keyvault_rbac_enabled"
]
}
},
{
"id": "CE-SC-06",
"name": "Device locking and brute-force protection for unlocking credentials",
"description": "Devices that require a user's physical presence must use an unlocking credential (biometric, password or PIN) of at least 6 characters, protected against brute-force guessing by throttling or lockout after no more than 10 attempts.",
"attributes": {
"Theme": "Secure Configuration",
"AssessmentStatus": "Manual",
"CloudApplicability": "non-applicable",
"RemediationProcedure": "This is an end-user device control (screen lock credential length and brute-force lockout) and has no cloud control-plane equivalent. Enforce a minimum unlock credential length and a lockout threshold of no more than 10 attempts through your device management policy.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": []
}
},
{
"id": "CE-SUM-01",
"name": "All software licensed and supported",
"description": "All software on in-scope devices must be licensed and supported by the vendor.",
"attributes": {
"Theme": "Security Update Management",
"AssessmentStatus": "Manual",
"CloudApplicability": "non-applicable",
"RemediationProcedure": "Maintain a software asset inventory confirming each product is licensed and has an active vendor support commitment, including operating systems and any third-party software running on cloud compute resources.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": []
}
},
{
"id": "CE-SUM-02",
"name": "Remove unsupported software",
"description": "Software must be removed from devices when it becomes unsupported, or isolated into a defined sub-set that prevents all internet traffic.",
"attributes": {
"Theme": "Security Update Management",
"AssessmentStatus": "Manual",
"CloudApplicability": "non-applicable",
"RemediationProcedure": "Track vendor end-of-support dates for operating systems and applications running on cloud compute resources, and decommission or isolate workloads before support ends.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": []
}
},
{
"id": "CE-SUM-03",
"name": "Automatic updates enabled where possible",
"description": "Automatic updates must be enabled on in-scope software where this is possible.",
"attributes": {
"Theme": "Security Update Management",
"AssessmentStatus": "Manual",
"CloudApplicability": "partial",
"RemediationProcedure": "Enable automatic updates on in-scope software where possible: configure platform-managed guest patching on cloud virtual machines and instance groups, and enable vendor auto-update mechanisms for any third-party software running on those workloads. Note: no check currently observes whether automatic updates are enabled (vulnerability assessment and security-posture provisioning only prove monitoring and assessment coverage), so this requirement must be verified manually until a dedicated check validating update automation exists.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": []
}
},
{
"id": "CE-SUM-04",
"name": "Critical and high-risk updates applied within 14 days",
"description": "Updates that fix vulnerabilities described by the vendor as critical or high risk, or that address a CVSS v3 base score of 7 or above (or where severity is unspecified), must be applied within 14 days of release.",
"attributes": {
"Theme": "Security Update Management",
"AssessmentStatus": "Automated",
"CloudApplicability": "partial",
"RemediationProcedure": "Enable the provider's security-posture service for system update recommendations and vulnerability assessment, and remediate flagged virtual machines within 14 days of a critical or high-risk update being released. Note: these checks evidence that update monitoring and vulnerability assessment coverage is enabled, not that a given update was applied within the 14-day window, which must be verified from your patch management records.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": [
"defender_ensure_system_updates_are_applied",
"defender_auto_provisioning_vulnerabilty_assessments_machines_on"
]
}
},
{
"id": "CE-UAC-01",
"name": "Process to create and approve user accounts",
"description": "A documented process must be in place to create and approve user accounts before access is granted.",
"attributes": {
"Theme": "User Access Control",
"AssessmentStatus": "Manual",
"CloudApplicability": "non-applicable",
"RemediationProcedure": "Maintain a joiner/mover/leaver process with documented approval steps for creating identity-provider accounts and assigning cloud roles.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": []
}
},
{
"id": "CE-UAC-02",
"name": "Authenticate users with unique credentials",
"description": "Users must be authenticated with unique credentials before being granted access to applications or devices.",
"attributes": {
"Theme": "User Access Control",
"AssessmentStatus": "Automated",
"CloudApplicability": "partial",
"RemediationProcedure": "Enable identity-provider security defaults and require every user to authenticate with their own directory-backed identity rather than shared account keys or long-lived access keys when accessing cloud resources such as object storage.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": [
"entra_security_defaults_enabled",
"storage_default_to_entra_authorization_enabled"
]
}
},
{
"id": "CE-UAC-03",
"name": "Remove or disable user accounts when no longer required",
"description": "User accounts must be removed or disabled when they are no longer required, for example when a user leaves the organisation or after a defined period of inactivity.",
"attributes": {
"Theme": "User Access Control",
"AssessmentStatus": "Manual",
"CloudApplicability": "non-applicable",
"RemediationProcedure": "Implement a leaver process and periodic identity-provider access reviews to disable or remove accounts that are no longer required.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": []
}
},
{
"id": "CE-UAC-04",
"name": "Multi-factor authentication for cloud services",
"description": "Multi-factor authentication must be implemented where available, and authentication to cloud services must always use MFA.",
"attributes": {
"Theme": "User Access Control",
"AssessmentStatus": "Automated",
"CloudApplicability": "full",
"RemediationProcedure": "Require multi-factor authentication for all users through an enforced sign-in policy, covering administrative consoles, management APIs, and users with access to virtual machines.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": [
"entra_privileged_user_has_mfa",
"entra_non_privileged_user_has_mfa",
"entra_conditional_access_policy_require_mfa_for_admin_portals",
"entra_conditional_access_policy_require_mfa_for_management_api",
"entra_user_with_vm_access_has_mfa"
]
}
},
{
"id": "CE-UAC-05",
"name": "Separate accounts for administrative activities",
"description": "Separate accounts must be used to perform administrative activities only, with no email, web browsing or other standard user activity that could expose administrative privileges to avoidable risk.",
"attributes": {
"Theme": "User Access Control",
"AssessmentStatus": "Automated",
"CloudApplicability": "partial",
"RemediationProcedure": "Limit the number of highly privileged role assignments (global or organisation administrator), avoid granting owner or access-administrator roles at the account, subscription or project scope, and require named administrators to use dedicated privileged accounts for administrative tasks.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": [
"entra_global_admin_in_less_than_five_users",
"iam_role_user_access_admin_restricted",
"iam_subscription_roles_owner_custom_not_created"
]
}
},
{
"id": "CE-UAC-06",
"name": "Remove or disable special access privileges when no longer required",
"description": "Special access privileges must be removed or disabled when they are no longer required, for example when a member of staff changes role.",
"attributes": {
"Theme": "User Access Control",
"AssessmentStatus": "Automated",
"CloudApplicability": "partial",
"RemediationProcedure": "Periodically review identity-provider directory role assignments and cloud role-based access control assignments, removing privileged roles that are no longer needed for a user's current role.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": [
"entra_global_admin_in_less_than_five_users",
"iam_role_user_access_admin_restricted"
]
}
},
{
"id": "CE-UAC-07",
"name": "Protect password-based authentication against brute-force attacks",
"description": "Where authentication is carried out using a password, accounts must be protected against brute-force guessing using MFA, attempt throttling, or account lockout.",
"attributes": {
"Theme": "User Access Control",
"AssessmentStatus": "Automated",
"CloudApplicability": "partial",
"RemediationProcedure": "Enable identity-provider security defaults (which include account lockout on repeated failed sign-ins) and require MFA, particularly for privileged accounts, to mitigate brute-force password attacks.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": [
"entra_security_defaults_enabled",
"entra_privileged_user_has_mfa"
]
}
},
{
"id": "CE-UAC-08",
"name": "Technical controls to manage password quality",
"description": "Technical controls must be used to manage the quality of passwords, using MFA, a minimum password length of at least 12 characters with no maximum length, or a minimum of 8 characters combined with a common-password deny list. Regular forced password expiry and complexity requirements should not be enforced.",
"attributes": {
"Theme": "User Access Control",
"AssessmentStatus": "Automated",
"CloudApplicability": "partial",
"RemediationProcedure": "Enable identity-provider security defaults and password protection (banned or breached password lists), and require MFA so that password length alone is not the only protection.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": [
"entra_security_defaults_enabled",
"entra_privileged_user_has_mfa",
"entra_non_privileged_user_has_mfa"
]
}
},
{
"id": "CE-MP-01",
"name": "Malware protection mechanism active on all in-scope devices",
"description": "A malware protection mechanism must be active on all devices in scope, using anti-malware software, application allow listing, or application sandboxing.",
"attributes": {
"Theme": "Malware Protection",
"AssessmentStatus": "Automated",
"CloudApplicability": "partial",
"RemediationProcedure": "Ensure endpoint protection is installed on all virtual machines and enable the provider's workload protection service for servers, including endpoint detection and response integration.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": [
"defender_assessments_vm_endpoint_protection_installed",
"defender_ensure_wdatp_is_enabled",
"defender_ensure_defender_for_server_is_on"
]
}
},
{
"id": "CE-MP-02",
"name": "Anti-malware software configuration",
"description": "Anti-malware software must be kept up to date in line with vendor recommendations, prevent malware from running, prevent execution of malicious code, and prevent connections to malicious websites.",
"attributes": {
"Theme": "Malware Protection",
"AssessmentStatus": "Automated",
"CloudApplicability": "partial",
"RemediationProcedure": "Enable the provider's workload protection services for endpoints, servers and object storage so that signatures stay current and malicious files, code execution and connections are blocked.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": [
"defender_ensure_wdatp_is_enabled",
"defender_ensure_defender_for_server_is_on",
"defender_ensure_defender_for_storage_is_on"
]
}
},
{
"id": "CE-MP-03",
"name": "Application allow listing",
"description": "Where used as an alternative to anti-malware software, only approved applications restricted by code signing must be allowed to execute, with a maintained list of approved applications and no execution of unsigned or invalidly signed applications.",
"attributes": {
"Theme": "Malware Protection",
"AssessmentStatus": "Manual",
"CloudApplicability": "non-applicable",
"RemediationProcedure": "This is an end-user device control implemented through application control or allow-listing policies and has no cloud control-plane equivalent.",
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": []
}
}
]
}
+3 -1
View File
@@ -526,7 +526,9 @@
{
"Id": "2.1.10",
"Description": "DMARC, or Domain-based Message Authentication, Reporting, and Conformance, assists recipient mail systems in determining the appropriate action to take when messages from a domain fail to meet SPF or DKIM authentication criteria.",
"Checks": [],
"Checks": [
"defender_domain_dmarc_records_published"
],
"Attributes": [
{
"Section": "2 Microsoft 365 Defender",
+3 -1
View File
@@ -543,7 +543,9 @@
{
"Id": "2.1.10",
"Description": "DMARC, or Domain-based Message Authentication, Reporting, and Conformance, assists recipient mail systems in determining the appropriate action to take when messages from a domain fail to meet SPF or DKIM authentication criteria.",
"Checks": [],
"Checks": [
"defender_domain_dmarc_records_published"
],
"Attributes": [
{
"Section": "2 Microsoft 365 Defender",
+3 -1
View File
@@ -554,7 +554,9 @@
{
"Id": "2.1.10",
"Description": "DMARC, or Domain-based Message Authentication, Reporting, and Conformance, assists recipient mail systems in determining the appropriate action to take when messages from a domain fail to meet SPF or DKIM authentication criteria.",
"Checks": [],
"Checks": [
"defender_domain_dmarc_records_published"
],
"Attributes": [
{
"Section": "2 Microsoft Defender",
+6 -17
View File
@@ -10,7 +10,10 @@ import requests
import yaml
from packaging import version
from prowler.lib.check.compliance_models import load_compliance_framework_universal
from prowler.lib.check.compliance_models import (
get_universal_compliance_entry_point_dirs,
load_compliance_framework_universal,
)
# Re-exported from a leaf module so prowler.lib.check.utils can import the
# constant without participating in the config <-> compliance_models <-> utils
@@ -49,7 +52,7 @@ class _MutableTimestamp:
timestamp = _MutableTimestamp(datetime.today())
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
prowler_version = "5.40.0"
prowler_version = "5.40.1"
html_logo_url = "https://github.com/prowler-cloud/prowler/"
square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png"
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
@@ -170,21 +173,7 @@ def get_available_compliance_frameworks(provider=None):
available_compliance_frameworks.append(name)
# External multi-provider frameworks via the dedicated universal group;
# filtered by supports_provider when a provider is given.
for ep in importlib.metadata.entry_points(group="prowler.compliance.universal"):
try:
module = ep.load()
path = (
module.__path__[0]
if hasattr(module, "__path__")
else os.path.dirname(module.__file__)
)
except Exception as error:
logger.warning(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
continue
if not os.path.isdir(path):
continue
for path in get_universal_compliance_entry_point_dirs():
for file in os.scandir(path):
if file.is_file() and file.name.endswith(".json"):
name = file.name.removesuffix(".json")
+43 -13
View File
@@ -1049,6 +1049,46 @@ def load_compliance_framework_universal(path: str) -> ComplianceFramework:
return None
# Kept apart from the per-provider `prowler.compliance` group so the legacy
# loader never parses a universal JSON.
UNIVERSAL_COMPLIANCE_ENTRY_POINT_GROUP = "prowler.compliance.universal"
def get_universal_compliance_entry_point_dirs() -> list[str]:
"""Existing directories contributed through the universal compliance entry
point group, in entry point order.
Deduped by resolved path, so a directory reached through a symlink counts
once. A package that fails to import is logged and skipped: one broken
plugin must not hide the rest.
"""
dirs = []
seen = set()
for ep in importlib.metadata.entry_points(
group=UNIVERSAL_COMPLIANCE_ENTRY_POINT_GROUP
):
try:
module = ep.load()
path = (
module.__path__[0]
if hasattr(module, "__path__")
else os.path.dirname(module.__file__)
)
except Exception as error:
logger.warning(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
continue
if not os.path.isdir(path):
continue
resolved = os.path.realpath(path)
if resolved in seen:
continue
seen.add(resolved)
dirs.append(path)
return dirs
def _load_jsons_from_dir(dir_path: str, provider: str, bulk: dict) -> None:
"""Scan *dir_path* for JSON files and add matching frameworks to *bulk*."""
for filename in os.listdir(dir_path):
@@ -1109,20 +1149,10 @@ def get_bulk_compliance_frameworks_universal(provider: str) -> dict:
if compliance_root and os.path.isdir(compliance_root):
_load_jsons_from_dir(compliance_root, provider, bulk)
# External multi-provider frameworks via the dedicated universal entry
# point group, kept separate from the per-provider `prowler.compliance`
# group so the legacy loader never parses a universal JSON. Built-ins
# (already in bulk) win on a name collision.
for ep in importlib.metadata.entry_points(group="prowler.compliance.universal"):
# Built-ins are already in `bulk` and win on a name collision.
for ep_dir in get_universal_compliance_entry_point_dirs():
try:
module = ep.load()
ep_dir = (
module.__path__[0]
if hasattr(module, "__path__")
else os.path.dirname(module.__file__)
)
if os.path.isdir(ep_dir):
_load_jsons_from_dir(ep_dir, provider, bulk)
_load_jsons_from_dir(ep_dir, provider, bulk)
except Exception as error:
logger.warning(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
@@ -55,6 +55,7 @@ class KubernetesCIS(ComplianceOutput):
Provider=finding.provider,
Description=compliance.Description,
Context=finding.account_name,
Cluster=finding.account_uid,
Namespace=finding.region,
AssessmentDate=str(timestamp),
Requirements_Id=requirement.Id,
@@ -89,6 +90,7 @@ class KubernetesCIS(ComplianceOutput):
Provider=compliance.Provider.lower(),
Description=compliance.Description,
Context="",
Cluster="",
Namespace="",
AssessmentDate=str(timestamp),
Requirements_Id=requirement.Id,
@@ -148,6 +148,7 @@ class KubernetesCISModel(BaseModel):
Provider: str
Description: str
Context: str
Cluster: str
Namespace: str
AssessmentDate: str
Requirements_Id: str
@@ -55,6 +55,7 @@ class KubernetesISO27001(ComplianceOutput):
Provider=finding.provider,
Description=compliance.Description,
Context=finding.account_name,
Cluster=finding.account_uid,
Namespace=finding.region,
AssessmentDate=str(timestamp),
Requirements_Id=requirement.Id,
@@ -82,6 +83,7 @@ class KubernetesISO27001(ComplianceOutput):
Provider=compliance.Provider.lower(),
Description=compliance.Description,
Context="",
Cluster="",
Namespace="",
AssessmentDate=str(timestamp),
Requirements_Id=requirement.Id,
@@ -90,6 +90,7 @@ class KubernetesISO27001Model(BaseModel):
Provider: str
Description: str
Context: str
Cluster: str
Namespace: str
AssessmentDate: str
Requirements_Id: str
@@ -127,6 +127,7 @@ class ProwlerThreatScoreKubernetesModel(BaseModel):
Provider: str
Description: str
Context: str
Cluster: str
Namespace: str
AssessmentDate: str
Requirements_Id: str
@@ -58,6 +58,7 @@ class ProwlerThreatScoreKubernetes(ComplianceOutput):
Provider=finding.provider,
Description=compliance.Description,
Context=finding.account_name,
Cluster=finding.account_uid,
Namespace=finding.region,
AssessmentDate=str(timestamp),
Requirements_Id=requirement.Id,
@@ -87,6 +88,7 @@ class ProwlerThreatScoreKubernetes(ComplianceOutput):
Provider=compliance.Provider.lower(),
Description=compliance.Description,
Context="",
Cluster="",
Namespace="",
AssessmentDate=str(timestamp),
Requirements_Id=requirement.Id,
@@ -30,6 +30,9 @@ PROVIDER_HEADER_MAP = {
"e2enetworks": ("ProjectId", "account_uid", "Location", "region"),
}
_DEFAULT_HEADERS = ("AccountId", "account_uid", "Region", "region")
PROVIDER_EXTRA_HEADER_MAP = {
"kubernetes": (("Cluster", "account_uid"),),
}
class UniversalComplianceOutput:
@@ -88,11 +91,19 @@ class UniversalComplianceOutput:
"Provider": (str, ...),
"Description": (str, ...),
acct_header: (str, ...),
loc_header: (str, ...),
"AssessmentDate": (str, ...),
"Requirements_Id": (str, ...),
"Requirements_Description": (str, ...),
}
for header, _ in PROVIDER_EXTRA_HEADER_MAP.get(
(self._provider or "").lower(), ()
):
fields[header] = (str, ...)
fields.update(
{
loc_header: (str, ...),
"AssessmentDate": (str, ...),
"Requirements_Id": (str, ...),
"Requirements_Description": (str, ...),
}
)
# Dynamic attribute columns from metadata
if framework.attributes_metadata:
@@ -154,13 +165,17 @@ class UniversalComplianceOutput:
self._acct_header: (
getattr(finding, self._acct_field, "") if not is_manual else ""
),
self._loc_header: (
getattr(finding, self._loc_field, "") if not is_manual else ""
),
"AssessmentDate": str(timestamp),
"Requirements_Id": requirement.id,
"Requirements_Description": requirement.description,
}
for header, field in PROVIDER_EXTRA_HEADER_MAP.get(
(self._provider or "").lower(), ()
):
row[header] = getattr(finding, field, "") if not is_manual else ""
row[self._loc_header] = (
getattr(finding, self._loc_field, "") if not is_manual else ""
)
# Add dynamic attribute columns
if framework.attributes_metadata:
+141 -12
View File
@@ -1,7 +1,9 @@
import os
import ssl
from typing import Any, Dict, Optional
import requests
from requests.adapters import HTTPAdapter
from prowler.config.config import (
cloud_api_base_url,
@@ -10,6 +12,116 @@ from prowler.config.config import (
)
class SystemTrustStoreError(RuntimeError):
"""Raised when the operating system trust store cannot be initialized."""
def _load_ca_bundle(ssl_context: ssl.SSLContext, ca_bundle_path: str) -> None:
"""Add a CA bundle file or directory to an existing TLS context."""
if os.path.isfile(ca_bundle_path):
ssl_context.load_verify_locations(cafile=ca_bundle_path)
elif os.path.isdir(ca_bundle_path):
ssl_context.load_verify_locations(capath=ca_bundle_path)
else:
raise FileNotFoundError
class _SystemTrustHTTPAdapter(HTTPAdapter):
"""Use one combined trust context for HTTPS origin connection pools."""
def __init__(self, ssl_context: ssl.SSLContext) -> None:
"""Initialize the adapter with a preconfigured TLS context.
Args:
ssl_context: TLS context used for HTTPS connections.
"""
self._ssl_context = ssl_context
super().__init__()
def build_connection_pool_key_attributes(
self,
request: requests.PreparedRequest,
verify: Any,
cert: Any = None,
) -> tuple[Dict[str, Any], Dict[str, Any]]:
"""Build pool attributes that enforce the configured TLS context.
Args:
request: Prepared request used to derive host parameters.
verify: Certificate verification setting supplied by Requests.
cert: Optional client certificate configuration.
Returns:
Host parameters and connection pool keyword arguments.
"""
verify = True
host_params, pool_kwargs = super().build_connection_pool_key_attributes(
request, verify, cert
)
pool_kwargs["ssl_context"] = self._ssl_context
pool_kwargs["cert_reqs"] = "CERT_REQUIRED"
pool_kwargs.pop("ca_certs", None)
pool_kwargs.pop("ca_cert_dir", None)
return host_params, pool_kwargs
def cert_verify(self, conn: Any, url: str, verify: Any, cert: Any) -> None:
"""Keep certificate verification in the supplied native context."""
if verify is not True:
raise ValueError("TLS certificate verification is required for ingestion.")
conn.cert_reqs = "CERT_REQUIRED"
conn.ca_certs = None
conn.ca_cert_dir = None
def proxy_manager_for(self, proxy: str, **proxy_kwargs: Any) -> Any:
"""Use native trust for HTTPS proxies without changing SOCKS support."""
if proxy.lower().startswith("https://"):
proxy_kwargs.setdefault("proxy_ssl_context", self._ssl_context)
return super().proxy_manager_for(proxy, **proxy_kwargs)
class _SystemTrustSession(requests.Session):
"""Requests session using native trust augmented with compatibility roots."""
def __init__(self) -> None:
super().__init__()
try:
import truststore
ssl_context = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
_load_ca_bundle(ssl_context, requests.certs.where())
configured_ca_bundle = os.environ.get(
"REQUESTS_CA_BUNDLE"
) or os.environ.get("CURL_CA_BUNDLE")
if configured_ca_bundle:
_load_ca_bundle(ssl_context, configured_ca_bundle)
ssl_context.check_hostname = True
ssl_context.verify_mode = ssl.CERT_REQUIRED
except Exception as error:
self.close()
raise SystemTrustStoreError(
"Could not initialize the operating system trust store. "
"Check the configured CA bundle paths."
) from error
self.mount("https://", _SystemTrustHTTPAdapter(ssl_context))
def merge_environment_settings(
self,
url: str,
proxies: Optional[Dict[str, str]],
stream: Optional[bool],
verify: Any,
cert: Any,
) -> Dict[str, Any]:
"""Preserve environment proxies without replacing the prepared context."""
verify = False
settings = super().merge_environment_settings(
url, proxies, stream, verify, cert
)
settings["verify"] = True
return settings
def send_ocsf_to_api(
file_path: str,
*,
@@ -32,6 +144,7 @@ def send_ocsf_to_api(
Raises:
FileNotFoundError: If the OCSF file does not exist.
ValueError: If no API key is available.
SystemTrustStoreError: If the operating system trust store cannot initialize.
requests.HTTPError: If the API returns an error status.
"""
if not file_path:
@@ -53,15 +166,31 @@ def send_ocsf_to_api(
url = f"{base_url}{cloud_api_ingestion_path}"
with open(file_path, "rb") as fh:
response = requests.post(
url,
headers={
"Authorization": f"Api-Key {api_key}",
"Accept": "application/vnd.api+json",
},
files={"file": (os.path.basename(file_path), fh, "application/json")},
timeout=timeout,
)
response.raise_for_status()
return response.json() if response.text else {}
session = _SystemTrustSession()
try:
with open(file_path, "rb") as fh:
response = session.post(
url,
headers={
"Authorization": f"Api-Key {api_key}",
"Accept": "application/vnd.api+json",
},
files={
"file": (
os.path.basename(file_path),
fh,
"application/json",
)
},
timeout=timeout,
allow_redirects=False,
)
if 300 <= response.status_code < 400:
raise requests.HTTPError(
f"Prowler Cloud ingestion refused HTTP redirect {response.status_code}.",
response=response,
)
response.raise_for_status()
return response.json() if response.text else {}
finally:
session.close()
+16 -2
View File
@@ -110,6 +110,20 @@ class Slack:
list: list of Slack message blocks.
"""
try:
# A scan can legitimately produce no findings, in which case
# `findings_count` is 0 and the percentages below would raise
# `ZeroDivisionError`.
findings_count = stats["findings_count"]
pass_percentage = (
round(stats["total_pass"] / findings_count * 100, 2)
if findings_count
else 0
)
fail_percentage = (
round(stats["total_fail"] / findings_count * 100, 2)
if findings_count
else 0
)
blocks = [
{
"type": "section",
@@ -128,7 +142,7 @@ class Slack:
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"\n:white_check_mark: *{stats['total_pass']} Passed findings* ({round(stats['total_pass'] / stats['findings_count'] * 100, 2)}%)\n",
"text": f"\n:white_check_mark: *{stats['total_pass']} Passed findings* ({pass_percentage}%)\n",
},
},
{
@@ -148,7 +162,7 @@ class Slack:
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"\n:x: *{stats['total_fail']} Failed findings* ({round(stats['total_fail'] / stats['findings_count'] * 100, 2)}%)\n ",
"text": f"\n:x: *{stats['total_fail']} Failed findings* ({fail_percentage}%)\n ",
},
},
{
@@ -0,0 +1,37 @@
{
"Provider": "alibabacloud",
"CheckID": "oss_bucket_server_side_encryption_enabled",
"CheckTitle": "Server-side encryption is enabled for OSS buckets",
"CheckType": [],
"ServiceName": "oss",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "ALIYUN::OSS::Bucket",
"ResourceGroup": "storage",
"Description": "**Alibaba Cloud OSS** buckets should define a **default server-side encryption** rule so every newly uploaded object is encrypted at rest with SSE-OSS (`AES256`) or SSE-KMS (`KMS`) without relying on per-request headers.",
"Risk": "Without a default encryption rule, objects may be stored unencrypted unless every client sets encryption headers. This weakens **confidentiality** of data at rest and increases the impact of unauthorized bucket access or data exfiltration.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://www.alibabacloud.com/help/en/oss/user-guide/server-side-encryption-8",
"https://www.alibabacloud.com/help/en/oss/developer-reference/getbucketencryption"
],
"Remediation": {
"Code": {
"CLI": "ossutil bucket-encryption --method put oss://<bucket-name> --sse-algorithm AES256",
"NativeIaC": "",
"Other": "1. Log on to the **OSS Console**\n2. In the bucket-list pane, click on a target OSS bucket\n3. Open **Security** / **Encryption** settings\n4. Enable default encryption and choose **SSE-OSS (AES256)** or **SSE-KMS**\n5. Click **Save**",
"Terraform": "resource \"alicloud_oss_bucket\" \"example\" {\n bucket = \"example-bucket\"\n\n server_side_encryption_rule {\n sse_algorithm = \"AES256\"\n }\n}"
},
"Recommendation": {
"Text": "Configure a default server-side encryption rule on every OSS bucket (AES256 or KMS) so all new objects are encrypted at rest without depending on client-side headers.",
"Url": "https://hub.prowler.com/check/oss_bucket_server_side_encryption_enabled"
}
},
"Categories": [
"encryption"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,36 @@
from prowler.lib.check.models import Check, CheckReportAlibabaCloud
from prowler.providers.alibabacloud.services.oss.oss_client import oss_client
class oss_bucket_server_side_encryption_enabled(Check):
"""Check if default server-side encryption is enabled for OSS buckets."""
def execute(self) -> list[CheckReportAlibabaCloud]:
"""Return PASS/FAIL findings for each OSS bucket's default server-side encryption."""
findings = []
for bucket in oss_client.buckets.values():
report = CheckReportAlibabaCloud(metadata=self.metadata(), resource=bucket)
report.region = bucket.region
report.resource_id = bucket.name
report.resource_arn = bucket.arn
algorithm = (bucket.encryption_algorithm or "").upper()
if algorithm in {"AES256", "KMS"}:
report.status = "PASS"
encryption_details = bucket.encryption_algorithm
if bucket.encryption_kms_key_id:
encryption_details += f" (KMS key {bucket.encryption_kms_key_id}"
if bucket.encryption_kms_data_algorithm:
encryption_details += (
f", data encryption {bucket.encryption_kms_data_algorithm}"
)
encryption_details += ")"
report.status_extended = f"OSS bucket {bucket.name} has server-side encryption enabled with {encryption_details}."
else:
report.status = "FAIL"
report.status_extended = f"OSS bucket {bucket.name} does not have default server-side encryption enabled."
findings.append(report)
return findings
@@ -0,0 +1,36 @@
{
"Provider": "alibabacloud",
"CheckID": "oss_bucket_versioning_enabled",
"CheckTitle": "Versioning is enabled for OSS buckets",
"CheckType": [],
"ServiceName": "oss",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "ALIYUN::OSS::Bucket",
"ResourceGroup": "storage",
"Description": "**Alibaba Cloud OSS Bucket Versioning** stores previous versions of objects when they are overwritten or deleted, allowing any version to be restored at a later time. Enabling versioning on all OSS buckets ensures that data can be recovered after unintended changes or deletions.",
"Risk": "Without **OSS bucket versioning** enabled, objects that are overwritten or deleted cannot be recovered. This increases the impact of **accidental deletion**, **misconfigured automation**, and **malicious activity such as ransomware-style overwrites**, as no prior object versions are retained for recovery.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://www.alibabacloud.com/help/doc-detail/109695.htm"
],
"Remediation": {
"Code": {
"CLI": "ossutil bucket-versioning --method put oss://<bucket-name> enabled",
"NativeIaC": "",
"Other": "1. Log on to the **OSS Console**\n2. In the bucket-list pane, click on a target OSS bucket\n3. Find the **Versioning** setting\n4. Click **Configure** and select **Enabled**\n5. Click **Save**",
"Terraform": "resource \"alicloud_oss_bucket_versioning\" \"example\" {\n bucket = alicloud_oss_bucket.example.bucket\n status = \"Enabled\"\n}"
},
"Recommendation": {
"Text": "Enable versioning on all OSS buckets so that overwritten or deleted objects can be recovered, protecting data against accidental loss and malicious overwrites.",
"Url": "https://hub.prowler.com/check/oss_bucket_versioning_enabled"
}
},
"Categories": [
"resilience"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,35 @@
from prowler.lib.check.models import Check, CheckReportAlibabaCloud
from prowler.providers.alibabacloud.services.oss.oss_client import oss_client
class oss_bucket_versioning_enabled(Check):
"""Check if versioning is enabled for OSS buckets."""
def execute(self) -> list[CheckReportAlibabaCloud]:
findings = []
for bucket in oss_client.buckets.values():
report = CheckReportAlibabaCloud(metadata=self.metadata(), resource=bucket)
report.region = bucket.region
report.resource_id = bucket.name
report.resource_arn = bucket.arn
if bucket.versioning_status == "Enabled":
report.status = "PASS"
report.status_extended = (
f"OSS bucket {bucket.name} has versioning enabled."
)
else:
report.status = "FAIL"
if bucket.versioning_status == "Suspended":
report.status_extended = (
f"OSS bucket {bucket.name} has versioning suspended."
)
else:
report.status_extended = (
f"OSS bucket {bucket.name} does not have versioning enabled."
)
findings.append(report)
return findings
@@ -8,6 +8,8 @@ from threading import Lock
from typing import Optional
import requests
from alibabacloud_tea_openapi import models as open_api_models
from alibabacloud_tea_util import models as util_models
from defusedxml import ElementTree
from pydantic.v1 import BaseModel
@@ -38,6 +40,8 @@ class OSS(AlibabaCloudService):
self.__threading_call__(self._get_bucket_acl, self.buckets.values())
self.__threading_call__(self._get_bucket_policy, self.buckets.values())
self.__threading_call__(self._get_bucket_logging, self.buckets.values())
self.__threading_call__(self._get_bucket_encryption, self.buckets.values())
self.__threading_call__(self._get_bucket_versioning, self.buckets.values())
def _list_buckets(self, regional_client=None):
region = "unknown"
@@ -131,58 +135,59 @@ class OSS(AlibabaCloudService):
)
return
def _get_bucket_subresource(self, bucket, action: str, subresource: str) -> dict:
"""Call a bucket sub-resource API (GET /?<subresource>) and return its parsed body.
The generated OSS SDK methods return empty response models for these
APIs: the OSS gateway keeps the XML root element when it deserializes
the body, while the generated response models expect its children at the
top level. Calling the shared ``execute`` path directly and unwrapping the
root element preserves the actual configuration.
Args:
bucket: Bucket to query.
action: OSS API action name (e.g. ``GetBucketEncryption``).
subresource: Sub-resource query string (e.g. ``encryption``).
Returns:
dict: Content of the XML root element, or an empty dict when the
response carries no configuration.
Raises:
Exception: Any error raised by the OSS SDK, including ``TeaException``
with the OSS error code for 4xx/5xx responses.
"""
oss_client = self.session.client("oss", bucket.region)
params = open_api_models.Params(
action=action,
version="2019-05-17",
protocol="HTTPS",
pathname=f"/?{subresource}",
method="GET",
auth_type="AK",
style="ROA",
req_body_type="xml",
body_type="xml",
)
request = open_api_models.OpenApiRequest(
host_map={"bucket": bucket.name}, headers={}
)
response = oss_client.execute(params, request, util_models.RuntimeOptions())
body = response.get("body") if isinstance(response, dict) else None
if not isinstance(body, dict):
return {}
if len(body) == 1:
root_content = next(iter(body.values()))
return root_content if isinstance(root_content, dict) else {}
return body
def _get_bucket_acl(self, bucket):
"""Get bucket ACL."""
"""Get bucket ACL (private, public-read or public-read-write)."""
logger.info(f"OSS - Getting ACL for bucket {bucket.name}...")
try:
# Get OSS client for the bucket's region
# OSS bucket operations use regional endpoint: oss-{region}.aliyuncs.com
oss_client = self.session.client("oss", bucket.region)
# Get bucket ACL
response = oss_client.get_bucket_acl(bucket.name)
if response and response.body:
# ACL can be retrieved from the response
# The ACL value is typically in the response body
acl_value = getattr(response.body, "acl", None)
if acl_value:
# ACL values: private, public-read, public-read-write
bucket.acl = acl_value
else:
# Try to get from access_control_list if available
acl_list = getattr(response.body, "access_control_list", None)
if acl_list:
grant = getattr(acl_list, "grant", None)
if grant:
# Check grants to determine ACL type
if isinstance(grant, list):
# Check if any grant has public access
for g in grant:
permission = getattr(g, "permission", "")
if permission in ["READ", "FULL_CONTROL"]:
if permission == "READ":
bucket.acl = "public-read"
else:
bucket.acl = "public-read-write"
break
else:
bucket.acl = "private"
else:
permission = getattr(grant, "permission", "")
if permission == "READ":
bucket.acl = "public-read"
elif permission == "FULL_CONTROL":
bucket.acl = "public-read-write"
else:
bucket.acl = "private"
else:
bucket.acl = "private"
else:
bucket.acl = "private"
else:
bucket.acl = "private"
acl_policy = self._get_bucket_subresource(bucket, "GetBucketAcl", "acl")
grant = (acl_policy.get("AccessControlList") or {}).get("Grant")
bucket.acl = str(grant) if grant else "private"
except Exception as error:
logger.error(
f"{bucket.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
@@ -219,67 +224,66 @@ class OSS(AlibabaCloudService):
bucket.policy = {}
def _get_bucket_logging(self, bucket):
"""Get bucket logging configuration using OSS SDK."""
"""Get bucket logging configuration."""
logger.info(f"OSS - Getting logging configuration for bucket {bucket.name}...")
try:
oss_client = self.session.client("oss", bucket.region)
response = oss_client.get_bucket_logging(bucket.name)
if response and response.body:
logging_enabled = None
if hasattr(response.body, "logging_enabled"):
logging_enabled = response.body.logging_enabled
elif hasattr(response.body, "loggingenabled"):
logging_enabled = response.body.loggingenabled
elif hasattr(response.body, "bucket_logging"):
logging_enabled = response.body.bucket_logging
if logging_enabled:
target_bucket = None
target_prefix = None
for attr_name in [
"target_bucket",
"targetBucket",
"target_bucket_name",
"targetBucketName",
]:
if hasattr(logging_enabled, attr_name):
target_bucket = getattr(logging_enabled, attr_name)
break
for attr_name in [
"target_prefix",
"targetPrefix",
"target_prefix_name",
"targetPrefixName",
]:
if hasattr(logging_enabled, attr_name):
target_prefix = getattr(logging_enabled, attr_name)
break
if target_bucket:
bucket.logging_enabled = True
bucket.logging_target_bucket = (
str(target_bucket) if target_bucket else ""
)
bucket.logging_target_prefix = (
str(target_prefix) if target_prefix else ""
)
else:
bucket.logging_enabled = False
bucket.logging_target_bucket = ""
bucket.logging_target_prefix = ""
else:
bucket.logging_enabled = False
bucket.logging_target_bucket = ""
bucket.logging_target_prefix = ""
logging_status = self._get_bucket_subresource(
bucket, "GetBucketLogging", "logging"
)
logging_enabled = logging_status.get("LoggingEnabled") or {}
target_bucket = logging_enabled.get("TargetBucket")
if target_bucket:
bucket.logging_enabled = True
bucket.logging_target_bucket = str(target_bucket)
bucket.logging_target_prefix = str(
logging_enabled.get("TargetPrefix") or ""
)
else:
bucket.logging_enabled = False
bucket.logging_target_bucket = ""
bucket.logging_target_prefix = ""
except Exception as error:
logger.error(
f"{bucket.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _get_bucket_encryption(self, bucket):
"""Get bucket default server-side encryption configuration."""
logger.info(
f"OSS - Getting encryption configuration for bucket {bucket.name}..."
)
try:
encryption_rule = self._get_bucket_subresource(
bucket, "GetBucketEncryption", "encryption"
)
default_rule = (
encryption_rule.get("ApplyServerSideEncryptionByDefault") or {}
)
bucket.encryption_algorithm = str(default_rule.get("SSEAlgorithm") or "")
bucket.encryption_kms_key_id = str(default_rule.get("KMSMasterKeyID") or "")
bucket.encryption_kms_data_algorithm = str(
default_rule.get("KMSDataEncryption") or ""
)
except Exception as error:
# No encryption rule configured means default encryption is disabled
error_code = getattr(error, "code", "")
if error_code == "NoSuchServerSideEncryptionRule":
bucket.encryption_algorithm = ""
bucket.encryption_kms_key_id = ""
bucket.encryption_kms_data_algorithm = ""
else:
logger.error(
f"{bucket.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _get_bucket_versioning(self, bucket):
"""Get bucket versioning status (Enabled, Suspended or unset)."""
logger.info(f"OSS - Getting versioning status for bucket {bucket.name}...")
try:
versioning_configuration = self._get_bucket_subresource(
bucket, "GetBucketVersioning", "versioning"
)
bucket.versioning_status = str(versioning_configuration.get("Status") or "")
except Exception as error:
logger.error(
f"{bucket.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
@@ -330,4 +334,8 @@ class Bucket(BaseModel):
logging_enabled: bool = False
logging_target_bucket: str = ""
logging_target_prefix: str = ""
encryption_algorithm: str = "" # "", AES256, KMS
encryption_kms_key_id: str = ""
encryption_kms_data_algorithm: str = "" # "", AES256, SM4 (only with KMS)
versioning_status: str = "" # "", Enabled, Suspended
creation_date: Optional[datetime] = None
@@ -1,6 +1,7 @@
from itertools import zip_longest
from typing import Optional
from botocore.exceptions import ClientError
from pydantic.v1 import BaseModel
from prowler.lib.logger import logger
@@ -34,18 +35,38 @@ class BatchJobDefinition(BaseModel):
container_properties: BatchContainerProperties
class BatchComputeEnvironment(BaseModel):
"""An AWS Batch compute environment with its networking configuration."""
name: str
arn: str
region: str
security_groups: list[str] = []
subnets: list[str] = []
class Batch(AWSService):
"""AWS Batch service client for listing job definitions."""
"""AWS Batch service client for listing job definitions and compute environments."""
def __init__(self, provider):
super().__init__(__class__.__name__, provider)
self.job_definitions = {}
self._job_definitions_by_region = {}
self.compute_environments = {}
# Security groups referenced by compute environments. A compute
# environment holds them in its configuration even while it is scaled
# down to zero instances, so no ENI exists to reveal the association.
self.security_groups_in_use = set()
# Regions whose compute environments could not be listed. Their
# security group associations are unknown rather than absent, so
# consumers must not read an empty result as "nothing is attached".
self.compute_environment_lookup_failed_regions = set()
self.job_definition_limit = get_resource_scan_limit(
self.audit_config, "max_batch_job_definitions"
)
self.__threading_call__(self._list_job_definitions)
self._select_job_definitions_for_analysis()
self.__threading_call__(self._describe_compute_environments)
def _list_job_definitions(self, regional_client):
"""List ACTIVE job definitions for a regional client."""
@@ -89,6 +110,47 @@ class Batch(AWSService):
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _describe_compute_environments(self, regional_client):
"""Describe the compute environments for a regional client."""
logger.info("Batch - Describing Compute Environments...")
try:
paginator = regional_client.get_paginator("describe_compute_environments")
for page in paginator.paginate():
for compute_environment in page.get("computeEnvironments", []):
arn = compute_environment["computeEnvironmentArn"]
if self.audit_resources and not is_resource_filtered(
arn, self.audit_resources
):
continue
compute_resources = compute_environment.get("computeResources", {})
security_groups = compute_resources.get("securityGroupIds", [])
self.security_groups_in_use.update(security_groups)
self.compute_environments[arn] = BatchComputeEnvironment(
name=compute_environment["computeEnvironmentName"],
arn=arn,
region=regional_client.region,
security_groups=security_groups,
subnets=compute_resources.get("subnets", []),
)
except ClientError as error:
self.compute_environment_lookup_failed_regions.add(regional_client.region)
if error.response["Error"]["Code"] in (
"AccessDeniedException",
"UnrecognizedClientException",
):
logger.warning(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
else:
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
except Exception as error:
self.compute_environment_lookup_failed_regions.add(regional_client.region)
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _select_job_definitions_for_analysis(self):
"""Apply the global resource limit, interleaving regions fairly."""
interleaved = [
@@ -11,7 +11,7 @@
"Severity": "low",
"ResourceType": "AwsEc2SecurityGroup",
"ResourceGroup": "network",
"Description": "EC2 security groups, except `default`, are assessed for **unused** status: zero attached network interfaces, no AWS Lambda associations, and no references from other security groups.",
"Description": "EC2 security groups, except `default`, are assessed for **unused** status: zero attached network interfaces, no AWS Lambda associations, no AWS Batch compute environment associations, and no references from other security groups.",
"Risk": "Orphaned security groups may later be attached with **overly permissive rules** without review, enabling unintended inbound or lateral access that compromises **confidentiality** and **integrity**. They also create **configuration drift**, increasing the chance of misapplied access controls.",
"RelatedUrl": "",
"AdditionalURLs": [
@@ -1,5 +1,6 @@
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.providers.aws.services.awslambda.awslambda_client import awslambda_client
from prowler.providers.aws.services.batch.batch_client import batch_client
from prowler.providers.aws.services.ec2.ec2_client import ec2_client
@@ -18,6 +19,9 @@ class ec2_securitygroup_not_used(Check):
sg_in_lambda = (
security_group.id in awslambda_client.security_groups_in_use
)
# A Batch compute environment scaled down to zero instances
# keeps its security groups in configuration without any ENI
sg_in_batch = security_group.id in batch_client.security_groups_in_use
sg_associated = False
for sg in ec2_client.security_groups.values():
if security_group.id in sg.associated_sgs:
@@ -25,10 +29,24 @@ class ec2_securitygroup_not_used(Check):
if (
len(security_group.network_interfaces) == 0
and not sg_in_lambda
and not sg_in_batch
and not sg_associated
):
report.status = "FAIL"
report.status_extended = f"Security group {security_group.name} ({security_group.id}) it is not being used."
# Compute environments failing to list leaves their security
# group associations unknown, not absent, so reporting the
# group as unused would be a guess. Not being able to read
# the compute environments is a lack of visibility, not a
# misconfiguration, so report MANUAL rather than asserting a
# status either way.
if (
security_group.region
in batch_client.compute_environment_lookup_failed_regions
):
report.status = "MANUAL"
report.status_extended = f"Security group {security_group.name} ({security_group.id}) usage could not be verified because AWS Batch compute environments could not be listed in region {security_group.region}; grant batch:DescribeComputeEnvironments and run the check again."
else:
report.status = "FAIL"
report.status_extended = f"Security group {security_group.name} ({security_group.id}) it is not being used."
findings.append(report)
@@ -0,0 +1,39 @@
{
"Provider": "github",
"CheckID": "organization_actions_pull_request_approval_disabled",
"CheckTitle": "Organization prevents GitHub Actions from approving pull requests",
"CheckType": [],
"ServiceName": "organization",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "governance",
"Description": "GitHub Actions organization settings define whether workflows are allowed to create and approve pull requests.\n\nThe evaluation determines whether that permission is disabled, so that approving a pull request always requires a human reviewer.",
"Risk": "When workflows can approve pull requests, automation can satisfy the review requirement it was meant to be checked by, leading to:\n- Required reviews being cleared without any human inspecting the change\n- Self-approving pull requests opened by a workflow\n- Merge gates that appear enforced while providing no real oversight",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.github.com/en/organizations/managing-organization-settings/disabling-or-limiting-github-actions-for-your-organization#preventing-github-actions-from-creating-or-approving-pull-requests",
"https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication",
"https://docs.github.com/en/rest/actions/permissions"
],
"Remediation": {
"Code": {
"CLI": "gh api --method PUT /orgs/<organization>/actions/permissions/workflow -F can_approve_pull_request_reviews=false",
"NativeIaC": "",
"Other": "1. Sign in to GitHub as an organization owner\n2. Go to your organization > Settings\n3. In the left sidebar, click Actions > General\n4. Under Workflow permissions, clear Allow GitHub Actions to create and approve pull requests\n5. Click Save",
"Terraform": ""
},
"Recommendation": {
"Text": "Disable **Allow GitHub Actions to create and approve pull requests** so that pull request approvals always come from a human reviewer.\n\nWhere automation genuinely needs to open pull requests, let it create them without granting approval rights, and keep the approval step with a reviewer who is not the author.",
"Url": "https://hub.prowler.com/check/organization_actions_pull_request_approval_disabled"
}
},
"Categories": [
"ci-cd",
"software-supply-chain"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,38 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportGithub
from prowler.providers.github.services.organization.organization_client import (
organization_client,
)
class organization_actions_pull_request_approval_disabled(Check):
"""Check if GitHub Actions workflows are prevented from approving pull requests.
This class verifies whether each organization disallows workflows from creating and
approving pull requests, so that required reviews cannot be satisfied by automation.
"""
def execute(self) -> List[CheckReportGithub]:
"""Execute the Github Organization Actions Pull Request Approval Disabled check.
Iterates over all organizations and checks whether GitHub Actions workflows are
allowed to approve pull requests.
Returns:
List[CheckReportGithub]: A list of reports for each organization
"""
findings = []
for org in organization_client.organizations.values():
if org.can_approve_pull_request_reviews is not None:
report = CheckReportGithub(metadata=self.metadata(), resource=org)
report.status = "PASS"
report.status_extended = f"Organization {org.name} does not allow GitHub Actions to approve pull requests."
if org.can_approve_pull_request_reviews:
report.status = "FAIL"
report.status_extended = f"Organization {org.name} allows GitHub Actions to approve pull requests."
findings.append(report)
return findings
@@ -0,0 +1,39 @@
{
"Provider": "github",
"CheckID": "organization_default_workflow_permissions_read_only",
"CheckTitle": "Organization grants workflows a read-only default GITHUB_TOKEN",
"CheckType": [],
"ServiceName": "organization",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "high",
"ResourceType": "NotDefined",
"ResourceGroup": "governance",
"Description": "GitHub Actions organization settings define the default **GITHUB_TOKEN** permissions granted to every workflow run.\n\nThe evaluation determines whether the organization default is read-only, so that workflows have to opt in to write access through an explicit `permissions` block.",
"Risk": "A write-capable default **GITHUB_TOKEN** is available to every workflow in the organization, so any compromised step or dependency inherits it, leading to:\n- Code integrity loss from pushed commits, moved tags, or altered releases\n- Supply chain compromise when the token publishes packages or artifacts\n- Weakened review controls when the token acts on pull requests and issues",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.github.com/en/organizations/managing-organization-settings/disabling-or-limiting-github-actions-for-your-organization#setting-the-permissions-of-the-github_token-for-your-organization",
"https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication",
"https://docs.github.com/en/actions/reference/security/secure-use#using-the-github_token"
],
"Remediation": {
"Code": {
"CLI": "gh api --method PUT /orgs/<organization>/actions/permissions/workflow -f default_workflow_permissions=read",
"NativeIaC": "",
"Other": "1. Sign in to GitHub as an organization owner\n2. Go to your organization > Settings\n3. In the left sidebar, click Actions > General\n4. Under Workflow permissions, select Read repository contents and packages permissions\n5. Click Save",
"Terraform": ""
},
"Recommendation": {
"Text": "Set the organization default **GITHUB_TOKEN** permissions to read-only and grant write scopes per workflow with an explicit `permissions` block, following **least privilege**.\n\nAlso prevent Actions from approving pull requests, and prefer short-lived **GitHub App** tokens over broad credentials when a workflow genuinely needs write access.",
"Url": "https://hub.prowler.com/check/organization_default_workflow_permissions_read_only"
}
},
"Categories": [
"ci-cd",
"software-supply-chain"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,38 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportGithub
from prowler.providers.github.services.organization.organization_client import (
organization_client,
)
class organization_default_workflow_permissions_read_only(Check):
"""Check if the default GITHUB_TOKEN permissions granted to workflows are read-only.
This class verifies whether each organization grants workflows a read-only GITHUB_TOKEN
by default, instead of a token with write access to the repository contents.
"""
def execute(self) -> List[CheckReportGithub]:
"""Execute the Github Organization Default Workflow Permissions Read Only check.
Iterates over all organizations and checks the default GITHUB_TOKEN permissions
granted to GitHub Actions workflows.
Returns:
List[CheckReportGithub]: A list of reports for each organization
"""
findings = []
for org in organization_client.organizations.values():
if org.default_workflow_permissions is not None:
report = CheckReportGithub(metadata=self.metadata(), resource=org)
report.status = "FAIL"
report.status_extended = f"Organization {org.name} grants workflows a default GITHUB_TOKEN with {org.default_workflow_permissions} permissions."
if org.default_workflow_permissions == "read":
report.status = "PASS"
report.status_extended = f"Organization {org.name} grants workflows a read-only default GITHUB_TOKEN."
findings.append(report)
return findings
@@ -1,4 +1,4 @@
from typing import Optional
from typing import Optional, Tuple
import github
from pydantic.v1 import BaseModel
@@ -63,6 +63,10 @@ class Organization(GithubService):
try:
org = client.get_organization(org_name)
self._process_organization(org, organizations)
except github.RateLimitExceededException:
# Rate limits are transient and must not be mistaken
# for a missing organization or a permissions gap
raise
except github.GithubException as org_error:
# If organization fails, try as a user (personal account)
if "404" in str(org_error):
@@ -201,6 +205,10 @@ class Organization(GithubService):
else None
)
is_verified = _extract_flag("is_verified", bool)
(
default_workflow_permissions,
can_approve_pull_request_reviews,
) = self._get_actions_workflow_permissions(org)
organizations[org.id] = Org(
id=org.id,
name=org.login,
@@ -223,8 +231,73 @@ class Organization(GithubService):
],
base_permission=base_permission,
is_verified=is_verified,
default_workflow_permissions=default_workflow_permissions,
can_approve_pull_request_reviews=can_approve_pull_request_reviews,
)
def _get_actions_workflow_permissions(
self, org
) -> Tuple[Optional[str], Optional[bool]]:
"""Fetch the Actions workflow permissions settings of the organization.
The API returns a response in the format:
{
"default_workflow_permissions": "read",
"can_approve_pull_request_reviews": false
}
Args:
org: PyGithub Organization object.
Returns:
Tuple[Optional[str], Optional[bool]]: The default GITHUB_TOKEN permissions
("read" or "write") and whether workflows can approve pull requests.
Each value is None when it cannot be read.
Raises:
github.RateLimitExceededException: When API rate limits are exceeded
"""
default_workflow_permissions = None
can_approve_pull_request_reviews = None
try:
_, response = org._requester.requestJsonAndCheck( # type: ignore[attr-defined]
"GET",
f"/orgs/{org.login}/actions/permissions/workflow",
headers={
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
)
if isinstance(response, dict):
permissions = response.get("default_workflow_permissions")
if permissions in ("read", "write"):
default_workflow_permissions = permissions
can_approve = response.get("can_approve_pull_request_reviews")
if isinstance(can_approve, bool):
can_approve_pull_request_reviews = can_approve
except github.RateLimitExceededException as error:
logger.error(f"GitHub API rate limit exceeded: {error}")
raise # Re-raise rate limit errors as they need special handling
except github.GithubException as error:
status_code = getattr(error, "status", None)
if status_code == 404:
logger.info(
f"'{org.login}': Actions workflow permissions endpoint not available for this account."
)
elif status_code == 403:
logger.warning(
f"Access denied reading Actions workflow permissions for '{org.login}' - insufficient permissions"
)
else:
logger.error(
f"GitHub API error reading Actions workflow permissions for '{org.login}': {error}"
)
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return default_workflow_permissions, can_approve_pull_request_reviews
class Org(BaseModel):
"""Model for Github Organization"""
@@ -240,3 +313,5 @@ class Org(BaseModel):
members_allowed_repository_creation_type: Optional[str] = None
base_permission: Optional[str] = None
is_verified: Optional[bool] = None
default_workflow_permissions: Optional[str] = None
can_approve_pull_request_reviews: Optional[bool] = None
@@ -0,0 +1,39 @@
{
"Provider": "github",
"CheckID": "repository_default_workflow_permissions_read_only",
"CheckTitle": "Repository grants workflows a read-only default GITHUB_TOKEN",
"CheckType": [],
"ServiceName": "repository",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "high",
"ResourceType": "NotDefined",
"ResourceGroup": "devops",
"Description": "GitHub Actions repository settings define the default **GITHUB_TOKEN** permissions granted to every workflow run in the repository.\n\nThe evaluation determines whether the repository default is read-only, so that workflows have to opt in to write access through an explicit `permissions` block.",
"Risk": "A write-capable default **GITHUB_TOKEN** is available to every workflow in the repository, so any compromised step or dependency inherits it, leading to:\n- Code integrity loss from pushed commits, moved tags, or altered releases\n- Supply chain compromise when the token publishes packages or artifacts\n- Weakened review controls when the token acts on pull requests and issues",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository#setting-the-permissions-of-the-github_token-for-your-repository",
"https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication",
"https://docs.github.com/en/rest/actions/permissions"
],
"Remediation": {
"Code": {
"CLI": "gh api --method PUT /repos/<owner>/<repository>/actions/permissions/workflow -f default_workflow_permissions=read",
"NativeIaC": "",
"Other": "1. Sign in to GitHub as a repository administrator\n2. Go to the repository > Settings\n3. In the left sidebar, click Actions > General\n4. Under Workflow permissions, select Read repository contents and packages permissions\n5. Click Save",
"Terraform": ""
},
"Recommendation": {
"Text": "Set the repository default **GITHUB_TOKEN** permissions to read-only and grant write scopes per workflow with an explicit `permissions` block, following **least privilege**.\n\nA repository can override an organization default, so verify this setting per repository even when the organization already defaults to read-only. Prefer short-lived **GitHub App** tokens over broad credentials when a workflow genuinely needs write access.",
"Url": "https://hub.prowler.com/check/repository_default_workflow_permissions_read_only"
}
},
"Categories": [
"ci-cd",
"software-supply-chain"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,38 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportGithub
from prowler.providers.github.services.repository.repository_client import (
repository_client,
)
class repository_default_workflow_permissions_read_only(Check):
"""Ensure repositories grant workflows a read-only default GITHUB_TOKEN.
A read-only default forces workflows to request write access explicitly, instead of
every workflow run starting with a token that can write to the repository.
"""
def execute(self) -> List[CheckReportGithub]:
"""Run the default workflow permissions verification for each discovered repository.
Returns:
List[CheckReportGithub]: Collection of check reports describing the default GITHUB_TOKEN permissions.
"""
findings: List[CheckReportGithub] = []
for repo in repository_client.repositories.values():
if repo.default_workflow_permissions is None:
continue
report = CheckReportGithub(metadata=self.metadata(), resource=repo)
if repo.default_workflow_permissions == "read":
report.status = "PASS"
report.status_extended = f"Repository {repo.name} grants workflows a read-only default GITHUB_TOKEN."
else:
report.status = "FAIL"
report.status_extended = f"Repository {repo.name} grants workflows a default GITHUB_TOKEN with {repo.default_workflow_permissions} permissions."
findings.append(report)
return findings

Some files were not shown because too many files have changed in this diff Show More