Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
851c8dbed3 | ||
|
|
14ddd9641d | ||
|
|
1b1d118e7d | ||
|
|
f4459ff7c8 | ||
|
|
49104f39df | ||
|
|
4d13e8432e | ||
|
|
5f24bec9fe | ||
|
|
afefb8f333 | ||
|
|
2b81fdcc04 | ||
|
|
db298c1d48 | ||
|
|
2877c3d6c0 | ||
|
|
ee64c17108 | ||
|
|
a610314eba | ||
|
|
301edea7ce | ||
|
|
c89d900aae | ||
|
|
4cfb4eeb96 | ||
|
|
f19478f2f6 | ||
|
|
2f11b16299 | ||
|
|
654d2c9f17 | ||
|
|
2721d42594 | ||
|
|
bd1956446d | ||
|
|
d26d7cf91a | ||
|
|
4c20bf1fac | ||
|
|
9dc53ffc60 | ||
|
|
95642fb220 | ||
|
|
6449f3a592 | ||
|
|
91e6cb798d | ||
|
|
bcd37988b5 | ||
|
|
7a64e1a1d1 | ||
|
|
39c85ffb77 | ||
|
|
301ca50541 | ||
|
|
f299e1d9ac | ||
|
|
465e35bf54 | ||
|
|
829af2e3f7 | ||
|
|
51c65bdc69 | ||
|
|
d0c088dd0a | ||
|
|
411d112165 | ||
|
|
8a4cc8780d | ||
|
|
cd4d2a27e3 | ||
|
|
9898cf7364 | ||
|
|
83d8cfa829 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
`sqlparse` upgraded to 0.6.0, patching CVE-2026-54284, CVE-2026-59893, and CVE-2026-71491
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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)
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 192 KiB After Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 210 KiB After Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 93 KiB |
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||

|
||||
|
||||
After creating the management group, add the subscriptions that Prowler should access and scan.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
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. 
|
||||
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>
|
||||
|
||||
@@ -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).
|
||||
|
||||

|
||||
|
||||
@@ -86,11 +88,18 @@ Navigate to **Alerts** to review and manage existing Alerts.
|
||||
|
||||

|
||||
|
||||
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**.
|
||||
|
||||

|
||||
|
||||
3. Click **Add to Slack**.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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**.
|
||||
|
||||

|
||||
|
||||
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**.
|
||||
|
||||

|
||||
|
||||
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:
|
||||
|
||||
@@ -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/`),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 && \
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||