Compare commits

...
Author SHA1 Message Date
prowler-bot ba14f1e3d4 feat(aws): update regions for AWS services 2026-09-07 09:03:06 +00:00
Pedro Martínandalejandrobailo 1edcf6e5de fix(jira): fix connection check timeout (#12742)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-09-04 15:51:09 +02:00
Pedro Martín 8bdb597921 perf(api): speed up compliance overview ingestion (#12738) 2026-09-04 11:13:25 +02:00
Pedro Martín 1746e1052b fix(ci): suppress unfixed x/crypto CVEs from Trivy binary (#12740) 2026-09-04 10:09:17 +02:00
Pedro Martín 6827eef347 fix(ci): don't fail setup-python-uv on empty grep match (#12737) 2026-09-04 09:12:15 +02:00
90fc815d3c chore(release): Bump versions to v5.42.0 (#12713)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
Co-authored-by: pedrooot <pedromarting3@gmail.com>
2026-09-03 14:23:50 +02:00
Pedro Martín 8f35fff255 fix(compliance): remove duplicate ids and stale check refs (#12717) 2026-09-03 13:39:12 +02:00
Pedro Martín ab51d09543 fix(tests): isolate mock class attrs leaking across tests (#12728) 2026-09-03 12:20:33 +02:00
Pedro Martín 12faeb9aa2 chore(trivy): suppress fast-uri CVEs from Teams SPDX manifest (#12727) 2026-09-03 11:09:13 +02:00
Alejandro Bailo 9ed07de610 feat(ui): separate PostHog hosts and enable Toolbar in development (#12582) 2026-09-03 10:36:47 +02:00
Alejandro Bailo 8007501574 fix(ui): avoid missing selector in scan tour (#12705) 2026-09-03 10:23:24 +02:00
Pedro Martín 36514534cb chore(trivy): suppress CVE-2026-84304 in embedded grpc (#12720) 2026-09-03 10:17:14 +02:00
Pedro Martín 9621bdfb9c fix(tests): isolate provider mock in agentcore passrole (#12724) 2026-09-03 10:15:49 +02:00
f013a5e1ad chore(changelog): v5.41.0 highlights (#12709)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2026-09-02 13:51:45 +02:00
Prowler Botandprowler-bot 18453e592e chore(changelog): v5.41.0 (#12707)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-09-02 11:23:18 +02:00
Jonathan Nguyen 86e4408f29 feat(ecr): assess enhanced scanning on registries holding repositories (#12660) 2026-09-02 08:53:52 +02:00
Jonathan Nguyen ae43d21efb fix(sagemaker): read DirectInternetAccess instead of RootAccess on notebook instances (#12659) 2026-09-01 18:22:41 +02:00
Pedro Martín 7c84822fa3 fix(image): skip non-image OCI artifacts in registry scan (#12695) 2026-09-01 17:18:47 +02:00
Daniel Barranquero 51c5fa7168 fix(checks): report MANUAL instead of FAIL on permission and data-availability errors (#12645) 2026-09-01 17:16:56 +02:00
Jonathan Nguyen e9121f5f1a feat(cloudwatch): add agentcore log group data protection policy check (#12662) 2026-09-01 17:04:16 +02:00
Jonathan Nguyen 821fe43efd feat(iam): scope AgentCore PassRole and workload token grants, and flag unbound service trust (#12664) 2026-09-01 17:02:05 +02:00
Jonathan Nguyen 9ffbb4b758 fix(ecr): read each registry scanning rule's frequency instead of assuming scan on push (#12560) 2026-09-01 16:53:58 +02:00
Jonathan Nguyen 9c5285adc3 fix(cloudwatch): skip metric filters whose log group was not retrieved (#12561) 2026-09-01 14:00:41 +02:00
Pedro Martín f295d290dd feat(image): private network allowlist for SSRF guard (#12678) 2026-09-01 14:00:04 +02:00
Jonathan Nguyen e5df95c259 feat(eks): assess Kubernetes network policy enforcement in the Amazon VPC CNI add-on (#12661) 2026-09-01 13:40:45 +02:00
Jonathan Nguyen b6a8af3c54 feat(guardduty): assess unified Runtime Monitoring and AI Protection (#12564) 2026-09-01 13:18:57 +02:00
Pedro MartínandLydia Vilchez fb7064401b feat(compliance): add CIS 1.4 google workspace compliance (#12513)
Co-authored-by: Lydia Vilchez <lydiavilchezlopez@gmail.com>
2026-09-01 09:35:39 +02:00
Josema Camacho 8d60f9703a fix(api): limit mute rules to current and future scans (#12681) 2026-09-01 09:33:15 +02:00
Pablo Fernandez Guerra (PFE) ceb601028e fix(ui): apply Slack integration design feedback (#12677) 2026-08-31 18:27:11 +02:00
Pedro MartínandDavid 6422178b76 feat(sdk): AWS partition selection via PROWLER_AWS_PARTITION (#12680)
Co-authored-by: David <david.copo@gmail.com>
2026-08-31 18:13:30 +02:00
Daniel BarranqueroandJosema Camacho 587c47bfe2 feat(api): add finding labels, finding URL and tenant info to Jira issues (#12540)
Co-authored-by: Josema Camacho <josema@prowler.com>
2026-08-31 18:00:46 +02:00
Rubén De la Torre VicoandClaude Opus 5 13a31d9225 feat(mcp): raise instead of returning error objects in the Prowler Docs tools (#12534)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 17:36:19 +02:00
Pablo Fernandez Guerra (PFE)andalejandrobailo 0715619435 feat(ui): import Prowler OCSF findings from the Scans page (#12554)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-08-31 17:14:12 +02:00
Rubén De la Torre Vico 1679094f22 feat(mcp): raise instead of returning error objects in the Prowler Hub tools (#12533) 2026-08-31 13:09:34 +02:00
Rubén De la Torre Vico f05a490cd7 feat(mcp): raise instead of returning error objects in the Prowler App tools (#12532) 2026-08-31 10:57:58 +02:00
Alejandro Bailo 89988dada5 fix(ui): refresh cached permissions after token rotation (#12640) 2026-08-31 10:49:10 +02:00
ye11oc4tandDaniel Barranquero 0326844527 fix(github): paginate repository discovery (#12460)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-31 10:15:29 +02:00
mintlify[bot] 73d5c6952b docs: fix typos and grammar (#12672)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-08-31 09:18:08 +02:00
mintlify[bot] ad76b3026f docs: brand tone and writing style fixes (#12671)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-08-31 09:15:46 +02:00
Utkarsh Batham e21946874f feat(memorydb): add memorydb_cluster_in_transit_encryption_enabled check (#12246) 2026-08-28 14:03:10 +02:00
SejalandDaniel Barranquero 2cae2058e9 feat(aws): add elasticbeanstalk_environment_no_secrets_in_configuration check (#12378)
Signed-off-by: unknown <sej1306kook@gmail.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-28 13:54:23 +02:00
Daniel BarranqueroandJosema Camacho c88f745038 feat(jira): return the created issue, sanitize labels and add bulk status lookup (#12539)
Co-authored-by: Josema Camacho <josema@prowler.com>
2026-08-28 13:47:12 +02:00
Prowler Botandprowler-bot c923c58a39 chore(release): Bump versions to v5.41.0 (#12647)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-08-28 13:16:00 +02:00
c3bee8c21e chore(changelog): v5.40.0 highlights (#12569)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
Co-authored-by: pedrooot <pedromarting3@gmail.com>
2026-08-28 13:10:06 +02:00
Prowler Botandprowler-bot 4d13e8432e chore(changelog): v5.40.0 (#12642)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-08-28 11:51:30 +02:00
Pedro Martín 5f24bec9fe fix(compliance): correct Cyber Essentials mappings and remediation text (#12596) 2026-08-28 11:08:15 +02:00
César Arroba afefb8f333 fix(api): apply findings partition max age in months, not days (#12580) 2026-08-28 10:34:12 +02:00
503 changed files with 31722 additions and 5216 deletions
+1 -1
View File
@@ -158,7 +158,7 @@ SENTRY_RELEASE=local
# REO_DEV_CLIENT_ID=
#### Prowler release version ####
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.40.0
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.42.0
# Social login credentials
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
@@ -46,6 +46,17 @@ runs:
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
if grep -q "prowler-cloud/prowler" uv.lock; then
:
else
status=$?
if [ "$status" -ne 1 ]; then
echo "::error::grep failed reading uv.lock (exit code $status)."
exit "$status"
fi
echo "No prowler-cloud/prowler entry in uv.lock, nothing to update."
exit 0
fi
LATEST_COMMIT=$(curl -sf --retry 3 --retry-all-errors --retry-delay 2 --retry-max-time 60 \
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github+json" \
@@ -66,6 +77,17 @@ runs:
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
if grep -q "prowler-cloud/prowler" uv.lock; then
:
else
status=$?
if [ "$status" -ne 1 ]; then
echo "::error::grep failed reading uv.lock (exit code $status)."
exit "$status"
fi
echo "No prowler-cloud/prowler entry in uv.lock, nothing to update."
exit 0
fi
LATEST_COMMIT=$(curl -sf --retry 3 --retry-all-errors --retry-delay 2 --retry-max-time 60 \
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github+json" \
+23
View File
@@ -17,6 +17,29 @@ ignore:
- vulnerability: CVE-2026-71556
package:
name: github.com/go-git/go-git/v5
# CVE-2026-84304 is the same temporary exception documented in .trivyignore.yaml:
# Trivy 0.74.0 still embeds grpc 1.82.1, while the 1.83.1 fix is not in any release.
# Prowler only runs `trivy image` / `trivy fs`, never client/server mode, so no gRPC
# endpoint exists in the image. Pinned to the embedded version so the rule stops
# matching on its own once Trivy bumps grpc. Remove with the Trivy exception by 2026-10-15.
# https://github.com/aquasecurity/trivy/pull/11176
- vulnerability: CVE-2026-84304
package:
name: google.golang.org/grpc
version: v1.82.1
# CVE-2026-56855 / CVE-2026-78662 are the same temporary exception documented in
# .trivyignore.yaml: Trivy 0.74.0 still embeds golang.org/x/crypto v0.55.0, while the
# 0.56.0 fix (published 2026-09-02) hasn't reached any Trivy release, or even Trivy
# main, yet. Pinned to the embedded version so the rule stops matching on its own once
# Trivy bumps it. Remove with the Trivy exception by 2026-10-15.
- vulnerability: CVE-2026-56855
package:
name: golang.org/x/crypto
version: v0.55.0
- vulnerability: CVE-2026-78662
package:
name: golang.org/x/crypto
version: v0.55.0
- vulnerability: CVE-2026-56852
package:
name: golang.org/x/text
+49
View File
@@ -113,6 +113,18 @@ vulnerabilities:
purls:
- "pkg:npm/fast-uri"
expired_at: 2027-01-31
- id: CVE-2026-75899
purls:
- "pkg:npm/fast-uri"
expired_at: 2027-01-31
- id: CVE-2026-75975
purls:
- "pkg:npm/fast-uri"
expired_at: 2027-01-31
- id: CVE-2026-76172
purls:
- "pkg:npm/fast-uri"
expired_at: 2027-01-31
- id: CVE-2026-69192
purls:
- "pkg:npm/ip-address"
@@ -148,6 +160,43 @@ vulnerabilities:
- "pkg:golang/github.com/go-git/go-git/v5"
expired_at: 2026-09-15
# CVE-2026-84304 is a DoS in grpc-go <= 1.83.0: a peer fragments a gRPC stream into
# millions of tiny HTTP/2 DATA frames until the receiver runs out of heap. Fixed in
# 1.83.1 (published 2026-09-01). Trivy 0.74.0, the latest published release and the
# version the images ship, pins 1.82.1 as an indirect dependency:
# https://github.com/aquasecurity/trivy/blob/v0.74.0/go.mod
# Upstream bump still open: https://github.com/aquasecurity/trivy/pull/11176
# Trivy only speaks gRPC in client/server mode (`trivy server`, `--server`). Prowler
# invokes it exclusively as `trivy image` and `trivy fs` on a local path, so no gRPC
# listener or connection ever exists in the image and the affected path is not
# reachable. Remove this temporary suppression as soon as a Trivy release pins
# grpc >= 1.83.1.
- id: CVE-2026-84304
purls:
- "pkg:golang/google.golang.org/grpc"
expired_at: 2026-10-15
# CVE-2026-56855 and CVE-2026-78662 are DoS deadlocks in x/crypto/ssh: a malicious peer
# can flood or misuse channel messages (RFC 4254) to block the whole connection.
# Fixed in golang.org/x/crypto v0.56.0 (published 2026-09-02). Trivy 0.74.0, the latest
# published release and the version the images ship, still pins v0.55.0, and Trivy main
# has not bumped it either:
# https://github.com/aquasecurity/trivy/blob/v0.74.0/go.mod
# x/crypto/ssh is pulled in transitively through go-git's ssh transport, the same
# dependency chain as the CVE-2026-71556 entry above. Prowler invokes Trivy only with
# `fs` on an existing local path or with `image`; it never asks Trivy to clone over SSH
# or to run `trivy server`, so no SSH connection -- as client or server -- ever exists in
# the image and the affected code path is not reachable. Remove this temporary
# suppression as soon as a fixed Trivy release is available.
- id: CVE-2026-56855
purls:
- "pkg:golang/golang.org/x/crypto@v0.55.0"
expired_at: 2026-10-15
- id: CVE-2026-78662
purls:
- "pkg:golang/golang.org/x/crypto@v0.55.0"
expired_at: 2026-10-15
- id: CVE-2026-56852
purls:
- "pkg:golang/golang.org/x/text"
+1 -1
View File
@@ -139,7 +139,7 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically
| MongoDB Atlas | 10 | 3 | 1 | 8 | Official | UI, API, CLI |
| LLM | [See `promptfoo` docs.](https://www.promptfoo.dev/docs/red-team/plugins/) | N/A | N/A | N/A | Official | CLI |
| Image | N/A | N/A | N/A | N/A | Official | UI, API, CLI |
| Google Workspace | 65 | 11 | 3 | 6 | Official | UI, API, CLI |
| Google Workspace | 65 | 11 | 4 | 6 | Official | UI, API, CLI |
| OpenStack | 34 | 5 | 1 | 9 | Official | UI, API, CLI |
| Vercel | 26 | 6 | 1 | 8 | Official | UI, API, CLI |
| Okta | 29 | 8 | 2 | 2 | Official | UI, API, CLI |
+4
View File
@@ -59,5 +59,9 @@ DJANGO_GITHUB_OAUTH_CLIENT_ID=""
DJANGO_GITHUB_OAUTH_CLIENT_SECRET=""
DJANGO_GITHUB_OAUTH_CALLBACK_URL=""
# Public base URL of the Prowler UI, used to link Jira issues back to findings.
# Leave empty to omit the link.
DJANGO_UI_BASE_URL=""
# Deletion Task Batch Size
DJANGO_DELETION_BATCH_SIZE=5000
+25
View File
@@ -4,6 +4,31 @@ All notable changes to the **Prowler API** are documented in this file.
<!-- changelog: release notes start -->
## [1.42.0] (Prowler v5.41.0)
### 🚀 Added
- Jira issues created from Prowler Cloud now carry the `prowler`, `prowler-<provider>`, `prowler-<severity>`, `prowler-<check-id>`, and `prowler-finding-<finding-uid>` labels, a link back to the finding when `DJANGO_UI_BASE_URL` is configured, and the tenant name [(#12540)](https://github.com/prowler-cloud/prowler/pull/12540)
### 🐞 Fixed
- `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 [(#12681)](https://github.com/prowler-cloud/prowler/pull/12681)
---
## [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
@@ -1 +0,0 @@
`openssl`, `libssl3t64` and `openssl-provider-legacy` upgraded to 3.5.7-1~deb13u2 in the API container image, patching ten high OpenSSL CVEs
@@ -0,0 +1 @@
Speed up compliance overview ingestion by reading ThreatScore mappings from the compliance template instead of each finding, generating time-ordered `uuid7` row ids and grouping inserted rows by framework and requirement
-1
View File
@@ -1 +0,0 @@
`sqlparse` upgraded to 0.6.0, patching CVE-2026-54284, CVE-2026-59893, and CVE-2026-71491
+1 -1
View File
@@ -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.43.0"
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
# target-version tracks this project's lowest supported Python.
+13 -5
View File
@@ -6,6 +6,7 @@ from api.rls import RowLevelSecurityConstraint
from api.uuid_utils import datetime_to_uuid7
from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from psqlextra.partitioning import (
PostgresPartitioningError,
PostgresPartitioningManager,
@@ -153,10 +154,17 @@ class PostgresUUIDv7PartitioningStrategy(PostgresRangePartitioningStrategy):
)
def relative_days_or_none(value):
if value is None:
def relative_months_or_none(value):
# A negative value would set the cutoff in the future and delete every
# partition, so it is rejected rather than silently ignored.
if value is not None and value < 0:
raise ImproperlyConfigured(
"FINDINGS_TABLE_PARTITION_MAX_AGE_MONTHS must not be negative; "
"leave it unset or use 0 to keep partitions indefinitely"
)
if not value:
return None
return relativedelta(days=value)
return relativedelta(months=value)
#
@@ -173,7 +181,7 @@ manager = PostgresPartitioningManager(
months=settings.FINDINGS_TABLE_PARTITION_MONTHS
),
count=settings.FINDINGS_TABLE_PARTITION_COUNT,
max_age=relative_days_or_none(
max_age=relative_months_or_none(
settings.FINDINGS_TABLE_PARTITION_MAX_AGE_MONTHS
),
name_format="%Y_%b",
@@ -189,7 +197,7 @@ manager = PostgresPartitioningManager(
months=settings.FINDINGS_TABLE_PARTITION_MONTHS
),
count=settings.FINDINGS_TABLE_PARTITION_COUNT,
max_age=relative_days_or_none(
max_age=relative_months_or_none(
settings.FINDINGS_TABLE_PARTITION_MAX_AGE_MONTHS
),
name_format="%Y_%b",
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
version: 1.41.0
version: 1.43.0
description: |-
Prowler API specification.
@@ -0,0 +1,63 @@
from datetime import UTC, datetime
from itertools import islice
import pytest
from api.partitions import (
PostgresUUIDv7PartitioningStrategy,
relative_months_or_none,
)
from dateutil.relativedelta import relativedelta
from django.core.exceptions import ImproperlyConfigured
from psqlextra.partitioning import PostgresTimePartitionSize
def build_strategy(max_age):
return PostgresUUIDv7PartitioningStrategy(
size=PostgresTimePartitionSize(months=1),
count=1,
start_date=datetime.now(UTC),
max_age=max_age,
name_format="%Y_%b",
)
class TestRelativeMonthsOrNone:
@pytest.mark.parametrize("value", [None, 0])
def test_unset_or_zero_keeps_partitions_indefinitely(self, value):
assert relative_months_or_none(value) is None
@pytest.mark.parametrize("months", [1, 3, 12])
def test_value_is_interpreted_as_months(self, months):
assert relative_months_or_none(months) == relativedelta(months=months)
def test_value_is_not_interpreted_as_days(self):
assert relative_months_or_none(12) != relativedelta(days=12)
def test_negative_is_rejected(self):
with pytest.raises(ImproperlyConfigured):
relative_months_or_none(-12)
class TestToDelete:
@pytest.mark.parametrize("max_age", [None, relative_months_or_none(0)])
def test_nothing_is_deleted_without_max_age(self, max_age):
strategy = build_strategy(max_age)
assert list(islice(strategy.to_delete(), 5)) == []
def test_first_deleted_partition_is_max_age_old(self):
months = 3
strategy = build_strategy(relative_months_or_none(months))
first = next(strategy.to_delete())
expected = strategy.get_start_datetime() - relativedelta(months=months)
assert first.name() == expected.strftime("%Y_%b").lower()
def test_deleted_partitions_go_further_back_in_time(self):
strategy = build_strategy(relative_months_or_none(3))
names = [p.name() for p in islice(strategy.to_delete(), 3)]
starts = [datetime.strptime(n, "%Y_%b") for n in names]
assert starts == sorted(starts, reverse=True)
+18 -27
View File
@@ -18333,19 +18333,14 @@ class TestMuteRuleViewSet:
assert len(data) == 2
assert data[0]["id"] == str(mute_rules_fixture[first_index].id)
@patch("api.v1.views.chain")
@patch("api.v1.views.reaggregate_all_finding_group_summaries_task.si")
@patch("api.v1.views.mute_historical_findings_task.si")
@patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async")
@patch("api.v1.views.transaction.on_commit", side_effect=lambda fn: fn())
def test_mute_rules_create_valid(
self,
_mock_on_commit,
mock_mute_signature,
mock_reaggregate_signature,
mock_chain,
mock_mute_task,
authenticated_client,
findings_fixture,
create_test_user,
):
"""Test creating a valid mute rule."""
finding_ids = [str(findings_fixture[0].id)]
@@ -18372,24 +18367,20 @@ class TestMuteRuleViewSet:
assert response_data["attributes"]["name"] == "New Mute Rule"
assert response_data["attributes"]["reason"] == "Security exception approved"
# Verify the finding was immediately muted
from api.models import Finding
finding = Finding.objects.get(id=findings_fixture[0].id)
assert finding.muted is True
assert finding.muted_at is not None
assert finding.muted_reason == "Security exception approved"
assert finding.muted is False
assert finding.muted_at is None
assert finding.muted_reason is None
# Verify background task chain was called: mute → reaggregate all
mock_mute_signature.assert_called_once()
mock_reaggregate_signature.assert_called_once()
mock_chain.assert_called_once_with(
mock_mute_signature.return_value,
mock_reaggregate_signature.return_value,
mock_mute_task.assert_called_once_with(
kwargs={
"tenant_id": str(finding.tenant_id),
"mute_rule_id": response_data["id"],
"provider_ids": [str(finding.scan.provider_id)],
}
)
mock_chain.return_value.apply_async.assert_called_once()
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
@patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async")
def test_mute_rules_create_converts_finding_ids_to_uids(
self,
mock_task,
@@ -18425,7 +18416,7 @@ class TestMuteRuleViewSet:
]
assert set(mute_rule.finding_uids) == set(expected_uids)
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
@patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async")
def test_mute_rules_deduplicates_uids(
self,
mock_task,
@@ -18492,10 +18483,10 @@ class TestMuteRuleViewSet:
finding1.refresh_from_db()
finding2.refresh_from_db()
assert finding1.muted is True
assert finding2.muted is True
assert finding1.muted is False
assert finding2.muted is False
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
@patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async")
def test_mute_rules_create_overlap_detection_active(
self,
mock_task,
@@ -18528,7 +18519,7 @@ class TestMuteRuleViewSet:
"already muted" in error_detail.lower() or "overlap" in error_detail.lower()
)
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
@patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async")
def test_mute_rules_create_no_overlap_with_inactive(
self,
mock_task,
@@ -18584,7 +18575,7 @@ class TestMuteRuleViewSet:
== "/data/attributes/finding_ids"
)
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
@patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async")
def test_mute_rules_create_invalid_finding_ids(
self, mock_task, authenticated_client
):
+18 -27
View File
@@ -244,7 +244,6 @@ from api.v1.serializers import (
UserUpdateSerializer,
)
from botocore.exceptions import ClientError, NoCredentialsError, ParamValidationError
from celery import chain
from celery.result import AsyncResult
from config.custom_logging import BackendLogger
from config.env import env
@@ -342,8 +341,7 @@ from tasks.tasks import (
enqueue_scan_execution_on_commit,
get_active_provider_scan,
jira_integration_task,
mute_historical_findings_task,
reaggregate_all_finding_group_summaries_task,
mute_findings_in_latest_scans_task,
refresh_lighthouse_provider_models_task,
)
@@ -7551,35 +7549,28 @@ class MuteRuleViewSet(BaseRLSViewSet):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
# Create the mute rule
tenant_id = str(request.tenant_id)
finding_ids = serializer.validated_data["finding_ids"]
provider_ids = list(
dict.fromkeys(
Finding.all_objects.filter(
id__in=finding_ids, tenant_id=tenant_id
).values_list("scan__provider_id", flat=True)
)
)
mute_rule = serializer.save()
tenant_id = str(request.tenant_id)
finding_ids = request.data.get("finding_ids", [])
# Immediately mute the selected findings
Finding.all_objects.filter(
id__in=finding_ids, tenant_id=tenant_id, muted=False
).update(
muted=True,
muted_at=mute_rule.inserted_at,
muted_reason=mute_rule.reason,
)
# Launch background task for historical muting + reaggregation
transaction.on_commit(
lambda: chain(
mute_historical_findings_task.si(
tenant_id=tenant_id,
mute_rule_id=str(mute_rule.id),
),
reaggregate_all_finding_group_summaries_task.si(
tenant_id=tenant_id,
),
).apply_async()
lambda: mute_findings_in_latest_scans_task.apply_async(
kwargs={
"tenant_id": tenant_id,
"mute_rule_id": str(mute_rule.id),
"provider_ids": [str(provider_id) for provider_id in provider_ids],
}
)
)
# Return the created mute rule
serializer = self.get_serializer(mute_rule)
return Response(
data=serializer.data,
+5
View File
@@ -303,6 +303,11 @@ SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"
DJANGO_DELETION_BATCH_SIZE = env.int("DJANGO_DELETION_BATCH_SIZE", 5000)
# Public base URL of the Prowler UI (for example https://cloud.prowler.com). Used to
# build links back to findings in outbound integrations such as Jira. Empty by
# default, so self-hosted deployments emit no links unless they configure it.
UI_BASE_URL = env.str("DJANGO_UI_BASE_URL", "").rstrip("/")
# SAML requirement
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SECURE = True
+72 -1
View File
@@ -2,13 +2,16 @@ import os
import time
from datetime import UTC, datetime
from glob import glob
from urllib.parse import quote
from api.db_router import READ_REPLICA_ALIAS, MainRouter
from api.db_utils import REPLICA_MAX_ATTEMPTS, REPLICA_RETRY_BASE_DELAY, rls_transaction
from api.models import Finding, Integration, Provider
from api.rls import Tenant
from api.utils import initialize_prowler_integration, initialize_prowler_provider
from celery.utils.log import get_task_logger
from config.django.base import DJANGO_FINDINGS_BATCH_SIZE
from django.conf import settings
from django.db import OperationalError
from prowler.lib.outputs.asff.asff import ASFF
from prowler.lib.outputs.compliance.generic.generic import GenericCompliance
@@ -16,6 +19,7 @@ from prowler.lib.outputs.csv.csv import CSV
from prowler.lib.outputs.finding import Finding as FindingOutput
from prowler.lib.outputs.html.html import HTML
from prowler.lib.outputs.jira.exceptions.exceptions import JiraBaseException
from prowler.lib.outputs.jira.jira import Jira
from prowler.lib.outputs.ocsf.ocsf import OCSF
from prowler.providers.aws.aws_provider import AwsProvider
from prowler.providers.aws.lib.s3.s3 import S3
@@ -477,6 +481,55 @@ def upload_security_hub_integration(
return False
JIRA_LABEL_PREFIX = "prowler"
def build_jira_finding_url(finding_uid: str) -> str:
"""Build the Prowler UI link for a finding, or "" when no UI base URL is set.
The link filters by the finding ``uid`` rather than the per-scan record id so
it keeps resolving after the finding is seen again in later scans.
"""
base_url = getattr(settings, "UI_BASE_URL", "")
if not base_url or not finding_uid:
return ""
return f"{base_url}/findings?filter[uid]={quote(finding_uid, safe='')}"
def build_jira_issue_labels(
finding_uid: str, provider: str, severity: str, check_id: str
) -> list[str]:
"""Build the deterministic label set written to every Jira issue.
Labels are prefixed to avoid colliding with customer labels and sanitized so
Jira never rejects them; the finding-uid label is what lets a ticket be traced
back (or JQL-filtered) to its finding.
"""
raw_labels = [
JIRA_LABEL_PREFIX,
f"{JIRA_LABEL_PREFIX}-{provider}" if provider else "",
f"{JIRA_LABEL_PREFIX}-{severity}" if severity else "",
f"{JIRA_LABEL_PREFIX}-{check_id}" if check_id else "",
Jira.build_finding_label(finding_uid),
]
return Jira.sanitize_labels(raw_labels)
def get_tenant_name(tenant_id: str) -> str:
"""Return the tenant name for the Jira issue "Tenant Info" row, or "" if unknown.
The name is informational only, so a lookup failure must never block the send.
"""
try:
return (
Tenant.objects.filter(id=tenant_id).values_list("name", flat=True).first()
or ""
)
except Exception:
logger.warning("Could not resolve tenant name for %s", tenant_id)
return ""
def send_findings_to_jira(
tenant_id: str,
integration_id: str,
@@ -487,6 +540,7 @@ def send_findings_to_jira(
with rls_transaction(tenant_id):
integration = Integration.objects.get(id=integration_id)
jira_integration = initialize_prowler_integration(integration)
tenant_info = get_tenant_name(tenant_id)
num_tickets_created = 0
error_messages = []
@@ -519,6 +573,15 @@ def send_findings_to_jira(
recommendation = remediation.get("recommendation", {})
remediation_code = remediation.get("code", {})
provider_type = finding_instance.scan.provider.provider
issue_labels = build_jira_issue_labels(
finding_uid=finding_instance.uid,
provider=provider_type,
severity=finding_instance.severity,
check_id=finding_instance.check_id,
)
finding_url = build_jira_finding_url(finding_instance.uid)
try:
# Send the individual finding to Jira
result = jira_integration.send_finding(
@@ -527,7 +590,7 @@ def send_findings_to_jira(
severity=finding_instance.severity,
status=finding_instance.status,
status_extended=finding_instance.status_extended or "",
provider=finding_instance.scan.provider.provider,
provider=provider_type,
region=region,
resource_uid=resource_uid,
resource_name=resource_name,
@@ -542,6 +605,9 @@ def send_findings_to_jira(
compliance=finding_instance.compliance or {},
project_key=project_key,
issue_type=issue_type,
issue_labels=issue_labels,
finding_url=finding_url,
tenant_info=tenant_info,
)
except JiraBaseException as error:
error_message = error.message or JIRA_GENERIC_SEND_ERROR
@@ -557,6 +623,11 @@ def send_findings_to_jira(
if result:
num_tickets_created += 1
logger.info(
"Finding %s sent to Jira as %s",
finding_id,
result.get("key") if isinstance(result, dict) else result,
)
else:
error_message = JIRA_GENERIC_SEND_ERROR
logger.error(error_message)
+86 -45
View File
@@ -1,63 +1,104 @@
from collections.abc import Iterable
from api.db_utils import rls_transaction
from api.models import Finding, MuteRule
from api.models import Finding, MuteRule, Scan, StateChoices
from celery.utils.log import get_task_logger
from config.django.base import DJANGO_FINDINGS_BATCH_SIZE
from tasks.utils import batched
logger = get_task_logger(__name__)
def mute_historical_findings(tenant_id: str, mute_rule_id: str):
"""
Mute historical findings that match the given mute rule.
def _mute_findings_for_rule(
*,
tenant_id: str,
scan_id: str,
finding_uids: Iterable[str],
muted_at,
muted_reason: str,
) -> int:
finding_uids = list(finding_uids)
if not finding_uids:
return 0
This function processes findings in batches, updating their muted status
and adding the mute reason.
return Finding.all_objects.filter(
tenant_id=tenant_id,
scan_id=scan_id,
uid__in=finding_uids,
muted=False,
).update(
muted=True,
muted_at=muted_at,
muted_reason=muted_reason,
)
Args:
tenant_id (str): The tenant ID for RLS context
mute_rule_id (str): The ID of the mute rule to apply
Returns:
dict: Summary of the muting operation with findings_muted count
"""
findings_muted_count = 0
def mute_findings_in_latest_scans(
tenant_id: str, mute_rule_id: str, provider_ids: list[str]
) -> dict:
"""Apply a mute rule to the latest completed scan of each provider."""
provider_ids = list(dict.fromkeys(provider_ids))
# Get the list of UIDs to mute and the reason
with rls_transaction(tenant_id):
mute_rule = MuteRule.objects.get(id=mute_rule_id, tenant_id=tenant_id)
finding_uids = mute_rule.finding_uids
mute_reason = mute_rule.reason
muted_at = mute_rule.inserted_at
# Query findings that match the UIDs and are not already muted
with rls_transaction(tenant_id):
findings_to_mute = Finding.objects.filter(
tenant_id=tenant_id, uid__in=finding_uids, muted=False
)
total_findings = findings_to_mute.count()
logger.info(
f"Processing {total_findings} findings for mute rule {mute_rule_id}"
latest_scans = list(
Scan.objects.filter(
tenant_id=tenant_id,
provider_id__in=provider_ids,
state=StateChoices.COMPLETED,
completed_at__isnull=False,
)
.order_by("provider_id", "-completed_at", "-inserted_at", "-id")
.distinct("provider_id")
.values_list("id", flat=True)
)
if total_findings > 0:
for batch, is_last in batched(
findings_to_mute.iterator(), DJANGO_FINDINGS_BATCH_SIZE
):
batch_ids = [f.id for f in batch]
updated_count = Finding.all_objects.filter(
id__in=batch_ids, tenant_id=tenant_id
).update(
muted=True,
muted_at=muted_at,
muted_reason=mute_reason,
)
findings_muted_count += updated_count
logger.info(f"Muted {findings_muted_count} findings for rule {mute_rule_id}")
changed_scan_ids = []
findings_muted = 0
for scan_id in latest_scans:
updated = _mute_findings_for_rule(
tenant_id=tenant_id,
scan_id=str(scan_id),
finding_uids=mute_rule.finding_uids,
muted_at=mute_rule.inserted_at,
muted_reason=mute_rule.reason,
)
if updated:
findings_muted += updated
changed_scan_ids.append(str(scan_id))
logger.info(
"Muted %d findings in %d latest scans for rule %s",
findings_muted,
len(changed_scan_ids),
mute_rule_id,
)
return {
"findings_muted": findings_muted_count,
"findings_muted": findings_muted,
"rule_id": mute_rule_id,
"scan_ids": changed_scan_ids,
}
def reconcile_scan_mute_rules(tenant_id: str, scan_id: str) -> dict:
"""Apply the current enabled mute rules to one completed scan."""
findings_muted = 0
with rls_transaction(tenant_id):
mute_rules = MuteRule.objects.filter(tenant_id=tenant_id, enabled=True).values(
"finding_uids", "reason", "inserted_at"
)
for mute_rule in mute_rules:
findings_muted += _mute_findings_for_rule(
tenant_id=tenant_id,
scan_id=scan_id,
finding_uids=mute_rule["finding_uids"],
muted_at=mute_rule["inserted_at"],
muted_reason=mute_rule["reason"],
)
logger.info(
"Reconciled mute rules for scan %s; muted %d findings",
scan_id,
findings_muted,
)
return {"findings_muted": findings_muted, "scan_id": str(scan_id)}
+57 -36
View File
@@ -5,7 +5,6 @@ import json
import random
import re
import time
import uuid
from collections import defaultdict
from collections.abc import Callable, Iterable
from datetime import UTC, datetime
@@ -73,6 +72,7 @@ from tasks.jobs.queries import (
COMPLIANCE_UPSERT_TENANT_SUMMARY_SQL,
)
from tasks.utils import CustomEncoder, batched
from uuid6 import uuid7
logger = get_task_logger(__name__)
@@ -1756,32 +1756,27 @@ def aggregate_findings(tenant_id: str, scan_id: str):
def _aggregate_findings_by_region(
tenant_id: str, scan_id: str, modeled_threatscore_compliance_id: str
tenant_id: str,
scan_id: str,
normalized_threatscore_id: str,
threatscore_requirements_by_check: dict[str, list[str]],
) -> tuple[dict, dict]:
"""
Aggregate findings by region using streaming, column-scoped ORM reads.
Reads only the consumed columns as tuples via ``values_list`` and streams
them with ``.iterator()``, using the denormalized ``resource_regions`` array
instead of ``prefetch_related("resources")``. ``resource_regions`` mirrors the
regions of a finding's related resources, so it yields the same per-region
tally without joining the resource table.
Args:
tenant_id: Tenant UUID
scan_id: Scan UUID
modeled_threatscore_compliance_id: ID for ThreatScore compliance framework
instead of ``prefetch_related("resources")``. ThreatScore requirement ids
are resolved per ``check_id`` from ``threatscore_requirements_by_check``.
Returns:
tuple: (check_status_by_region, findings_count_by_compliance)
- check_status_by_region: {region: {check_id: status}}
- findings_count_by_compliance: {region: {normalized_id: {requirement_id: {total, pass}}}}
- findings_count_by_compliance: {region: {normalized_threatscore_id: {requirement_id: {total, pass}}}}
"""
check_status_by_region: dict = {}
findings_count_by_compliance: dict = {}
normalized_id = re.sub(r"[^a-z0-9]", "", modeled_threatscore_compliance_id.lower())
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
findings = (
Finding.all_objects.filter(
@@ -1790,14 +1785,12 @@ def _aggregate_findings_by_region(
muted=False,
status__in=["PASS", "FAIL"],
)
.values_list("check_id", "status", "resource_regions", "compliance")
.values_list("check_id", "status", "resource_regions")
.iterator(chunk_size=DJANGO_FINDINGS_BATCH_SIZE)
)
for check_id, status, resource_regions, compliance in findings:
threatscore_requirements = (compliance or {}).get(
modeled_threatscore_compliance_id
)
for check_id, status, resource_regions in findings:
threatscore_requirements = threatscore_requirements_by_check.get(check_id)
for region in resource_regions or ():
# Priority: FAIL > any other status
@@ -1809,7 +1802,7 @@ def _aggregate_findings_by_region(
if threatscore_requirements:
compliance_key = findings_count_by_compliance.setdefault(
region, {}
).setdefault(normalized_id, {})
).setdefault(normalized_threatscore_id, {})
for requirement_id in threatscore_requirements:
requirement_stats = compliance_key.setdefault(
@@ -1848,15 +1841,28 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
compliance_template = PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE[
provider_instance.provider
]
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
normalized_threatscore_id = _normalized_compliance_key(
"ProwlerThreatScore", "1.0"
)
requirement_lookup: dict[str, list[tuple[str, str]]] = {}
threatscore_requirements_by_check: dict[str, list[str]] = {}
for compliance_id, compliance in compliance_template.items():
is_threatscore = (
_normalized_compliance_key(
compliance["framework"], compliance["version"]
)
== normalized_threatscore_id
)
for requirement_id, requirement in compliance["requirements"].items():
for check_id in requirement["checks"].keys():
requirement_lookup.setdefault(check_id, []).append(
(compliance_id, requirement_id)
)
if is_threatscore:
threatscore_requirements_by_check.setdefault(
check_id, []
).append(requirement_id)
regions = []
requirements_created = 0
@@ -1869,7 +1875,10 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
# Aggregate findings by region using SQL for optimal performance
check_status_by_region, findings_count_by_compliance = (
_aggregate_findings_by_region(
tenant_id, scan_id, modeled_threatscore_compliance_id
tenant_id,
scan_id,
normalized_threatscore_id,
threatscore_requirements_by_check,
)
)
@@ -1934,23 +1943,35 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
# Yield rows lazily (consumed batch-by-batch by COPY) so peak memory
# stays bounded; tally requirement_statuses in the same pass. The
# ORM fallback re-iterates from scratch, so the tally resets first.
# Region is the innermost loop so consecutive rows share the leading
# columns of the table's secondary indexes.
def _iter_compliance_requirement_rows():
requirement_statuses.clear()
for region in regions:
region_stats = region_requirement_stats.get(region, {})
region_findings = findings_count_by_compliance.get(region, {})
for (
compliance_id,
framework,
version,
modeled_compliance_id,
requirements,
) in compliance_plan:
compliance_stats = region_stats.get(compliance_id, {})
compliance_findings = region_findings.get(
modeled_compliance_id, {}
for (
compliance_id,
framework,
version,
modeled_compliance_id,
requirements,
) in compliance_plan:
stats_by_region = [
(
region,
region_requirement_stats.get(region, {}).get(
compliance_id, {}
),
findings_count_by_compliance.get(region, {}).get(
modeled_compliance_id, {}
),
)
for requirement_id, description, total_checks in requirements:
for region in regions
]
for requirement_id, description, total_checks in requirements:
for (
region,
compliance_stats,
compliance_findings,
) in stats_by_region:
stats = compliance_stats.get(requirement_id)
if stats:
passed_checks = stats["passed_checks"]
@@ -1981,7 +2002,7 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
requirement_statuses[key]["pass_count"] += 1
yield {
"id": uuid.uuid4(),
"id": uuid7(),
"tenant_id": tenant_id_str,
"inserted_at": utc_datetime_now,
"compliance_id": compliance_id,
+45 -99
View File
@@ -73,7 +73,10 @@ from tasks.jobs.lighthouse_providers import (
check_lighthouse_provider_connection,
refresh_lighthouse_provider_models,
)
from tasks.jobs.muting import mute_historical_findings
from tasks.jobs.muting import (
mute_findings_in_latest_scans,
reconcile_scan_mute_rules,
)
from tasks.jobs.orphan_recovery import reconcile_orphans
from tasks.jobs.report import (
STALE_TMP_OUTPUT_MAX_AGE_HOURS,
@@ -526,6 +529,7 @@ def perform_scan_task(
provider_id=provider_id,
checks_to_execute=checks_to_execute,
)
reconcile_scan_mute_rules(tenant_id, scan_id)
_perform_scan_complete_tasks(tenant_id, scan_id, provider_id)
return result
finally:
@@ -635,6 +639,7 @@ def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str):
scan_id=str(scan_instance.id),
provider_id=provider_id,
)
reconcile_scan_mute_rules(tenant_id, str(scan_instance.id))
_perform_scan_complete_tasks(tenant_id, str(scan_instance.id), provider_id)
return result
finally:
@@ -1188,85 +1193,48 @@ def aggregate_finding_group_summaries_task(tenant_id: str, scan_id: str):
return aggregate_finding_group_summaries(tenant_id=tenant_id, scan_id=scan_id)
@shared_task(
base=RLSTask, name="reaggregate-all-finding-group-summaries", queue="overview"
)
@set_tenant(keep_tenant=True)
def reaggregate_all_finding_group_summaries_task(tenant_id: str):
"""Reaggregate every pre-aggregated summary table for this tenant.
def _dispatch_scan_summary_reaggregation(tenant_id: str, scan_ids: list[str]) -> None:
if not scan_ids:
return
Mirrors the unbounded scope of `mute_historical_findings_task`: that task
rewrites every Finding row whose UID matches a mute rule, with no time
limit. To keep the pre-aggregated tables consistent with that update,
this task re-runs the same per-scan aggregation pipeline that scan
completion runs on the latest completed scan of every (provider, day)
pair, rebuilding the tables that power the read endpoints:
- `ScanSummary` and `DailySeveritySummary` -> `/overviews/findings`,
`/overviews/findings-severity`, `/overviews/services`.
- `FindingGroupDailySummary` -> `/finding-groups` and
`/finding-groups/latest`.
- `ScanGroupSummary` -> `/overviews/resource-groups` (resource
inventory).
- `ScanCategorySummary` -> `/overviews/categories`.
- `AttackSurfaceOverview` -> `/overviews/attack-surfaces`.
Per-scan pipelines are dispatched in parallel via a Celery group so
wallclock scales with the worker pool.
"""
completed_scans = list(
Scan.objects.filter(
tenant_id=tenant_id,
state=StateChoices.COMPLETED,
completed_at__isnull=False,
)
.order_by("-completed_at")
.values("id", "completed_at", "provider_id")
logger.info(
"Reaggregating overview/finding summaries for %d latest scans",
len(scan_ids),
)
# Keep the latest scan per (provider, day) pair so the daily summary row
# the aggregator writes is the most recent snapshot of that day for that
# provider. Iterating from most recent to oldest means the first scan we
# see for a given key wins.
latest_scans: dict[tuple, str] = {}
for scan in completed_scans:
key = (scan["provider_id"], scan["completed_at"].date())
if key not in latest_scans:
latest_scans[key] = str(scan["id"])
scan_ids = list(latest_scans.values())
if scan_ids:
logger.info(
"Reaggregating overview/finding summaries for %d scans (provider x day)",
len(scan_ids),
)
# DailySeveritySummary reads from ScanSummary, so ScanSummary must be
# recomputed first; the other aggregators read Finding directly and
# can run in parallel with the severity step.
group(
chain(
perform_scan_summary_task.si(tenant_id=tenant_id, scan_id=scan_id),
group(
aggregate_daily_severity_task.si(
tenant_id=tenant_id, scan_id=scan_id
),
aggregate_finding_group_summaries_task.si(
tenant_id=tenant_id, scan_id=scan_id
),
aggregate_scan_resource_group_summaries_task.si(
tenant_id=tenant_id, scan_id=scan_id
),
aggregate_scan_category_summaries_task.si(
tenant_id=tenant_id, scan_id=scan_id
),
aggregate_attack_surface_task.si(
tenant_id=tenant_id, scan_id=scan_id
),
group(
chain(
perform_scan_summary_task.si(tenant_id=tenant_id, scan_id=scan_id),
group(
aggregate_daily_severity_task.si(tenant_id=tenant_id, scan_id=scan_id),
aggregate_finding_group_summaries_task.si(
tenant_id=tenant_id, scan_id=scan_id
),
)
for scan_id in scan_ids
).apply_async()
return {"scans_reaggregated": len(scan_ids)}
aggregate_scan_resource_group_summaries_task.si(
tenant_id=tenant_id, scan_id=scan_id
),
aggregate_scan_category_summaries_task.si(
tenant_id=tenant_id, scan_id=scan_id
),
aggregate_attack_surface_task.si(tenant_id=tenant_id, scan_id=scan_id),
),
)
for scan_id in scan_ids
).apply_async()
@shared_task(base=RLSTask, name="findings-mute-latest-scans", queue="overview")
@set_tenant(keep_tenant=True)
def mute_findings_in_latest_scans_task(
tenant_id: str, mute_rule_id: str, provider_ids: list[str]
):
"""Apply a mute rule to current scans and rebuild only changed summaries."""
result = mute_findings_in_latest_scans(
tenant_id=tenant_id,
mute_rule_id=mute_rule_id,
provider_ids=provider_ids,
)
_dispatch_scan_summary_reaggregation(tenant_id, result["scan_ids"])
return result
@shared_task(base=RLSTask, name="lighthouse-connection-check")
@@ -1467,25 +1435,3 @@ def generate_compliance_reports_task(tenant_id: str, scan_id: str, provider_id:
generate_csa=True,
generate_cis=True,
)
@shared_task(name="findings-mute-historical")
def mute_historical_findings_task(tenant_id: str, mute_rule_id: str):
"""
Background task to mute all historical findings matching a mute rule.
This task processes findings in batches to avoid memory issues with large datasets.
It updates the Finding.muted, Finding.muted_at, and Finding.muted_reason fields
for all findings whose UID is in the mute rule's finding_uids list.
Args:
tenant_id (str): The tenant ID for RLS context.
mute_rule_id (str): The primary key of the MuteRule to apply.
Returns:
dict: A dictionary containing:
- 'findings_muted' (int): Total number of findings muted.
- 'rule_id' (str): The mute rule ID.
- 'status' (str): Final status ('completed').
"""
return mute_historical_findings(tenant_id, mute_rule_id)
@@ -6,15 +6,20 @@ from api.db_router import READ_REPLICA_ALIAS, MainRouter
from api.models import Integration
from api.utils import prowler_integration_connection_test
from django.db import OperationalError
from django.test import override_settings
from prowler.lib.outputs.jira.exceptions.exceptions import (
JiraRefreshTokenError,
JiraRequiredCustomFieldsError,
)
from prowler.lib.outputs.jira.jira import Jira
from prowler.providers.aws.lib.security_hub.security_hub import SecurityHubConnection
from prowler.providers.common.models import Connection
from tasks.jobs.integrations import (
build_jira_finding_url,
build_jira_issue_labels,
get_s3_client_from_integration,
get_security_hub_client_from_integration,
get_tenant_name,
send_findings_to_jira,
upload_s3_integration,
upload_security_hub_integration,
@@ -1696,6 +1701,7 @@ class TestJiraIntegration:
finding1 = MagicMock()
finding1.id = "finding-1"
finding1.uid = "prowler-aws-check_001-123456789012-us-east-1-my bucket"
finding1.check_id = "check_001"
finding1.severity = "high"
finding1.status = "FAIL"
@@ -1724,6 +1730,7 @@ class TestJiraIntegration:
finding2 = MagicMock()
finding2.id = "finding-2"
finding2.uid = "prowler-azure-check_002-sub/resource"
finding2.check_id = "check_002"
finding2.severity = "medium"
finding2.status = "PASS"
@@ -1748,9 +1755,13 @@ class TestJiraIntegration:
]
# Call the function
result = send_findings_to_jira(
tenant_id, integration_id, project_key, issue_type, finding_ids
)
with (
override_settings(UI_BASE_URL="https://cloud.example.com"),
patch("tasks.jobs.integrations.get_tenant_name", return_value="Acme"),
):
result = send_findings_to_jira(
tenant_id, integration_id, project_key, issue_type, finding_ids
)
# Assertions
assert result == {"created_count": 2, "failed_count": 0}
@@ -1773,12 +1784,36 @@ class TestJiraIntegration:
assert first_call.kwargs["provider"] == "aws"
assert first_call.kwargs["project_key"] == project_key
assert first_call.kwargs["issue_type"] == issue_type
# Finding reference: labels, link back and tenant info
assert first_call.kwargs["issue_labels"] == [
"prowler",
"prowler-aws",
"prowler-high",
"prowler-check_001",
"prowler-finding-prowler-aws-check_001-123456789012-us-east-1-my_bucket",
]
assert first_call.kwargs["finding_url"] == (
"https://cloud.example.com/findings?filter[uid]="
"prowler-aws-check_001-123456789012-us-east-1-my%20bucket"
)
assert first_call.kwargs["tenant_info"] == "Acme"
# Verify second call
second_call = mock_jira_integration.send_finding.call_args_list[1]
assert second_call.kwargs["check_id"] == "check_002"
assert second_call.kwargs["severity"] == "medium"
assert second_call.kwargs["status"] == "PASS"
assert second_call.kwargs["issue_labels"] == [
"prowler",
"prowler-azure",
"prowler-medium",
"prowler-check_002",
"prowler-finding-prowler-azure-check_002-sub/resource",
]
assert second_call.kwargs["finding_url"] == (
"https://cloud.example.com/findings?filter[uid]="
"prowler-azure-check_002-sub%2Fresource"
)
@patch("tasks.jobs.integrations.rls_transaction")
@patch("tasks.jobs.integrations.Finding")
@@ -2200,3 +2235,101 @@ class TestJiraIntegration:
assert call_kwargs["remediation_code_cli"] == ""
assert call_kwargs["remediation_code_other"] == ""
assert call_kwargs["compliance"] == {}
class TestJiraFindingReference:
"""Helpers that give Jira issues a stable reference back to the finding."""
def test_build_jira_issue_labels(self):
assert build_jira_issue_labels(
finding_uid="prowler-aws-check-123-eu-west-1-hub/unknown",
provider="aws",
severity="critical",
check_id="iam_root_mfa",
) == [
"prowler",
"prowler-aws",
"prowler-critical",
"prowler-iam_root_mfa",
"prowler-finding-prowler-aws-check-123-eu-west-1-hub/unknown",
]
def test_build_jira_issue_labels_skips_empty_parts(self):
assert build_jira_issue_labels(
finding_uid="", provider="", severity="", check_id=""
) == ["prowler"]
def test_build_jira_issue_labels_sanitizes_metadata(self):
assert build_jira_issue_labels(
finding_uid=" uid\x00 with spaces ",
provider="aws cloud",
severity="high severity",
check_id="check id",
) == [
"prowler",
"prowler-aws_cloud",
"prowler-high_severity",
"prowler-check_id",
"prowler-finding-uid_with_spaces",
]
def test_build_jira_issue_labels_preserves_maximum_length_uid(self):
finding_uid = "u" * (Jira.LABEL_MAX_LENGTH - len(Jira.FINDING_LABEL_PREFIX) - 1)
finding_label = build_jira_issue_labels(
finding_uid=finding_uid,
provider="gcp",
severity="low",
check_id="check",
)[-1]
assert finding_label == f"{Jira.FINDING_LABEL_PREFIX}-{finding_uid}"
assert len(finding_label) == Jira.LABEL_MAX_LENGTH
def test_build_jira_issue_labels_distinguishes_long_uids(self):
common_prefix = "u" * 300
first_uid = f"{common_prefix}-first"
second_uid = f"{common_prefix}-second"
first_label = build_jira_issue_labels(
finding_uid=first_uid,
provider="gcp",
severity="low",
check_id="check",
)[-1]
second_label = build_jira_issue_labels(
finding_uid=second_uid,
provider="gcp",
severity="low",
check_id="check",
)[-1]
assert first_label == Jira.build_finding_label(first_uid)
assert second_label == Jira.build_finding_label(second_uid)
assert first_label != second_label
assert len(first_label) == Jira.LABEL_MAX_LENGTH
assert len(second_label) == Jira.LABEL_MAX_LENGTH
@override_settings(UI_BASE_URL="")
def test_build_jira_finding_url_without_base_url(self):
assert build_jira_finding_url("prowler-aws-check-1") == ""
@override_settings(UI_BASE_URL="https://cloud.example.com")
def test_build_jira_finding_url_with_base_url(self):
assert build_jira_finding_url("prowler-aws-check-1") == (
"https://cloud.example.com/findings?filter[uid]=prowler-aws-check-1"
)
# uid characters that would break the query string are encoded
assert build_jira_finding_url("a/b c&d") == (
"https://cloud.example.com/findings?filter[uid]=a%2Fb%20c%26d"
)
assert build_jira_finding_url("") == ""
@pytest.mark.django_db
def test_get_tenant_name(self, tenants_fixture):
tenant = tenants_fixture[0]
assert get_tenant_name(str(tenant.id)) == tenant.name
@pytest.mark.django_db
def test_get_tenant_name_unknown_or_invalid(self):
assert get_tenant_name("00000000-0000-0000-0000-000000000000") == ""
assert get_tenant_name("not-a-uuid") == ""
+176 -502
View File
@@ -1,531 +1,205 @@
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from uuid import uuid4
import pytest
from api.models import Finding, MuteRule
from django.core.exceptions import ObjectDoesNotExist
from api.models import Finding, MuteRule, Scan, StateChoices
from prowler.lib.check.models import Severity
from prowler.lib.outputs.finding import Status
from tasks.jobs.muting import mute_historical_findings
from tasks.jobs.muting import (
mute_findings_in_latest_scans,
reconcile_scan_mute_rules,
)
def _create_finding(scan: Scan, uid: str) -> Finding:
return Finding.objects.create(
tenant_id=scan.tenant_id,
uid=uid,
scan=scan,
status=Status.FAIL,
status_extended="Test finding",
impact=Severity.high,
severity=Severity.high,
raw_result={},
check_id="test_check",
check_metadata={"CheckId": "test_check"},
muted=False,
)
def _create_mute_rule(tenant_id, user, finding_uids, *, enabled=True) -> MuteRule:
return MuteRule.objects.create(
tenant_id=tenant_id,
name=f"Mute rule {uuid4()}",
reason="Approved exception",
enabled=enabled,
created_by=user,
finding_uids=finding_uids,
)
@pytest.mark.django_db
class TestMuteHistoricalFindings:
"""
Test suite for the mute_historical_findings function.
class TestMuteFindingsInLatestScans:
def test_mutes_latest_scan_and_leaves_older_scan_unchanged(
self, scans_fixture, create_test_user
):
latest_scan = scans_fixture[0]
older_scan = Scan.objects.create(
tenant_id=latest_scan.tenant_id,
provider=latest_scan.provider,
name="Older scan",
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
started_at=datetime.now(UTC) - timedelta(days=1),
completed_at=datetime.now(UTC) - timedelta(days=1),
)
uid = "latest-scan-only"
older_finding = _create_finding(older_scan, uid)
latest_finding = _create_finding(latest_scan, uid)
mute_rule = _create_mute_rule(latest_scan.tenant_id, create_test_user, [uid])
This class tests the batch processing of findings to update their muted status
based on MuteRule criteria.
"""
result = mute_findings_in_latest_scans(
str(latest_scan.tenant_id),
str(mute_rule.id),
[str(latest_scan.provider_id)],
)
@pytest.fixture(scope="function")
def test_user(self, create_test_user):
"""Create a test user for mute rule creation."""
return create_test_user
older_finding.refresh_from_db()
latest_finding.refresh_from_db()
assert older_finding.muted is False
assert latest_finding.muted is True
assert latest_finding.muted_at == mute_rule.inserted_at
assert latest_finding.muted_reason == mute_rule.reason
assert result == {
"findings_muted": 1,
"rule_id": str(mute_rule.id),
"scan_ids": [str(latest_scan.id)],
}
@pytest.fixture(scope="function")
def mute_rule_with_findings(self, tenants_fixture, findings_fixture, test_user):
"""
Create a mute rule that targets the first finding in the fixture.
"""
def test_mutes_one_latest_scan_per_provider(self, scans_fixture, create_test_user):
first_scan, second_scan, _ = scans_fixture
uid = "shared-selected-uid"
first_finding = _create_finding(first_scan, uid)
second_finding = _create_finding(second_scan, uid)
mute_rule = _create_mute_rule(first_scan.tenant_id, create_test_user, [uid])
result = mute_findings_in_latest_scans(
str(first_scan.tenant_id),
str(mute_rule.id),
[str(first_scan.provider_id), str(second_scan.provider_id)],
)
first_finding.refresh_from_db()
second_finding.refresh_from_db()
assert first_finding.muted is True
assert second_finding.muted is True
assert result["findings_muted"] == 2
assert set(result["scan_ids"]) == {str(first_scan.id), str(second_scan.id)}
def test_provider_without_completed_scan_does_nothing(
self, tenants_fixture, provider_factory, create_test_user
):
tenant = tenants_fixture[0]
finding = findings_fixture[0]
mute_rule = MuteRule.objects.create(
tenant_id=tenant.id,
name="Test Mute Rule",
reason="Testing mute functionality",
enabled=True,
created_by=test_user,
finding_uids=[finding.uid],
provider = provider_factory()
mute_rule = _create_mute_rule(
tenant.id, create_test_user, ["future-scan-finding"]
)
return mute_rule
result = mute_findings_in_latest_scans(
str(tenant.id), str(mute_rule.id), [str(provider.id)]
)
@pytest.fixture(scope="function")
def mute_rule_multiple_findings(self, scans_fixture, test_user):
"""
Create multiple unmuted findings and a mute rule targeting all of them.
"""
assert result == {
"findings_muted": 0,
"rule_id": str(mute_rule.id),
"scan_ids": [],
}
def test_retry_does_not_report_changed_scans_twice(
self, scans_fixture, create_test_user
):
scan = scans_fixture[0]
tenant_id = scan.tenant_id
# Create 5 unmuted findings
finding_uids = []
for i in range(5):
finding = Finding.objects.create(
tenant_id=tenant_id,
uid=f"test_finding_uid_mute_{i}",
scan=scan,
status=Status.FAIL,
status_extended=f"Test status {i}",
impact=Severity.high,
severity=Severity.high,
raw_result={
"status": Status.FAIL,
"impact": Severity.high,
"severity": Severity.high,
},
check_id=f"test_check_id_{i}",
check_metadata={
"CheckId": f"test_check_id_{i}",
"Description": f"Test description {i}",
},
muted=False,
)
finding_uids.append(finding.uid)
# Create mute rule targeting all findings
mute_rule = MuteRule.objects.create(
tenant_id=tenant_id,
name="Test Multiple Findings Mute Rule",
reason="Testing batch muting",
enabled=True,
created_by=test_user,
finding_uids=finding_uids,
finding = _create_finding(scan, "idempotent-mute")
mute_rule = _create_mute_rule(scan.tenant_id, create_test_user, [finding.uid])
args = (
str(scan.tenant_id),
str(mute_rule.id),
[str(scan.provider_id)],
)
return mute_rule, finding_uids
first_result = mute_findings_in_latest_scans(*args)
second_result = mute_findings_in_latest_scans(*args)
@pytest.fixture(scope="function")
def mute_rule_already_muted(self, findings_fixture, test_user):
"""
Create a mute rule that targets an already-muted finding.
"""
tenant_id = findings_fixture[1].tenant_id
already_muted_finding = findings_fixture[1]
assert first_result["scan_ids"] == [str(scan.id)]
assert second_result["findings_muted"] == 0
assert second_result["scan_ids"] == []
mute_rule = MuteRule.objects.create(
tenant_id=tenant_id,
name="Test Already Muted Rule",
reason="Testing already muted findings",
enabled=True,
created_by=test_user,
finding_uids=[already_muted_finding.uid],
def test_does_not_cross_tenant_boundary(
self, tenants_fixture, provider_factory, create_test_user
):
tenant = tenants_fixture[0]
other_tenant = tenants_fixture[2]
other_provider = provider_factory(tenant=other_tenant)
other_scan = Scan.objects.create(
tenant_id=other_tenant.id,
provider=other_provider,
name="Other tenant scan",
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
started_at=datetime.now(UTC),
completed_at=datetime.now(UTC),
)
other_finding = _create_finding(other_scan, "tenant-isolated-uid")
mute_rule = _create_mute_rule(tenant.id, create_test_user, [other_finding.uid])
result = mute_findings_in_latest_scans(
str(tenant.id), str(mute_rule.id), [str(other_provider.id)]
)
return mute_rule
other_finding.refresh_from_db()
assert other_finding.muted is False
assert result["scan_ids"] == []
@pytest.fixture(scope="function")
def mute_rule_mixed_findings(self, scans_fixture, test_user):
"""
Create a mute rule with a mix of muted and unmuted findings.
"""
def test_nonexistent_rule_raises(self, tenants_fixture):
with pytest.raises(MuteRule.DoesNotExist):
mute_findings_in_latest_scans(str(tenants_fixture[0].id), str(uuid4()), [])
@pytest.mark.django_db
class TestReconcileScanMuteRules:
def test_applies_only_enabled_rules_to_requested_scan(
self, scans_fixture, create_test_user
):
scan = scans_fixture[0]
tenant_id = scan.tenant_id
# Create 3 unmuted findings
unmuted_uids = []
for i in range(3):
finding = Finding.objects.create(
tenant_id=tenant_id,
uid=f"unmuted_finding_{i}",
scan=scan,
status=Status.FAIL,
status_extended=f"Unmuted status {i}",
impact=Severity.medium,
severity=Severity.medium,
raw_result={
"status": Status.FAIL,
"impact": Severity.medium,
"severity": Severity.medium,
},
check_id=f"unmuted_check_{i}",
check_metadata={
"CheckId": f"unmuted_check_{i}",
"Description": f"Unmuted description {i}",
},
muted=False,
)
unmuted_uids.append(finding.uid)
# Create 2 already muted findings
muted_uids = []
for i in range(2):
finding = Finding.objects.create(
tenant_id=tenant_id,
uid=f"muted_finding_{i}",
scan=scan,
status=Status.FAIL,
status_extended=f"Muted status {i}",
impact=Severity.low,
severity=Severity.low,
raw_result={
"status": Status.FAIL,
"impact": Severity.low,
"severity": Severity.low,
},
check_id=f"muted_check_{i}",
check_metadata={
"CheckId": f"muted_check_{i}",
"Description": f"Muted description {i}",
},
muted=True,
muted_at=datetime.now(UTC),
muted_reason="Already muted",
)
muted_uids.append(finding.uid)
# Create mute rule targeting all findings
all_uids = unmuted_uids + muted_uids
mute_rule = MuteRule.objects.create(
tenant_id=tenant_id,
name="Test Mixed Findings Rule",
reason="Testing mixed muted/unmuted findings",
enabled=True,
created_by=test_user,
finding_uids=all_uids,
active_finding = _create_finding(scan, "active-rule-uid")
disabled_finding = _create_finding(scan, "disabled-rule-uid")
active_rule = _create_mute_rule(
scan.tenant_id, create_test_user, [active_finding.uid]
)
return mute_rule, unmuted_uids, muted_uids
@pytest.fixture(scope="function")
def mute_rule_batch_test(self, scans_fixture, test_user):
"""
Create enough findings to test batch processing (>1000 for default batch size).
"""
scan = scans_fixture[0]
tenant_id = scan.tenant_id
# Create 1500 findings to exceed default batch size of 1000
finding_uids = []
for i in range(1500):
finding = Finding.objects.create(
tenant_id=tenant_id,
uid=f"batch_test_finding_{i}",
scan=scan,
status=Status.FAIL,
status_extended=f"Batch test status {i}",
impact=Severity.critical,
severity=Severity.critical,
raw_result={
"status": Status.FAIL,
"impact": Severity.critical,
"severity": Severity.critical,
},
check_id=f"batch_test_check_{i}",
check_metadata={
"CheckId": f"batch_test_check_{i}",
"Description": f"Batch test description {i}",
},
muted=False,
)
finding_uids.append(finding.uid)
# Create mute rule targeting all findings
mute_rule = MuteRule.objects.create(
tenant_id=tenant_id,
name="Test Batch Processing Rule",
reason="Testing batch processing functionality",
enabled=True,
created_by=test_user,
finding_uids=finding_uids,
_create_mute_rule(
scan.tenant_id,
create_test_user,
[disabled_finding.uid],
enabled=False,
)
return mute_rule, finding_uids
def test_mute_historical_findings_single_finding(
self, mute_rule_with_findings, findings_fixture
):
"""
Test muting a single historical finding.
"""
mute_rule = mute_rule_with_findings
tenant_id = str(mute_rule.tenant_id)
finding = findings_fixture[0]
# Ensure the finding is not muted before execution
finding.refresh_from_db()
assert finding.muted is False
assert finding.muted_at is None
assert finding.muted_reason is None
# Execute the muting function
result = mute_historical_findings(tenant_id, str(mute_rule.id))
# Verify return value
assert result["findings_muted"] == 1
assert result["rule_id"] == str(mute_rule.id)
# Verify the finding was muted
finding.refresh_from_db()
assert finding.muted is True
assert finding.muted_at == mute_rule.inserted_at
assert finding.muted_reason == mute_rule.reason
def test_mute_historical_findings_multiple_findings(
self, mute_rule_multiple_findings
):
"""
Test muting multiple historical findings.
"""
mute_rule, finding_uids = mute_rule_multiple_findings
tenant_id = str(mute_rule.tenant_id)
# Verify all findings are unmuted
findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=finding_uids)
assert findings.count() == 5
for finding in findings:
assert finding.muted is False
# Execute the muting function
result = mute_historical_findings(tenant_id, str(mute_rule.id))
# Verify return value
assert result["findings_muted"] == 5
assert result["rule_id"] == str(mute_rule.id)
# Verify all findings were muted
findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=finding_uids)
for finding in findings:
assert finding.muted is True
assert finding.muted_at == mute_rule.inserted_at
assert finding.muted_reason == mute_rule.reason
def test_mute_historical_findings_already_muted(
self, mute_rule_already_muted, findings_fixture
):
"""
Test that already-muted findings are not counted or updated.
"""
mute_rule = mute_rule_already_muted
tenant_id = str(mute_rule.tenant_id)
finding = findings_fixture[1]
# Verify the finding is already muted
finding.refresh_from_db()
assert finding.muted is True
original_muted_at = finding.muted_at
original_muted_reason = finding.muted_reason
# Execute the muting function
result = mute_historical_findings(tenant_id, str(mute_rule.id))
# Verify no findings were muted
assert result["findings_muted"] == 0
assert result["rule_id"] == str(mute_rule.id)
# Verify the finding's mute status did not change
finding.refresh_from_db()
assert finding.muted is True
assert finding.muted_at == original_muted_at
assert finding.muted_reason == original_muted_reason
def test_mute_historical_findings_mixed_status(self, mute_rule_mixed_findings):
"""
Test muting when some findings are already muted and others are not.
"""
mute_rule, unmuted_uids, muted_uids = mute_rule_mixed_findings
tenant_id = str(mute_rule.tenant_id)
# Execute the muting function
result = mute_historical_findings(tenant_id, str(mute_rule.id))
# Verify only unmuted findings were counted
assert result["findings_muted"] == 3
assert result["rule_id"] == str(mute_rule.id)
# Verify unmuted findings are now muted
unmuted_findings = Finding.objects.filter(
tenant_id=tenant_id, uid__in=unmuted_uids
older_scan = Scan.objects.create(
tenant_id=scan.tenant_id,
provider=scan.provider,
name="Older matching scan",
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
started_at=datetime.now(UTC) - timedelta(days=1),
completed_at=datetime.now(UTC) - timedelta(days=1),
)
for finding in unmuted_findings:
assert finding.muted is True
assert finding.muted_at == mute_rule.inserted_at
assert finding.muted_reason == mute_rule.reason
older_finding = _create_finding(older_scan, active_finding.uid)
# Verify already-muted findings remained unchanged
already_muted_findings = Finding.objects.filter(
tenant_id=tenant_id, uid__in=muted_uids
)
for finding in already_muted_findings:
assert finding.muted is True
assert finding.muted_reason == "Already muted"
result = reconcile_scan_mute_rules(str(scan.tenant_id), str(scan.id))
def test_mute_historical_findings_nonexistent_rule(self, tenants_fixture):
"""
Test that a nonexistent mute rule raises ObjectDoesNotExist.
"""
tenant_id = str(tenants_fixture[0].id)
nonexistent_rule_id = str(uuid4())
with pytest.raises(ObjectDoesNotExist):
mute_historical_findings(tenant_id, nonexistent_rule_id)
def test_mute_historical_findings_no_matching_findings(
self, tenants_fixture, test_user
):
"""
Test muting when no findings match the rule's UIDs.
"""
tenant_id = str(tenants_fixture[0].id)
# Create a mute rule with non-existent finding UIDs
mute_rule = MuteRule.objects.create(
tenant_id=tenant_id,
name="Test No Match Rule",
reason="Testing no matching findings",
enabled=True,
created_by=test_user,
finding_uids=[
"nonexistent_uid_1",
"nonexistent_uid_2",
"nonexistent_uid_3",
],
)
# Execute the muting function
result = mute_historical_findings(tenant_id, str(mute_rule.id))
# Verify no findings were muted
assert result["findings_muted"] == 0
assert result["rule_id"] == str(mute_rule.id)
def test_mute_historical_findings_batch_processing(self, mute_rule_batch_test):
"""
Test that large numbers of findings are processed in batches correctly.
"""
mute_rule, finding_uids = mute_rule_batch_test
tenant_id = str(mute_rule.tenant_id)
# Verify all findings exist and are unmuted
findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=finding_uids)
assert findings.count() == 1500
for finding in findings:
assert finding.muted is False
# Execute the muting function
result = mute_historical_findings(tenant_id, str(mute_rule.id))
# Verify return value
assert result["findings_muted"] == 1500
assert result["rule_id"] == str(mute_rule.id)
# Verify all findings were muted
findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=finding_uids)
for finding in findings:
assert finding.muted is True
assert finding.muted_at == mute_rule.inserted_at
assert finding.muted_reason == mute_rule.reason
def test_mute_historical_findings_preserves_muted_at_timestamp(
self, mute_rule_with_findings, findings_fixture
):
"""
Test that muted_at is set to the rule's inserted_at, not the current time.
"""
mute_rule = mute_rule_with_findings
tenant_id = str(mute_rule.tenant_id)
finding = findings_fixture[0]
# Execute the muting function
result = mute_historical_findings(tenant_id, str(mute_rule.id))
# Verify the finding was muted
assert result["findings_muted"] == 1
# Verify muted_at matches the rule's inserted_at timestamp
finding.refresh_from_db()
assert finding.muted_at == mute_rule.inserted_at
assert finding.muted_at is not None
def test_mute_historical_findings_partial_match(self, scans_fixture, test_user):
"""
Test muting when only some of the rule's UIDs exist as findings.
"""
scan = scans_fixture[0]
tenant_id = str(scan.tenant_id)
# Create 3 findings
existing_uids = []
for i in range(3):
finding = Finding.objects.create(
tenant_id=tenant_id,
uid=f"partial_match_finding_{i}",
scan=scan,
status=Status.FAIL,
status_extended=f"Partial match status {i}",
impact=Severity.high,
severity=Severity.high,
raw_result={
"status": Status.FAIL,
"impact": Severity.high,
"severity": Severity.high,
},
check_id=f"partial_match_check_{i}",
check_metadata={
"CheckId": f"partial_match_check_{i}",
"Description": f"Partial match description {i}",
},
muted=False,
)
existing_uids.append(finding.uid)
# Create a mute rule with both existing and non-existing UIDs
all_uids = existing_uids + [
"nonexistent_uid_1",
"nonexistent_uid_2",
]
mute_rule = MuteRule.objects.create(
tenant_id=tenant_id,
name="Test Partial Match Rule",
reason="Testing partial matching",
enabled=True,
created_by=test_user,
finding_uids=all_uids,
)
# Execute the muting function
result = mute_historical_findings(tenant_id, str(mute_rule.id))
# Verify only existing findings were muted
assert result["findings_muted"] == 3
assert result["rule_id"] == str(mute_rule.id)
# Verify the existing findings were muted
findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=existing_uids)
assert findings.count() == 3
for finding in findings:
assert finding.muted is True
assert finding.muted_at == mute_rule.inserted_at
assert finding.muted_reason == mute_rule.reason
def test_mute_historical_findings_empty_uids(self, tenants_fixture, test_user):
"""
Test muting when the rule has an empty finding_uids array.
"""
tenant_id = str(tenants_fixture[0].id)
# Create a mute rule with empty finding_uids
mute_rule = MuteRule.objects.create(
tenant_id=tenant_id,
name="Test Empty UIDs Rule",
reason="Testing empty UIDs",
enabled=True,
created_by=test_user,
finding_uids=[],
)
# Execute the muting function
result = mute_historical_findings(tenant_id, str(mute_rule.id))
# Verify no findings were muted
assert result["findings_muted"] == 0
assert result["rule_id"] == str(mute_rule.id)
def test_mute_historical_findings_return_format(self, mute_rule_with_findings):
"""
Test that the return value has the correct format and fields.
"""
mute_rule = mute_rule_with_findings
tenant_id = str(mute_rule.tenant_id)
result = mute_historical_findings(tenant_id, str(mute_rule.id))
# Verify return value structure
assert isinstance(result, dict)
assert "findings_muted" in result
assert "rule_id" in result
assert isinstance(result["findings_muted"], int)
assert isinstance(result["rule_id"], str)
assert result["rule_id"] == str(mute_rule.id)
active_finding.refresh_from_db()
disabled_finding.refresh_from_db()
older_finding.refresh_from_db()
assert active_finding.muted is True
assert active_finding.muted_at == active_rule.inserted_at
assert disabled_finding.muted is False
assert older_finding.muted is False
assert result == {"findings_muted": 1, "scan_id": str(scan.id)}
+229 -66
View File
@@ -1,6 +1,5 @@
import csv
import json
import re
import uuid
from collections.abc import MutableMapping
from contextlib import contextmanager
@@ -10,6 +9,7 @@ from unittest.mock import MagicMock, patch
import pytest
from api.db_router import MainRouter
from api.db_utils import rls_transaction
from api.exceptions import ProviderConnectionError, ProviderDeletedException
from api.models import (
Finding,
@@ -2795,6 +2795,167 @@ class TestCreateComplianceRequirements:
assert count_after_first > 0
assert count_after_second == count_after_first
with rls_transaction(tenant_id):
row_versions = {
row_id.version
for row_id in ComplianceRequirementOverview.objects.filter(
scan_id=scan_id
).values_list("id", flat=True)
}
assert row_versions == {7}
def test_create_compliance_requirements_threatscore_counts_from_template(
self,
tenants_fixture,
scans_fixture,
aws_provider,
findings_fixture,
):
"""ThreatScore finding counts are derived from the template mapping,
not from each finding's stored ``compliance`` payload."""
from api.models import ComplianceRequirementOverview
with patch(
"tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE"
) as mock_compliance_template:
tenant_id = str(tenants_fixture[0].id)
scan_id = str(scans_fixture[0].id)
mock_compliance_template.__getitem__.return_value = {
"prowler_threatscore_aws": {
"framework": "ProwlerThreatScore",
"version": "1.0",
"requirements": {
"1.1.1": {
"description": "ThreatScore requirement",
"checks": {"test_check_id": None},
},
"1.1.2": {
"description": "Unrelated requirement",
"checks": {"other_check_id": None},
},
},
},
"other_framework": {
"framework": "Other",
"version": "2.0",
"requirements": {
"a": {
"description": "Same check, other framework",
"checks": {"test_check_id": None},
},
},
},
}
create_compliance_requirements(tenant_id, scan_id)
with rls_transaction(tenant_id):
counted = sum(
len(finding.resource_regions or [])
for finding in Finding.all_objects.filter(
scan_id=scan_id,
muted=False,
status__in=["PASS", "FAIL"],
check_id="test_check_id",
)
)
rows = list(
ComplianceRequirementOverview.objects.filter(
scan_id=scan_id
).values_list("compliance_id", "requirement_id", "total_findings")
)
assert counted > 0
assert (
sum(
total
for compliance_id, requirement_id, total in rows
if (compliance_id, requirement_id)
== ("prowler_threatscore_aws", "1.1.1")
)
== counted
)
assert all(
total == 0
for compliance_id, requirement_id, total in rows
if (compliance_id, requirement_id) == ("prowler_threatscore_aws", "1.1.2")
)
assert all(
total == 0
for compliance_id, _, total in rows
if compliance_id == "other_framework"
)
def test_create_compliance_requirements_rows_across_regions_and_frameworks(
self,
tenants_fixture,
scans_fixture,
aws_provider,
):
from api.models import ComplianceRequirementOverview
tenant_id = str(tenants_fixture[0].id)
scan_id = str(scans_fixture[0].id)
check_status_by_region = {
"us-east-1": {"check_a": "FAIL", "check_b": "PASS"},
"eu-west-1": {"check_a": "PASS"},
}
template = {
"fw_one": {
"framework": "One",
"version": "1",
"requirements": {
"r1": {"description": "a", "checks": {"check_a": None}},
"r2": {
"description": "a+b",
"checks": {"check_a": None, "check_b": None},
},
},
},
"fw_two": {
"framework": "Two",
"version": "2",
"requirements": {
"m1": {"description": "manual", "checks": {}},
},
},
}
with (
patch(
"tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE"
) as mock_compliance_template,
patch(
"tasks.jobs.scan._aggregate_findings_by_region",
return_value=(check_status_by_region, {}),
),
):
mock_compliance_template.__getitem__.return_value = template
result = create_compliance_requirements(tenant_id, scan_id)
assert result["requirements_created"] == 6
with rls_transaction(tenant_id):
rows = set(
ComplianceRequirementOverview.objects.filter(
scan_id=scan_id
).values_list(
"compliance_id",
"requirement_id",
"region",
"requirement_status",
"passed_checks",
"failed_checks",
"total_checks",
)
)
assert rows == {
("fw_one", "r1", "us-east-1", "FAIL", 0, 1, 1),
("fw_one", "r1", "eu-west-1", "PASS", 1, 0, 1),
("fw_one", "r2", "us-east-1", "FAIL", 1, 1, 2),
("fw_one", "r2", "eu-west-1", "PASS", 1, 0, 2),
("fw_two", "m1", "us-east-1", "MANUAL", 0, 0, 0),
("fw_two", "m1", "eu-west-1", "MANUAL", 0, 0, 0),
}
def test_create_compliance_requirements_kubernetes_provider(
self,
@@ -4723,17 +4884,11 @@ class TestAggregateFindingsByRegion:
"""Test function returns correct data structure."""
tenant_id = str(uuid.uuid4())
scan_id = str(uuid.uuid4())
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
normalized_id = "prowlerthreatscore10"
# (check_id, status, resource_regions, compliance) tuples
finding_rows = [
(
"check1",
"FAIL",
["us-east-1"],
{modeled_threatscore_compliance_id: ["req1", "req2"]},
)
]
# (check_id, status, resource_regions) tuples
finding_rows = [("check1", "FAIL", ["us-east-1"])]
threatscore_by_check = {"check1": ["req1", "req2"]}
mock_queryset = MagicMock()
mock_queryset.values_list.return_value = mock_queryset
@@ -4747,13 +4902,16 @@ class TestAggregateFindingsByRegion:
check_status_by_region, findings_count_by_compliance = (
_aggregate_findings_by_region(
tenant_id, scan_id, modeled_threatscore_compliance_id
tenant_id,
scan_id,
normalized_id,
threatscore_by_check,
)
)
# Streaming query contract: column-scoped values_list + iterator
mock_queryset.values_list.assert_called_once_with(
"check_id", "status", "resource_regions", "compliance"
"check_id", "status", "resource_regions"
)
mock_queryset.iterator.assert_called_once()
@@ -4774,13 +4932,14 @@ class TestAggregateFindingsByRegion:
"""Test that FAIL status takes priority over other statuses."""
tenant_id = str(uuid.uuid4())
scan_id = str(uuid.uuid4())
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
normalized_id = "prowlerthreatscore10"
# Same check/region: PASS first, then FAIL — FAIL must win
finding_rows = [
("check1", "PASS", ["us-east-1"], {}),
("check1", "FAIL", ["us-east-1"], {}),
("check1", "PASS", ["us-east-1"]),
("check1", "FAIL", ["us-east-1"]),
]
threatscore_by_check = {}
mock_queryset = MagicMock()
mock_queryset.values_list.return_value = mock_queryset
@@ -4793,12 +4952,15 @@ class TestAggregateFindingsByRegion:
mock_findings_filter.return_value = mock_queryset
check_status_by_region, _ = _aggregate_findings_by_region(
tenant_id, scan_id, modeled_threatscore_compliance_id
tenant_id,
scan_id,
normalized_id,
threatscore_by_check,
)
# Streaming query contract: column-scoped values_list + iterator
mock_queryset.values_list.assert_called_once_with(
"check_id", "status", "resource_regions", "compliance"
"check_id", "status", "resource_regions"
)
mock_queryset.iterator.assert_called_once()
@@ -4813,8 +4975,9 @@ class TestAggregateFindingsByRegion:
"""Test that muted findings are filtered out (muted=False in query)."""
tenant_id = str(uuid.uuid4())
scan_id = str(uuid.uuid4())
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
normalized_id = "prowlerthreatscore10"
threatscore_by_check = {}
mock_queryset = MagicMock()
mock_queryset.values_list.return_value = mock_queryset
mock_queryset.iterator.return_value = []
@@ -4826,12 +4989,15 @@ class TestAggregateFindingsByRegion:
mock_findings_filter.return_value = mock_queryset
_aggregate_findings_by_region(
tenant_id, scan_id, modeled_threatscore_compliance_id
tenant_id,
scan_id,
normalized_id,
threatscore_by_check,
)
# Streaming query contract: column-scoped values_list + iterator
mock_queryset.values_list.assert_called_once_with(
"check_id", "status", "resource_regions", "compliance"
"check_id", "status", "resource_regions"
)
mock_queryset.iterator.assert_called_once()
@@ -4851,23 +5017,14 @@ class TestAggregateFindingsByRegion:
"""Test that ThreatScore compliance counts are processed correctly."""
tenant_id = str(uuid.uuid4())
scan_id = str(uuid.uuid4())
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
normalized_id = "prowlerthreatscore10"
# PASS and FAIL findings mapped to the same ThreatScore requirement
finding_rows = [
(
"check1",
"PASS",
["us-east-1"],
{modeled_threatscore_compliance_id: ["req1"]},
),
(
"check2",
"FAIL",
["us-east-1"],
{modeled_threatscore_compliance_id: ["req1"]},
),
("check1", "PASS", ["us-east-1"]),
("check2", "FAIL", ["us-east-1"]),
]
threatscore_by_check = {"check1": ["req1"], "check2": ["req1"]}
mock_queryset = MagicMock()
mock_queryset.values_list.return_value = mock_queryset
@@ -4880,19 +5037,19 @@ class TestAggregateFindingsByRegion:
mock_findings_filter.return_value = mock_queryset
_, findings_count_by_compliance = _aggregate_findings_by_region(
tenant_id, scan_id, modeled_threatscore_compliance_id
tenant_id,
scan_id,
normalized_id,
threatscore_by_check,
)
# Streaming query contract: column-scoped values_list + iterator
mock_queryset.values_list.assert_called_once_with(
"check_id", "status", "resource_regions", "compliance"
"check_id", "status", "resource_regions"
)
mock_queryset.iterator.assert_called_once()
# Verify compliance counts
normalized_id = re.sub(
r"[^a-z0-9]", "", modeled_threatscore_compliance_id.lower()
)
assert "us-east-1" in findings_count_by_compliance
assert normalized_id in findings_count_by_compliance["us-east-1"]
assert "req1" in findings_count_by_compliance["us-east-1"][normalized_id]
@@ -4909,13 +5066,14 @@ class TestAggregateFindingsByRegion:
"""Test aggregation across multiple regions."""
tenant_id = str(uuid.uuid4())
scan_id = str(uuid.uuid4())
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
normalized_id = "prowlerthreatscore10"
# One finding per region
finding_rows = [
("check1", "FAIL", ["us-east-1"], {}),
("check1", "PASS", ["us-west-2"], {}),
("check1", "FAIL", ["us-east-1"]),
("check1", "PASS", ["us-west-2"]),
]
threatscore_by_check = {}
mock_queryset = MagicMock()
mock_queryset.values_list.return_value = mock_queryset
@@ -4928,12 +5086,15 @@ class TestAggregateFindingsByRegion:
mock_findings_filter.return_value = mock_queryset
check_status_by_region, _ = _aggregate_findings_by_region(
tenant_id, scan_id, modeled_threatscore_compliance_id
tenant_id,
scan_id,
normalized_id,
threatscore_by_check,
)
# Streaming query contract: column-scoped values_list + iterator
mock_queryset.values_list.assert_called_once_with(
"check_id", "status", "resource_regions", "compliance"
"check_id", "status", "resource_regions"
)
mock_queryset.iterator.assert_called_once()
@@ -4951,16 +5112,10 @@ class TestAggregateFindingsByRegion:
"""A finding with multiple resource_regions is tallied in every region."""
tenant_id = str(uuid.uuid4())
scan_id = str(uuid.uuid4())
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
normalized_id = "prowlerthreatscore10"
finding_rows = [
(
"check1",
"FAIL",
["us-east-1", "eu-west-1"],
{modeled_threatscore_compliance_id: ["req1"]},
)
]
finding_rows = [("check1", "FAIL", ["us-east-1", "eu-west-1"])]
threatscore_by_check = {"check1": ["req1"]}
mock_queryset = MagicMock()
mock_queryset.values_list.return_value = mock_queryset
@@ -4974,19 +5129,19 @@ class TestAggregateFindingsByRegion:
check_status_by_region, findings_count_by_compliance = (
_aggregate_findings_by_region(
tenant_id, scan_id, modeled_threatscore_compliance_id
tenant_id,
scan_id,
normalized_id,
threatscore_by_check,
)
)
# Streaming query contract: column-scoped values_list + iterator
mock_queryset.values_list.assert_called_once_with(
"check_id", "status", "resource_regions", "compliance"
"check_id", "status", "resource_regions"
)
mock_queryset.iterator.assert_called_once()
normalized_id = re.sub(
r"[^a-z0-9]", "", modeled_threatscore_compliance_id.lower()
)
for region in ("us-east-1", "eu-west-1"):
assert check_status_by_region[region]["check1"] == "FAIL"
req_stats = findings_count_by_compliance[region][normalized_id]["req1"]
@@ -5000,12 +5155,13 @@ class TestAggregateFindingsByRegion:
"""A finding with no denormalized regions contributes nothing."""
tenant_id = str(uuid.uuid4())
scan_id = str(uuid.uuid4())
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
normalized_id = "prowlerthreatscore10"
finding_rows = [
("check1", "FAIL", [], {modeled_threatscore_compliance_id: ["req1"]}),
("check2", "PASS", None, {}),
("check1", "FAIL", []),
("check2", "PASS", None),
]
threatscore_by_check = {"check1": ["req1"]}
mock_queryset = MagicMock()
mock_queryset.values_list.return_value = mock_queryset
@@ -5019,13 +5175,16 @@ class TestAggregateFindingsByRegion:
check_status_by_region, findings_count_by_compliance = (
_aggregate_findings_by_region(
tenant_id, scan_id, modeled_threatscore_compliance_id
tenant_id,
scan_id,
normalized_id,
threatscore_by_check,
)
)
# Streaming query contract: column-scoped values_list + iterator
mock_queryset.values_list.assert_called_once_with(
"check_id", "status", "resource_regions", "compliance"
"check_id", "status", "resource_regions"
)
mock_queryset.iterator.assert_called_once()
@@ -5040,8 +5199,9 @@ class TestAggregateFindingsByRegion:
"""Test with no findings - should return empty dicts."""
tenant_id = str(uuid.uuid4())
scan_id = str(uuid.uuid4())
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
normalized_id = "prowlerthreatscore10"
threatscore_by_check = {}
mock_queryset = MagicMock()
mock_queryset.values_list.return_value = mock_queryset
mock_queryset.iterator.return_value = []
@@ -5054,13 +5214,16 @@ class TestAggregateFindingsByRegion:
check_status_by_region, findings_count_by_compliance = (
_aggregate_findings_by_region(
tenant_id, scan_id, modeled_threatscore_compliance_id
tenant_id,
scan_id,
normalized_id,
threatscore_by_check,
)
)
# Streaming query contract: column-scoped values_list + iterator
mock_queryset.values_list.assert_called_once_with(
"check_id", "status", "resource_regions", "compliance"
"check_id", "status", "resource_regions"
)
mock_queryset.iterator.assert_called_once()
+69 -124
View File
@@ -1,6 +1,6 @@
import uuid
from contextlib import contextmanager
from datetime import UTC, datetime, timedelta
from datetime import UTC, datetime
from unittest.mock import MagicMock, patch
import httpx
@@ -33,10 +33,10 @@ from tasks.tasks import (
check_integrations_task,
check_lighthouse_provider_connection_task,
generate_outputs_task,
mute_findings_in_latest_scans_task,
perform_attack_paths_scan_task,
perform_scan_task,
perform_scheduled_scan_task,
reaggregate_all_finding_group_summaries_task,
refresh_lighthouse_provider_models_task,
s3_integration_task,
security_hub_integration_task,
@@ -2959,6 +2959,7 @@ class TestPerformScheduledScanTask:
with (
patch("tasks.tasks.perform_prowler_scan", side_effect=_complete_scan),
patch("tasks.tasks._perform_scan_complete_tasks"),
patch("tasks.tasks.reconcile_scan_mute_rules") as mock_reconcile,
self._override_task_request(perform_scheduled_scan_task, id=task_id),
):
perform_scheduled_scan_task.run(
@@ -2982,6 +2983,13 @@ class TestPerformScheduledScanTask:
).count()
== 1
)
completed_scan = Scan.objects.get(
tenant_id=tenant.id,
provider=provider,
trigger=Scan.TriggerChoices.SCHEDULED,
state=StateChoices.COMPLETED,
)
mock_reconcile.assert_called_once_with(str(tenant.id), str(completed_scan.id))
assert (
Scan.objects.filter(
tenant_id=tenant.id,
@@ -3176,7 +3184,10 @@ class TestPerformScanTask:
task=queued_task,
)
events = []
def _complete_scan(tenant_id, scan_id, provider_id, checks_to_execute=None):
events.append("scan")
scan_instance = Scan.objects.get(id=scan_id)
scan_instance.state = StateChoices.COMPLETED
scan_instance.save()
@@ -3184,7 +3195,14 @@ class TestPerformScanTask:
with (
patch("tasks.tasks.perform_prowler_scan", side_effect=_complete_scan),
patch("tasks.tasks._perform_scan_complete_tasks"),
patch(
"tasks.tasks.reconcile_scan_mute_rules",
side_effect=lambda *_args: events.append("reconcile"),
),
patch(
"tasks.tasks._perform_scan_complete_tasks",
side_effect=lambda *_args: events.append("summaries"),
),
patch("tasks.tasks.perform_scan_task.apply_async") as mock_apply_async,
):
with django_capture_on_commit_callbacks(execute=True):
@@ -3196,6 +3214,7 @@ class TestPerformScanTask:
queued_task_result.refresh_from_db()
assert result == {"status": "ok"}
assert events == ["scan", "reconcile", "summaries"]
assert queued_task_result.status == states.PENDING
mock_apply_async.assert_called_once_with(
kwargs={
@@ -3241,10 +3260,7 @@ class TestPerformScanTask:
@pytest.mark.django_db
class TestReaggregateAllFindingGroupSummaries:
def setup_method(self):
self.tenant_id = str(uuid.uuid4())
class TestMuteFindingsInLatestScansTask:
@patch("tasks.tasks.chain")
@patch("tasks.tasks.group")
@patch("tasks.tasks.aggregate_attack_surface_task")
@@ -3253,10 +3269,10 @@ class TestReaggregateAllFindingGroupSummaries:
@patch("tasks.tasks.aggregate_finding_group_summaries_task")
@patch("tasks.tasks.aggregate_daily_severity_task")
@patch("tasks.tasks.perform_scan_summary_task")
@patch("tasks.tasks.Scan.objects.filter")
def test_dispatches_subtasks_for_each_provider_per_day(
@patch("tasks.tasks.mute_findings_in_latest_scans")
def test_reaggregates_only_changed_scans(
self,
mock_scan_filter,
mock_mute_findings,
mock_scan_summary_task,
mock_daily_severity_task,
mock_finding_group_task,
@@ -3265,119 +3281,36 @@ class TestReaggregateAllFindingGroupSummaries:
mock_attack_surface_task,
mock_group,
mock_chain,
tenants_fixture,
):
provider_id_1 = uuid.uuid4()
provider_id_2 = uuid.uuid4()
scan_id_today_p1 = uuid.uuid4()
scan_id_yesterday_p1 = uuid.uuid4()
scan_id_today_p2 = uuid.uuid4()
today = datetime.now(tz=UTC)
yesterday = today - timedelta(days=1)
mock_outer_group_result = MagicMock()
# The first `group()` call wraps the inner parallel step; subsequent
# calls wrap the outer per-scan generator.
mock_group.side_effect = lambda *args, **kwargs: (
list(args[0]) if args and hasattr(args[0], "__iter__") else None,
mock_outer_group_result,
)[1]
mock_scan_filter.return_value.order_by.return_value.values.return_value = [
{
"id": scan_id_today_p1,
"completed_at": today,
"provider_id": provider_id_1,
},
{
"id": scan_id_today_p2,
"completed_at": today,
"provider_id": provider_id_2,
},
{
"id": scan_id_yesterday_p1,
"completed_at": yesterday,
"provider_id": provider_id_1,
},
]
result = reaggregate_all_finding_group_summaries_task(tenant_id=self.tenant_id)
assert result == {"scans_reaggregated": 3}
expected_scan_ids = {
str(scan_id_today_p1),
str(scan_id_today_p2),
str(scan_id_yesterday_p1),
tenant_id = str(tenants_fixture[0].id)
mute_rule_id = str(uuid.uuid4())
provider_ids = [str(uuid.uuid4()), str(uuid.uuid4())]
scan_ids = [str(uuid.uuid4()), str(uuid.uuid4())]
result = {
"findings_muted": 2,
"rule_id": mute_rule_id,
"scan_ids": scan_ids,
}
for task_mock in (
mock_scan_summary_task,
mock_daily_severity_task,
mock_finding_group_task,
mock_resource_group_task,
mock_category_task,
mock_attack_surface_task,
):
assert task_mock.si.call_count == 3
dispatched = {
call.kwargs["scan_id"] for call in task_mock.si.call_args_list
}
assert dispatched == expected_scan_ids
for call in task_mock.si.call_args_list:
assert call.kwargs["tenant_id"] == self.tenant_id
assert mock_chain.call_count == 3
mock_outer_group_result.apply_async.assert_called_once()
@patch("tasks.tasks.chain")
@patch("tasks.tasks.group")
@patch("tasks.tasks.aggregate_attack_surface_task")
@patch("tasks.tasks.aggregate_scan_category_summaries_task")
@patch("tasks.tasks.aggregate_scan_resource_group_summaries_task")
@patch("tasks.tasks.aggregate_finding_group_summaries_task")
@patch("tasks.tasks.aggregate_daily_severity_task")
@patch("tasks.tasks.perform_scan_summary_task")
@patch("tasks.tasks.Scan.objects.filter")
def test_dedupes_scans_to_latest_per_provider_per_day(
self,
mock_scan_filter,
mock_scan_summary_task,
mock_daily_severity_task,
mock_finding_group_task,
mock_resource_group_task,
mock_category_task,
mock_attack_surface_task,
mock_group,
mock_chain,
):
"""When several scans run on the same day for the same provider, only
the latest one is dispatched (matching the daily summary unique key)."""
provider_id = uuid.uuid4()
latest_scan_today = uuid.uuid4()
earlier_scan_today = uuid.uuid4()
today_late = datetime.now(tz=UTC)
today_early = today_late - timedelta(hours=4)
mock_mute_findings.return_value = result
mock_outer_group_result = MagicMock()
mock_group.side_effect = lambda *args, **kwargs: (
list(args[0]) if args and hasattr(args[0], "__iter__") else None,
mock_outer_group_result,
)[1]
# Returned ordered by `-completed_at`, so the most recent comes first.
mock_scan_filter.return_value.order_by.return_value.values.return_value = [
{
"id": latest_scan_today,
"completed_at": today_late,
"provider_id": provider_id,
},
{
"id": earlier_scan_today,
"completed_at": today_early,
"provider_id": provider_id,
},
]
task_result = mute_findings_in_latest_scans_task(
tenant_id=tenant_id,
mute_rule_id=mute_rule_id,
provider_ids=provider_ids,
)
result = reaggregate_all_finding_group_summaries_task(tenant_id=self.tenant_id)
assert result == {"scans_reaggregated": 1}
assert task_result == result
mock_mute_findings.assert_called_once_with(
tenant_id=tenant_id,
mute_rule_id=mute_rule_id,
provider_ids=provider_ids,
)
for task_mock in (
mock_scan_summary_task,
mock_daily_severity_task,
@@ -3386,23 +3319,35 @@ class TestReaggregateAllFindingGroupSummaries:
mock_category_task,
mock_attack_surface_task,
):
task_mock.si.assert_called_once_with(
tenant_id=self.tenant_id, scan_id=str(latest_scan_today)
)
mock_chain.assert_called_once()
assert task_mock.si.call_count == 2
assert {
call.kwargs["scan_id"] for call in task_mock.si.call_args_list
} == set(scan_ids)
assert mock_chain.call_count == 2
mock_outer_group_result.apply_async.assert_called_once()
@patch("tasks.tasks.chain")
@patch("tasks.tasks.group")
@patch("tasks.tasks.Scan.objects.filter")
def test_no_completed_scans_skips_dispatch(
self, mock_scan_filter, mock_group, mock_chain
@patch("tasks.tasks.mute_findings_in_latest_scans")
def test_skips_reaggregation_when_no_scan_changed(
self, mock_mute_findings, mock_group, mock_chain, tenants_fixture
):
mock_scan_filter.return_value.order_by.return_value.values.return_value = []
tenant_id = str(tenants_fixture[0].id)
mute_rule_id = str(uuid.uuid4())
result = {
"findings_muted": 0,
"rule_id": mute_rule_id,
"scan_ids": [],
}
mock_mute_findings.return_value = result
result = reaggregate_all_finding_group_summaries_task(tenant_id=self.tenant_id)
task_result = mute_findings_in_latest_scans_task(
tenant_id=tenant_id,
mute_rule_id=mute_rule_id,
provider_ids=[],
)
assert result == {"scans_reaggregated": 0}
assert task_result == result
mock_group.assert_not_called()
mock_chain.assert_not_called()
Generated
+72 -3
View File
@@ -4835,8 +4835,8 @@ wheels = [
[[package]]
name = "prowler"
version = "5.40.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#b6e9967da6bebd6c7b8b237317a2a95e2e0c65bc" }
version = "5.41.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#f05a490cd74a2c0f11a5d66d8ce29d03fa5c64a2" }
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.43.0"
source = { virtual = "." }
dependencies = [
{ name = "cartography" },
@@ -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"
+223 -1
View File
@@ -4,6 +4,228 @@ description: "New features and improvements in each Prowler release"
rss: true
---
<Update label="v5.41.0" description="September 2, 2026">
### 📥 Scans — Import Findings from the Browser
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
Findings produced outside the platform, by the Prowler CLI or a CI pipeline, can now be brought into the app without leaving the browser. The Scans page gains an "Import Findings" dialog that takes a Prowler `.ocsf.json` report by drag-and-drop or file picker, hands it to the ingestion API and tracks the job to completion, reporting how many records were processed and how many were invalid. Files that are not a `.ocsf.json` report, or are empty, are refused before any upload starts, and a rejected upload or a failed status poll can be retried in place. The dialog is available to roles holding the Manage Ingestions permission.
![Import Findings button on the Scans page](/images/prowler-app/import-findings/import-findings-button.png)
![Import findings dialog with the drag-and-drop area](/images/prowler-app/import-findings/import-findings-dialog.png)
Read more in the [Import Findings documentation](https://docs.prowler.com/user-guide/tutorials/prowler-import-findings#using-the-ui).
### 🎫 Jira Integration — Finding Reference in Every Issue
Every Jira issue created from a finding now carries a stable reference back to it. Issues are labeled `prowler`, `prowler-<provider>`, `prowler-<severity>`, `prowler-<check-id>` and `prowler-finding-<finding-uid>`, so they can be filtered, searched with JQL or matched by automation; labels are sanitized to Jira's limits so a long or unusual value never blocks issue creation. The issue also links back to the finding in Prowler, filtered by its UID so the link keeps working after later scans, and names the Prowler organization that sent it. Prowler Cloud always includes the link; Prowler Local Server enables it by setting `DJANGO_UI_BASE_URL` in the API environment.
Read more in the [Jira integration documentation](https://docs.prowler.com/user-guide/tutorials/prowler-app-jira-integration).
### 📚 Compliance — CIS Google Workspace Foundations Benchmark v1.4.0
Prowler now ships the CIS Google Workspace Foundations Benchmark v1.4.0. Alongside the new framework, the Google Workspace checks mapped to CIS were reworked to evaluate the benchmark's full audit procedure instead of a single condition, so Gmail spoofing actions, 2-Step Verification, password expiration and alert severity left on Google's defaults no longer pass. Expect new `FAIL` findings on domains that rely on those defaults. Three accuracy fixes also land:
- `security_2sv_enforced` and `security_2sv_hardware_keys_admins` report `MANUAL` instead of judging domain-wide values that a group or a sub-organizational unit overrides; a domain-wide failure is still reported as such, with the override noted.
- `rules_*_alert_configured` no longer passes a rule whose delivery to the alert center is disabled.
- `security_password_policy_strong` no longer fails a domain that never touched the password strength setting, since Google enforces strong passwords by default.
`security_login_challenges_configured` was unmapped from CIS Google Workspace requirement 4.1.4.1 (Post-SSO verification) and `security_2sv_enforced` from CISA SCuBA `GWS.COMMONCONTROLS.1.1` (phishing-resistant MFA), because neither check can prove what those requirements ask for.
Read more in the [Compliance documentation](https://docs.prowler.com/user-guide/compliance/tutorials/compliance).
### 🔍 Checks
Ten new AWS checks land in this release, eight of them contributed by @tamg-aws. Thank you!
#### Amazon Bedrock AgentCore
- `iam_policy_passrole_to_bedrock_agentcore_restricted` flags customer-managed IAM policies that allow `iam:PassRole` over every role where the passed role can reach Bedrock AgentCore, so any principal holding the policy could run agent code under any role in the account.
- `iam_policy_no_agentcore_workload_access_token_wildcard` flags customer-managed IAM policies that allow `bedrock-agentcore:GetWorkloadAccessToken`, `GetWorkloadAccessTokenForJWT` or `GetWorkloadAccessTokenForUserId` on resources reaching workload identities other than the caller's own.
- `cloudwatch_log_group_agentcore_data_protection_policy_enabled` verifies that Bedrock AgentCore log groups mask sensitive data with a CloudWatch Logs data protection policy. The log group prefixes are configurable through `agentcore_log_group_name_prefixes` in `config.yaml`.
#### Amazon GuardDuty
- `guardduty_runtime_monitoring_enabled` flags detectors without unified Runtime Monitoring, the only feature that covers Amazon EC2 instances and Amazon ECS on AWS Fargate tasks in addition to Amazon EKS.
- `guardduty_ai_protection_enabled` flags detectors without AI Protection, which analyzes CloudTrail data events from Amazon Bedrock, Amazon Bedrock AgentCore and Amazon SageMaker AI. A detector that does not report the feature is `MANUAL` rather than `FAIL`.
`guardduty_eks_runtime_monitoring_enabled` no longer reports `FAIL` for detectors that use unified Runtime Monitoring, which is mutually exclusive with `EKS_RUNTIME_MONITORING` and already covers Amazon EKS.
#### Amazon ECR and EKS
- `ecr_registry_enhanced_scanning_enabled` verifies that the ECR registry scan type is enhanced (Amazon Inspector, covering programming language packages and continuous rescanning) instead of basic, reporting `MANUAL` when the registry scanning configuration cannot be read.
- `eks_cluster_vpc_cni_network_policy_enforced` flags EKS clusters whose Amazon VPC CNI managed add-on does not enable Kubernetes network policy enforcement, reporting `MANUAL` where the EKS API cannot show the setting.
#### AWS IAM, Elastic Beanstalk and MemoryDB
- `iam_role_service_trust_restricts_source_to_account` flags IAM roles whose trust policy lets an AWS service principal assume the role without confining the request to a specific source account, including trust policies that `iam_role_cross_service_confused_deputy_prevention` does not evaluate.
- `elasticbeanstalk_environment_no_secrets_in_configuration` scans the option settings of every Elastic Beanstalk environment for hardcoded secrets. Thanks to @haneul-24!
- `memorydb_cluster_in_transit_encryption_enabled` verifies that MemoryDB clusters have in-transit encryption (TLS) enabled. Thanks to @UTKARSH698!
Explore all AWS checks at [Prowler Hub](https://hub.prowler.com/check?provider=aws).
### 🐳 Image Provider — On-Premises Registries
Scanning registries that live on private networks is now supported end to end. `PROWLER_IMAGE_PROVIDER_ALLOWED_PRIVATE_NETWORKS` takes a comma-separated list of IPs and CIDRs the provider may reach, while every other non-public address, including link-local and loopback, stays blocked by the SSRF guard. Authentication negotiation is also more resilient: the provider falls back to Basic when a registry such as Harbor rejects the negotiated bearer token, and switches to a bearer token when the server answers a Basic or anonymous request with a Bearer challenge. `--registry-insecure` now propagates to Trivy through `TRIVY_INSECURE`, so images behind self-signed certificates can be pulled and scanned, not just enumerated. The flag now disables certificate validation for the image pull too, so keep it for trusted internal registries only.
Registry scans also skip non-image OCI artifacts (Helm charts, cosign signatures, SBOM attestations), no longer abort the whole scan when Trivy fails on a single image, and enumerate repositories in parallel instead of one request at a time.
Read more in the [Image provider documentation](https://docs.prowler.com/user-guide/providers/image/getting-started-image#on-premises-registries-and-private-networks).
### 🛠️ Prowler MCP Server — Tool Failures Reported as Errors
Prowler Local Server tools now report a failure as an MCP tool execution error (`isError: true`, with the explanation in `content`) instead of a successful result carrying an `{"error": ...}` object, which clients and models read as a success. The Prowler Documentation and Prowler Hub tools follow the same rule: `prowler_docs_search` no longer reports a failed search as zero matches, `prowler_docs_get_document` no longer reports a failed fetch as a missing page, and `prowler_hub_get_check_code` and `prowler_hub_get_check_fixer` now name the provider a check ID actually belongs to instead of reporting it as nonexistent. `prowler_get_compliance_framework_state_details` also rejects a call that passes both `scan_id` and `provider_id` instead of silently ignoring the provider.
Read more in the [Prowler MCP documentation](https://docs.prowler.com/getting-started/products/prowler-mcp).
### 🙌 External Contributors
Thank you to our community contributors for this release!
- @tamg-aws: GuardDuty unified Runtime Monitoring and AI Protection checks ([#12564](https://github.com/prowler-cloud/prowler/pull/12564)), EKS VPC CNI network policy check ([#12661](https://github.com/prowler-cloud/prowler/pull/12661)), ECR enhanced scanning check ([#12660](https://github.com/prowler-cloud/prowler/pull/12660)), Bedrock AgentCore IAM and service trust checks ([#12664](https://github.com/prowler-cloud/prowler/pull/12664)), AgentCore log group data protection check ([#12662](https://github.com/prowler-cloud/prowler/pull/12662)), and fixes to ECR scan frequency ([#12560](https://github.com/prowler-cloud/prowler/pull/12560)), CloudWatch metric filters ([#12561](https://github.com/prowler-cloud/prowler/pull/12561)) and SageMaker direct internet access ([#12659](https://github.com/prowler-cloud/prowler/pull/12659))
- @haneul-24: AWS `elasticbeanstalk_environment_no_secrets_in_configuration` check ([#12378](https://github.com/prowler-cloud/prowler/pull/12378))
- @UTKARSH698: AWS `memorydb_cluster_in_transit_encryption_enabled` check ([#12246](https://github.com/prowler-cloud/prowler/pull/12246))
- @ye11oc4t: GitHub repository discovery pagination for unscoped scans ([#12460](https://github.com/prowler-cloud/prowler/pull/12460))
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.41.0) for the complete list of changes.
</Update>
<Update label="v5.40.0" description="August 28, 2026">
### 💬 Slack Integration — Alert Channel Destinations
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
Alerts can now reach Slack. Connect a Slack workspace from the Integrations page, authorize one or several destination channels (the connection check confirms each channel with a one-time message and names any channel Slack refuses), and pick those channels in the alert modal's "Destination channels" selector, next to the "Recipients" selector for email. The alerts list summarizes both in a single "Destinations" column, showing a rule's email recipients and Slack channels at a glance. Disconnecting the workspace and recovering from revoked credentials are handled from the same page.
![Connected Slack workspace on the Integrations page](/images/prowler-app/slack/connected-workspace.png)
![Alert rule with Slack channel destinations](/images/prowler-app/alerts/create-alert-modal.png)
Read more in the [Slack integration documentation](https://docs.prowler.com/user-guide/tutorials/prowler-app-slack-integration) and the [Alerts documentation](https://docs.prowler.com/user-guide/tutorials/prowler-alerts).
### 🤖 Lighthouse AI — Answer Feedback
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
Every Lighthouse AI answer can now be rated with a thumbs up or thumbs down, with an optional field to describe what worked or what did not. Feedback is collected per answer, directly in the chat, and tells the team where Lighthouse should improve next.
Read more in the [Lighthouse AI documentation](https://docs.prowler.com/getting-started/products/prowler-cloud-lighthouse).
### 📥 Providers — Imported Findings Indicator
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
Providers whose findings were imported with the Prowler CLI now show an "Imported provider" indicator next to their connection status in the providers table. In accounts that mix connected providers with Import Findings uploads, the table now tells them apart at a glance.
![Provider row with the Imported provider indicator and its tooltip](/images/changelog/v5.40.0-imported-provider-indicator.png)
Read more in the [Import Findings documentation](https://docs.prowler.com/user-guide/tutorials/prowler-import-findings).
### 📚 Compliance — NCSC Cyber Essentials 3.3
Cyber Essentials is the UK National Cyber Security Centre (NCSC) scheme certifying the baseline technical controls an organization must implement, and cloud services are explicitly in scope and cannot be excluded from an assessment. Prowler now includes NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026) as a universal framework, with its 28 requirements organized in the five control themes: Firewalls, Secure Configuration, Security Update Management, User Access Control, and Malware Protection.
Sixteen requirements map to Azure checks covering the controls the applicant organization owns under the shared responsibility model. The remaining twelve apply to end-user devices, on-premises network appliances, or organizational process, which cloud control-plane evidence cannot observe, so they are reported as Manual.
Contributed by @m-khan-97. Thank you!
Read more in the [Compliance documentation](https://docs.prowler.com/user-guide/compliance/tutorials/compliance).
### 🔍 Checks
Fifteen new checks land across seven providers in this release.
#### AWS
- `ecr_repository_image_no_secrets` scans the latest image of each ECR repository, both its configuration and its filesystem layers, for hardcoded secrets. Thanks to @esquaredsec!
- Four new Amazon Bedrock checks, thanks to @tamg-aws!
- `bedrock_guardrail_contextual_grounding_filter_enabled` verifies that guardrails enable both contextual grounding filters, blocking responses that are not supported by the retrieved source or do not answer the question asked.
- `bedrock_custom_model_encrypted_with_cmk` verifies that custom models are encrypted at rest with a customer-managed KMS key instead of an AWS-owned key the organization cannot audit, rotate, or revoke.
- `bedrock_knowledge_base_encrypted_with_cmk` verifies that each knowledge-base data source encrypts with a customer-managed KMS key the transient storage used while documents are chunked and embedded.
- `bedrock_agent_role_not_shared_across_agents` verifies that every agent has a dedicated execution role, so no agent inherits another's permissions.
- `rolesanywhere_profile_restricts_session_permissions` flags IAM Roles Anywhere profiles that reference an administrative role without scoping down the vended session with a session policy or managed policies.
Explore all AWS checks at [Prowler Hub](https://hub.prowler.com/check?provider=aws).
#### GCP
- `iam_workload_identity_pool_provider_attribute_condition` flags Workload Identity Federation providers that trust a multi-tenant issuer without an attribute condition restricting which external identities can impersonate federated principals.
Explore all GCP checks at [Prowler Hub](https://hub.prowler.com/check?provider=gcp).
#### GitHub
Three new checks harden GitHub Actions defaults, all contributed by @Edneam. Thank you!
- `organization_default_workflow_permissions_read_only` and `repository_default_workflow_permissions_read_only` verify that workflows get a read-only default `GITHUB_TOKEN` at the organization and repository level.
- `organization_actions_pull_request_approval_disabled` verifies that organizations prevent GitHub Actions from creating and approving pull requests.
Explore all GitHub checks at [Prowler Hub](https://hub.prowler.com/check?provider=github).
#### Microsoft 365
- `defender_domain_dmarc_records_published` checks that every Exchange Online domain publishes a DMARC record with an enforcing policy (`p=quarantine` or `p=reject`). Thanks to @Rishi943!
Explore all Microsoft 365 checks at [Prowler Hub](https://hub.prowler.com/check?provider=m365).
#### Alibaba Cloud
- `oss_bucket_versioning_enabled` verifies that OSS buckets keep versioning enabled, allowing recovery from accidental or malicious object overwrite and deletion. Thanks to @abidedavana!
- `oss_bucket_server_side_encryption_enabled` verifies that OSS buckets define a default server-side encryption rule, either AES256 or KMS. Thanks to @alexchen-sys!
OSS bucket logging, versioning, default encryption, and ACL configurations are also now read correctly from the Alibaba Cloud SDK, so the checks reading them no longer report every bucket as unconfigured.
Explore all Alibaba Cloud checks at [Prowler Hub](https://hub.prowler.com/check?provider=alibabacloud).
#### Huawei Cloud
- `vpc_security_group_open_egress` flags VPC security groups that allow open egress to the internet. Thanks to @tomitobio!
Explore all Huawei Cloud checks at [Prowler Hub](https://hub.prowler.com/check?provider=huaweicloud).
#### STACKIT
- `ske_cluster_no_public_endpoint` flags 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`. Thanks to @johannes-engler-mw!
Explore all STACKIT checks at [Prowler Hub](https://hub.prowler.com/check?provider=stackit).
### 🔐 Security Updates
- The API and SDK container images upgrade OpenSSL to 3.5.7-1~deb13u2, patching ten high CVEs; the UI image upgrades `libcrypto3` and `libssl3` to 3.5.8-r0, patching seven high CVEs; the MCP Server image patches CVE-2026-14456 (OpenSSL), CVE-2026-11822, and CVE-2026-11824 (SQLite).
- `sqlparse` upgraded to 0.6.0 in the API, patching CVE-2026-54284, CVE-2026-59893, and CVE-2026-71491.
### 🙌 External Contributors
Thank you to our community contributors for this release!
- @Edneam: GitHub `organization_default_workflow_permissions_read_only` ([#12122](https://github.com/prowler-cloud/prowler/pull/12122)), `repository_default_workflow_permissions_read_only` ([#12143](https://github.com/prowler-cloud/prowler/pull/12143)), and `organization_actions_pull_request_approval_disabled` ([#12394](https://github.com/prowler-cloud/prowler/pull/12394)) checks
- @tamg-aws: four AWS Bedrock checks covering guardrail grounding, CMK encryption, and agent role isolation ([#12459](https://github.com/prowler-cloud/prowler/pull/12459))
- @esquaredsec: AWS `ecr_repository_image_no_secrets` check ([#12123](https://github.com/prowler-cloud/prowler/pull/12123))
- @Rishi943: Microsoft 365 `defender_domain_dmarc_records_published` check ([#11936](https://github.com/prowler-cloud/prowler/pull/11936))
- @abidedavana: Alibaba Cloud `oss_bucket_versioning_enabled` check ([#11913](https://github.com/prowler-cloud/prowler/pull/11913))
- @alexchen-sys: Alibaba Cloud `oss_bucket_server_side_encryption_enabled` check ([#11981](https://github.com/prowler-cloud/prowler/pull/11981))
- @tomitobio: Huawei Cloud `vpc_security_group_open_egress` check ([#12209](https://github.com/prowler-cloud/prowler/pull/12209))
- @johannes-engler-mw: STACKIT `ske_cluster_no_public_endpoint` check ([#11943](https://github.com/prowler-cloud/prowler/pull/11943))
- @gabrielfrdev: cluster name in Kubernetes compliance report outputs ([#12506](https://github.com/prowler-cloud/prowler/pull/12506))
- @jfgmesquita: AWS FSBP compliance mapping fix for IAM.9 and EKS.1 ([#12372](https://github.com/prowler-cloud/prowler/pull/12372))
- @hackertwinten: `ec2_securitygroup_not_used` no longer flags security groups held only by scaled-down AWS Batch compute environments ([#12458](https://github.com/prowler-cloud/prowler/pull/12458))
- @0xTaoZ: ECS task-definition checks no longer report PASS when `DescribeTaskDefinition` fails, shipped early in v5.39.1 ([#12217](https://github.com/prowler-cloud/prowler/pull/12217))
- @ye11oc4t: `ses_identity_not_publicly_accessible` now evaluates every identity authorization policy, shipped early in v5.39.1 ([#12464](https://github.com/prowler-cloud/prowler/pull/12464))
- @Zuhef: IaC provider raises typed exceptions instead of `sys.exit` when cloning the scanned repository or running Trivy fails ([#12227](https://github.com/prowler-cloud/prowler/pull/12227)), Kubernetes kubelet checks no longer disappear from the scan when a `kubelet-config` ConfigMap is broken ([#12225](https://github.com/prowler-cloud/prowler/pull/12225)), and the CLI `--slack` summary is sent for scans with no findings instead of failing with `ZeroDivisionError` ([#12229](https://github.com/prowler-cloud/prowler/pull/12229))
- @m-khan-97: 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))
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.40.0) for the complete list of changes.
</Update>
<Update label="v5.39.0" description="August 13, 2026">
### 🤖 Lighthouse AI — Finding Skills
@@ -276,7 +498,7 @@ rss: true
All checks are fully passive, using AWS APIs and CloudTrail with no instance access or SSM agent required, and are mapped across 23 compliance frameworks, including NIST 800-53 Rev 5, PCI-DSS v4.0, ISO 27001:2022, SOC 2, HIPAA, and MITRE ATT&CK.
Read more about it this [blog post](https://prowler.com/blog/your-llm-runs-in-a-nitro-enclave-who-is-checking-the-enclave).
Read more about it in this [blog post](https://prowler.com/blog/your-llm-runs-in-a-nitro-enclave-who-is-checking-the-enclave).
Try them out now at [cloud.prowler.com](https://cloud.prowler.com/sign-up)!
+2 -2
View File
@@ -57,7 +57,7 @@ The AWS provider implementation follows the general [Provider structure](/develo
The generic service pattern is described in [service page](/developer-guide/services#service-structure-and-initialisation). You can find all the right now implemented services in the following locations:
- Directly in the code, in location [`prowler/providers/aws/services/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/aws/services)
- In the [Prowler Hub](https://hub.prowler.com/). For a more human-readable view.
- In the [Prowler Hub](https://hub.prowler.com/) for a more human-readable view.
The best reference to understand how to implement a new service is following the [service implementation documentation](/developer-guide/services#adding-a-new-service) and taking other services already implemented as reference. In next subsection you can find a list of common patterns that are used across all AWS services.
@@ -131,7 +131,7 @@ def _get_email_identities(self, identity):
The AWS checks pattern is described in [checks page](/developer-guide/checks). You can find all the right now implemented checks:
- Directly in the code, within each service folder, each check has its own folder named after the name of the check. (e.g. [`prowler/providers/aws/services/s3/s3_bucket_acl_prohibited/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/aws/services/s3/s3_bucket_acl_prohibited))
- In the [Prowler Hub](https://hub.prowler.com/). For a more human-readable view.
- In the [Prowler Hub](https://hub.prowler.com/) for a more human-readable view.
The best reference to understand how to implement a new check is following the [check creation documentation](/developer-guide/checks#creating-a-check) and taking other similar checks as reference.
+32 -1
View File
@@ -129,12 +129,42 @@ Each check **must** populate the `report.status` and `report.status_extended` fi
- Status field: `report.status`
- `PASS` – Assigned when the check confirms compliance with the configured value.
- `FAIL` – Assigned when the check detects non-compliance with the configured value.
- `MANUAL` – This status must not be used unless manual verification is necessary to determine whether the status (`report.status`) passes (`PASS`) or fails (`FAIL`).
- `MANUAL` – This status must not be used unless manual verification is necessary to determine whether the status (`report.status`) passes (`PASS`) or fails (`FAIL`). This includes the case where Prowler could not retrieve the data needed to evaluate the resource (see below).
- Status extended field: `report.status_extended`
- It **must** end with a period (`.`).
- It **must** include the audited service, the resource, and a concise explanation of the check result, for instance: `EC2 AMI ami-0123456789 is not public.`.
### Permission and Data-Availability Errors Are Not Findings
A `FAIL` must only be emitted when an insecure condition has actually been detected. A check **must never** report `FAIL` because the underlying API call failed: missing permissions or scopes on the scanning identity, an API that is not enabled, a feature that is not licensed, or data that could not be retrieved are scan-configuration problems, not security issues. Reporting them as `FAIL` surfaces a misleading (and often high-severity) finding to the user and skews compliance scores.
When the service layer cannot obtain the data a check depends on, the check must:
1. Emit a single `MANUAL` finding scoped to the widest affected resource (the tenant, account, project or subscription), not one finding per resource. For example, if user registration details cannot be read, emit one tenant-level `MANUAL` instead of one per user.
2. Explain in `status_extended` that the check could not be evaluated and what to fix, naming the permission, scope, API or license required, for instance: `Cannot evaluate credential exposure for privileged users: unable to query Microsoft Defender XDR Advanced Hunting. Verify that the ThreatHunting.Read.All permission is granted to the scanning application.`
3. Leave the check's severity untouched. Do not override `report.check_metadata.Severity` to hide the problem.
The service layer must make the distinction possible: log the error and expose it to checks in a way that cannot be confused with a legitimate empty result. Common patterns already used in Prowler are:
- Defaulting the attribute to `None` (data could not be read) instead of `[]`/`{}` (data was read and is empty), e.g. the `metric_filters is not None` guard in `prowler/providers/aws/services/cloudwatch/lib/metric_filters.py`.
- Keeping an availability flag raised on any denied listing, e.g. `logs_client.metric_filters_unavailable` consumed by the AWS CloudWatch metric filter checks.
- Keeping an error flag or message next to the data, e.g. `entra_client.user_registration_details_error` in M365 or `*_scan_errors` in AWS Bedrock.
- Keeping a set of resources whose lookup failed, e.g. `accessapproval_client.settings_lookup_failed` in GCP.
Make sure the error branch only captures real access errors. A `404`/not-found response frequently means the feature is simply not configured, which **is** a legitimate `FAIL`; a `403` or an unexpected exception is not. An "API not enabled" error is usually a scan-configuration problem too — **except** when the API's activation is itself the control being audited (e.g. GCP Access Approval: with `accessapproval.googleapis.com` disabled the feature provably cannot be enabled, so a definitive API-disabled state is a legitimate `FAIL`, while an undetermined state stays `MANUAL`).
```python
if <service>_client.<data> is None:
report = CheckReport<Provider>(metadata=self.metadata(), resource={})
report.resource_name = "<Tenant/Account-level resource>"
report.resource_id = "<stable-id>"
report.status = "MANUAL"
report.status_extended = "Cannot evaluate <requirement>: <data> could not be retrieved. Verify that <permission/API/license> is granted to the scanning identity."
findings.append(report)
return findings
```
### Prowler's Check Severity Levels
The severity of each check is defined in the metadata file using the `Severity` field. Severity values are always lowercase and must be one of the predefined categories below.
@@ -437,6 +467,7 @@ The metadata structure is enforced in code using a Pydantic model. For reference
- Use clear, actionable, and user-friendly language in `status_extended` to explain the result. Always provide information to identify the resource.
- Use helper functions/utilities for repeated logic to avoid code duplication. Save them in the `lib` folder of the service.
- Handle exceptions gracefully: catch errors per resource, log them, and continue processing other resources.
- Never report `FAIL` because data could not be retrieved (missing permissions, API not enabled, feature not licensed). Emit a single `MANUAL` finding explaining what is required instead; see [Permission and Data-Availability Errors Are Not Findings](#permission-and-data-availability-errors-are-not-findings).
- Document the check with a class and function level docstring explaining what it does, what it checks, and any caveats or provider-specific behaviors.
- Use type hints for the `execute()` method (e.g., `-> list[CheckReport<Provider>]`) for clarity and static analysis.
- Ensure checks are efficient; avoid excessive nested loops. If the complexity is high, consider refactoring the check.
+1 -1
View File
@@ -10,7 +10,7 @@ Visual Studio Code (also referred to as VSCode) provides an integrated debugger
### Debugging Configuration Example
The following file is an example of a [debugging configuration](https://code.visualstudio.com/docs/editor/debugging#_launch-configurations) file for [Virtual Studio Code](https://code.visualstudio.com/).
The following file is an example of a [debugging configuration](https://code.visualstudio.com/docs/editor/debugging#_launch-configurations) file for [Visual Studio Code](https://code.visualstudio.com/).
This file must be placed inside the *.vscode* directory and named *launch.json*:
+3 -3
View File
@@ -50,7 +50,7 @@ When adding or maintaining E2E tests for Prowler Local Server, follow these guid
```
5. **Tag and document scenarios**
- Follow the existing naming convention for suites and test cases (for example, `SCANS-E2E-001`, `PROVIDER-E2E-003`) and use tags such as `@e2e`, `@serial` and feature tags (for example, `@providers`, `@scans`,`@aws`) to filter and organize tests.
- Follow the existing naming convention for suites and test cases (for example, `SCANS-E2E-001`, `PROVIDER-E2E-003`) and use tags such as `@e2e`, `@serial` and feature tags (for example, `@providers`, `@scans`, `@aws`) to filter and organize tests.
**Example:**
```typescript
@@ -71,7 +71,7 @@ When adding or maintaining E2E tests for Prowler Local Server, follow these guid
}
);
```
- Document each one in the Markdown files under `ui/tests`, including **Priority**, **Tags**, **Description**, **Preconditions**, **Flow steps**, **Expected results**,**Key verification points** and **Notes**.
- Document each one in the Markdown files under `ui/tests`, including **Priority**, **Tags**, **Description**, **Preconditions**, **Flow steps**, **Expected results**, **Key verification points** and **Notes**.
**Example**
```Markdown
@@ -256,7 +256,7 @@ To execute E2E tests for Prowler Local Server:
pnpm run test:e2e
```
This command runs Playwright with the configured projects
This command runs Playwright with the configured projects.
2. **Run E2E tests with the Playwright UI runner**
+7 -7
View File
@@ -25,7 +25,7 @@ For providers supported by Prowler, refer to [Prowler Hub](https://hub.prowler.c
Prowler supports several types of providers, each with its own implementation pattern and use case. Understanding these differences is key to designing your provider correctly.
### Classifying your Provider
### Classifying Your Provider
Before implementing a new provider, you need to determine which type it belongs to. This classification will guide your implementation approach and help you choose the right patterns and libraries.
@@ -1090,7 +1090,7 @@ Main registration makes your provider discoverable by Prowler's core system. It'
cis.batch_write_data_to_file()
```
#### Step 11: Register in the list of providers
#### Step 11: Register in the List of Providers
**Explanation:**
This is needed to be able to use the provider in the generic checks. The provider must be registered in the `init_global_provider` method to handle CLI arguments and initialization.
@@ -1966,7 +1966,7 @@ Main registration makes your provider discoverable by Prowler's core system. It'
This step is the same as the [SDK providers](#step-10-register-in-main).
#### Step 11: Register in the list of providers
#### Step 11: Register in the List of Providers
**Explanation:**
This is needed to be able to use the provider in the generic checks. The provider must be registered in the `init_global_provider` method to handle CLI arguments and initialization.
@@ -2648,7 +2648,7 @@ Main registration makes your provider discoverable by Prowler's core system. It'
This step is the same as the [SDK providers](#step-10-register-in-main).
#### Step 7: Register in the list of providers
#### Step 7: Register in the List of Providers
**Explanation:**
This is needed to be able to use the provider in the generic checks. The provider must be registered in the `init_global_provider` method to handle CLI arguments and initialization.
@@ -2808,7 +2808,7 @@ def validate_your_provider_uid(value):
**Provider Model:**
The `Provider` model already exists and supports all provider types. Ensure your provider type is included in the choices.
### 2.2. Add the provider to the Provider Choices
### 2.2. Add the Provider to the Provider Choices
Update the `return_prowler_provider` function to include your provider. This function is crucial for the API to instantiate the correct provider class.
@@ -3209,7 +3209,7 @@ class YourProviderAPITestCase(APITestCase):
self.assertEqual(response.status_code, 201)
```
#### 2.6.1. Add your mocked provider to the tests
#### 2.6.1. Add Your Mocked Provider to the Tests
If needed, add a named provider fixture or extend the provider factory defaults so tests can request only the provider they need.
@@ -3272,7 +3272,7 @@ Your provider will be available through these endpoints:
- `DELETE /api/v1/providers/{id}/` - Delete provider
- `POST /api/v1/providers/secrets/` - Add provider credentials
### 2.9. Update the provider if needed
### 2.9. Update the Provider If Needed
Depending on your provider's authentication requirements, you may need to add new authentication methods that are compatible with the API. This involves updating the provider class to support additional credential types beyond the basic ones.
@@ -18,7 +18,7 @@ A compliance framework must represent the **complete state** of the source catal
Requirement coverage feeds the compliance percentage calculations and the metadata surfaces (dashboards, widgets, exports). Missing requirements skew those metrics and break the report as a faithful snapshot of the framework.
</Warning>
### Two supported schemas
### Two Supported Schemas
| Schema | When to use | File location | Discovered as |
| --- | --- | --- | --- |
@@ -45,7 +45,7 @@ Before adding a new framework, complete the following checks:
## Universal Compliance Framework
### Where the file lives
### Where the File Lives
Place the file at the top level of the compliance directory:
@@ -57,7 +57,7 @@ Examples in the repository: `prowler/compliance/csa_ccm_4.0.json`, `prowler/comp
The file is auto-discovered — there is **no** need to register it in any `__init__.py`, modify `prowler/lib/outputs/`, or update any other Python module. The framework key Prowler CLI accepts via `--compliance` is the basename of the JSON file without `.json` (`dora_2022_2554.json` → `dora_2022_2554`).
### Top-level structure
### Top-Level Structure
```json
{
@@ -198,7 +198,7 @@ Per requirement:
For MITRE-style frameworks, additional optional fields are available on the requirement: `tactics`, `sub_techniques`, `platforms`, `technique_url` (these are populated automatically when adapting a legacy MITRE JSON to the universal model).
### Multi-provider frameworks
### Multi-Provider Frameworks
A single universal file can cover any number of providers. The framework appears under each provider's `--list-compliance` output as long as **at least one** requirement has that provider key in its `checks` dict.
@@ -226,7 +226,7 @@ The legacy schema spans **four layers** — a complete contribution must touch e
The universal schema collapses Layers 3 and 4 into declarative configuration inside the JSON — that is the main reason it is preferred for new contributions.
### Directory structure and file naming
### Directory Structure and File Naming
Compliance frameworks live at:
@@ -259,7 +259,7 @@ prowler/lib/outputs/compliance/<framework>/
└── __init__.py
```
### JSON schema reference
### JSON Schema Reference
Every legacy compliance file is a JSON document with the following top-level keys. `Framework`, `Name` and `Provider` are validated non-empty by the root validator `framework_and_provider_must_not_be_empty` (`compliance_models.py`).
@@ -362,7 +362,7 @@ For the remaining attribute classes (`AWS_Well_Architected_Requirement_Attribute
The `Attributes` field is a Pydantic `Union`. The generic attribute model **must** remain the last element of that Union — otherwise Pydantic v1 silently coerces every framework into the generic shape and your specialized fields are dropped. Adding a brand-new attribute shape requires inserting the Pydantic class **before** `Generic_Compliance_Requirement_Attribute`.
</Note>
#### Minimal working example
#### Minimal Working Example
The following snippet is a complete, valid framework file named `my_framework_1.0_aws.json`, saved at `prowler/compliance/aws/my_framework_1.0_aws.json`. It uses the generic attribute shape for simplicity.
@@ -408,7 +408,7 @@ The following snippet is a complete, valid framework file named `my_framework_1.
}
```
### Mapping checks to requirements
### Mapping Checks to Requirements
Each requirement links to the Prowler checks that, together, produce a PASS or FAIL verdict for that control.
@@ -425,7 +425,7 @@ To discover available checks:
uv run python prowler-cli.py <provider> --list-checks
```
### Supporting multiple providers (legacy)
### Supporting Multiple Providers (Legacy)
The legacy schema binds each file to a single provider. To cover several providers with the same framework, ship one JSON file per provider:
@@ -439,7 +439,7 @@ Keep the `Framework` and `Version` values identical across the files so the disp
For a brand-new framework that spans several providers, **prefer the universal schema** — it covers every provider from a single file. If you must use the legacy schema, add one transformer per provider in `prowler/lib/outputs/compliance/<framework>/` and extend the summary-table dispatcher accordingly. See [Output Formatter](#output-formatter).
### Output formatter
### Output Formatter
Legacy frameworks render in two forms: a detailed CSV report written to disk, and a summary table printed in the CLI. Both are produced by the output formatter package for the framework. Universal frameworks do **not** need a Python output formatter — the `outputs` config inside the JSON drives rendering — so this section applies only to the legacy schema.
@@ -453,19 +453,19 @@ prowler/lib/outputs/compliance/my_framework/
└── models.py # CSV row Pydantic model
```
#### Step 1 — Define the CSV row model
#### Step 1 — Define the CSV Row Model
In `models.py`, declare a Pydantic v1 model with one field per CSV column. Use existing models such as `AWSCISModel` in `prowler/lib/outputs/compliance/cis/models.py` as the reference. Fields typically include `Provider`, `Description`, `AccountId`, `Region`, `AssessmentDate`, `Requirements_Id`, `Requirements_Description`, one `Requirements_Attributes_*` field per attribute key, plus the finding fields `Status`, `StatusExtended`, `ResourceId`, `ResourceName`, `CheckId`, `Muted`, `Framework`, `Name`.
#### Step 2 — Implement the transformer
#### Step 2 — Implement the Transformer
In `my_framework_aws.py`, subclass `ComplianceOutput` from `prowler.lib.outputs.compliance.compliance_output` and implement `transform(findings, compliance, compliance_name)`. Iterate over `findings`, match each finding to the requirements it satisfies through `finding.compliance.get(compliance_name, [])`, and append one row per attribute to `self._data`.
#### Step 3 — Add the summary-table dispatcher
#### Step 3 — Add the Summary-Table Dispatcher
In `my_framework.py`, implement `get_my_framework_table(findings, bulk_checks_metadata, compliance_framework, output_filename, output_directory, compliance_overview)` following the pattern in `prowler/lib/outputs/compliance/cis/cis.py`.
#### Step 4 — Register the framework in the dispatchers
#### Step 4 — Register the Framework in the Dispatchers
- Add the dispatcher call in `prowler/lib/outputs/compliance/compliance.py`, inside `display_compliance_table`, with a branch such as `elif "my_framework" in compliance_framework:`.
- Register the CSV model and transformer in `prowler/lib/outputs/compliance/compliance_output.py` so the CSV file is emitted during the scan.
@@ -474,7 +474,7 @@ In `my_framework.py`, implement `get_my_framework_table(findings, bulk_checks_me
For NIST-style catalogs that use `Generic_Compliance_Requirement_Attribute`, no custom formatter is needed. The generic formatter in `prowler/lib/outputs/compliance/generic/` handles them automatically, provided the JSON validates against the generic attribute schema.
</Note>
### Legacy-to-universal adapter
### Legacy-to-Universal Adapter
At load time, every legacy file is transparently adapted to a `ComplianceFramework` via `adapt_legacy_to_universal()` (`compliance_models.py`), which: (a) flattens the first element of `Attributes` into a flat `attributes` dict, (b) wraps `Checks` as `{provider_lower: [...]}`, (c) infers `attributes_metadata` from the matched Pydantic class via `_infer_attribute_metadata()`. The rest of Prowler (CSV/OCSF/PDF output, CLI table) then treats both formats identically.
@@ -497,7 +497,7 @@ Configuration guardrails close that gap. A requirement declares the configuratio
Guardrails are an **optional** safety net for configurable checks. A requirement that maps only to non-configurable checks does not need them. When the field is absent, behavior is unchanged.
</Note>
### Where guardrails are declared
### Where Guardrails Are Declared
The field is attached to each requirement and exists in both schemas:
@@ -506,7 +506,7 @@ The field is attached to each requirement and exists in both schemas:
When a legacy file is adapted to the universal model, `adapt_legacy_to_universal()` copies `ConfigRequirements` into `config_requirements` (`compliance_models.py`), so downstream code only ever reads one shape.
### Constraint schema
### Constraint Schema
Each entry in the list is a single constraint with the following fields:
@@ -533,7 +533,7 @@ Each entry in the list is a single constraint with the following fields:
`subset` / `superset` require both the applied value and `Value` to be lists; any other type is treated as not satisfied. For `eq` against a boolean, declare `Value` as a JSON boolean (`false`, not `0`) — the model keeps booleans distinct from integers.
</Note>
### How guardrails are evaluated
### How Guardrails Are Evaluated
All evaluation lives in one shared module, `prowler/lib/check/compliance_config_eval.py`, consumed by every compliance output (CSV, OCSF, and the CLI tables) and reused by the Prowler API backend so the rule is defined exactly once.
@@ -547,7 +547,7 @@ All evaluation lives in one shared module, `prowler/lib/check/compliance_config_
Guardrails only ever make a result **stricter** (they can turn PASS into FAIL); they never relax a real FAIL into PASS. A requirement with no constraints, or whose keys all use defaults, is reported exactly as before.
</Warning>
### Example: legacy framework
### Example: Legacy Framework
From `prowler/compliance/aws/cis_6.0_aws.json`, requirement 2.11 declares two guardrails — one per configurable check it maps to:
@@ -590,7 +590,7 @@ A boolean guardrail from the same file: requirement 2.5 (IAM Access Analyzer) on
]
```
### Example: universal framework
### Example: Universal Framework
The universal schema uses the lowercase `config_requirements` key with the identical object shape:
@@ -616,7 +616,7 @@ The universal schema uses the lowercase `config_requirements` key with the ident
Each constraint declares the `Provider` it targets so the guardrail is only evaluated on scans of that provider — essential for universal frameworks like CSA CCM and DORA, where one requirement maps checks across `aws`, `azure`, `gcp` and more. Because the operator is `subset`, adding `"TLS 1.0"` to `recommended_minimal_tls_versions` widens the allowlist beyond `["TLS 1.2", "TLS 1.3"]` and the requirement is forced to FAIL.
### What the user sees
### What the User Sees
With a loosened config, the affected requirement's findings report:
@@ -630,7 +630,7 @@ StatusExtended: Configuration not valid for this requirement. The check
The same `Configuration not valid for this requirement.` message appears identically across the CSV, OCSF, and console-table outputs.
### Authoring guidelines
### Authoring Guidelines
- Declare a guardrail only for keys whose value actually changes whether the requirement is met. Most configurable checks do not need one.
- Set `Value` to the **strictest** configuration the control tolerates — the same number the control text cites (CIS 45 days, NIST ≤90, and so on).
@@ -639,7 +639,7 @@ The same `Configuration not valid for this requirement.` message appears identic
- Pick the operator from the value's role: a max threshold is `lte`, a min threshold is `gte`, a toggle is `eq`, an allowlist is `subset`, a denylist is `superset`.
- An unrecognized operator does **not** block the requirement — a malformed constraint is treated as satisfied rather than failing the whole framework. Validate your JSON with the tests below.
### Testing guardrails
### Testing Guardrails
The shared evaluator and the per-output integration are covered by:
@@ -670,7 +670,7 @@ Prowler matches frameworks by concatenating `Framework` and `Version`. A missing
Before opening a PR, validate the JSON loads cleanly against the model and that every referenced check actually exists.
### 1. Schema validation
### 1. Schema Validation
For **universal** frameworks, load the file and inspect what was parsed. The framework key inside `bulk` is the **basename of the JSON file** (without `.json`); for `prowler/compliance/dora_2022_2554.json` that key is `dora_2022_2554`, for `prowler/compliance/aws/cis_5.0_aws.json` it is `cis_5.0_aws`.
@@ -688,7 +688,7 @@ bulk = get_bulk_compliance_frameworks_universal("aws")
assert "<your_framework_filename_without_json>" in bulk
```
### 2. Check existence cross-check
### 2. Check Existence Cross-Check
There is **no automatic check-existence validation** at load time. Cross-check that every check name in your framework maps to a real check directory:
@@ -708,7 +708,7 @@ missing = referenced - real
assert not missing, f"checks referenced in framework but not found in repo: {sorted(missing)}"
```
### 3. CLI smoke test
### 3. CLI Smoke Test
```bash
uv run python prowler-cli.py <provider> --list-compliance
@@ -728,7 +728,7 @@ Verify that:
- The CLI summary table lists every section / pillar of the framework.
- Findings roll up under the expected requirements.
### 4. Inspect the CSV output
### 4. Inspect the CSV Output
Open the generated CSV and confirm:
+9 -9
View File
@@ -12,7 +12,7 @@ This guide explains how to add a **Server-Sent Events (SSE)** endpoint to the Pr
The platform ships the SSE **infrastructure** (`api.sse`) and wiring. No feature endpoint streams over SSE out of the box — this guide shows how to build one on top of the shared base.
</Info>
## When to use SSE
## When to Use SSE
| Need | Use |
|------|-----|
@@ -22,7 +22,7 @@ The platform ships the SSE **infrastructure** (`api.sse`) and wiring. No feature
SSE is the right tool when the **client only consumes**: scan progress, long-running job checkpoints, streamed LLM tokens, cross-client resource-sync notifications. It rides on plain HTTP, reconnects automatically in the browser via the native [`EventSource`](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) API, and needs no extra protocol.
## How it works
## How It Works
SSE is wired through [`django-eventstream`](https://github.com/fanout/django_eventstream) and a small platform layer in `api/src/backend/api/sse/`:
@@ -34,11 +34,11 @@ SSE is wired through [`django-eventstream`](https://github.com/fanout/django_eve
| `make_channel_name` / `tenant_id_from_channel` | `api/sse/utils.py` | Single source of truth for the channel-name format, so publishers and the channel manager agree byte-for-byte. |
| Settings | `config/settings/eventstream.py` | Valkey Pub/Sub backend (dedicated DB), channel manager, allowed headers. |
### Transport: the server runs on ASGI
### Transport: The Server Runs on ASGI
SSE connections are long-lived. Holding one open per synchronous worker would exhaust the worker pool, so the API runs under Gunicorn's native **`asgi` worker** (`config.asgi:application`). Streams are parked on the event loop while ordinary CRUD endpoints keep their synchronous execution (Django runs sync views in a thread-sensitive executor under ASGI). This is configured in `config/guniconf.py` and used by both the dev and production entrypoints — no separate server process is needed.
### The data flow
### The Data Flow
```
publisher (Celery task / view) subscriber (browser, CLI)
@@ -53,7 +53,7 @@ publisher (Celery task / view) subscriber (browser, CLI)
A publisher anywhere in the system (most often a Celery task) calls `send_event(channel, event_type, payload)`. `django-eventstream` fans it out over Valkey Pub/Sub to every connection subscribed to that channel.
## Adding an SSE endpoint to your feature
## Adding an SSE Endpoint to Your Feature
The example below streams progress for a long-running **scan**. Adapt the resource, prefix, and event names to your feature.
@@ -161,7 +161,7 @@ publish_end(channel, scan_id=str(scan.id))
</Steps>
## Event naming convention
## Event Naming Convention
Every event uses an event type of the form **`<resource>.<verb>`** (lowercased, dot-separated). The verb comes from this platform-wide vocabulary — if you need a verb that is not listed, document the addition in this guide so the catalog stays discoverable.
@@ -197,7 +197,7 @@ curl -N -H "Authorization: Bearer $JWT" \
https://<host>/api/v1/scans/$SCAN_ID/event-stream
```
## Tenant isolation & security model
## Tenant Isolation & Security Model
Authorization is enforced at two layers:
@@ -206,7 +206,7 @@ Authorization is enforced at two layers:
Because the tenant id lives inside the channel name, this gate works for any feature without the platform knowing anything about it.
## Reconnect & state recovery
## Reconnect & State Recovery
The platform deliberately ships **without server-side replay** (`is_channel_reliable` returns `False`). When a client reconnects, it does **not** receive missed events. Instead:
@@ -215,7 +215,7 @@ The platform deliberately ships **without server-side replay** (`is_channel_reli
Design your event payloads accordingly: deltas are ephemeral and concatenated in-flight; the durable truth always lives behind a REST resource.
## Local development
## Local Development
- The dev and production entrypoints both launch Gunicorn with the `asgi` worker (`config.asgi:application`). In dev, `DJANGO_DEBUG=True` enables hot reload; `preload_app` is automatically disabled under debug so edited code is picked up.
- SSE uses a **dedicated Valkey database** (`EVENTSTREAM_VALKEY_DB`, default `2`) kept separate from the Celery broker so a noisy broker cannot crowd out streaming traffic. It reuses the same `VALKEY_*` connection settings as the rest of the platform.
+1 -1
View File
@@ -537,7 +537,7 @@ This architecture allows Prowler to efficiently scan AWS accounts with resources
## Best Practices
- When available in the provider, use threading or parallelization utilities for all methods that can be parallelized by to maximize performance and reduce scan time.
- When available in the provider, use threading or parallelization utilities for all methods that can be parallelized to maximize performance and reduce scan time.
- Define a Pydantic `BaseModel` for every resource you manage, and use these models for all resource data handling.
- Log every major step (start, success, error) in resource discovery and attribute collection for traceability and debugging; include as much context as possible.
- Catch and log all exceptions, providing detailed context (region, subscription, resource, error type, line number) to aid troubleshooting.
+4 -4
View File
@@ -154,7 +154,7 @@ Failing to update this table when adding cross-service dependencies may result i
For AWS provider, different testing approaches apply based on API coverage based on several criteria.
<Note>
Prowler leverages and contributes to the[Moto](https://github.com/getmoto/moto) library for mocking AWS infrastructure in tests.
Prowler leverages and contributes to the [Moto](https://github.com/getmoto/moto) library for mocking AWS infrastructure in tests.
</Note>
- AWS API Calls Covered by [Moto](https://github.com/getmoto/moto):
@@ -408,7 +408,7 @@ In all above scenarios, check execution must occur within the context of mocked
When a service requires API calls that are partially covered by the Moto decorator, additional mocking is necessary. In such cases, custom mocked API calls must be implemented alongside Moto to ensure full coverage.
To achieve this, mock the `botocore.client.BaseClient._make_api_call` function—the method responsible for making actual API requests to AWS—using `mock.patch <https://docs.python.org/3/library/unittest.mock.html#patch>`:
To achieve this, mock the `botocore.client.BaseClient._make_api_call` function—the method responsible for making actual API requests to AWS—using [`mock.patch`](https://docs.python.org/3/library/unittest.mock.html#patch):
```python
@@ -475,7 +475,7 @@ However, if additional `moto` decorators are applied alongside the patch, Moto w
</Note>
<Note>
The source of the above implementation can be found here:[Patch Other Services with Moto](https://docs.getmoto.org/en/latest/docs/services/patching\_other\_services.html)
The source of the above implementation can be found here: [Patch Other Services with Moto](https://docs.getmoto.org/en/latest/docs/services/patching_other_services.html)
</Note>
#### Mocking Several Services
@@ -603,7 +603,7 @@ with mock.patch(
will cause that the service is initialized only once—at the moment of mocking out `set_mocked_aws_provider([<region>])` using `mock.patch`.
Later, when Python attempts to import the client at the check level, the execution continues using`from prowler.providers.<provider>.services.<service>.<service>_client`. As a result of it being already mocked out, the execution will continue using `service_client` without getting into `<service>_client.py`.
Later, when Python attempts to import the client at the check level, the execution continues using `from prowler.providers.<provider>.services.<service>.<service>_client`. As a result of it being already mocked out, the execution will continue using `service_client` without getting into `<service>_client.py`.
### Testing AWS Services
@@ -4,7 +4,7 @@ title: 'Basic Usage'
## Running Prowler
Running Prowler requires specifying the provider (e.g `aws`, `gcp`, `azure`, `kubernetes`, `m365`, `github`, `iac` or `mongodbatlas`):
Running Prowler requires specifying the provider (e.g. `aws`, `gcp`, `azure`, `kubernetes`, `m365`, `github`, `iac` or `mongodbatlas`):
<Note>
If no provider is specified, AWS is used by default for backward compatibility with Prowler v2.
@@ -128,8 +128,8 @@ To update the environment file:
Edit the `.env` file and change version values:
```env
PROWLER_UI_VERSION="5.39.0"
PROWLER_API_VERSION="5.39.0"
PROWLER_UI_VERSION="5.41.0"
PROWLER_API_VERSION="5.41.0"
```
<Note>
@@ -161,7 +161,7 @@ The Prowler MCP Server enables powerful workflows through AI assistants:
- "What authentication methods does Prowler support for Azure?"
- "How can I contribute with a new security check to Prowler?"
### Example: Creating a custom dashboard with Prowler extracted data
### Example: Creating a Custom Dashboard with Prowler Extracted Data
In the next example you can see how to create a dashboard using Prowler MCP Server and Claude Desktop.
Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

+1 -1
View File
@@ -22,7 +22,7 @@ See section [Logging](/user-guide/cli/tutorials/logging) for further information
Common issues with the Docker Compose installation of Prowler Local Server.
### Problem adding AWS Provider using "Connect assuming IAM Role" in Docker
### Problem Adding AWS Provider Using "Connect assuming IAM Role" in Docker
See [GitHub Issue #7745](https://github.com/prowler-cloud/prowler/issues/7745) for more details.
@@ -51,6 +51,7 @@ The following list includes all the AWS checks with configurable variables that
| `cloudtrail_threat_detection_privilege_escalation` | `threat_detection_privilege_escalation_actions` | List of Strings | See `config.yaml` |
| `cloudtrail_threat_detection_privilege_escalation` | `threat_detection_privilege_escalation_minutes` | Integer | `1440` |
| `cloudtrail_threat_detection_privilege_escalation` | `threat_detection_privilege_escalation_threshold` | Float | `0.2` |
| `cloudwatch_log_group_agentcore_data_protection_policy_enabled` | `agentcore_log_group_name_prefixes` | List of Strings | See `config.yaml` |
| `cloudwatch_log_group_no_secrets_in_logs` | `secrets_ignore_patterns` | List of Strings | `[]` |
| `cloudwatch_log_group_retention_policy_specific_days_enabled` | `log_group_retention_days` | Integer | `365` |
| `codebuild_project_no_secrets_in_variables` | `excluded_sensitive_environment_variables` | List of Strings | `[]` |
+1 -1
View File
@@ -105,7 +105,7 @@ def fixer(resource_id: str) -> bool:
return True
```
## Fixer Config file
## Fixer Config File
For some fixers, you can have configurable parameters depending on your use case. You can either use the default config file in `prowler/config/fixer_config.yaml` or create a custom config file and pass it to the fixer with the `--fixer-config` flag. The config file should be a YAML file with the following structure:
+2 -2
View File
@@ -4,7 +4,7 @@ title: 'Miscellaneous'
## Prowler Version
### Showing the Prowler version:
### Showing the Prowler Version
```console
prowler <provider> -V/-v/--version
@@ -22,7 +22,7 @@ To enable verbose mode in Prowler, similar to Version 2, use:
prowler <provider> --verbose
```
### Filter findings by status
### Filter Findings by Status
Prowler allows filtering findings based on their status, ensuring reports and CLI display only relevant findings:
+1 -1
View File
@@ -268,7 +268,7 @@ Accounts:
## AWS Mutelist
### Muting specific AWS regions
### Muting Specific AWS Regions
If you want to mute failed findings only in specific regions, create a file with the following syntax and run it with `prowler aws -w mutelist.yaml`:
+1 -4
View File
@@ -43,8 +43,7 @@ prowler <provider> --categories secrets
Several checks analyse resources that are exposed to the Internet, these are:
1. apigateway\_restapi\_public
- apigateway\_restapi\_public
- appstream\_fleet\_default\_internet\_access\_disabled
- awslambda\_function\_not\_publicly\_accessible
- ec2\_ami\_public
@@ -58,8 +57,6 @@ Several checks analyse resources that are exposed to the Internet, these are:
- ecr\_repositories\_not\_publicly\_accessible
- eks\_control\_plane\_endpoint\_access\_restricted
- eks\_endpoints\_not\_publicly\_accessible
- eks\_control\_plane\_endpoint\_access\_restricted
- eks\_endpoints\_not\_publicly\_accessible
- elbv2\_internet\_facing
- kms\_key\_not\_publicly\_accessible
- opensearch\_service\_domains\_not\_publicly\_accessible
@@ -144,25 +144,25 @@ prowler alibabacloud --ecs-ram-role RoleName
### Step 2: Run the First Scan
#### Scan all regions
#### Scan All Regions
```bash
prowler alibabacloud
```
#### Scan specific regions
#### Scan Specific Regions
```bash
prowler alibabacloud --region cn-hangzhou cn-shanghai
```
#### Run specific checks
#### Run Specific Checks
```bash
prowler alibabacloud --checks ram_no_root_access_key ram_user_mfa_enabled_console_access
```
#### Run a compliance framework
#### Run a Compliance Framework
```bash
prowler alibabacloud --compliance cis_2.0_alibabacloud
@@ -167,7 +167,7 @@ Include the `ExternalId` parameter in the StackSet if required by the organizati
When encountering issues during deployment or needing to target specific OUs or environments (e.g., dev/staging/prod), reach out to the Prowler team via [Slack Community](https://prowler.com/slack) or [Support](mailto:support@prowler.com).
## Extra: Run Prowler across all accounts in AWS Organizations by assuming roles
## Extra: Run Prowler Across All Accounts in AWS Organizations by Assuming Roles
### Running Prowler Across All AWS Organization Accounts
@@ -306,9 +306,21 @@ prowler image --registry internal-registry.local --registry-insecure
```
<Warning>
Skipping TLS verification disables certificate validation for registry connections. Use this flag only for trusted internal registries with self-signed certificates.
Skipping TLS verification disables certificate validation for registry connections, including the Trivy image pull (`TRIVY_INSECURE`). Use this flag only for trusted internal registries with self-signed certificates.
</Warning>
#### On-Premises Registries and Private Networks
<VersionBadge version="5.41.0" />
By default, Prowler rejects registry-provided URLs (token endpoints, pagination links) that resolve to non-public addresses, as an SSRF defense. On-premises registries live on private networks by definition, so to scan them declare the trusted ranges explicitly:
```bash
export PROWLER_IMAGE_PROVIDER_ALLOWED_PRIVATE_NETWORKS="192.168.65.254/32,10.20.0.0/16"
```
The value is a comma-separated list of IPs and CIDRs. A resolved address inside an allowlisted range is permitted; every other non-public address stays blocked, so link-local (`169.254.169.254`), loopback, and the rest of the internal network remain protected. The variable applies to registry enumeration and to the connection test. Malformed entries fail at startup, and a non-empty allowlist is logged as a relaxed security control. When unset, behavior is unchanged: only public addresses are followed.
#### Supported Registries
Registry Scan Mode supports the following registry types:
@@ -36,7 +36,7 @@ If **Require IP Access List for the Atlas Administration API** is enabled in the
<VersionBadge version="5.15.0" />
### Step 1: Add the provider
### Step 1: Add the Provider
1. Navigate to **Providers** and click **Add Provider**.
![Add provider list](./img/add-provider-list.png)
@@ -45,13 +45,13 @@ If **Require IP Access List for the Atlas Administration API** is enabled in the
![Add organization ID](./img/add-org-id.png)
4. (Optional) Add a friendly alias to identify this organization in dashboards.
### Step 2: Provide API credentials
### Step 2: Provide API Credentials
1. Click **Next** to open the credentials form.
2. Paste the **Atlas Public Key** and **Atlas Private Key** generated in the Atlas console.
![Add credentials](./img/add-credentials.png)
### Step 3: Test the connection and start scanning
### Step 3: Test the Connection and Start Scanning
1. Click **Test connection** to ensure Prowler Cloud can reach the Atlas API.
2. Save the credentials. The provider will appear in the list with its current connection status.
@@ -66,11 +66,11 @@ If **Require IP Access List for the Atlas Administration API** is enabled in the
You can also run MongoDB Atlas assessments directly from the CLI. Both command-line flags and environment variables are supported.
### Step 1: Select an authentication method
### Step 1: Select an Authentication Method
Choose one of the following authentication methods:
#### Command-line arguments
#### Command-Line Arguments
```bash
prowler mongodbatlas \
@@ -78,7 +78,7 @@ prowler mongodbatlas \
--atlas-private-key <private_key>
```
#### Environment variables
#### Environment Variables
```bash
export ATLAS_PUBLIC_KEY=<public_key>
@@ -86,9 +86,9 @@ export ATLAS_PRIVATE_KEY=<private_key>
prowler mongodbatlas
```
### Step 2: Run the first scan
### Step 2: Run the First Scan
#### Scan all projects and clusters
#### Scan All Projects and Clusters
```bash
prowler mongodbatlas
@@ -96,7 +96,7 @@ prowler mongodbatlas
This command enumerates all projects accessible to the API key and scans every cluster.
#### Scan a specific project
#### Scan a Specific Project
Add the `--atlas-project-id` flag when you only want to assess one project:
@@ -104,7 +104,7 @@ Add the `--atlas-project-id` flag when you only want to assess one project:
prowler mongodbatlas --atlas-project-id <project-id>
```
### Additional tips
### Additional Tips
- Combine flags (for example, `--checks` or `--services`) just like with other providers.
- Use `--output-modes` to export findings in JSON, CSV, ASFF, etc.
@@ -67,7 +67,7 @@ The service application must be assigned **one** of the following Okta admin rol
Okta's Management API enforces a two-layer authorization model: an OAuth **scope** decides which API endpoints the token can call, and an **admin role** decides whether the call returns data. With only a scope granted, the token mint succeeds but every read returns `403 Forbidden`. Read-Only Administrator is the minimum role that lets the granted `okta.*.read` scopes return configuration data to Prowler's checks; without it, the credential probe at provider startup fails and the scan never gets to evaluate any check.
#### When Super Administrator is required
#### When Super Administrator Is Required
Four checks need to resolve the Authentication Policy bound to Okta's first-party apps (Okta Admin Console, Okta Dashboard) and depend on `/api/v1/apps` returning those system apps — which Okta restricts to Super Administrator:
@@ -92,17 +92,17 @@ Read-Only Administrator stays the recommended default for the least-privilege fr
## Step-by-Step Setup
### 1. Go to the admin console
### 1. Go to the Admin Console
![Okta — admin console page](/user-guide/providers/okta/images/select-admin-console.png)
### 2. [Optional] - Disable the privilege-escalation bypass (org-wide, one-time)
### 2. [Optional] - Disable the Privilege-Escalation Bypass (Org-Wide, One-Time)
In the Okta Admin Console, go to **Settings → Account → Public client app admins** and ensure it is **off**. When enabled, every API Services app can be auto-assigned the Super Administrator role after scopes are granted, which would invalidate the read-only premise of this integration.
![Okta — disable Public client app admins](/user-guide/providers/okta/images/public-client-app-admins.png)
### 3. Create the API Services app
### 3. Create the API Services App
1. Go to **Applications → Applications**.
@@ -118,7 +118,7 @@ In the Okta Admin Console, go to **Settings → Account → Public client app ad
![Okta — copy client id](/user-guide/providers/okta/images/copy-client-id.png)
### 4. Switch to private-key authentication and generate a keypair
### 4. Switch to Private-Key Authentication and Generate a Keypair
On the new app's **General** tab, scroll to **Client Credentials**:
@@ -136,13 +136,13 @@ Okta displays the private key **only once**. If you close the modal without copy
![Okta — create Public Key](/user-guide/providers/okta/images/create-public-key.png)
### 5. Grant the required OAuth scopes
### 5. Grant the Required OAuth Scopes
On the app, open the **Okta API Scopes** tab and click **Grant** on every scope Prowler needs. The bundled checks require `okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.authenticators.read`, `okta.networkZones.read`, `okta.apiTokens.read`, `okta.roles.read`, `okta.groups.read`, `okta.logStreams.read`, and `okta.idps.read`.
![Okta — grant OAuth scopes](/user-guide/providers/okta/images/grant-permissions.png)
### 6. Assign an admin role
### 6. Assign an Admin Role
On the app, open the **Admin roles** tab and click **Edit assignments → Add assignment**:
@@ -155,7 +155,7 @@ To additionally evaluate the first-party application checks (Okta Admin Console
![Okta — grant Read-Only role](/user-guide/providers/okta/images/grant-roles.png)
### 7. [Optional] Verify DPoP setting
### 7. [Optional] Verify DPoP Setting
Prowler sends DPoP (Demonstrating Proof of Possession) proofs on every token request. The integration works whether the **Require Demonstrating Proof of Possession (DPoP) header in token requests** setting on the service app is on or off — but enabling it is the more secure default.
@@ -206,20 +206,20 @@ The org domain must be `<org>.okta.com` (or `.oktapreview.com` / `.okta-emea.com
The file at `OKTA_PRIVATE_KEY_FILE` is missing, unreadable, or empty. Confirm the path and that the file contains a non-empty PEM block or JWK JSON document.
### `OktaInvalidCredentialsError` at provider init
### `OktaInvalidCredentialsError` at Provider Init
Prowler validates credentials at startup by listing one sign-on policy. This error indicates the credential material itself was rejected:
- **`invalid_client`** — the public key registered in Okta does not match the private key on disk. Generate a fresh keypair and try again.
### `OktaInsufficientPermissionsError` at provider init
### `OktaInsufficientPermissionsError` at Provider Init
Raised when the credential probe succeeds at the OAuth layer but the request is rejected because the service app lacks the required scope or admin role:
- **`invalid_scope`** — one of the requested scopes (`okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.authenticators.read`, `okta.networkZones.read`, `okta.apiTokens.read`, `okta.roles.read`, `okta.groups.read`, `okta.logStreams.read`, and `okta.idps.read`) is not granted on the service app. Grant the missing scope from **Okta API Scopes**.
- **`Forbidden` / `not authorized`** — no admin role is assigned to the service app. Assign **Read-Only Administrator** (or **Super Administrator** for the first-party application checks) from **Admin roles**.
### Application-service checks return MANUAL on first-party apps
### Application-Service Checks Return MANUAL on First-Party Apps
When the service app runs with Read-Only Administrator, the five application-service checks targeting the Okta Admin Console and Okta Dashboard return MANUAL. This is by design — Okta restricts the underlying endpoints (`/api/v1/first-party-app-settings/{appName}` and `/api/v1/apps` for first-party app `name` values `saasure` / `okta_enduser`) to **Super Administrator**. Assign the Super Administrator role to the service app to evaluate those checks. See [Required Admin Role](#required-admin-role) for the full list.
@@ -141,18 +141,18 @@ Muting a finding does not fix the underlying configuration. Review the finding b
## Troubleshooting
### Triage controls do not appear
### Triage Controls Do Not Appear
Make sure the row is an individual finding row. Finding Groups rows do not show triage controls. Expand a group to see affected resources and their triage controls.
### Changes cannot be saved
### Changes Cannot Be Saved
Confirm that the user role has **Manage Scans** permission. Prowler Local Server does not support Findings Triage writes.
### Resolved or Reopened is missing from the selector
### Resolved or Reopened Is Missing from the Selector
**Reopened** is always automatic. **Resolved** is set automatically from scan result changes and appears as a selector option only on `MANUAL` findings, where it records a [Manual Pass](#verify-a-manual-finding-as-pass). On findings with any other status, this is expected.
### Risk Accepted or False Positive muted a finding
### Risk Accepted or False Positive Muted a Finding
This is expected. Those statuses create a mute rule through Mutelist.
@@ -28,7 +28,7 @@ Source: [`prowler-cloud/prowler`](https://github.com/prowler-cloud/prowler) · M
## Usage
### AWS scan
### AWS Scan
```yaml
- uses: prowler-cloud/prowler@5.25
@@ -41,7 +41,7 @@ Source: [`prowler-cloud/prowler`](https://github.com/prowler-cloud/prowler) · M
AWS_SESSION_TOKEN: ${{ secrets.AWS_SESSION_TOKEN }}
```
### Push findings to Prowler Cloud
### Push Findings to Prowler Cloud
Send scan results directly to [Prowler Cloud](/user-guide/tutorials/prowler-import-findings) for centralized visibility, compliance tracking, and team collaboration.
@@ -97,7 +97,7 @@ jobs:
- GitHub Code Scanning is free for public repositories. Private repositories require a [GitHub Code Security](https://docs.github.com/en/get-started/learning-about-github/about-github-advanced-security) license.
</Warning>
### Combine push-to-cloud with SARIF upload
### Combine Push-to-Cloud with SARIF Upload
```yaml
- uses: prowler-cloud/prowler@5.25
@@ -114,7 +114,7 @@ jobs:
PROWLER_CLOUD_API_KEY: ${{ secrets.PROWLER_CLOUD_API_KEY }}
```
### Scan the current repository with the GitHub provider
### Scan the Current Repository with the GitHub Provider
```yaml
name: Prowler GitHub Scan
@@ -142,7 +142,7 @@ jobs:
`--repository` scans a single repo. Use `--organization <name>` instead to include org-level checks (MFA, security policies, etc.). See the [GitHub provider authentication](/user-guide/providers/github/authentication) for required token permissions.
</Info>
### Fail the PR on findings
### Fail the PR on Findings
By default the action tolerates findings (exit code 3) and succeeds. Set `fail-on-findings: true` to fail the workflow step when Prowler detects findings. Combine with `--severity` to control which severity levels trigger the failure:
@@ -258,7 +258,7 @@ Scan results are written to `output/` in the workspace and uploaded as artifacts
When `upload-sarif` is enabled, SARIF results are also uploaded to GitHub Code Scanning and appear on the repository's **Security → Code scanning** tab, filtered by the branch that ran the scan.
### Step summary
### Step Summary
The action writes a summary to the run page with a per-severity breakdown of failing checks, artifact and Code Scanning links, and (when `push-to-cloud: false`) a pointer to [Prowler Cloud](https://cloud.prowler.com) for continuous monitoring.
@@ -124,6 +124,18 @@ To manually send individual Findings to Jira:
![Send to Jira modal](/images/prowler-app/jira/send-to-jira-modal.png)
### Finding Reference in the Jira Issue
<VersionBadge version="5.41.0" />
Every Jira issue created from a single Finding carries a stable reference back to that Finding, so issues can be filtered, searched with Jira Query Language (JQL), or matched by automation:
* **Labels**: `prowler`, `prowler-<provider>`, `prowler-<severity>`, `prowler-<check-id>` and `prowler-finding-<finding-uid>`. Labels are sanitized deterministically: whitespace becomes `_`, control characters are removed, and values are truncated to Jira's 255-character label limit.
* **Finding URL**: a link that opens the Finding in Prowler, filtered by its unique identifier (UID) so it keeps working after later scans.
* **Tenant Info**: the name of the Prowler organization that sent the Finding.
Prowler Cloud always includes the Finding URL. In Prowler Local Server, set `DJANGO_UI_BASE_URL` in the API environment (for example, `https://prowler.example.com`) to enable it. When the variable is empty, the issue is created without the link.
## Integration Status
Monitor and manage your Jira integrations through the management interface:
@@ -159,13 +171,13 @@ Support for custom field mapping is planned for a future release.
## Troubleshooting
### Connection test fails
### Connection Test Fails
* Verify Jira instance domain is correct and accessible
* Confirm API token or credentials are valid
* Ensure API access is enabled in Jira settings and the needed scopes are granted
### Check task status (API)
### Check Task Status (API)
If the Jira issue does not appear in your Jira project, follow these steps to verify the export task status via the API.
@@ -257,11 +257,11 @@ The **Scope** column indicates where each permission applies. **All** means the
</Note>
To grant all administrative permissions, select the **Grant all admin permissions** option.
### Prowler Cloud exclusive permissions
### Prowler Cloud Exclusive Permissions
The following permissions are available exclusively in **Prowler Cloud**:
**Manage Ingestions:** Submit and manage findings ingestion jobs via the API. Required to upload OCSF scan results using the `--push-to-cloud` CLI flag or the ingestion endpoints. See [Import Findings](/user-guide/tutorials/prowler-import-findings) for details.
**Manage Ingestions:** Submit and manage findings ingestion jobs. Required to upload OCSF scan results from the Scans page, with the `--push-to-cloud` CLI flag or through the ingestion endpoints. See [Import Findings](/user-guide/tutorials/prowler-import-findings) for details.
**Manage Billing:** Access and manage billing settings, subscription plans, and payment methods.
@@ -58,12 +58,14 @@ Two of these read more broadly than they behave, and both are worth understandin
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.
{/* The Prowler UI deep-links to this heading's anchor, so rewording the heading breaks that link. */}
### 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:
`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 Cloud` to it in Slack:
```text
/invite @Prowler
/invite @Prowler Cloud
```
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.
@@ -100,14 +102,14 @@ Prowler posts to the channels authorized on the integration. Several channels ca
![Destination channels selection listing public channels and an invited private channel marked Private](/images/prowler-app/slack/channel-picker.png)
2. Select one or more channels. A selected private channel keeps its lock and **Private** identification with the list closed, so the authorized set stays readable at a glance.
2. Select one or more channels. A selected private channel keeps the same **Private** marking 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**.
If the selection reports that no channels are available, the workspace exposes nothing Prowler can see. Create a public channel, or invite `@Prowler Cloud` 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.
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 Cloud` 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.
@@ -159,7 +161,7 @@ The Slack management page reports the state of the connection and offers these a
| 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 |
| **Refresh channels** | Re-read the workspace's channel list | Use after inviting `@Prowler Cloud` 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 |
@@ -171,7 +173,7 @@ The Prowler Slack app is not configured for the deployment being used, so no wor
### 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.
The Prowler app has not been invited to it. In Slack, run `/invite @Prowler Cloud` 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
+1 -1
View File
@@ -166,6 +166,6 @@ Once your scan has finished, you don’t need to grab the entire ZIP—just pull
<Note>
**API Note**
To fetch a single compliance report via API, see the Retrieve compliance report as CSV endpoint in the Prowler API Reference.[Prowler API Reference - Retrieve compliance report as CSV](https://api.prowler.com/api/v1/docs#tag/Scan/operation/scans_compliance_retrieve)
To fetch a single compliance report via API, see the Retrieve compliance report as CSV endpoint in the Prowler API Reference. [Prowler API Reference - Retrieve compliance report as CSV](https://api.prowler.com/api/v1/docs#tag/Scan/operation/scans_compliance_retrieve)
</Note>
@@ -1,7 +1,7 @@
---
title: 'Import Findings'
sidebarTitle: 'Import Findings'
description: 'Upload OCSF scan results to Prowler Cloud from external sources or the CLI'
description: 'Upload OCSF scan results to Prowler Cloud from the UI, the CLI or the API'
---
import { VersionBadge } from "/snippets/version-badge.mdx"
@@ -9,7 +9,7 @@ import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
<VersionBadge version="5.19.0" />
Findings Ingestion enables uploading OCSF (Open Cybersecurity Schema Framework) scan results to Prowler Cloud. This feature supports importing findings from Prowler CLI output files that use the [Detection Finding](https://schema.ocsf.io/classes/detection_finding) class.
Findings Ingestion enables uploading OCSF (Open Cybersecurity Schema Framework) scan results to Prowler Cloud. This feature supports importing findings from Prowler CLI output files that use the [Detection Finding](https://schema.ocsf.io/classes/detection_finding) class. Reports can be imported from the Scans page in the Prowler Cloud UI, pushed by the CLI with `--push-to-cloud`, or submitted through the API.
<SubscriptionBanner />
@@ -132,10 +132,32 @@ Only **Detection Finding** (`class_uid: 2004`) records are accepted. Other OCSF
## Required Permissions
The **Manage Ingestions** RBAC permission controls access to the ingestion endpoints. Without this permission, findings cannot be submitted via the API or `--push-to-cloud`.
The **Manage Ingestions** RBAC permission controls access to the ingestion endpoints. Without this permission, findings cannot be submitted from the Scans page, via the API or with `--push-to-cloud`.
For more information about RBAC permissions, refer to the [Prowler Cloud RBAC documentation](/user-guide/tutorials/prowler-app-rbac).
## Using the UI
<VersionBadge version="5.41.0" />
The Scans page imports a Prowler OCSF report from the browser, with no CLI or API key involved. The import runs as a regular ingestion job, so the [status values](#ingestion-status-values), the [billing impact](#billing-impact) and the [errors endpoint](#get-ingestion-errors) apply as they do for the CLI and the API.
1. Go to **Scans** and click **Import Findings**. The button is shown only to roles with the **Manage Ingestions** permission.
![Import Findings button on the Scans page](/images/prowler-app/import-findings/import-findings-button.png)
2. Drag a `.ocsf.json` report onto the drop area, or click **Select File** to pick one. The dialog takes one file per import. A file whose name does not end in `.ocsf.json`, or an empty file, is rejected before the upload starts.
![Import findings dialog with the drag-and-drop area](/images/prowler-app/import-findings/import-findings-dialog.png)
3. Click **Start import**. The dialog uploads the report, creates the ingestion job and follows its status until the job finishes. On completion it reports the total number of records, how many were processed and how many were invalid.
Closing the dialog while an import is running does not cancel the job. When the job completes in the background, a notification confirms it and the imported findings appear in Scans.
If the upload is rejected or the job fails, the dialog shows the reason and a **Retry import** button that sends the same file again. A failed job also shows the progress it reported before failing. A different file can be selected instead of retrying. If the status check fails after the upload was accepted, **Retry status** resumes tracking the same job without uploading the file again.
Invalid records are counted in the summary but not listed in the dialog. To see why each one was rejected, [list the ingestion jobs](#list-ingestion-jobs) through the API and query the [errors endpoint](#get-ingestion-errors) for that job.
## Using the CLI
The `--push-to-cloud` flag uploads scan results directly to Prowler Cloud after a scan completes. This approach automates the ingestion process without manual file uploads.
@@ -444,7 +466,7 @@ For pricing details, see [Prowler Cloud Pricing](https://prowler.com/pricing).
- The user associated with the API key lacks the **Manage Ingestions** permission
- Contact the tenant administrator to grant the required permission
### Ingestion job status is "failed"
### Ingestion Job Status Is "failed"
- Check the `/api/v1/ingestions/{id}/errors` endpoint for details
- Verify the OCSF file format is valid
+35
View File
@@ -4,6 +4,41 @@ All notable changes to the **Prowler MCP Server** are documented in this file.
<!-- changelog: release notes start -->
## [0.12.0] (Prowler v5.41.0)
### 🚀 Added
- Prowler App tools now report a failure as an MCP tool execution error (`isError: true`, explanation in `content`) instead of as a successful result carrying an `{"error": ...}` object, which clients and models read as a success [(#12532)](https://github.com/prowler-cloud/prowler/pull/12532)
### 🔄 Changed
- `prowler_get_compliance_framework_state_details` now rejects a call that passes both `scan_id` and `provider_id` instead of silently ignoring the provider, which could report on a scan belonging to a different provider than the one that was asked about [(#12532)](https://github.com/prowler-cloud/prowler/pull/12532)
### 🐞 Fixed
- `prowler_hub_get_check_code` and `prowler_hub_get_check_fixer` now report a check ID that belongs to another provider as such, naming that provider, instead of reporting the ID as one that does not exist [(#12533)](https://github.com/prowler-cloud/prowler/pull/12533)
- `prowler_docs_search` no longer reports a failed search as zero matches or an unreadable answer as a bad search term, and `prowler_docs_get_document` no longer reports a failed fetch as a missing page [(#12534)](https://github.com/prowler-cloud/prowler/pull/12534)
---
## [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
@@ -1 +0,0 @@
`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
@@ -1 +0,0 @@
`libcrypto3` and `libssl3` upgraded to 3.5.8-r0 in the container image, patching CVE-2026-14456
@@ -1 +0,0 @@
`sqlite-libs` upgraded to 3.53.4-r0 in the container image, patching CVE-2026-11822 and CVE-2026-11824
@@ -1 +0,0 @@
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
@@ -1 +0,0 @@
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
+92 -1
View File
@@ -19,10 +19,17 @@ class ProwlerAPIError(Exception):
Attributes:
status_code: HTTP status the API answered with
detail: JSON:API `errors[0].detail`, None when there is none to trust
payload: Parsed JSON body, for a tool that has to read the answer rather
than only report it
"""
def __init__(
self, message: str, status_code: int, *, detail: str | None = None
self,
message: str,
status_code: int,
*,
detail: str | None = None,
payload: dict[str, Any] | None = None,
) -> None:
super().__init__(message)
self.status_code: int = status_code
@@ -32,6 +39,12 @@ class ProwlerAPIError(Exception):
# that must never be repeated to a model -- and None for a 5xx, see
# `jsonapi_detail`.
self.detail: str | None = detail
# Not every error status means the request failed: Prowler answers 404
# with the result itself when a query ran and matched nothing. A tool
# reads this to tell such an answer apart from a real failure. It is the
# upstream body, so it is read structurally and never relayed as text --
# `detail` above is the only part of it that may be repeated to a model.
self.payload: dict[str, Any] | None = payload
class ProwlerAPIUnreachable(Exception):
@@ -42,6 +55,59 @@ class ProwlerAPIInvalidResponse(Exception):
"""The API answered, but with a body this server could not read as JSON."""
class UpstreamInvalidResponse(Exception):
"""An upstream this server reads directly answered with a body that is not JSON.
Raised in place of the `json.JSONDecodeError` httpx would otherwise let out.
That one is a ValueError this module reads as a malformed argument, which is
the opposite story: it sends a model off to fix a call that was fine.
Attributes:
host: Host that answered, so the message can name what has to be fixed
"""
def __init__(self, message: str, *, host: str) -> None:
super().__init__(message)
self.host: str = host
def parse_json_response(response: httpx.Response) -> Any:
"""Parse an upstream answer as JSON, telling an unreadable body from a bad
argument.
For every upstream a sub-server reads with an httpx client of its own --
Prowler Hub, the documentation site. `httpx` lets a body it cannot decode
out as a `json.JSONDecodeError`, which is a ValueError this module reads as
a malformed argument. Coming from an upstream -- an HTML error page from an
edge, a truncated body -- that is the wrong story, and the caller has no
argument to fix.
The Prowler API client parses its own answers and raises
`ProwlerAPIInvalidResponse` instead: it also carries writes, where an
unreadable answer leaves the outcome unknown rather than merely absent.
Args:
response: The answer to parse.
Returns:
The parsed body.
Raises:
UpstreamInvalidResponse: The body is not JSON.
"""
try:
return response.json()
except ValueError as e:
# `.request` raises rather than returning None when it was never set.
request = getattr(response, "_request", None)
host = request.url.host if request is not None else "The upstream service"
# Status only: the decoder's own message quotes the body it choked on,
# and that body is the upstream text this server never relays.
raise UpstreamInvalidResponse(
f"{response.status_code} body is not JSON", host=host
) from e
def jsonapi_detail(response: httpx.Response) -> str | None:
"""Return the API's own JSON:API error detail, when there is one to trust.
@@ -71,6 +137,10 @@ class InvalidArgument(ValueError):
"""An argument this server rejected before any request went out."""
class CredentialError(Exception):
"""The credential the caller sent is missing, malformed or expired."""
# ------------------------------------------------------------------- messages
@@ -154,6 +224,27 @@ def _describe_failure(exc: BaseException) -> str | None:
"current state before sending it again."
)
if isinstance(exc, UpstreamInvalidResponse):
# The counterpart of the `json.JSONDecodeError` branch below: the same
# decode failure is a malformed argument on one side of this server and
# an upstream fault on the other, and only the type tells them apart.
return (
f"{exc.host} answered with a body this server could not read as JSON, "
"so the call has no result to return. Nothing in the arguments caused "
f"this and changing them will not help -- {exc.host} is answering with "
"something other than the JSON it documents. Retry later."
)
if isinstance(exc, CredentialError):
# Not an argument problem, so it is worth saying that plainly: the
# answer is a credential the user has to fix, not another attempt.
return (
f"This request carried no usable credential: {exc}. Retrying or "
"changing the arguments will not help -- the client has to send an "
"'Authorization: Bearer <token>' header holding a valid Prowler API "
"key or an unexpired JWT."
)
if isinstance(exc, ProwlerAPIUnreachable):
# The only failure a model can turn into a duplicate write by repeating.
return (
@@ -0,0 +1,18 @@
"""Argument types shared by every tool in this server."""
from typing import Annotated
from pydantic import StringConstraints
# The identifiers tools take -- a scan UUID, a query id, a Jira project key --
# are required because there is nothing sensible to do without them. A model
# that does not have one to hand tends to send an empty string rather than omit
# the argument, and an empty string is not caught by "required": it travels into
# a URL path or a request body and comes back as a 404 or an opaque API error
# ("This field may not be blank") that says nothing about which argument was at
# fault. Rejecting it here names the argument instead, and `minLength` puts the
# constraint in the tool schema so a client can see it before calling.
#
# Whitespace is stripped first, so " abc " is accepted as "abc" and " " is
# rejected like "".
NonBlankStr = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
+36
View File
@@ -0,0 +1,36 @@
"""URL construction shared by every sub-server.
An identifier joined into a path unencoded is not sent as itself: httpx resolves
the URL per RFC 3986, so "../" walks the request onto another endpoint.
"""
from urllib.parse import quote
_DOT_SEGMENTS = frozenset({".", ".."})
def path_segment(value: str) -> str:
"""Encode one path segment, so an identifier names a resource and nothing else.
Args:
value: The segment to encode, taken as a name in full.
Returns:
The segment percent-encoded, with the dots escaped when it is only dots.
"""
encoded = quote(value, safe="")
# A dot is legal in a name, so `quote` keeps it: a segment of nothing but
# dots would still resolve away rather than name anything.
return encoded.replace(".", "%2E") if encoded in _DOT_SEGMENTS else encoded
def url_path(*segments: str) -> str:
"""Build a URL path from one argument per segment, each of them encoded.
Args:
*segments: The path segments, in order.
Returns:
The joined path, with a leading slash.
"""
return "/" + "/".join(path_segment(segment) for segment in segments)
@@ -354,6 +354,14 @@ class AttackPathQueryResult(MinimalSerializerMixin, BaseModel):
relationships: list[AttackPathsGraphRelationship] = Field(
default_factory=list, description="Relationships connecting the nodes"
)
# A graph with nothing in it serializes to `{}`, since the mixin drops empty
# lists. That reads as an answer that went missing rather than as the finding
# it is -- the query ran and this account has no such attack path -- so the
# empty case carries a sentence saying so.
message: str | None = Field(
default=None,
description="Present only when the query matched nothing, to say the query ran and found no attack path rather than leaving an empty result to interpret",
)
@classmethod
def from_api_response(
@@ -368,7 +376,15 @@ class AttackPathQueryResult(MinimalSerializerMixin, BaseModel):
Returns:
AttackPathQueryResult with parsed data and summary
"""
attributes = response.get("data", {}).get("attributes")
data = response.get("data")
attributes = data.get("attributes") if data is not None else None
# Prowler spells a graph with nothing in it either as empty lists or as
# a null `attributes`. Both say the same thing -- the query ran and
# matched nothing -- so the null reads as the empty graph it stands for
# instead of crashing the parse.
if attributes is None:
attributes = {}
nodes_data = attributes.get("nodes", [])
relationships_data = attributes.get("relationships", [])
@@ -2,7 +2,7 @@
from typing import Any, Literal
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict, Field
from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin
@@ -104,6 +104,29 @@ class ProvidersListResponse(BaseModel):
)
class ProviderDeletionResult(MinimalSerializerMixin, BaseModel):
"""Outcome of a provider deletion.
Prowler deletes a provider in a background task, so the answer is not always
a finished deletion. A deletion that never started is raised as an error
instead of being reported here: this model only describes a deletion Prowler
accepted and began.
"""
model_config = ConfigDict(frozen=True)
status: Literal["deleted", "in_progress"] = Field(
description="Outcome of the deletion: 'deleted' when Prowler finished removing the provider, 'in_progress' when the background task was accepted and is still running, which is normal for a provider with many scans and findings"
)
task_id: str | None = Field(
default=None,
description="UUIDv4 of the background deletion task, present when the deletion did not finish within the polling window so its state can be checked later",
)
message: str = Field(
description="Human-readable description of what happened and what to do next"
)
class ProviderConnectionStatus(MinimalSerializerMixin, BaseModel):
"""Result of provider connection operation."""
@@ -191,18 +191,18 @@ class ScansListResponse(BaseModel):
class ScanCreationResult(MinimalSerializerMixin, BaseModel):
"""Result of scan creation operation.
"""Result of a scan creation that succeeded.
Used by trigger_scan() to communicate the outcome of scan creation.
Status indicates whether scan was created successfully or failed.
Used by trigger_scan(). A scan that was not created leaves the tool as an
error instead of being reported here, so this model only ever describes a
scan that exists -- which is why it carries no success flag: a field with
one reachable value says nothing, and inviting a reader to branch on it
suggests there is a failure shape to look for here. There is not; the
failure is the error.
"""
scan: DetailedScan | None = Field(
default=None,
description="Detailed scan information if creation succeeded, None otherwise",
)
status: Literal["success", "failed"] = Field(
description="Outcome of scan creation: success (scan created successfully) or failed (error)"
scan: DetailedScan = Field(
description="Detailed information about the scan that was created"
)
message: str = Field(
description="Human-readable message describing the scan creation result"
@@ -210,13 +210,26 @@ class ScanCreationResult(MinimalSerializerMixin, BaseModel):
class ScheduleCreationResult(MinimalSerializerMixin, BaseModel):
"""Result of async schedule creation operation.
"""Result of a daily schedule creation that succeeded.
Used by schedule_daily_scan() to communicate scheduling outcome.
Used by schedule_daily_scan(). Prowler commits the schedule inside the
request that creates it, so an answer means it exists; a provider that
already has one is refused with a 409 and leaves the tool as an error. That
leaves nothing for a success flag to distinguish, so there is none.
"""
scheduled: bool = Field(
description="Whether the daily scan schedule was created successfully"
first_run_state: (
Literal[
"available", "scheduled", "executing", "completed", "failed", "cancelled"
]
| None
) = Field(
default=None,
description=(
"State of the first scan Prowler starts immediately alongside the schedule. "
"This describes that one run, not the recurring schedule, which stands "
"regardless of it"
),
)
message: str = Field(
description="Human-readable message describing the scheduling result"
@@ -3,7 +3,7 @@ from fastmcp import FastMCP
from prowler_mcp_server.prowler_app.utils.tool_loader import load_all_tools
# Initialize MCP server
app_mcp_server = FastMCP("prowler-app")
app_mcp_server = FastMCP("prowler-app", mask_error_details=True)
# Auto-discover and load all tools from the tools package
load_all_tools(app_mcp_server)
@@ -7,8 +7,11 @@ through cloud infrastructure relationships.
from typing import Any, Literal
from fastmcp.exceptions import ToolError
from pydantic import Field
from prowler_mcp_server.lib.errors import ProwlerAPIError
from prowler_mcp_server.lib.types import NonBlankStr
from prowler_mcp_server.prowler_app.models.attack_paths import (
AttackPathCartographySchema,
AttackPathQuery,
@@ -76,50 +79,47 @@ class AttackPathsTools(BaseTool):
2. Use prowler_list_attack_paths_queries to see available queries for a scan
3. Use prowler_run_attack_paths_query to execute analysis
"""
try:
# Validate pagination
self.api_client.validate_page_size(page_size)
# Validate pagination
self.api_client.validate_page_size(page_size)
# Build query parameters
params: dict[str, Any] = {
"page[size]": page_size,
"page[number]": page_number,
}
# Build query parameters
params: dict[str, Any] = {
"page[size]": page_size,
"page[number]": page_number,
}
# Apply provider filters
if provider_id:
params["filter[provider__in]"] = provider_id
if provider_type:
params["filter[provider_type__in]"] = provider_type
# Apply provider filters
if provider_id:
params["filter[provider__in]"] = provider_id
if provider_type:
params["filter[provider_type__in]"] = provider_type
# Apply state filter
if state:
params["filter[state__in]"] = state
# Apply state filter
if state:
params["filter[state__in]"] = state
clean_params = self.api_client.build_filter_params(params)
clean_params = self.api_client.build_filter_params(params)
api_response = await self.api_client.get(
"/attack-paths-scans", params=clean_params
)
simplified_response = AttackPathScansListResponse.from_api_response(
api_response
)
api_response = await self.api_client.get(
"/attack-paths-scans", params=clean_params
)
simplified_response = AttackPathScansListResponse.from_api_response(
api_response
)
return simplified_response.model_dump()
except Exception as e:
self.logger.error(f"Failed to list attack paths scans: {e}")
return {"error": f"Failed to list attack paths scans: {str(e)}"}
return simplified_response.model_dump()
async def list_attack_paths_queries(
self,
scan_id: str = Field(
description="UUID of a COMPLETED attack paths scan. Use `prowler_list_attack_paths_scans` with state=['completed'] to find scan IDs"
scan_id: NonBlankStr = Field(
description="UUID of a COMPLETED attack paths scan, as returned by `prowler_list_attack_paths_scans` with state=['completed']. This is NOT a regular scan ID: an ID from `prowler_search_scans` or `prowler_get_scan` names a different resource and is rejected here"
),
) -> list[dict[str, Any]]:
"""Discover available Attack Paths queries for a completed scan.
IMPORTANT: The scan must be in 'completed' state to list queries.
Queries are provider-specific
Attack Paths covers AWS providers only, so only an AWS provider has an
Attack Paths scan to name here, and every query is an AWS one.
Each query includes:
- id: Query identifier to use with run_attack_paths_query
@@ -141,23 +141,32 @@ class AttackPathsTools(BaseTool):
api_response = await self.api_client.get(
f"/attack-paths-scans/{scan_id}/queries"
)
except ProwlerAPIError as e:
# A 404 here is Prowler failing to resolve `scan_id` to an Attack
# Paths scan, and its own reason for it -- a bare "Not found." --
# does not say what kind of ID it was looking for. The mistake it
# stands for is a regular scan ID: an Attack Paths scan is a separate
# resource with IDs of its own, and Prowler only creates one for an
# AWS provider, so a scan of any other provider has none to pass.
#
# The endpoint answers 404 for a second thing -- a provider type with
# no query catalog -- but that one cannot happen: a scan only exists
# where Attack Paths runs, which is AWS, and AWS has a catalog.
if e.status_code == 404:
raise self._unknown_scan_error(scan_id)
raise
return [
AttackPathQuery.from_api_response(query).model_dump()
for query in api_response.get("data", [])
]
except Exception as e:
self.logger.error(
f"Failed to list attack paths queries for scan {scan_id}: {e}"
)
return [{"error": f"Failed to list attack paths queries: {str(e)}"}]
return [
AttackPathQuery.from_api_response(query).model_dump()
for query in api_response.get("data", [])
]
async def run_attack_paths_query(
self,
scan_id: str = Field(
scan_id: NonBlankStr = Field(
description="UUID of a COMPLETED attack paths scan. The scan must be in 'completed' state"
),
query_id: str = Field(
query_id: NonBlankStr = Field(
description="Query ID to execute (e.g., 'aws-internet-exposed-ec2-sensitive-s3-access'). Use `prowler_list_attack_paths_queries` to discover available queries"
),
parameters: dict[str, str] = Field(
@@ -198,39 +207,61 @@ class AttackPathsTools(BaseTool):
3. Execute this tool with appropriate parameters
4. Analyze the returned graph for security insights
"""
try:
# Build the request payload following JSON:API format
request_data: dict[str, Any] = {
"data": {
"type": "attack-paths-query-run-requests",
"attributes": {
"id": query_id,
},
# Build the request payload following JSON:API format
request_data: dict[str, Any] = {
"data": {
"type": "attack-paths-query-run-requests",
"attributes": {
"id": query_id,
},
}
},
}
# Add parameters if provided
if parameters:
request_data["data"]["attributes"]["parameters"] = parameters
# Add parameters if provided
if parameters:
request_data["data"]["attributes"]["parameters"] = parameters
try:
api_response = await self.api_client.post(
f"/attack-paths-scans/{scan_id}/queries/run",
json_data=request_data,
)
except ProwlerAPIError as e:
# Prowler answers a query that matched nothing with 404 and the empty
# result as the body. That is an answer -- this account has no such
# attack path, which is the good outcome -- so it is returned rather
# than raised: reporting it as a failure invites a retry of a call
# whose arguments were right, and hides a clean result.
if e.status_code == 404 and isinstance(e.payload, dict):
if "data" in e.payload:
api_response = e.payload
else:
# No result body, so `scan_id` did not resolve to an Attack
# Paths scan. An unknown query_id is a 400, not this.
raise self._unknown_scan_error(scan_id)
else:
raise
# Parse the response
query_result = AttackPathQueryResult.from_api_response(api_response)
# Parse the response
query_result = AttackPathQueryResult.from_api_response(api_response)
return query_result.model_dump()
except Exception as e:
self.logger.error(
f"Failed to run attack paths query '{query_id}' on scan {scan_id}: {e}"
if not query_result.nodes:
query_result = query_result.model_copy(
update={
"message": (
f"The query '{query_id}' ran against scan {scan_id} and matched "
"nothing, so this provider has no attack path of that shape. "
"The scan and the query ID were both valid; running it again "
"will return the same thing."
)
}
)
return {"error": f"Failed to run attack paths query '{query_id}': {str(e)}"}
return query_result.model_dump()
async def get_attack_paths_cartography_schema(
self,
scan_id: str = Field(
scan_id: NonBlankStr = Field(
description="UUID of a COMPLETED attack paths scan. Use `prowler_list_attack_paths_scans` with state=['completed'] to find scan IDs"
),
) -> dict[str, Any]:
@@ -262,18 +293,43 @@ class AttackPathsTools(BaseTool):
api_response = await self.api_client.get(
f"/attack-paths-scans/{scan_id}/schema"
)
except ProwlerAPIError as e:
# Two 404s again, told apart by whether Prowler wrote a JSON:API
# error. Absent means the scan resolved and its graph simply records
# no Cartography module, so the ID is not the thing to change.
if e.status_code == 404:
if e.detail is None:
raise ToolError(
f"Scan {scan_id} has no Cartography schema recorded, so there is "
"nothing to write custom queries against. Use "
"prowler_list_attack_paths_queries for the ready-made queries of "
"this scan, which do not need the schema."
)
else:
raise self._unknown_scan_error(scan_id)
raise
schema = AttackPathCartographySchema.from_api_response(api_response)
schema = AttackPathCartographySchema.from_api_response(api_response)
schema_content = await self.api_client.fetch_external_url(
schema.raw_schema_url
)
schema_content = await self.api_client.fetch_external_url(schema.raw_schema_url)
return schema.model_copy(
update={"schema_content": schema_content}
).model_dump()
except Exception as e:
self.logger.error(
f"Failed to get cartography schema for scan {scan_id}: {e}"
)
return {"error": f"Failed to get cartography schema: {str(e)}"}
return schema.model_copy(update={"schema_content": schema_content}).model_dump()
# Private helper methods
@staticmethod
def _unknown_scan_error(scan_id: str) -> ToolError:
"""Describe a scan ID Prowler could not resolve to an Attack Paths scan.
Returns:
The ``ToolError`` for the caller to raise. Built without a ``from``
clause on purpose: the sentence is the final word, not a wrapper
around the API's.
"""
return ToolError(
f"Prowler has no Attack Paths scan with ID {scan_id}. These are a "
"different resource from regular scans and only exist for AWS "
"providers, so an ID from prowler_search_scans or prowler_get_scan "
"never resolves here. Use prowler_list_attack_paths_scans to get an "
"ID these tools take."
)
@@ -6,8 +6,11 @@ across all cloud providers.
from typing import Any
from fastmcp.exceptions import ToolError
from pydantic import Field
from prowler_mcp_server.lib.errors import InvalidArgument
from prowler_mcp_server.lib.types import NonBlankStr
from prowler_mcp_server.prowler_app.models.compliance import (
ComplianceFrameworksListResponse,
ComplianceRequirementAttributesListResponse,
@@ -34,7 +37,7 @@ class ComplianceTools(BaseTool):
The scan_id of the latest completed scan for the provider.
Raises:
ValueError: If no completed scans are found for the provider.
ToolError: If no completed scans are found for the provider
"""
scan_params = {
"filter[provider]": provider_id,
@@ -48,7 +51,7 @@ class ComplianceTools(BaseTool):
scans_data = scans_response.get("data", [])
if not scans_data:
raise ValueError(
raise ToolError(
f"No completed scans found for provider {provider_id}. "
"Run a scan first using prowler_trigger_scan."
)
@@ -93,18 +96,15 @@ class ComplianceTools(BaseTool):
2. Use prowler_get_compliance_framework_state_details with a specific compliance_id to see which requirements failed
"""
if not scan_id and not provider_id:
return {
"error": "Either scan_id or provider_id must be provided. Use prowler_search_providers to find provider IDs or prowler_list_scans to find scan IDs."
}
raise InvalidArgument(
"Either scan_id or provider_id must be provided. Use prowler_search_providers to find provider IDs or prowler_list_scans to find scan IDs."
)
elif scan_id and provider_id:
return {
"error": "Provide either scan_id or provider_id, not both. To get compliance data for a specific scan, use scan_id. To get data for the latest scan of a provider, use provider_id."
}
raise InvalidArgument(
"Provide either scan_id or provider_id, not both. To get compliance data for a specific scan, use scan_id. To get data for the latest scan of a provider, use provider_id."
)
elif not scan_id and provider_id:
try:
scan_id = await self._get_latest_scan_id_for_provider(provider_id)
except ValueError as e:
return {"error": str(e)}
scan_id = await self._get_latest_scan_id_for_provider(provider_id)
params: dict[str, Any] = {"filter[scan_id]": scan_id}
@@ -253,16 +253,16 @@ class ComplianceTools(BaseTool):
async def get_compliance_framework_state_details(
self,
compliance_id: str = Field(
compliance_id: NonBlankStr = Field(
description="Compliance framework ID to get details for (e.g., 'cis_1.5_aws', 'pci_dss_v4.0_aws'). You can get compliance IDs from prowler_get_compliance_overview or consulting Prowler Hub/Prowler Documentation that you can also find in form of tools in this MCP Server",
),
scan_id: str | None = Field(
default=None,
description="UUID of a specific scan to get compliance data for. Required if provider_id is not specified.",
description="UUID of a specific scan to get compliance data for. Required if provider_id is not specified. Do not pass it together with provider_id.",
),
provider_id: str | None = Field(
default=None,
description="Prowler's internal UUID (v4) for a specific provider. If provided without scan_id, the tool will automatically find the latest completed scan for this provider. Use `prowler_search_providers` tool to find provider IDs.",
description="Prowler's internal UUID (v4) for a specific provider. The tool will automatically find the latest completed scan for this provider. Use `prowler_search_providers` tool to find provider IDs. Do not pass it together with scan_id.",
),
) -> dict[str, Any]:
"""Get detailed requirement-level breakdown for a specific compliance framework.
@@ -283,8 +283,8 @@ class ComplianceTools(BaseTool):
- Use prowler_get_finding_details with these finding IDs for more details and remediation guidance
Default behavior:
- Requires either scan_id OR provider_id
- With provider_id (no scan_id): Automatically finds the latest completed scan for that provider
- Requires exactly one of scan_id OR provider_id; providing both is rejected
- With provider_id: Automatically finds the latest completed scan for that provider
- With scan_id: Uses that specific scan's compliance data
- Only shows failed requirements with their associated failed finding IDs
@@ -293,21 +293,22 @@ class ComplianceTools(BaseTool):
2. Use this tool with the compliance_id to see failed requirements and their finding IDs
3. Use prowler_get_finding_details with the finding IDs to get remediation guidance
"""
# Validate that either scan_id or provider_id is provided
# Exactly one of the two: taking scan_id and ignoring provider_id would
# answer for whatever provider that scan belongs to, which is not
# necessarily the one the caller named.
if not scan_id and not provider_id:
return {
"error": "Either scan_id or provider_id must be provided. Use prowler_search_providers to find provider IDs or prowler_list_scans to find scan IDs."
}
raise InvalidArgument(
"Either scan_id or provider_id must be provided. Use prowler_search_providers to find provider IDs or prowler_list_scans to find scan IDs."
)
elif scan_id and provider_id:
raise InvalidArgument(
"Provide either scan_id or provider_id, not both. To get compliance data for a specific scan, use scan_id. To get data for the latest scan of a provider, use provider_id."
)
# Resolve provider_id to latest scan_id if needed
resolved_scan_id = scan_id
if not scan_id and provider_id:
try:
resolved_scan_id = await self._get_latest_scan_id_for_provider(
provider_id
)
except ValueError as e:
return {"error": str(e)}
resolved_scan_id = await self._get_latest_scan_id_for_provider(provider_id)
# Build params for requirements endpoint
params: dict[str, Any] = {
@@ -6,8 +6,10 @@ This module provides read-only tools for finding group triage and drill-downs.
from typing import Any, Literal
from urllib.parse import quote
from fastmcp.exceptions import ToolError
from pydantic import Field
from prowler_mcp_server.lib.types import NonBlankStr
from prowler_mcp_server.prowler_app.models.finding_groups import (
DetailedFindingGroup,
FindingGroupResourcesListResponse,
@@ -236,50 +238,46 @@ class FindingGroupsTools(BaseTool):
prowler_get_finding_group_details for complete counters or
prowler_list_finding_group_resources to drill into affected resources.
"""
try:
self.api_client.validate_page_size(page_size)
date_range, params = self._base_date_params(date_from, date_to)
endpoint = self._group_endpoint(date_range)
self.api_client.validate_page_size(page_size)
date_range, params = self._base_date_params(date_from, date_to)
endpoint = self._group_endpoint(date_range)
self._apply_common_filters(
params,
provider,
provider_type,
provider_uid,
provider_alias,
region,
service,
resource_type,
resource_name,
resource_uid,
resource_group,
category,
check_id,
check_title,
severity,
status,
muted,
delta,
)
self._apply_common_filters(
params,
provider,
provider_type,
provider_uid,
provider_alias,
region,
service,
resource_type,
resource_name,
resource_uid,
resource_group,
category,
check_id,
check_title,
severity,
status,
muted,
delta,
)
params["filter[include_muted]"] = self._bool_value(include_muted)
params["page[size]"] = page_size
params["page[number]"] = page_number
params["fields[finding-groups]"] = GROUP_LIST_FIELDS
if sort:
params["sort"] = sort
params["filter[include_muted]"] = self._bool_value(include_muted)
params["page[size]"] = page_size
params["page[number]"] = page_number
params["fields[finding-groups]"] = GROUP_LIST_FIELDS
if sort:
params["sort"] = sort
clean_params = self.api_client.build_filter_params(params)
api_response = await self.api_client.get(endpoint, params=clean_params)
response = FindingGroupsListResponse.from_api_response(api_response)
return response.model_dump()
except Exception as e:
self.logger.error(f"Error listing finding groups: {e}")
return {"error": str(e), "status": "failed"}
clean_params = self.api_client.build_filter_params(params)
api_response = await self.api_client.get(endpoint, params=clean_params)
response = FindingGroupsListResponse.from_api_response(api_response)
return response.model_dump()
async def get_finding_group_details(
self,
check_id: str = Field(
check_id: NonBlankStr = Field(
description="Public check ID that identifies the finding group. This is not a UUID."
),
date_from: str | None = Field(
@@ -297,39 +295,37 @@ class FindingGroupsTools(BaseTool):
or historical data when dates are provided. Fully muted groups are
included by default so accepted risk does not look like a missing group.
"""
try:
date_range, params = self._base_date_params(date_from, date_to)
endpoint = self._group_endpoint(date_range)
date_range, params = self._base_date_params(date_from, date_to)
endpoint = self._group_endpoint(date_range)
params.update(
{
"filter[check_id]": check_id,
"filter[include_muted]": True,
"page[size]": 1,
"page[number]": 1,
"fields[finding-groups]": GROUP_DETAIL_FIELDS,
}
params.update(
{
"filter[check_id]": check_id,
"filter[include_muted]": True,
"page[size]": 1,
"page[number]": 1,
"fields[finding-groups]": GROUP_DETAIL_FIELDS,
}
)
clean_params = self.api_client.build_filter_params(params)
api_response = await self.api_client.get(endpoint, params=clean_params)
data = api_response.get("data", [])
if not data:
# No `from`: this names the check and the tool that lists valid ones,
# neither of which the shared classifier can know.
raise ToolError(
f"No finding group exists for check '{check_id}' in this scan. Use "
"prowler_list_finding_groups to see the checks that have findings."
)
clean_params = self.api_client.build_filter_params(params)
api_response = await self.api_client.get(endpoint, params=clean_params)
data = api_response.get("data", [])
if not data:
return {
"error": f"Finding group '{check_id}' not found.",
"status": "not_found",
}
group = DetailedFindingGroup.from_api_response(data[0])
return group.model_dump()
except Exception as e:
self.logger.error(f"Error getting finding group details: {e}")
return {"error": str(e), "status": "failed"}
group = DetailedFindingGroup.from_api_response(data[0])
return group.model_dump()
async def list_finding_group_resources(
self,
check_id: str = Field(
check_id: NonBlankStr = Field(
description="Public check ID that identifies the finding group. This is not a UUID."
),
provider: list[str] = Field(
@@ -426,45 +422,41 @@ class FindingGroupsTools(BaseTool):
`finding_id`. Use `prowler_get_finding_details(finding_id)` to
retrieve complete remediation guidance for a specific resource finding.
"""
try:
self.api_client.validate_page_size(page_size)
date_range, params = self._base_date_params(date_from, date_to)
endpoint = self._resource_endpoint(check_id, date_range)
self.api_client.validate_page_size(page_size)
date_range, params = self._base_date_params(date_from, date_to)
endpoint = self._resource_endpoint(check_id, date_range)
if muted is None and not self._bool_value(include_muted):
muted = False
if muted is None and not self._bool_value(include_muted):
muted = False
self._apply_common_filters(
params,
provider,
provider_type,
provider_uid,
provider_alias,
region,
service,
resource_type,
resource_name,
resource_uid,
resource_group,
category,
[],
None,
severity,
status,
muted,
delta,
)
self._apply_common_filters(
params,
provider,
provider_type,
provider_uid,
provider_alias,
region,
service,
resource_type,
resource_name,
resource_uid,
resource_group,
category,
[],
None,
severity,
status,
muted,
delta,
)
params["page[size]"] = page_size
params["page[number]"] = page_number
params["fields[finding-group-resources]"] = RESOURCE_FIELDS
if sort:
params["sort"] = sort
params["page[size]"] = page_size
params["page[number]"] = page_number
params["fields[finding-group-resources]"] = RESOURCE_FIELDS
if sort:
params["sort"] = sort
clean_params = self.api_client.build_filter_params(params)
api_response = await self.api_client.get(endpoint, params=clean_params)
response = FindingGroupResourcesListResponse.from_api_response(api_response)
return response.model_dump()
except Exception as e:
self.logger.error(f"Error listing finding group resources: {e}")
return {"error": str(e), "status": "failed"}
clean_params = self.api_client.build_filter_params(params)
api_response = await self.api_client.get(endpoint, params=clean_params)
response = FindingGroupResourcesListResponse.from_api_response(api_response)
return response.model_dump()
@@ -8,6 +8,7 @@ from typing import Any, Literal
from pydantic import Field
from prowler_mcp_server.lib.types import NonBlankStr
from prowler_mcp_server.prowler_app.models.findings import (
DetailedFinding,
FindingsListResponse,
@@ -180,7 +181,7 @@ class FindingsTools(BaseTool):
async def get_finding_details(
self,
finding_id: str = Field(
finding_id: NonBlankStr = Field(
description="UUID of the finding to retrieve (must be a valid UUID format, e.g., '019ac0d6-90d5-73e9-9acf-c22e256f1bac'). Returns an error if the finding ID is invalid or not found."
),
) -> dict[str, Any]:
@@ -9,8 +9,11 @@ This module provides tools for managing where Prowler sends its results, includi
import json
from typing import Any
from fastmcp.exceptions import ToolError
from pydantic import Field
from prowler_mcp_server.lib.errors import CredentialError, InvalidArgument
from prowler_mcp_server.lib.types import NonBlankStr
from prowler_mcp_server.prowler_app.models.integrations import (
DetailedIntegration,
IntegrationConnectionStatus,
@@ -126,7 +129,7 @@ class IntegrationsTools(BaseTool):
async def get_integration(
self,
integration_id: str = Field(
integration_id: NonBlankStr = Field(
description="UUID of the integration to retrieve. Must be a valid UUID format (e.g., '019ac0d6-90d5-73e9-9acf-c22e256f1bac'). Use prowler_list_integrations to find it."
),
) -> dict[str, Any]:
@@ -157,7 +160,7 @@ class IntegrationsTools(BaseTool):
async def create_amazon_s3_integration(
self,
bucket_name: str = Field(
bucket_name: NonBlankStr = Field(
description="Name of the S3 bucket where Prowler will upload the scan outputs (CSV, HTML, OCSF JSON and compliance reports)."
),
output_directory: str = Field(
@@ -168,15 +171,15 @@ class IntegrationsTools(BaseTool):
default=[],
description="Prowler UUIDs of the providers whose scan outputs are exported to this bucket. Use prowler_search_providers to find them. Leave empty to attach no provider yet.",
),
role_arn: str | None = Field(
role_arn: NonBlankStr | None = Field(
default=None,
description="ARN of the IAM role Prowler assumes to write to the bucket (e.g. 'arn:aws:iam::123456789012:role/ProwlerS3Integration'). Recommended over static keys.",
),
external_id: str | None = Field(
external_id: NonBlankStr | None = Field(
default=None,
description="External ID required by the trust policy of the assumed role. In Prowler Cloud this is the tenant ID.",
),
role_session_name: str | None = Field(
role_session_name: NonBlankStr | None = Field(
default=None,
description="Identifier for the role session, useful to track it in AWS logs. Only letters, digits and the characters =,.@_- are allowed.",
),
@@ -184,15 +187,15 @@ class IntegrationsTools(BaseTool):
default=3600,
description="Duration of the assumed role session in seconds. Must be between 900 and 43200. Defaults to 3600 when omitted.",
),
aws_access_key_id: str | None = Field(
aws_access_key_id: NonBlankStr | None = Field(
default=None,
description="AWS access key ID. Only needed when the Prowler deployment has no ambient AWS credentials.",
),
aws_secret_access_key: str | None = Field(
aws_secret_access_key: NonBlankStr | None = Field(
default=None,
description="AWS secret access key. Required when 'aws_access_key_id' is provided.",
),
aws_session_token: str | None = Field(
aws_session_token: NonBlankStr | None = Field(
default=None,
description="AWS session token, only for temporary credentials.",
),
@@ -244,34 +247,30 @@ class IntegrationsTools(BaseTool):
"""
self.logger.info(f"Creating Amazon S3 integration for bucket {bucket_name}...")
try:
credentials = self._build_aws_credentials(
role_arn=role_arn,
external_id=external_id,
role_session_name=role_session_name,
session_duration=session_duration,
aws_access_key_id=aws_access_key_id,
aws_secret_access_key=aws_secret_access_key,
aws_session_token=aws_session_token,
)
credentials = self._build_aws_credentials(
role_arn=role_arn,
external_id=external_id,
role_session_name=role_session_name,
session_duration=session_duration,
aws_access_key_id=aws_access_key_id,
aws_secret_access_key=aws_secret_access_key,
aws_session_token=aws_session_token,
)
return await self._create_integration(
integration_type="amazon_s3",
configuration={
"bucket_name": bucket_name,
"output_directory": output_directory,
},
credentials=credentials,
provider_ids=provider_ids,
enabled=enabled,
)
except Exception as e:
self.logger.error(f"Amazon S3 integration creation failed: {e}")
return {"error": str(e), "status": "failed"}
return await self._create_integration(
integration_type="amazon_s3",
configuration={
"bucket_name": bucket_name,
"output_directory": output_directory,
},
credentials=credentials,
provider_ids=provider_ids,
enabled=enabled,
)
async def create_aws_security_hub_integration(
self,
provider_id: str = Field(
provider_id: NonBlankStr = Field(
description="Prowler UUID of the AWS provider whose findings are sent to Security Hub. It must be an AWS provider, and it can only have one Security Hub integration. Use prowler_search_providers with provider_type=['aws'] to find it."
),
send_only_fails: bool = Field(
@@ -282,15 +281,15 @@ class IntegrationsTools(BaseTool):
default=False,
description="When true, findings that are no longer present in the latest scan are archived in Security Hub.",
),
role_arn: str | None = Field(
role_arn: NonBlankStr | None = Field(
default=None,
description="ARN of a dedicated IAM role Prowler assumes to write to Security Hub. Leave every credential parameter empty to reuse the credentials already stored for the provider, which is the recommended setup.",
),
external_id: str | None = Field(
external_id: NonBlankStr | None = Field(
default=None,
description="External ID required by the trust policy of the assumed role.",
),
role_session_name: str | None = Field(
role_session_name: NonBlankStr | None = Field(
default=None,
description="Identifier for the role session, useful to track it in AWS logs. Only letters, digits and the characters =,.@_- are allowed.",
),
@@ -298,14 +297,14 @@ class IntegrationsTools(BaseTool):
default=None,
description="Duration of the assumed role session in seconds. Must be between 900 and 43200. Defaults to 3600 when omitted.",
),
aws_access_key_id: str | None = Field(
aws_access_key_id: NonBlankStr | None = Field(
default=None, description="AWS access key ID for dedicated credentials."
),
aws_secret_access_key: str | None = Field(
aws_secret_access_key: NonBlankStr | None = Field(
default=None,
description="AWS secret access key. Required when 'aws_access_key_id' is provided.",
),
aws_session_token: str | None = Field(
aws_session_token: NonBlankStr | None = Field(
default=None,
description="AWS session token, only for temporary credentials.",
),
@@ -344,40 +343,36 @@ class IntegrationsTools(BaseTool):
f"Creating AWS Security Hub integration for provider {provider_id}..."
)
try:
credentials = self._build_aws_credentials(
role_arn=role_arn,
external_id=external_id,
role_session_name=role_session_name,
session_duration=session_duration,
aws_access_key_id=aws_access_key_id,
aws_secret_access_key=aws_secret_access_key,
aws_session_token=aws_session_token,
)
credentials = self._build_aws_credentials(
role_arn=role_arn,
external_id=external_id,
role_session_name=role_session_name,
session_duration=session_duration,
aws_access_key_id=aws_access_key_id,
aws_secret_access_key=aws_secret_access_key,
aws_session_token=aws_session_token,
)
return await self._create_integration(
integration_type="aws_security_hub",
configuration={
"send_only_fails": send_only_fails,
"archive_previous_findings": archive_previous_findings,
},
credentials=credentials,
provider_ids=[provider_id],
enabled=enabled,
)
except Exception as e:
self.logger.error(f"AWS Security Hub integration creation failed: {e}")
return {"error": str(e), "status": "failed"}
return await self._create_integration(
integration_type="aws_security_hub",
configuration={
"send_only_fails": send_only_fails,
"archive_previous_findings": archive_previous_findings,
},
credentials=credentials,
provider_ids=[provider_id],
enabled=enabled,
)
async def create_jira_integration(
self,
domain: str = Field(
domain: NonBlankStr = Field(
description="Atlassian site name, without the '.atlassian.net' suffix. For the site 'https://acme.atlassian.net' the value is 'acme'. Full URLs are accepted and normalized automatically."
),
user_mail: str = Field(
user_mail: NonBlankStr = Field(
description="Email address of the Atlassian account that owns the API token."
),
api_token: str = Field(
api_token: NonBlankStr = Field(
description="Atlassian API token, created from the account settings. It needs the 'read:jira-user', 'read:jira-work' and 'write:jira-work' scopes."
),
enabled: bool = Field(
@@ -416,31 +411,25 @@ class IntegrationsTools(BaseTool):
3. Use prowler_get_jira_issue_types with that project key to pick an issue type
4. Use prowler_send_findings_to_jira to create the work items
"""
try:
normalized_domain = self._normalize_atlassian_domain(domain)
self.logger.info(
f"Creating Jira integration for domain {normalized_domain}..."
)
normalized_domain = self._normalize_atlassian_domain(domain)
self.logger.info(f"Creating Jira integration for domain {normalized_domain}...")
return await self._create_integration(
integration_type="jira",
# Jira rejects any configuration in the payload, the API generates it
configuration={},
credentials={
"domain": normalized_domain,
"user_mail": user_mail,
"api_token": api_token,
},
provider_ids=[],
enabled=enabled,
)
except Exception as e:
self.logger.error(f"Jira integration creation failed: {e}")
return {"error": str(e), "status": "failed"}
return await self._create_integration(
integration_type="jira",
# Jira rejects any configuration in the payload, the API generates it
configuration={},
credentials={
"domain": normalized_domain,
"user_mail": user_mail,
"api_token": api_token,
},
provider_ids=[],
enabled=enabled,
)
async def update_integration(
self,
integration_id: str = Field(
integration_id: NonBlankStr = Field(
description="UUID of the integration to update. Use prowler_list_integrations to find it."
),
enabled: bool | None = Field(
@@ -494,96 +483,86 @@ class IntegrationsTools(BaseTool):
"""
self.logger.info(f"Updating integration {integration_id}...")
try:
current = DetailedIntegration.from_api_response(
await self._get_integration_raw(integration_id)
)
integration_type = current.integration_type
current = DetailedIntegration.from_api_response(
await self._get_integration_raw(integration_id)
)
integration_type = current.integration_type
if provider_ids is not None:
if integration_type == "jira":
raise ValueError(
"Jira integrations are tenant-wide and cannot be attached to providers."
)
if integration_type == "aws_security_hub" and len(provider_ids) != 1:
raise ValueError(
"AWS Security Hub integrations must stay attached to exactly one AWS "
f"provider, got {len(provider_ids)}. Pass a single provider ID, or use "
"prowler_delete_integration to stop sending findings to Security Hub."
)
attributes: dict[str, Any] = {}
if enabled is not None:
attributes["enabled"] = enabled
if credentials is not None:
attributes["credentials"] = self._validate_credentials(
integration_type, self._as_dict(credentials, "credentials")
if provider_ids is not None:
if integration_type == "jira":
raise InvalidArgument(
"Jira integrations are tenant-wide and cannot be attached to providers."
)
if integration_type == "aws_security_hub" and len(provider_ids) != 1:
raise InvalidArgument(
"AWS Security Hub integrations must stay attached to exactly one AWS "
f"provider, got {len(provider_ids)}. Pass a single provider ID, or use "
"prowler_delete_integration to stop sending findings to Security Hub."
)
if configuration is not None:
if integration_type == "jira":
raise ValueError(
"Jira integrations do not accept a configuration: it is generated by Prowler. "
"Update the credentials instead, or run prowler_test_integration_connection to "
"refresh the available projects and issue types."
)
merged = dict(current.configuration)
merged.update(self._as_dict(configuration, "configuration"))
# Server-owned, the API repopulates it from the connection check
merged.pop("regions", None)
merged.pop("enabled_regions", None)
attributes["configuration"] = merged
attributes: dict[str, Any] = {}
if enabled is not None:
attributes["enabled"] = enabled
if not attributes and provider_ids is None:
self.logger.info("No changes provided, returning the current state")
return current.model_dump()
if credentials is not None:
attributes["credentials"] = self._validate_credentials(
integration_type, self._as_dict(credentials, "credentials")
)
update_body: dict[str, Any] = {
"data": {
"type": "integrations",
"id": integration_id,
"attributes": attributes,
}
if configuration is not None:
if integration_type == "jira":
raise InvalidArgument(
"Jira integrations do not accept a configuration: it is generated by Prowler. "
"Update the credentials instead, or run prowler_test_integration_connection to "
"refresh the available projects and issue types."
)
merged = dict(current.configuration)
merged.update(self._as_dict(configuration, "configuration"))
# Server-owned, the API repopulates it from the connection check
merged.pop("regions", None)
merged.pop("enabled_regions", None)
attributes["configuration"] = merged
if not attributes and provider_ids is None:
self.logger.info("No changes provided, returning the current state")
return current.model_dump()
update_body: dict[str, Any] = {
"data": {
"type": "integrations",
"id": integration_id,
"attributes": attributes,
}
if provider_ids is not None:
update_body["data"]["relationships"] = _providers_relationship(
provider_ids
)
}
if provider_ids is not None:
update_body["data"]["relationships"] = _providers_relationship(provider_ids)
await self.api_client.patch(
f"/integrations/{integration_id}", json_data=update_body
)
await self.api_client.patch(
f"/integrations/{integration_id}", json_data=update_body
)
# A different provider means different effective credentials and different
# discovered configuration, so the stored connection state is stale too
providers_changed = provider_ids is not None and set(provider_ids) != set(
current.provider_ids
)
recheck_connection = (
credentials is not None
or configuration is not None
or providers_changed
)
connection_status = (
await self._test_connection(integration_id)
if recheck_connection
else None
)
# A different provider means different effective credentials and different
# discovered configuration, so the stored connection state is stale too
providers_changed = provider_ids is not None and set(provider_ids) != set(
current.provider_ids
)
recheck_connection = (
credentials is not None or configuration is not None or providers_changed
)
connection_status = (
await self._test_connection(integration_id) if recheck_connection else None
)
updated = await self._get_integration_raw(integration_id)
if connection_status is not None:
return IntegrationConnectionStatus.create(
updated, connection_status
).model_dump()
return DetailedIntegration.from_api_response(updated).model_dump()
except Exception as e:
self.logger.error(f"Integration update failed: {e}")
return {"error": str(e), "status": "failed"}
updated = await self._get_integration_raw(integration_id)
if connection_status is not None:
return IntegrationConnectionStatus.create(
updated, connection_status
).model_dump()
return DetailedIntegration.from_api_response(updated).model_dump()
async def delete_integration(
self,
integration_id: str = Field(
integration_id: NonBlankStr = Field(
description="UUID of the integration to permanently remove. Use prowler_list_integrations to find it."
),
) -> dict[str, Any]:
@@ -606,22 +585,15 @@ class IntegrationsTools(BaseTool):
"""
self.logger.info(f"Deleting integration {integration_id}...")
try:
await self.api_client.delete(f"/integrations/{integration_id}")
return {
"deleted": True,
"message": f"Integration {integration_id} deleted successfully",
}
except Exception as e:
self.logger.error(f"Integration deletion failed: {e}")
return {
"deleted": False,
"message": f"Integration {integration_id} deletion failed: {str(e)}",
}
await self.api_client.delete(f"/integrations/{integration_id}")
# No `deleted` flag: an integration that was not deleted leaves this tool
# as an error, so the flag could only ever be True and a reader branching
# on it would be looking for a shape that does not exist.
return {"message": f"Integration {integration_id} deleted successfully"}
async def test_integration_connection(
self,
integration_id: str = Field(
integration_id: NonBlankStr = Field(
description="UUID of the integration to check. Use prowler_list_integrations to find it."
),
) -> dict[str, Any]:
@@ -654,10 +626,10 @@ class IntegrationsTools(BaseTool):
async def get_jira_issue_types(
self,
integration_id: str = Field(
integration_id: NonBlankStr = Field(
description="UUID of the Jira integration. Use prowler_list_integrations with integration_type=['jira'] to find it."
),
project_key: str = Field(
project_key: NonBlankStr = Field(
description="Key of the Jira project to read the issue types from (e.g. 'PROJ'). It must be one of the keys in the 'projects' mapping of the integration configuration."
),
) -> dict[str, Any]:
@@ -692,13 +664,13 @@ class IntegrationsTools(BaseTool):
async def send_findings_to_jira(
self,
integration_id: str = Field(
integration_id: NonBlankStr = Field(
description="UUID of the Jira integration to send the findings through. It must be enabled."
),
project_key: str = Field(
project_key: NonBlankStr = Field(
description="Key of the Jira project the work items are created in (e.g. 'PROJ'). It must be one of the keys in the 'projects' mapping of the integration configuration."
),
issue_type: str = Field(
issue_type: NonBlankStr = Field(
description="Jira issue type for the created work items (e.g. 'Task', 'Bug', 'Story'). It must be one of the values returned by prowler_get_jira_issue_types for this project."
),
finding_ids: list[str] = Field(
@@ -783,20 +755,26 @@ class IntegrationsTools(BaseTool):
return self._jira_dispatch_unknown(
task_id=None,
error=(
f"the request that starts the dispatch failed on the server: {e} "
"the request that starts the dispatch failed on Prowler's side. "
"It may have been queued anyway."
),
)
self.logger.error(f"Jira dispatch was rejected by Prowler: {e}")
return self._jira_dispatch_rejected(str(e))
except CredentialError:
# Authentication happens before the request goes out, so nothing was
# queued. It is raised rather than reported as a dispatch outcome:
# there is no partial state to describe, and the shared classifier
# says what has to be fixed, which no retry of this call can.
raise
except Exception as e:
# No answer came back, so the request may still have been accepted
self.logger.error(f"Jira dispatch could not be started: {e}")
return self._jira_dispatch_unknown(
task_id=None,
error=(
f"the request that starts the dispatch got no answer: {e} "
"the request that starts the dispatch got no answer. "
"It may have been accepted anyway."
),
)
@@ -866,7 +844,7 @@ class IntegrationsTools(BaseTool):
normalized = normalized.removesuffix(".atlassian.net")
if not normalized:
raise ValueError(
raise InvalidArgument(
f"Invalid Jira domain: {domain}. Provide the Atlassian site name, for example "
"'acme' for the site 'https://acme.atlassian.net'."
)
@@ -890,7 +868,7 @@ class IntegrationsTools(BaseTool):
if not isinstance(credentials.get(key), str) or not credentials[key].strip()
]
if missing:
raise ValueError(
raise InvalidArgument(
"Jira credentials are replaced as a whole, so 'domain', 'user_mail' and "
f"'api_token' are all required. Missing or empty: {', '.join(missing)}. "
"Sending an incomplete object would destroy the stored credentials and break "
@@ -908,29 +886,33 @@ class IntegrationsTools(BaseTool):
try:
value = json.loads(value)
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON for {param_name}: {e}")
raise InvalidArgument(f"Invalid JSON for {param_name}: {e}") from e
if not isinstance(value, dict):
raise ValueError(f"{param_name} must be a JSON object.")
raise InvalidArgument(f"{param_name} must be a JSON object.")
return value
async def _get_integration_raw(self, integration_id: str) -> dict[str, Any]:
"""Fetch the raw JSON:API resource of an integration.
Raises:
ValueError: If the payload does not contain a usable integration resource
ToolError: If the payload does not contain a usable integration resource.
Raised without a ``from`` clause because these messages name the
integration and the tool that lists valid IDs, and the two cases
are reported differently: a missing resource is the caller's
mistake, a resource without attributes is the API's.
"""
response = await self.api_client.get(f"/integrations/{integration_id}")
integration = response.get("data")
if not isinstance(integration, dict) or not integration.get("id"):
raise ValueError(
raise ToolError(
f"Integration {integration_id} was not found. Use prowler_list_integrations "
"to get a valid integration ID."
)
if not isinstance(integration.get("attributes"), dict):
raise ValueError(
raise ToolError(
f"Prowler returned integration {integration_id} without its attributes, so "
"its state cannot be read."
)
@@ -970,7 +952,9 @@ class IntegrationsTools(BaseTool):
integration_id = api_response.get("data", {}).get("id")
if not integration_id:
raise ValueError(
# The integration may well exist, so this must not read as "nothing
# happened" and invite a duplicate.
raise ToolError(
"Prowler accepted the integration creation but did not return its ID, so the "
"connection could not be checked. Use prowler_list_integrations to see whether "
"the integration exists before creating it again."
@@ -981,11 +965,17 @@ class IntegrationsTools(BaseTool):
try:
integration = await self._get_integration_raw(integration_id)
except Exception as e:
# The integration exists, so surface its ID instead of a plain read failure
raise ValueError(
f"Integration {integration_id} was created, but reading its state failed: {e} "
# The integration exists, so surface its ID instead of a plain read
# failure. No `from` clause: a cause would let the shared classifier
# replace this with a sentence that does not mention the ID. The
# failure text stays in the log, where the classifier would keep it.
self.logger.error(
f"Integration {integration_id} could not be read back: {e}"
)
raise ToolError(
f"Integration {integration_id} was created, but reading its state failed. "
"Use prowler_get_integration with that ID to check it."
) from e
)
return IntegrationConnectionStatus.create(
integration, connection_status
@@ -1030,7 +1020,7 @@ class IntegrationsTools(BaseTool):
return {
"connected": None,
"error": (
f"The connection check could not be completed: {e} This says nothing "
"The connection check could not be completed. This says nothing "
"about the stored credentials, run prowler_test_integration_connection "
"to check them again."
),
@@ -8,8 +8,11 @@ This module provides tools for managing finding muting in Prowler, including:
import json
from typing import Any
from fastmcp.exceptions import ToolError
from pydantic import Field
from prowler_mcp_server.lib.errors import InvalidArgument
from prowler_mcp_server.lib.types import NonBlankStr
from prowler_mcp_server.prowler_app.models.muting import (
DetailedMuteRule,
MutelistResponse,
@@ -28,10 +31,31 @@ class MutingTools(BaseTool):
# ===== MUTELIST TOOLS =====
async def _get_mutelist_raw(self) -> dict[str, Any] | None:
"""Return the tenant's mutelist, or None when it has none.
Returns:
The mutelist configuration, or None when the tenant has none
"""
params = {
"filter[processor_type]": "mutelist",
"fields[processors]": "processor_type,configuration,inserted_at,updated_at",
}
clean_params = self.api_client.build_filter_params(params)
api_response = await self.api_client.get("/processors", params=clean_params)
data = api_response.get("data", [])
if not data:
return None
# Only one mutelist can exist per tenant
return MutelistResponse.from_api_response(data[0]).model_dump()
async def get_mutelist(self) -> dict[str, Any]:
"""Retrieve the current mutelist configuration for the tenant.
IMPORTANT: Only one mutelist can exist per tenant. Returns an error message if no mutelist exists.
IMPORTANT: Only one mutelist can exist per tenant. Fails with a message saying so if no mutelist exists.
For detailed information about mutelist structure and configuration, search Prowler documentation
using prowler_docs_search tool available in this MCP Server.
@@ -47,26 +71,15 @@ class MutingTools(BaseTool):
"""
self.logger.info("Retrieving mutelist configuration...")
# Query processors filtered by type=mutelist
params = {
"filter[processor_type]": "mutelist",
"fields[processors]": "processor_type,configuration,inserted_at,updated_at",
}
clean_params = self.api_client.build_filter_params(params)
api_response = await self.api_client.get("/processors", params=clean_params)
data = api_response.get("data", [])
if len(data) == 0:
return {
"error": "No mutelist found",
"message": "No mutelist configuration exists for this tenant. Use prowler_set_mutelist to create one.",
}
# Return the first (and only) mutelist
mutelist = MutelistResponse.from_api_response(data[0])
return mutelist.model_dump()
mutelist = await self._get_mutelist_raw()
if mutelist is None:
# No `from`: this names the tool that creates one, which the shared
# classifier cannot know.
raise ToolError(
"No mutelist configuration exists for this tenant. Use "
"prowler_set_mutelist to create one."
)
return mutelist
async def set_mutelist(
self,
@@ -128,9 +141,9 @@ Structure:
configuration = json.loads(configuration)
# Check if mutelist already exists
existing_mutelist = await self.get_mutelist()
existing_mutelist = await self._get_mutelist_raw()
if "error" in existing_mutelist:
if existing_mutelist is None:
# Create new mutelist
self.logger.info("Creating new mutelist...")
create_body = {
@@ -183,21 +196,22 @@ Structure:
self.logger.info("Deleting mutelist configuration...")
# Get existing mutelist
existing_mutelist = await self.get_mutelist()
existing_mutelist = await self._get_mutelist_raw()
if "error" in existing_mutelist:
return {
"success": False,
"message": "No mutelist found to delete",
}
if existing_mutelist is None:
raise ToolError(
"There is no mutelist configuration to delete. Use "
"prowler_get_mutelist to confirm the current state."
)
# Delete the mutelist
mutelist_id = existing_mutelist["id"]
await self.api_client.delete(f"/processors/{mutelist_id}")
# No success flag: a deletion that did not happen leaves this tool as an
# error, so there is no second shape for one to distinguish.
return {
"success": True,
"message": "Mutelist deleted successfully",
"message": "Mutelist deleted successfully. Findings it had muted stay muted."
}
# ===== MUTE RULES TOOLS =====
@@ -268,7 +282,7 @@ Structure:
elif enabled.lower() == "false":
params["filter[enabled]"] = False
else:
raise ValueError(
raise InvalidArgument(
f"Invalid enabled value: {enabled}. Valid values are True, False, 'true', 'false' or None."
)
if search:
@@ -282,7 +296,7 @@ Structure:
async def get_mute_rule(
self,
rule_id: str = Field(
rule_id: NonBlankStr = Field(
description="UUID of the mute rule to retrieve. Must be a valid UUID format (e.g., '019ac0d6-90d5-73e9-9acf-c22e256f1bac')."
),
) -> dict[str, Any]:
@@ -316,10 +330,10 @@ Structure:
async def create_mute_rule(
self,
name: str = Field(
name: NonBlankStr = Field(
description="Name for the mute rule. Should be descriptive and meaningful (e.g., 'Dev S3 Public Access', 'Test Environment IMDSv1')."
),
reason: str = Field(
reason: NonBlankStr = Field(
description="Reason for muting these findings. Document why this security issue is acceptable or intentional (e.g., 'Development environment with controlled access', 'Legacy application requires IMDSv1')."
),
finding_ids: list[str] = Field(
@@ -367,14 +381,14 @@ Structure:
async def update_mute_rule(
self,
rule_id: str = Field(
rule_id: NonBlankStr = Field(
description="UUID of the mute rule to update. Must be a valid UUID format."
),
name: str | None = Field(
name: NonBlankStr | None = Field(
default=None,
description="New name for the rule. If not specified, name remains unchanged.",
),
reason: str | None = Field(
reason: NonBlankStr | None = Field(
default=None,
description="New reason for the rule. If not specified, reason remains unchanged.",
),
@@ -435,7 +449,7 @@ Structure:
async def delete_mute_rule(
self,
rule_id: str = Field(
rule_id: NonBlankStr = Field(
description="UUID of the mute rule to delete. Must be a valid UUID format."
),
) -> dict[str, Any]:
@@ -457,15 +471,18 @@ Structure:
"""
self.logger.info(f"Deleting mute rule {rule_id}...")
result = await self.api_client.delete(f"/mute-rules/{rule_id}")
# A deletion that did not happen answers with an error status, which
# leaves this tool as an error. Reaching this line means Prowler accepted
# it, whether it answered 204 with no body or 200 with the deleted
# resource, so there is no second outcome to report: the previous
# "Failed to delete mute rule" fired on the shape of the answer rather
# than on anything having gone wrong, and said nothing a caller could act
# on.
await self.api_client.delete(f"/mute-rules/{rule_id}")
if result.get("success"):
return {
"success": True,
"message": "Mute rule deleted successfully",
}
else:
return {
"success": False,
"message": "Failed to delete mute rule",
}
return {
"message": (
f"Mute rule {rule_id} deleted successfully. The findings it muted stay "
"muted."
)
}
@@ -6,10 +6,14 @@ including searching, connecting, and deleting providers.
from typing import Any
from fastmcp.exceptions import ToolError
from pydantic import Field
from prowler_mcp_server.lib.errors import InvalidArgument
from prowler_mcp_server.lib.types import NonBlankStr
from prowler_mcp_server.prowler_app.models.providers import (
ProviderConnectionStatus,
ProviderDeletionResult,
ProvidersListResponse,
)
from prowler_mcp_server.prowler_app.tools.base import BaseTool
@@ -95,7 +99,7 @@ class ProvidersTools(BaseTool):
elif connected.lower() == "false":
params["filter[connected]"] = False
else:
raise ValueError(
raise InvalidArgument(
f"Invalid connected value: {connected}. Valid values are True, False, 'true', 'false' or None."
)
@@ -128,13 +132,13 @@ class ProvidersTools(BaseTool):
async def connect_provider(
self,
provider_uid: str = Field(
provider_uid: NonBlankStr = Field(
description="Provider's unique identifier. For supported UID provider formats, please refer to Prowler Hub/Prowler Documentation that you can also find in form of tools in this MCP Server"
),
provider_type: str = Field(
provider_type: NonBlankStr = Field(
description="Type of provider to be scanned with Prowler. Valid values include: 'aws', 'azure', 'gcp', 'kubernetes'... For more valid values, please refer to Prowler Hub/Prowler Documentation that you can also find in form of tools in this MCP Server."
),
alias: str | None = Field(
alias: NonBlankStr | None = Field(
default=None,
description="Human-friendly name for this provider. Optional but recommended for easy identification. Use descriptive names to distinguish multiple accounts of the same type.",
),
@@ -291,7 +295,7 @@ class ProvidersTools(BaseTool):
async def delete_provider(
self,
provider_id: str = Field(
provider_id: NonBlankStr = Field(
description="Prowler's internal UUID (v4) for the provider to permanently remove, generated when the provider was registered in the system. Use `prowler_search_providers` tool to find the provider_id if you only know the alias or the provider's own identifier (provider_uid)"
),
) -> dict[str, Any]:
@@ -300,33 +304,120 @@ class ProvidersTools(BaseTool):
WARNING: This is a destructive operation that cannot be undone. The provider will need to be
re-added with prowler_connect_provider if you want to scan it again.
The tool always returns the deletion status and message.
Prowler removes the provider and everything attached to it (its scans, findings and
resources) in a background task, so a large provider can take longer than the time
this tool waits for it.
The result includes:
- status: 'deleted' when Prowler finished removing the provider, 'in_progress' when
the deletion was accepted and is still running
- task_id: the background task, present when the deletion was still running
NEVER send the deletion again while status='in_progress'. Use prowler_search_providers
to check whether the provider is gone.
"""
self.logger.info(f"Deleting provider {provider_id}...")
try:
# Initiate the deletion task
task_response = await self.api_client.delete(f"/providers/{provider_id}")
task_id = task_response.get("data", {}).get("id")
# Poll until task completes (with 60 second timeout)
# A failure of the request itself is left to the shared classifier: the
# deletion never started, so there is no partial state to describe.
task_response = await self.api_client.delete(f"/providers/{provider_id}")
task_id = task_response.get("data", {}).get("id")
if not task_id:
# The deletion may well be running, so this must not read as "nothing
# happened". No `from` clause: this names the provider and the tool
# that checks it, neither of which the shared classifier can know.
raise ToolError(
f"Prowler accepted the deletion of provider {provider_id} but did not "
"return the ID of the background task, so its outcome cannot be checked. "
"Use prowler_search_providers to see whether the provider is still there "
"before sending the deletion again."
)
try:
await self.api_client.poll_task_until_complete(
task_id=task_id, timeout=60, poll_interval=1.0
)
# If we reach here, the task completed successfully
return {
"deleted": True,
"message": f"Provider {provider_id} deleted successfully",
}
except Exception as e:
self.logger.error(f"Provider deletion failed: {e}")
return {
"deleted": False,
"message": f"Provider {provider_id} deletion failed: {str(e)}",
}
self.logger.error(f"Provider deletion did not complete cleanly: {e}")
return await self._provider_deletion_fallback(provider_id, task_id)
return ProviderDeletionResult(
status="deleted",
message=f"Provider {provider_id} deleted successfully",
).model_dump()
# Private helper methods
async def _provider_deletion_fallback(
self, provider_id: str, task_id: str
) -> dict[str, Any]:
"""Report a provider deletion whose polling did not end on a completed task.
Running out of the polling window is not a failure: Prowler removes the
provider together with its scans, findings and resources, which outlives
60 seconds on a large account. The deletion was accepted and is still
going, so calling it failed would be wrong twice over -- it is not, and
it invites a retry of a destructive call already in flight.
The task is read once more here, because polling gives up on the clock
rather than on the task: a deletion that finished just after the last
poll is a finished deletion and is reported as one.
Only a task that actually stopped is an error, and it is raised rather
than returned, because then the provider is still there.
Raises:
ToolError: If the deletion task ended without deleting the provider.
Raised without a ``from`` clause because the message names what
was left behind, which the shared classifier cannot know.
"""
state = None
try:
task = await self.api_client.get(f"/tasks/{task_id}")
state = task.get("data", {}).get("attributes", {}).get("state")
except Exception as e:
self.logger.error(f"Could not read the state of task {task_id}: {e}")
if state == "completed":
# The deletion outran the polling window by a moment, not by more.
return ProviderDeletionResult(
status="deleted",
message=f"Provider {provider_id} deleted successfully",
).model_dump()
if state in ("failed", "cancelled"):
# The failure that got us here is logged, not relayed: it carries
# upstream text, and the classifier masks exactly this kind of
# message when a tool does not write it itself.
raise ToolError(
f"The task deleting provider {provider_id} ended as '{state}', so the "
"provider was not deleted. Prowler removes a provider together with its "
"scans, findings and resources, so part of that may already be gone. Use "
"prowler_search_providers to check the current state."
)
if state is None:
message = (
f"The deletion of provider {provider_id} was accepted, but its progress "
"could not be read, so whether it finished is unknown. Do not "
"send the deletion again. Use prowler_search_providers to check whether "
"the provider is gone."
)
else:
message = (
f"The deletion of provider {provider_id} was accepted and is still "
f"running (task state '{state}'), which is normal for a provider with "
"many scans and findings. Do not send the deletion again. Use "
"prowler_search_providers to check whether it is gone."
)
return ProviderDeletionResult(
status="in_progress",
task_id=task_id,
message=message,
).model_dump()
async def _check_provider_exists(self, provider_uid: str) -> str | None:
"""Check if a provider already exists by its UID.
@@ -357,7 +448,7 @@ class ProvidersTools(BaseTool):
return prowler_provider_id
else:
# Multiple providers with the same UID is a data integrity issue
raise Exception(
raise ToolError(
f"Data integrity error: Found {len(providers)} providers with UID '{provider_uid}'. "
f"Each provider UID should be unique. Please contact support or manually clean up duplicate providers."
)
@@ -392,7 +483,11 @@ class ProvidersTools(BaseTool):
provider_id = await self._check_provider_exists(provider_uid)
if provider_id is None:
raise Exception(f"Provider {provider_uid} creation failed")
raise ToolError(
f"Prowler accepted the creation of provider {provider_uid} but the "
"provider cannot be found afterwards. Use prowler_search_providers to "
"check whether it exists before creating it again."
)
return provider_id
async def _update_provider_alias(
@@ -418,7 +513,10 @@ class ProvidersTools(BaseTool):
f"/providers/{prowler_provider_id}", json_data=update_body
)
if result.get("data", {}).get("attributes", {}).get("alias") != alias:
raise Exception(f"Provider {prowler_provider_id} alias update failed")
raise ToolError(
f"Provider {prowler_provider_id} exists, but its alias was not updated. "
"Use prowler_search_providers to read its current alias."
)
def _determine_secret_type(self, credentials: dict[str, Any]) -> str:
"""Determine the secret type from credentials structure.
@@ -443,29 +541,32 @@ class ProvidersTools(BaseTool):
prowler_provider_id: The Prowler-generated provider ID
Returns:
The secret ID if exists, None otherwise
"""
try:
response = await self.api_client.get(
"/providers/secrets",
params={"filter[provider]": prowler_provider_id},
)
secrets = response.get("data", [])
The secret ID if the provider has one, None if it has none
if len(secrets) > 0:
secret_id = secrets[0].get("id")
self.logger.info(
f"Found existing secret {secret_id} for provider {prowler_provider_id}"
)
return secret_id
else:
self.logger.info(
f"No existing secret found for provider {prowler_provider_id}"
)
return None
except Exception as e:
self.logger.error(f"Error checking for existing secret: {e}")
return None
Raises:
Exception: If the lookup itself failed, so that "no secret" is never
reported for a provider whose secret could not be read
"""
# A failure here is not swallowed into None. None means "this provider has
# no secret", which sends `_store_credentials` down the create branch, and
# a provider holds at most one secret: creating a second one is refused,
# and the caller would be told its credentials were rejected when all that
# actually failed was this read.
response = await self.api_client.get(
"/providers/secrets",
params={"filter[provider]": prowler_provider_id},
)
secrets = response.get("data", [])
if len(secrets) > 0:
secret_id = secrets[0].get("id")
self.logger.info(
f"Found existing secret {secret_id} for provider {prowler_provider_id}"
)
return secret_id
self.logger.info(f"No existing secret found for provider {prowler_provider_id}")
return None
async def _get_secret_type(self, secret_id: str) -> str | None:
"""Get the secret type for a given secret ID.
@@ -573,13 +674,24 @@ class ProvidersTools(BaseTool):
raise
async def _test_connection(self, prowler_provider_id: str) -> dict[str, Any]:
"""Test connection to a provider.
"""Test connection to a provider and wait for the result.
A test that could not be run is reported as 'connected: None', which
`ProviderConnectionStatus` renders as 'not_tested', rather than as a
failure. Credentials that do not work come back as a completed task
carrying 'connected: False', so an exception here never describes them:
it means this server could not get the test run at all -- an expired
Prowler credential, a rate limit, a test that outlived the timeout.
Reporting that as 'failed' would blame the provider's credentials for
something they did not cause, and send the caller off to fix a working
role.
Args:
prowler_provider_id: The Prowler-generated provider ID
Returns:
Connection status dictionary with 'connected' boolean and optional 'error' message
Connection status dictionary with a 'connected' boolean or None, and
an optional 'error' message
"""
self.logger.info(f"Testing connection for provider {prowler_provider_id}...")
try:
@@ -589,6 +701,11 @@ class ProvidersTools(BaseTool):
)
task_id = task_response.get("data", {}).get("id")
if not task_id:
raise ValueError(
"Prowler did not return the ID of the connection test task."
)
# Poll until task completes (with 60 second timeout)
completed_task = await self.api_client.poll_task_until_complete(
task_id=task_id, timeout=60, poll_interval=1.0
@@ -596,13 +713,26 @@ class ProvidersTools(BaseTool):
# Extract the result from the completed task
task_result = (
completed_task.get("data", {}).get("attributes", {}).get("result", {})
completed_task.get("data", {}).get("attributes", {}).get("result")
)
if not isinstance(task_result, dict):
raise ValueError(
"The connection test task completed without reporting a result."
)
return task_result
except Exception as e:
self.logger.error(f"Connection test failed: {e}")
return {"connected": False, "error": str(e)}
self.logger.error(f"Connection test could not be completed: {e}")
return {
"connected": None,
"error": (
"The connection test could not be completed. This says nothing "
"about the provider's credentials, they were never tested. Use "
"prowler_search_providers to read the connection state Prowler has "
"stored for this provider."
),
}
async def _get_final_provider_state(
self, prowler_provider_id: str
@@ -8,6 +8,7 @@ from typing import Any
from pydantic import Field
from prowler_mcp_server.lib.types import NonBlankStr
from prowler_mcp_server.prowler_app.models.resources import (
DetailedResource,
ResourceEventsResponse,
@@ -176,7 +177,7 @@ class ResourcesTools(BaseTool):
async def get_resource(
self,
resource_id: str = Field(
resource_id: NonBlankStr = Field(
description="Prowler's internal UUID (v4) for the resource to retrieve, generated when the resource was discovered in the system. Use `prowler_list_resources` tool to find the right ID"
),
) -> dict[str, Any]:
@@ -347,7 +348,7 @@ class ResourcesTools(BaseTool):
async def get_resource_events(
self,
resource_id: str = Field(
resource_id: NonBlankStr = Field(
description="Prowler's internal UUID (v4) for the resource. Use `prowler_list_resources` to find the right ID, or get it from a finding's resource relationship via `prowler_get_finding_details`."
),
lookback_days: int = Field(
@@ -11,8 +11,11 @@ adding to it.
from typing import Any
from fastmcp.exceptions import ToolError
from pydantic import Field
from prowler_mcp_server.lib.errors import ProwlerAPIError
from prowler_mcp_server.lib.types import NonBlankStr
from prowler_mcp_server.prowler_app.models.roles import (
DetailedRole,
RolesListResponse,
@@ -70,7 +73,7 @@ class RolesTools(BaseTool):
async def get_role(
self,
role_id: str = Field(
role_id: NonBlankStr = Field(
description="Prowler's internal UUID (v4) for the role to retrieve. Use `prowler_list_roles` to find role IDs if you only know a name."
),
) -> dict[str, Any]:
@@ -98,7 +101,7 @@ class RolesTools(BaseTool):
async def get_user_roles(
self,
user_id: str = Field(
user_id: NonBlankStr = Field(
description="Prowler's internal UUID (v4) for the user whose roles you want. Use `prowler_list_users` to find user IDs, or `prowler_get_current_user` for the caller."
),
) -> dict[str, Any]:
@@ -124,10 +127,10 @@ class RolesTools(BaseTool):
async def set_user_role(
self,
user_id: str = Field(
user_id: NonBlankStr = Field(
description="Prowler's internal UUID (v4) for the user whose role you want to set. Use `prowler_list_users` to find user IDs."
),
role_id: str = Field(
role_id: NonBlankStr = Field(
description="Prowler's internal UUID (v4) for the role the user should hold. Use `prowler_list_roles` to find role IDs."
),
) -> dict[str, Any]:
@@ -166,11 +169,20 @@ class RolesTools(BaseTool):
# user with no role at all. Confirm the role exists before replacing.
try:
await self.api_client.get(f"/roles/{role_id}")
except Exception as e:
raise ValueError(
f"Role {role_id} could not be read ({e}), so user {user_id} was left "
f"unchanged. Use `prowler_list_roles` to find a valid role ID."
) from e
except ProwlerAPIError as e:
if e.status_code != 404:
# Only a not-found says anything about the role ID. A permission
# error, a rate limit or a server error is about the request, so
# it goes to the shared classifier rather than being reported as
# an ID the caller should replace.
raise
# No `from` clause: this says what state the user was left in, which
# the shared classifier cannot know, and a cause would let it replace
# this message with its own.
raise ToolError(
f"Role {role_id} does not exist in this tenant, so user {user_id} was "
f"left unchanged. Use `prowler_list_roles` to find a valid role ID."
)
# PATCH replaces the user's whole role set with this single role, the
# same call the Prowler UI makes when changing a user's role.
@@ -5,8 +5,10 @@ This module provides tools for managing and monitoring Prowler security scans.
from typing import Any, Literal
from fastmcp.exceptions import ToolError
from pydantic import Field
from prowler_mcp_server.lib.types import NonBlankStr
from prowler_mcp_server.prowler_app.models.scans import (
DetailedScan,
ScanCreationResult,
@@ -127,7 +129,7 @@ class ScansTools(BaseTool):
async def get_scan(
self,
scan_id: str = Field(
scan_id: NonBlankStr = Field(
description="Prowler's internal UUID (v4) for the scan to retrieve, generated when the scan was created (e.g., '123e4567-e89b-12d3-a456-426614174000'). Use `prowler_list_scans` tool to find scan IDs"
),
) -> dict[str, Any]:
@@ -171,10 +173,10 @@ class ScansTools(BaseTool):
async def trigger_scan(
self,
provider_id: str = Field(
provider_id: NonBlankStr = Field(
description="Prowler's internal UUID (v4) for the provider to scan, generated when the provider was registered in the system (e.g., '4d0e2614-6385-4fa7-bf0b-c2e2f75c6877'). Use `prowler_search_providers` tool to find the provider ID"
),
name: str | None = Field(
name: NonBlankStr | None = Field(
default=None,
description="Optional human-friendly name for the scan. Use descriptive names to identify scan purpose or context, e.g., 'Weekly Production Security Audit', 'Pre-Deployment Validation', 'Compliance Check Q4 2025'",
),
@@ -191,60 +193,70 @@ class ScansTools(BaseTool):
3. Use `prowler_get_scan` with the returned scan 'id' to monitor progress
4. Once completed, use `prowler_search_security_findings` to analyze results
"""
try:
# Build request data
request_data: dict[str, Any] = {
"data": {
"type": "scans",
"attributes": {},
"relationships": {
"provider": {
"data": {
"type": "providers",
"id": provider_id,
},
# Build request data
request_data: dict[str, Any] = {
"data": {
"type": "scans",
"attributes": {},
"relationships": {
"provider": {
"data": {
"type": "providers",
"id": provider_id,
},
},
},
}
if name:
request_data["data"]["attributes"]["name"] = name
},
}
if name:
request_data["data"]["attributes"]["name"] = name
# Create scan (returns Task)
self.logger.info(f"Creating scan for provider {provider_id}")
task_response = await self.api_client.post("/scans", json_data=request_data)
# Create scan (returns Task)
self.logger.info(f"Creating scan for provider {provider_id}")
task_response = await self.api_client.post("/scans", json_data=request_data)
scan_id = (
task_response.get("data", {})
.get("attributes", {})
.get("task_args", {})
.get("scan_id", None)
scan_id = (
task_response.get("data", {})
.get("attributes", {})
.get("task_args", {})
.get("scan_id", None)
)
if not scan_id:
# The scan may well have been queued, so this must not read as
# "nothing happened" and invite a duplicate run. No `from` clause:
# this names the provider and the tool that checks for the scan,
# neither of which the shared classifier can know.
raise ToolError(
"Prowler accepted the scan but did not return its ID, so it "
"cannot be looked up. Use prowler_list_scans for provider "
f"{provider_id} to see whether a scan is already running before "
"triggering another one."
)
if not scan_id:
raise Exception("No scan_id returned from scan creation")
self.logger.info(f"Scan created successfully: {scan_id}")
# The scan exists from here on, so a failure to read it back must name
# the ID rather than read as "the scan was not created".
try:
scan_response = await self.api_client.get(f"/scans/{scan_id}")
scan_info = DetailedScan.from_api_response(scan_response["data"])
return ScanCreationResult(
scan=scan_info,
status="success",
message=f"Scan {scan_id} created successfully. The scan may take some time to complete. Use prowler_get_scan tool with this ID to monitor progress.",
).model_dump()
except Exception as e:
self.logger.error(f"Scan creation failed: {e}")
return ScanCreationResult(
scan=None,
status="failed",
message=f"Scan creation failed: {str(e)}",
).model_dump()
# The failure itself is logged, not relayed: what it says is the
# shared classifier's to mask, and what the caller needs is the ID.
self.logger.error(f"Scan {scan_id} could not be read back: {e}")
raise ToolError(
f"Scan {scan_id} was created for provider {provider_id}, but reading "
"its state failed. Use prowler_get_scan with that ID to monitor "
"it. Do not trigger the scan again."
)
return ScanCreationResult(
scan=scan_info,
message=f"Scan {scan_id} created successfully. The scan may take some time to complete. Use prowler_get_scan tool with this ID to monitor progress.",
).model_dump()
async def schedule_daily_scan(
self,
provider_id: str = Field(
provider_id: NonBlankStr = Field(
description="Prowler's internal UUID (v4) for the provider to scan, generated when the provider was registered in the system (e.g., '4d0e2614-6385-4fa7-bf0b-c2e2f75c6877'). Use `prowler_search_providers` tool to find the provider ID"
),
) -> dict[str, Any]:
@@ -280,26 +292,49 @@ class ScansTools(BaseTool):
},
},
)
task_state = (
# Reaching this line means the schedule exists. Prowler commits the
# recurring schedule and its first scan inside the transaction that
# serves this request, so an answer at all means it was created; a
# provider that already has one is refused with a 409 instead, which
# leaves this tool as an error.
#
# The task in the answer is the FIRST scan run, queued to start a few
# seconds later, not the schedule. Its state therefore says nothing
# about whether the schedule was created, and reporting it as the
# outcome would call a schedule that exists a failure and invite a
# retry that can only hit that 409.
first_run_state = (
task_response.get("data", {}).get("attributes", {}).get("state", None)
)
if task_state == "available":
return_message = "Daily schedule created successfully. The schedule is being set up in the background. Use prowler_list_scans with provider_id filter to view scheduled scans."
else:
return_message = "Daily schedule creation failed. Please try again later."
message = (
f"Daily schedule created for provider {provider_id}. Prowler will scan it "
"every 24 hours until the provider is deleted. Use prowler_list_scans with "
"this provider_id and trigger='scheduled' to view its scheduled scans."
)
if first_run_state in ("failed", "cancelled"):
# Worth saying: the schedule stands, but the run that was supposed to
# start now will not produce findings, and only a manual scan fills
# the gap before tomorrow.
message = (
f"{message} Note that the first scan, which Prowler starts immediately, "
f"ended as '{first_run_state}'. The daily schedule is unaffected, but "
"use prowler_trigger_scan if you need results before the next run."
)
return ScheduleCreationResult(
scheduled=(task_state == "available"),
message=return_message,
first_run_state=first_run_state,
message=message,
).model_dump()
async def update_scan(
self,
scan_id: str = Field(
scan_id: NonBlankStr = Field(
description="Prowler's internal UUID (v4) for the scan to update, generated when the scan was created (e.g., '123e4567-e89b-12d3-a456-426614174000'). Use `prowler_list_scans` tool to find the scan ID if you only know the provider or scan name. Returns an error if the scan ID is invalid or not found."
),
name: str = Field(
name: NonBlankStr = Field(
description="New human-friendly name for the scan (3-100 characters). Use descriptive names to improve organization and tracking, e.g., 'Production Security Audit - Q4 2025', 'Post-Deployment Compliance Check'. IMPORTANT: Only the scan name can be updated - other attributes (state, progress, duration) are read-only and managed by the system."
),
) -> dict[str, Any]:
@@ -9,6 +9,7 @@ from typing import Any
from pydantic import Field
from prowler_mcp_server.lib.types import NonBlankStr
from prowler_mcp_server.prowler_app.models.users import (
DetailedUser,
UsersListResponse,
@@ -79,7 +80,7 @@ class UsersTools(BaseTool):
async def get_user(
self,
user_id: str = Field(
user_id: NonBlankStr = Field(
description="Prowler's internal UUID (v4) for the user to retrieve. Use `prowler_list_users` to find user IDs if you only know a name or email."
),
) -> dict[str, Any]:
@@ -118,7 +118,21 @@ class ProwlerAPIClient(metaclass=SingletonMeta):
if detail:
message = f"{message} - {detail}"
raise ProwlerAPIError(message, status, detail=detail) from e
# Carried on the exception, not into the message: a tool needs the
# body to tell an answer with an error status -- a 404 holding the
# empty result of a query that matched nothing -- apart from a
# request that actually failed.
try:
body = e.response.json()
except ValueError:
body = None
raise ProwlerAPIError(
message,
status,
detail=detail,
payload=body if isinstance(body, dict) else None,
) 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}")
@@ -6,6 +6,7 @@ from datetime import datetime
from fastmcp.server.dependencies import get_http_headers
from prowler_mcp_server import __version__
from prowler_mcp_server.lib.errors import CredentialError
from prowler_mcp_server.lib.logger import logger
@@ -64,7 +65,12 @@ class ProwlerAppAuth:
# Decode and parse JSON
decoded = base64.b64decode(base64_payload).decode("utf-8")
return json.loads(decoded)
payload = json.loads(decoded)
# A JWT payload is a JSON object. A list or a scalar decodes just as
# cleanly, so the type is checked here rather than left to blow up as
# an AttributeError on the first claim read.
return payload if isinstance(payload, dict) else None
except Exception as e:
logger.warning(f"Failed to parse JWT token: {e}")
return None
@@ -76,14 +82,16 @@ class ProwlerAppAuth:
authorization_header = headers.get("authorization", None)
if not authorization_header:
raise ValueError("No authorization header provided")
raise CredentialError("No Authorization header was sent")
# Extract token from Bearer header
if authorization_header.startswith("Bearer "):
token = authorization_header.replace("Bearer ", "")
else:
raise ValueError(
"Invalid authorization header format. Expected 'Bearer <token>'"
# Extract token from Bearer header. Authentication scheme names are
# case-insensitive (RFC 7235), and only the scheme prefix is removed:
# a token that happens to contain the word again keeps it.
scheme, _, credential = authorization_header.partition(" ")
token = credential.strip()
if scheme.lower() != "bearer" or not token:
raise CredentialError(
"The Authorization header is not in 'Bearer <token>' form"
)
# Check if it's an API key or JWT token
@@ -94,17 +102,29 @@ class ProwlerAppAuth:
# JWT token - validate and check expiration
payload = self._parse_jwt(token)
if not payload:
raise ValueError("Invalid JWT token format")
raise CredentialError("The token is not a readable JWT")
# Check if token is expired. `exp` is a numeric date in the
# spec, so a missing or non-numeric one makes the token
# unusable rather than merely stale -- comparing it would raise
# a TypeError and leave the failure masked as unclassified.
exp = payload.get("exp")
if isinstance(exp, bool) or not isinstance(exp, (int, float)):
raise CredentialError(
"The token carries no readable 'exp' expiration claim"
)
# Check if token is expired
now = int(datetime.now().timestamp())
exp = payload.get("exp", 0)
if exp <= now:
raise ValueError("Token has expired")
raise CredentialError("The token has expired")
return token
else:
raise ValueError(f"Invalid mode: {self.mode}")
# PROWLER_MCP_TRANSPORT_MODE holds something this server does not
# support. Nothing about a call caused it and nothing about a call
# can fix it, so it stays unclassified: masked for the model, logged
# for whoever runs the server.
raise RuntimeError(f"Invalid mode: {self.mode}")
async def get_valid_token(self) -> str:
"""Get a valid token (API key or JWT token)."""
@@ -2,6 +2,7 @@ import httpx
from pydantic import BaseModel, Field
from prowler_mcp_server import __version__
from prowler_mcp_server.lib.errors import parse_json_response
class SearchResult(BaseModel):
@@ -58,8 +59,7 @@ class ProwlerDocsSearchEngine:
)
def search(self, query: str, page_size: int = 5) -> list[SearchResult]:
"""
Search documentation using Mintlify API.
"""Search documentation using Mintlify API.
Args:
query: Search query string
@@ -69,82 +69,85 @@ class ProwlerDocsSearchEngine:
Returns:
list of search results
Raises:
httpx.HTTPError: If the search request failed, which is not the same
answer as no matches
UpstreamInvalidResponse: If the answer is not JSON, which is the
documentation site's fault and not the search term's
"""
try:
# Make request to Mintlify API
response = self.mintlify_client.post(
self.api_base_url,
json={"query": query, "filters": {}},
)
response.raise_for_status()
data = response.json()
# Make request to Mintlify API
response = self.mintlify_client.post(
self.api_base_url,
json={"query": query, "filters": {}},
)
response.raise_for_status()
# Not `response.json()`: the decode error it raises is a ValueError, which
# the shared classifier reads as a malformed argument and answers by
# telling the caller to fix a search term that was never the problem.
data = parse_json_response(response)
# Parse results
results = []
for match in data.get("results", [])[:page_size]:
metadata = match.get("metadata", {})
breadcrumbs = metadata.get("breadcrumbs", [])
doc_path = match.get("page", "")
# Parse results
results = []
for match in data.get("results", [])[:page_size]:
metadata = match.get("metadata", {})
breadcrumbs = metadata.get("breadcrumbs", [])
doc_path = match.get("page", "")
# 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
# 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
# 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}"
# 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,
section=section,
breadcrumbs=breadcrumbs,
url=url,
excerpt=match.get("content", ""),
score=match.get("score", 0.0),
)
results.append(
SearchResult(
path=doc_path,
title=title,
section=section,
breadcrumbs=breadcrumbs,
url=url,
excerpt=match.get("content", ""),
score=match.get("score", 0.0),
)
)
return results
except Exception as e:
# Return empty list on error
print(f"Search error: {e}")
return []
return results
def get_document(self, doc_path: str) -> str | None:
"""
Get full document content from Mintlify documentation.
"""Get full document content from Mintlify documentation.
Args:
doc_path: Path to the documentation file (e.g., "getting-started/installation")
Returns:
Full markdown content of the documentation, or None if not found
Full markdown content of the documentation, or None if there is no
page at that path
Raises:
httpx.HTTPError: If the fetch failed for any reason other than a 404
"""
try:
# Clean up the path
doc_path = doc_path.rstrip("/")
# Clean up the path
doc_path = doc_path.rstrip("/")
# Add .md extension if not present (Mintlify serves both .md and .mdx)
if not doc_path.endswith(".md"):
doc_path = f"{doc_path}.md"
# Add .md extension if not present (Mintlify serves both .md and .mdx)
if not doc_path.endswith(".md"):
doc_path = f"{doc_path}.md"
# Construct Mintlify URL
url = f"{self.docs_base_url}/{doc_path}"
# Construct Mintlify URL
url = f"{self.docs_base_url}/{doc_path}"
# Fetch the documentation page
response = self.docs_client.get(url)
response.raise_for_status()
return response.text
except Exception as e:
print(f"Error fetching document: {e}")
# Fetch the documentation page
response = self.docs_client.get(url)
if response.status_code == 404:
return None
response.raise_for_status()
return response.text
@@ -1,20 +1,24 @@
from typing import Any
from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
from pydantic import Field
from prowler_mcp_server.lib.types import NonBlankStr
from prowler_mcp_server.prowler_documentation.search_engine import (
ProwlerDocsSearchEngine,
)
# Initialize FastMCP server
docs_mcp_server = FastMCP("prowler-docs")
docs_mcp_server = FastMCP("prowler-docs", mask_error_details=True)
prowler_docs_search_engine = ProwlerDocsSearchEngine()
@docs_mcp_server.tool()
def search(
term: str = Field(description="The term to search for in the documentation"),
term: NonBlankStr = Field(
description="The term to search for in the documentation"
),
page_size: int = Field(
5,
description="Number of top results to return. It must be between 1 and 20.",
@@ -39,7 +43,7 @@ def search(
@docs_mcp_server.tool()
def get_document(
doc_path: str = Field(
doc_path: NonBlankStr = Field(
description="Path to the documentation file to retrieve. It is the same as the 'path' field of the search results. Use `prowler_docs_search` to find the path first."
),
) -> dict[str, str]:
@@ -53,6 +57,10 @@ def get_document(
"""
content: str | None = prowler_docs_search_engine.get_document(doc_path)
if content is None:
return {"error": f"Document '{doc_path}' not found."}
else:
return {"content": content}
# No `from`: this names the path asked for and the tool that produces a
# valid one, neither of which the shared classifier can know.
raise ToolError(
f"The Prowler documentation has no page at '{doc_path}'. Use "
"prowler_docs_search and pass the 'path' field of a result verbatim."
)
return {"content": content}
@@ -6,12 +6,19 @@ Provides access to Prowler Hub API for security checks and compliance frameworks
import httpx
from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
from pydantic import Field
from prowler_mcp_server import __version__
from prowler_mcp_server.lib.errors import (
UpstreamInvalidResponse,
parse_json_response,
)
from prowler_mcp_server.lib.types import NonBlankStr
from prowler_mcp_server.lib.urls import url_path
# Initialize FastMCP for Prowler Hub
hub_mcp_server = FastMCP("prowler-hub")
hub_mcp_server = FastMCP("prowler-hub", mask_error_details=True)
# API base URL
BASE_URL = "https://hub.prowler.com/api"
@@ -26,6 +33,19 @@ prowler_hub_client = httpx.Client(
},
)
# Sentences for the not-found cases. They are authored here, and raised as a
# ToolError without a `from` clause, because they name the resource the caller
# asked for and the next tool to reach for -- neither of which the shared
# classifier in lib/errors.py can know.
_CHECK_NOT_FOUND = (
"No check with the ID '{check_id}' exists in Prowler Hub. Use "
"prowler_hub_semantic_search_checks to find the right ID."
)
_COMPLIANCE_NOT_FOUND = (
"No compliance framework with the ID '{compliance_id}' exists in Prowler Hub. "
"Use prowler_hub_semantic_search_compliances to find the right ID."
)
# GitHub raw content base URL for Prowler checks
GITHUB_RAW_BASE = (
"https://raw.githubusercontent.com/prowler-cloud/prowler/refs/heads/master/"
@@ -42,6 +62,21 @@ github_raw_client = httpx.Client(
)
def _get_hub_endpoint(
*path_segments: str, params: dict[str, str] | None = None
) -> httpx.Response:
"""GET a Prowler Hub endpoint, named as one argument per path segment.
Args:
*path_segments: The endpoint path segments, in order.
params: Query parameters for the request.
Returns:
The response unread, so a caller can tell a 404 from a failed request.
"""
return prowler_hub_client.get(url_path(*path_segments), params=params)
def github_check_path(provider_id: str, check_id: str, suffix: str) -> str:
"""Build the GitHub raw URL for a given check artifact suffix using provider
and check_id.
@@ -52,7 +87,83 @@ def github_check_path(provider_id: str, check_id: str, suffix: str) -> str:
service_id = check_id.split("_", 1)[0]
except IndexError:
service_id = check_id
return f"{GITHUB_RAW_BASE}/{provider_id}/services/{service_id}/{check_id}/{check_id}{suffix}"
path = url_path(provider_id, "services", service_id, check_id, check_id)
return f"{GITHUB_RAW_BASE}{path}{suffix}"
def _hub_provider_for_check(check_id: str) -> str | None:
"""Ask Prowler Hub which provider it lists a check under.
Args:
check_id: Check ID the caller asked for
Returns:
The provider the Hub lists the check under, or None when the Hub knows
no such check.
Raises:
httpx.HTTPError: The Hub could not be reached.
UpstreamInvalidResponse: The Hub answered with a body that is not JSON.
ValueError: The Hub answered with something that names no provider.
"""
response = _get_hub_endpoint("check", check_id)
if response.status_code == 404:
return None
response.raise_for_status()
check = parse_json_response(response)
# An empty body is how the Hub reports an unknown ID on some routes, so it
# is read the same way get_check_details reads it: no such check.
if not isinstance(check, dict) or not check:
return None
provider = check.get("provider")
if isinstance(provider, str) and provider.strip():
return provider
# A check the Hub returned without a provider tells us nothing about the
# provider the caller asked for, so it counts as unanswered rather than as
# a check that does not exist.
raise ValueError(f"Prowler Hub listed check '{check_id}' without a provider")
def _explain_missing_check_file(
provider_id: str,
check_id: str,
*,
when_check_belongs_here: str,
when_unverified: str,
) -> str:
"""Explain a 404 from GitHub for one of a check's source files.
GitHub answers 404 to three different mistakes, an ID that exists nowhere,
an ID that exists under a different provider, and an ID that exists right
here whose file is simply absent, and cannot tell them apart. Prowler Hub
can, so it is asked before anything is claimed about the ID.
Args:
provider_id: Provider the caller asked for
check_id: Check the caller asked for
when_check_belongs_here: Message for the case where the Hub confirms the
check does belong to this provider
when_unverified: Message for the case where the Hub could not be asked
Returns:
The sentence to fail the tool with
"""
try:
hub_provider = _hub_provider_for_check(check_id)
except (httpx.HTTPError, UpstreamInvalidResponse, ValueError):
return when_unverified
if hub_provider is None:
return _CHECK_NOT_FOUND.format(check_id=check_id)
if hub_provider != provider_id:
return (
f"Provider '{provider_id}' has no check '{check_id}'. Prowler Hub lists "
f"that check under provider '{hub_provider}', so retry with "
f"provider_id='{hub_provider}'."
)
return when_check_belongs_here
# Security Check Tools
@@ -122,34 +233,27 @@ async def list_checks(
if compliances:
params["compliances"] = ",".join(compliances)
try:
response = prowler_hub_client.get("/check", params=params)
response.raise_for_status()
checks = response.json()
response = _get_hub_endpoint("check", params=params)
response.raise_for_status()
checks = parse_json_response(response)
# Return checks as a lightweight list
checks_list = []
for check in checks:
check_data = {
"id": check["id"],
"provider": check["provider"],
"title": check["title"],
"severity": check["severity"],
}
checks_list.append(check_data)
return {"count": len(checks), "checks": checks_list}
except httpx.HTTPStatusError as e:
return {
"error": f"HTTP error {e.response.status_code}: {e.response.text}",
# Return checks as a lightweight list
checks_list = []
for check in checks:
check_data = {
"id": check["id"],
"provider": check["provider"],
"title": check["title"],
"severity": check["severity"],
}
except Exception as e:
return {"error": str(e)}
checks_list.append(check_data)
return {"count": len(checks), "checks": checks_list}
@hub_mcp_server.tool()
async def semantic_search_checks(
term: str = Field(
term: NonBlankStr = Field(
description="Search term. Examples: 'public access', 'encryption', 'MFA', 'logging'.",
),
) -> dict:
@@ -181,34 +285,27 @@ async def semantic_search_checks(
2. Use `prowler_hub_list_checks` with filters for more targeted browsing
3. Use `prowler_hub_get_check_details` to get complete information for a specific check
"""
try:
response = prowler_hub_client.get("/check/search", params={"term": term})
response.raise_for_status()
checks = response.json()
response = _get_hub_endpoint("check", "search", params={"term": term})
response.raise_for_status()
checks = parse_json_response(response)
# Return checks as a lightweight list
checks_list = []
for check in checks:
check_data = {
"id": check["id"],
"provider": check["provider"],
"title": check["title"],
"severity": check["severity"],
}
checks_list.append(check_data)
return {"count": len(checks), "checks": checks_list}
except httpx.HTTPStatusError as e:
return {
"error": f"HTTP error {e.response.status_code}: {e.response.text}",
# Return checks as a lightweight list
checks_list = []
for check in checks:
check_data = {
"id": check["id"],
"provider": check["provider"],
"title": check["title"],
"severity": check["severity"],
}
except Exception as e:
return {"error": str(e)}
checks_list.append(check_data)
return {"count": len(checks), "checks": checks_list}
@hub_mcp_server.tool()
async def get_check_details(
check_id: str = Field(
check_id: NonBlankStr = Field(
description="The check ID to retrieve details for. Example: 's3_bucket_level_public_access_block'"
),
) -> dict:
@@ -273,83 +370,83 @@ async def get_check_details(
2. Use this tool with the check 'id' to get complete information including remediation guidance
"""
try:
response = prowler_hub_client.get(f"/check/{check_id}")
response = _get_hub_endpoint("check", check_id)
response.raise_for_status()
check = response.json()
if not check:
return {"error": f"Check '{check_id}' not found"}
# Build response with only non-empty fields to save tokens
result = {}
# Core fields
result["id"] = check["id"]
if check.get("title"):
result["title"] = check["title"]
if check.get("description"):
result["description"] = check["description"]
if check.get("provider"):
result["provider"] = check["provider"]
if check.get("service"):
result["service"] = check["service"]
if check.get("severity"):
result["severity"] = check["severity"]
if check.get("risk"):
result["risk"] = check["risk"]
if check.get("resource_type"):
result["resource_type"] = check["resource_type"]
# List fields
if check.get("reference"):
result["reference"] = check["reference"]
if check.get("additional_urls"):
result["additional_urls"] = check["additional_urls"]
if check.get("services_required"):
result["services_required"] = check["services_required"]
if check.get("categories"):
result["categories"] = check["categories"]
if check.get("compliances"):
result["compliances"] = check["compliances"]
# Other fields
if check.get("notes"):
result["notes"] = check["notes"]
if check.get("related_url"):
result["related_url"] = check["related_url"]
if check.get("fixer") is not None:
result["fixer"] = check["fixer"]
# Remediation - filter out empty nested values
remediation = check.get("remediation", {})
if remediation:
filtered_remediation = {}
for key, value in remediation.items():
if value and isinstance(value, dict):
# Filter out empty values within nested dict
filtered_value = {k: v for k, v in value.items() if v}
if filtered_value:
filtered_remediation[key] = filtered_value
elif value:
filtered_remediation[key] = value
if filtered_remediation:
result["remediation"] = filtered_remediation
return result
except httpx.HTTPStatusError as e:
return {
"error": f"HTTP error {e.response.status_code}: {e.response.text}",
}
except Exception as e:
return {"error": str(e)}
if e.response.status_code == 404:
# No `from`: this names the check, which the shared classifier cannot.
raise ToolError(_CHECK_NOT_FOUND.format(check_id=check_id))
raise
check = parse_json_response(response)
if not check:
raise ToolError(_CHECK_NOT_FOUND.format(check_id=check_id))
# Build response with only non-empty fields to save tokens
result = {}
# Core fields
result["id"] = check["id"]
if check.get("title"):
result["title"] = check["title"]
if check.get("description"):
result["description"] = check["description"]
if check.get("provider"):
result["provider"] = check["provider"]
if check.get("service"):
result["service"] = check["service"]
if check.get("severity"):
result["severity"] = check["severity"]
if check.get("risk"):
result["risk"] = check["risk"]
if check.get("resource_type"):
result["resource_type"] = check["resource_type"]
# List fields
if check.get("reference"):
result["reference"] = check["reference"]
if check.get("additional_urls"):
result["additional_urls"] = check["additional_urls"]
if check.get("services_required"):
result["services_required"] = check["services_required"]
if check.get("categories"):
result["categories"] = check["categories"]
if check.get("compliances"):
result["compliances"] = check["compliances"]
# Other fields
if check.get("notes"):
result["notes"] = check["notes"]
if check.get("related_url"):
result["related_url"] = check["related_url"]
if check.get("fixer") is not None:
result["fixer"] = check["fixer"]
# Remediation - filter out empty nested values
remediation = check.get("remediation", {})
if remediation:
filtered_remediation = {}
for key, value in remediation.items():
if value and isinstance(value, dict):
# Filter out empty values within nested dict
filtered_value = {k: v for k, v in value.items() if v}
if filtered_value:
filtered_remediation[key] = filtered_value
elif value:
filtered_remediation[key] = value
if filtered_remediation:
result["remediation"] = filtered_remediation
return result
@hub_mcp_server.tool()
async def get_check_code(
provider_id: str = Field(
provider_id: NonBlankStr = Field(
description="Prowler Provider ID. Example: 'aws', 'azure', 'gcp', 'kubernetes'. Use `prowler_hub_list_providers` to get available provider IDs.",
),
check_id: str = Field(
check_id: NonBlankStr = Field(
description="The check ID. Example: 's3_bucket_public_access'. Get IDs from `prowler_hub_list_checks` or `prowler_hub_search_checks`.",
),
) -> dict:
@@ -363,46 +460,54 @@ async def get_check_code(
"content": "Python source code of the check implementation"
}
"""
if provider_id and check_id:
url = github_check_path(provider_id, check_id, ".py")
try:
resp = github_raw_client.get(url)
resp.raise_for_status()
return {
"content": resp.text,
}
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
return {
"error": f"Check {check_id} not found in Prowler",
}
else:
return {
"error": f"HTTP error {e.response.status_code}: {e.response.text}",
}
except Exception as e:
return {
"error": str(e),
}
else:
return {
"error": "Provider ID and check ID are required",
}
url = github_check_path(provider_id, check_id, ".py")
try:
resp = github_raw_client.get(url)
resp.raise_for_status()
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
# No `from`: this names the check and the provider that does have
# it, neither of which the shared classifier in lib/errors.py knows.
raise ToolError(
_explain_missing_check_file(
provider_id,
check_id,
when_check_belongs_here=(
f"Prowler Hub lists check '{check_id}' under provider "
f"'{provider_id}', but prowler-cloud/prowler has no source file "
"for it on the master branch. The check may have been renamed or "
"moved since the Hub last indexed it."
),
when_unverified=(
f"Provider '{provider_id}' has no check '{check_id}' in "
"prowler-cloud/prowler, and Prowler Hub could not be asked which "
"provider does. Either the ID is wrong or the check belongs to "
"another provider, prowler_hub_get_check_details reports the "
"provider a check belongs to."
),
)
)
raise
return {
"content": resp.text,
}
@hub_mcp_server.tool()
async def get_check_fixer(
provider_id: str = Field(
provider_id: NonBlankStr = Field(
description="Prowler Provider ID. Example: 'aws', 'azure', 'gcp', 'kubernetes'. Use `prowler_hub_list_providers` to get available provider IDs.",
),
check_id: str = Field(
check_id: NonBlankStr = Field(
description="The check ID. Example: 's3_bucket_public_access'. Get IDs from `prowler_hub_list_checks` or `prowler_hub_search_checks`.",
),
) -> dict:
"""Fetch the auto-remediation (fixer) code for a Prowler security check.
IMPORTANT: Not all checks have fixers. A "fixer not found" response means the check
doesn't have auto-remediation code - this is normal for many checks.
IMPORTANT: Not all checks have fixers. A check with no auto-remediation code fails
with a message saying so - this is normal for many checks and not a problem to
report or retry.
Fixer code provides automated remediation that can fix security issues detected by checks.
Use this to understand how to programmatically remediate findings.
@@ -411,40 +516,37 @@ async def get_check_fixer(
{
"content": "Python source code of the auto-remediation implementation"
}
Or if no fixer exists:
{
"error": "Fixer not found for check {check_id}"
}
"""
if provider_id and check_id:
url = github_check_path(provider_id, check_id, "_fixer.py")
try:
resp = github_raw_client.get(url)
if resp.status_code == 404:
return {
"error": f"Fixer not found for check {check_id}",
}
resp.raise_for_status()
return {
"content": resp.text,
}
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
return {
"error": f"Check {check_id} not found in Prowler",
}
else:
return {
"error": f"HTTP error {e.response.status_code}: {e.response.text}",
}
except Exception as e:
return {
"error": str(e),
}
else:
return {
"error": "Provider ID and check ID are required",
}
url = github_check_path(provider_id, check_id, "_fixer.py")
try:
resp = github_raw_client.get(url)
resp.raise_for_status()
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
# "No fixer" is only one of the reasons the file is missing, and the
# others are the caller's to fix, so they are told apart first.
raise ToolError(
_explain_missing_check_file(
provider_id,
check_id,
when_check_belongs_here=(
f"Check {check_id} has no auto-remediation code. Many checks do "
"not, and that is normal."
),
when_unverified=(
f"Provider '{provider_id}' has no auto-remediation code for "
f"check '{check_id}'. Many checks have none, and that is normal, "
f"but Prowler Hub could not be asked whether the check belongs "
f"to '{provider_id}' at all. Confirm it with "
"prowler_hub_get_check_details if you expected a fixer."
),
)
)
raise
return {
"content": resp.text,
}
# Compliance Framework Tools
@@ -491,33 +593,26 @@ async def list_compliances(
if provider:
params["provider"] = ",".join(provider)
try:
response = prowler_hub_client.get("/compliance", params=params)
response.raise_for_status()
compliances = response.json()
response = _get_hub_endpoint("compliance", params=params)
response.raise_for_status()
compliances = parse_json_response(response)
# Return compliances as a lightweight list
compliances_list = []
for compliance in compliances:
compliance_data = {
"id": compliance["id"],
"name": compliance["name"],
"provider": compliance["provider"],
}
compliances_list.append(compliance_data)
return {"count": len(compliances), "compliances": compliances_list}
except httpx.HTTPStatusError as e:
return {
"error": f"HTTP error {e.response.status_code}: {e.response.text}",
# Return compliances as a lightweight list
compliances_list = []
for compliance in compliances:
compliance_data = {
"id": compliance["id"],
"name": compliance["name"],
"provider": compliance["provider"],
}
except Exception as e:
return {"error": str(e)}
compliances_list.append(compliance_data)
return {"count": len(compliances), "compliances": compliances_list}
@hub_mcp_server.tool()
async def semantic_search_compliances(
term: str = Field(
term: NonBlankStr = Field(
description="Search term. Examples: 'CIS', 'HIPAA', 'PCI', 'GDPR', 'SOC2', 'NIST'.",
),
) -> dict:
@@ -542,33 +637,26 @@ async def semantic_search_compliances(
]
}
"""
try:
response = prowler_hub_client.get("/compliance/search", params={"term": term})
response.raise_for_status()
compliances = response.json()
response = _get_hub_endpoint("compliance", "search", params={"term": term})
response.raise_for_status()
compliances = parse_json_response(response)
# Return compliances as a lightweight list
compliances_list = []
for compliance in compliances:
compliance_data = {
"id": compliance["id"],
"name": compliance["name"],
"provider": compliance["provider"],
}
compliances_list.append(compliance_data)
return {"count": len(compliances), "compliances": compliances_list}
except httpx.HTTPStatusError as e:
return {
"error": f"HTTP error {e.response.status_code}: {e.response.text}",
# Return compliances as a lightweight list
compliances_list = []
for compliance in compliances:
compliance_data = {
"id": compliance["id"],
"name": compliance["name"],
"provider": compliance["provider"],
}
except Exception as e:
return {"error": str(e)}
compliances_list.append(compliance_data)
return {"count": len(compliances), "compliances": compliances_list}
@hub_mcp_server.tool()
async def get_compliance_details(
compliance_id: str = Field(
compliance_id: NonBlankStr = Field(
description="The compliance framework ID to retrieve details for. Example: 'cis_4.0_aws'. Use `prowler_hub_list_compliances` or `prowler_hub_semantic_search_compliances` to find available compliance IDs.",
),
) -> dict:
@@ -598,63 +686,60 @@ async def get_compliance_details(
}
"""
try:
response = prowler_hub_client.get(f"/compliance/{compliance_id}")
response = _get_hub_endpoint("compliance", compliance_id)
response.raise_for_status()
compliance = response.json()
if not compliance:
return {"error": f"Compliance '{compliance_id}' not found"}
# Build response with only non-empty fields to save tokens
result = {}
# Core fields
result["id"] = compliance["id"]
if compliance.get("name"):
result["name"] = compliance["name"]
if compliance.get("framework"):
result["framework"] = compliance["framework"]
if compliance.get("provider"):
result["provider"] = compliance["provider"]
if compliance.get("version"):
result["version"] = compliance["version"]
if compliance.get("description"):
result["description"] = compliance["description"]
# Numeric fields
if compliance.get("total_checks"):
result["total_checks"] = compliance["total_checks"]
if compliance.get("total_requirements"):
result["total_requirements"] = compliance["total_requirements"]
# Requirements - filter out empty nested values
requirements = compliance.get("requirements", [])
if requirements:
filtered_requirements = []
for req in requirements:
filtered_req = {}
if req.get("id"):
filtered_req["id"] = req["id"]
if req.get("name"):
filtered_req["name"] = req["name"]
if req.get("description"):
filtered_req["description"] = req["description"]
if req.get("checks"):
filtered_req["checks"] = req["checks"]
if filtered_req:
filtered_requirements.append(filtered_req)
if filtered_requirements:
result["requirements"] = filtered_requirements
return result
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
return {"error": f"Compliance '{compliance_id}' not found"}
return {
"error": f"HTTP error {e.response.status_code}: {e.response.text}",
}
except Exception as e:
return {"error": str(e)}
raise ToolError(_COMPLIANCE_NOT_FOUND.format(compliance_id=compliance_id))
raise
compliance = parse_json_response(response)
if not compliance:
raise ToolError(_COMPLIANCE_NOT_FOUND.format(compliance_id=compliance_id))
# Build response with only non-empty fields to save tokens
result = {}
# Core fields
result["id"] = compliance["id"]
if compliance.get("name"):
result["name"] = compliance["name"]
if compliance.get("framework"):
result["framework"] = compliance["framework"]
if compliance.get("provider"):
result["provider"] = compliance["provider"]
if compliance.get("version"):
result["version"] = compliance["version"]
if compliance.get("description"):
result["description"] = compliance["description"]
# Numeric fields
if compliance.get("total_checks"):
result["total_checks"] = compliance["total_checks"]
if compliance.get("total_requirements"):
result["total_requirements"] = compliance["total_requirements"]
# Requirements - filter out empty nested values
requirements = compliance.get("requirements", [])
if requirements:
filtered_requirements = []
for req in requirements:
filtered_req = {}
if req.get("id"):
filtered_req["id"] = req["id"]
if req.get("name"):
filtered_req["name"] = req["name"]
if req.get("description"):
filtered_req["description"] = req["description"]
if req.get("checks"):
filtered_req["checks"] = req["checks"]
if filtered_req:
filtered_requirements.append(filtered_req)
if filtered_requirements:
result["requirements"] = filtered_requirements
return result
# Provider Tools
@@ -683,32 +768,25 @@ async def list_providers() -> dict:
]
}
"""
try:
response = prowler_hub_client.get("/providers")
response.raise_for_status()
providers = response.json()
response = _get_hub_endpoint("providers")
response.raise_for_status()
providers = parse_json_response(response)
providers_list = []
for provider in providers:
providers_list.append(
{
"id": provider["id"],
"name": provider.get("name", ""),
}
)
providers_list = []
for provider in providers:
providers_list.append(
{
"id": provider["id"],
"name": provider.get("name", ""),
}
)
return {"count": len(providers), "providers": providers_list}
except httpx.HTTPStatusError as e:
return {
"error": f"HTTP error {e.response.status_code}: {e.response.text}",
}
except Exception as e:
return {"error": str(e)}
return {"count": len(providers), "providers": providers_list}
@hub_mcp_server.tool()
async def get_provider_services(
provider_id: str = Field(
provider_id: NonBlankStr = Field(
description="The provider ID to get services for. Example: 'aws', 'azure', 'gcp', 'kubernetes'. Use `prowler_hub_list_providers` to get available provider IDs.",
),
) -> dict:
@@ -727,24 +805,20 @@ async def get_provider_services(
"services": ["s3", "ec2", "iam", "rds", "lambda", ...]
}
"""
try:
response = prowler_hub_client.get("/providers")
response.raise_for_status()
providers = response.json()
response = _get_hub_endpoint("providers")
response.raise_for_status()
providers = parse_json_response(response)
for provider in providers:
if provider["id"] == provider_id:
return {
"provider_id": provider["id"],
"provider_name": provider.get("name", ""),
"count": len(provider.get("services", [])),
"services": provider.get("services", []),
}
for provider in providers:
if provider["id"] == provider_id:
return {
"provider_id": provider["id"],
"provider_name": provider.get("name", ""),
"count": len(provider.get("services", [])),
"services": provider.get("services", []),
}
return {"error": f"Provider '{provider_id}' not found"}
except httpx.HTTPStatusError as e:
return {
"error": f"HTTP error {e.response.status_code}: {e.response.text}",
}
except Exception as e:
return {"error": str(e)}
known = ", ".join(sorted(str(provider["id"]) for provider in providers))
raise ToolError(
f"Prowler has no provider with the ID '{provider_id}'. Available: {known}."
)
+111 -1
View File
@@ -7,11 +7,18 @@ reaches a model is text this server produced.
import json
import httpx
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.lib.errors import (
CredentialError,
InvalidArgument,
UpstreamInvalidResponse,
_describe_failure,
parse_json_response,
)
from prowler_mcp_server.prowler_app.utils.api_client import (
ProwlerAPIError,
ProwlerAPIInvalidResponse,
@@ -22,6 +29,42 @@ from tests.helpers.jsonapi import jsonapi_error
LATEST = "/api/v1/findings/latest"
# --------------------------------------------------------------- json bodies
def _answer(
body: str, *, url: str = "https://hub.prowler.com/api/check"
) -> httpx.Response:
"""An answer as a client would hand it back, request attached."""
return httpx.Response(200, text=body, request=httpx.Request("GET", url))
def test_a_json_body_is_returned_as_it_is():
"""The helper only classifies the failure; the success path is untouched."""
assert parse_json_response(_answer('{"id": "s3_bucket_public_access"}')) == {
"id": "s3_bucket_public_access"
}
def test_a_body_that_is_not_json_names_the_host_that_answered():
"""Which upstream is misbehaving is the one useful fact here, and the shared
helper is reached from every sub-server that reads an upstream directly."""
with pytest.raises(UpstreamInvalidResponse) as raised:
parse_json_response(_answer("<html><body>502 Bad Gateway</body></html>"))
assert raised.value.host == "hub.prowler.com"
assert "Bad Gateway" not in str(raised.value)
def test_a_body_that_is_not_json_is_not_a_valueerror():
"""`JSONDecodeError` is a ValueError, and callers tell an upstream fault from
a bad argument by type alone."""
with pytest.raises(UpstreamInvalidResponse) as raised:
parse_json_response(_answer("not json"))
assert not isinstance(raised.value, ValueError)
# ------------------------------------------------------------ classification
@@ -90,6 +133,29 @@ def test_an_unreadable_api_answer_is_never_called_safe_to_repeat():
assert "check the current state" in message
def test_an_unreadable_upstream_answer_is_not_blamed_on_the_arguments():
"""A `JSONDecodeError` from an upstream and one from an argument are the same
exception and opposite instructions."""
message = _describe_failure(
UpstreamInvalidResponse("200 body is not JSON", host="hub.prowler.com")
)
assert "hub.prowler.com" in message
assert "could not read as JSON" in message
assert "changing them will not help" in message
def test_an_unreadable_upstream_answer_never_quotes_the_body():
"""The body is someone else's text, so only the host and the status leave here."""
message = _describe_failure(
UpstreamInvalidResponse(
"502 body is not JSON", host="raw.githubusercontent.com"
)
)
assert "body is not JSON" not 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(
@@ -99,6 +165,19 @@ def test_an_argument_this_server_rejected_is_repeated_verbatim():
assert message == "page_size must be between 1 and 1000."
def test_a_credential_caught_here_is_answered_like_the_401_it_would_have_got():
"""It is not an argument problem, and saying so stops a pointless retry."""
message = _describe_failure(CredentialError("the token has expired"))
assert "the token has expired" in message
assert "changing the arguments will not help" in message
def test_a_transport_this_server_cannot_serve_is_left_masked():
"""No call caused a bad PROWLER_MCP_TRANSPORT_MODE and no call can fix it."""
assert _describe_failure(RuntimeError("Invalid mode: websocket")) is None
def test_a_pydantic_rejection_names_the_field_without_echoing_the_value():
"""Pydantic quotes `input_value` back, and these tools take credentials."""
@@ -195,3 +274,34 @@ async def test_an_unreadable_api_answer_does_not_reach_the_agent_as_a_bad_argume
assert result.isError is True
assert "gateway timeout" not in result.content[0].text
assert "argument" not in result.content[0].text
async def test_a_tool_specific_message_survives_masking(
mcp_root_server, mock_api_client, mock_router
):
"""A `ToolError` raised without a `from` clause is the final word."""
mock_router.add("GET", "/api/v1/integrations/i1", json={"data": None})
async with Client(mcp_root_server) as client:
result = await client.call_tool_mcp(
"prowler_get_integration", {"integration_id": "i1"}
)
assert result.isError is True
assert "prowler_list_integrations" in result.content[0].text
async def test_a_hub_tool_failure_says_which_host_refused_it(
mcp_root_server, hub_router
):
"""Hub failures arrive as raw httpx errors: host and status relayed, body not."""
hub_router.add(
"GET", "/api/check", status=503, text="<html>upstream nginx 10.1.2.3</html>"
)
async with Client(mcp_root_server) as client:
result = await client.call_tool_mcp("prowler_hub_list_checks", {})
assert result.isError is True
assert "hub.prowler.com" in result.content[0].text
assert "10.1.2.3" not in result.content[0].text
+125
View File
@@ -0,0 +1,125 @@
"""Tests for the argument types every tool shares.
The bug these pin: "required" alone does not stop a blank identifier. A model
that has no scan or query id to hand sends ``""`` rather than omitting the
argument, and an unguarded empty string travels into a URL path or a request
body -- where it comes back as a 404, or as an API rejection ("This field may
not be blank") that names no argument and leaves the model with nothing to fix.
"""
import pytest
from fastmcp import Client
from tests.helpers.jsonapi import jsonapi_collection, jsonapi_resource
SCAN_ID = "019ac0d6-90d5-73e9-9acf-c22e256f1bac"
QUERIES = f"/api/v1/attack-paths-scans/{SCAN_ID}/queries"
@pytest.mark.parametrize("query_id", ["", " "], ids=["empty", "whitespace-only"])
async def test_a_blank_identifier_is_rejected_before_any_request_goes_out(
mcp_root_server, mock_api_client, mock_router, query_id
):
"""The reported failure: a blank `query_id` reached Prowler as a 400.
The message has to name the argument. Prowler's own answer to the blank value
("This field may not be blank") does not say which field, so the model had no
way to tell `scan_id` from `query_id` from the reply.
"""
async with Client(mcp_root_server) as client:
result = await client.call_tool_mcp(
"prowler_run_attack_paths_query",
{"scan_id": SCAN_ID, "query_id": query_id},
)
assert result.isError is True
assert "query_id" in result.content[0].text
assert mock_router.requests == []
async def test_an_identifier_keeps_its_surrounding_whitespace_out_of_the_url(
mcp_root_server, mock_api_client, mock_router
):
"""A padded id is the same id, and a raw one would build a URL-escaped path."""
mock_router.add("GET", QUERIES, json=jsonapi_collection([]))
async with Client(mcp_root_server) as client:
result = await client.call_tool_mcp(
"prowler_list_attack_paths_queries", {"scan_id": f" {SCAN_ID} "}
)
assert result.isError is False
assert mock_router.paths() == [f"GET {QUERIES}"]
async def test_a_blank_optional_value_is_rejected_rather_than_written(
mcp_root_server, mock_api_client, mock_router
):
"""An omitted optional means "leave it alone"; a blank one would blank the field.
The API refuses it, so the only difference an unguarded blank makes is a
round trip and an error that names nothing.
"""
async with Client(mcp_root_server) as client:
result = await client.call_tool_mcp(
"prowler_update_mute_rule", {"rule_id": SCAN_ID, "name": ""}
)
assert result.isError is True
assert "name" in result.content[0].text
assert mock_router.requests == []
async def test_an_omitted_optional_string_is_still_omitted(
mcp_root_server, mock_api_client, mock_router
):
"""`NonBlankStr | None` must not turn "not provided" into a rejection."""
mock_router.add(
"GET",
f"/api/v1/mute-rules/{SCAN_ID}",
json={
"data": jsonapi_resource(
"mute-rules",
SCAN_ID,
{
"name": "unchanged",
"reason": "already reviewed",
"enabled": True,
"finding_uids": [],
},
)
},
)
async with Client(mcp_root_server) as client:
result = await client.call_tool_mcp(
"prowler_update_mute_rule", {"rule_id": SCAN_ID}
)
assert result.isError is False
async def test_every_required_string_argument_is_guarded_against_a_blank(
mcp_root_server,
):
"""A guard only one tool carries is one the next tool will be written without.
Declared as `minLength` rather than checked inside each tool, so a client sees
the constraint in the schema before it calls.
"""
async with Client(mcp_root_server) as client:
tools = await client.list_tools()
unguarded = [
f"{tool.name}.{name}"
for tool in tools
for name, schema in tool.inputSchema.get("properties", {}).items()
# Plain required strings only. A union such as `dict | str` takes a JSON
# string, where a blank is a parse failure the classifier already
# explains, and a blank filter is a filter that matches everything.
if schema.get("type") == "string"
and name in tool.inputSchema.get("required", [])
and schema.get("minLength") != 1
]
assert unguarded == []
+59
View File
@@ -0,0 +1,59 @@
"""Tests for the shared URL path builder.
The bug these pin: an identifier interpolated into a path was resolved away by
httpx per RFC 3986, so "../" reached an endpoint no tool meant to call.
"""
import pytest
from prowler_mcp_server.lib.urls import path_segment, url_path
@pytest.mark.parametrize(
("value", "expected"),
[
("s3_bucket_public_access", "s3_bucket_public_access"),
("cis_4.0_aws", "cis_4.0_aws"),
("../../evil", "..%2F..%2Fevil"),
("....//evil", "....%2F%2Fevil"),
("%2e%2e%2f", "%252e%252e%252f"),
("..;/", "..%3B%2F"),
("s3/../evil", "s3%2F..%2Fevil"),
("evil?fields=all", "evil%3Ffields%3Dall"),
("evil#frag", "evil%23frag"),
("evil\\wrong", "evil%5Cwrong"),
("two words", "two%20words"),
],
ids=[
"plain",
"dots-in-a-name",
"traversal",
"stripped-filter-bypass",
"already-encoded",
"path-parameter",
"mid-path",
"query",
"fragment",
"backslash",
"space",
],
)
def test_a_segment_survives_as_a_name_and_never_as_syntax(value, expected):
"""A real ID passes through untouched; URL syntax comes back as characters."""
assert path_segment(value) == expected
@pytest.mark.parametrize("value", [".", ".."], ids=["here", "up-one"])
def test_a_segment_of_nothing_but_dots_is_escaped_rather_than_left_to_resolve(value):
"""`quote` keeps a dot, so a segment of only dots would still resolve away."""
assert path_segment(value) == value.replace(".", "%2E")
def test_a_path_is_the_segments_it_was_given_and_no_others():
"""One argument per segment, so no call site has to encode anything."""
assert url_path("users", "../../evil", "roles") == "/users/..%2F..%2Fevil/roles"
def test_a_single_segment_path_keeps_its_leading_slash():
"""Every caller joins this onto a base URL that ends without a slash."""
assert url_path("providers") == "/providers"
@@ -0,0 +1,225 @@
"""Tests for the Attack Paths tools.
An Attack Paths scan is a separate resource from a regular scan, with IDs of its
own, and Prowler only creates one for an AWS provider. So the 404 these tools get
is almost always a regular scan ID passed where an Attack Paths one belongs --
and Prowler's own reason for it, a bare "Not found.", names neither the resource
it looked in nor the tool that returns the right ID.
"""
import pytest
from fastmcp import Client
from tests.helpers.jsonapi import jsonapi_collection, jsonapi_resource
QUERIES = "/api/v1/attack-paths-scans/s1/queries"
async def test_an_id_that_is_not_an_attack_paths_scan_says_which_tool_returns_one(
mcp_root_server, mock_api_client, mock_router
):
"""Relaying "Not found." sends an agent to re-check an ID it cannot fix.
The reply has to name the confusion it stands for: regular scan IDs do not
resolve here, and only AWS providers have an Attack Paths scan at all.
"""
mock_router.add(
"GET",
QUERIES,
status=404,
json={"errors": [{"status": "404", "detail": "Not found."}]},
)
async with Client(mcp_root_server) as client:
with pytest.raises(Exception, match="different resource from regular scans"):
await client.call_tool(
"prowler_list_attack_paths_queries", {"scan_id": "s1"}
)
async def test_the_answer_names_the_tool_that_returns_a_usable_id(
mcp_root_server, mock_api_client, mock_router
):
"""An explanation with no next step leaves the agent guessing IDs."""
mock_router.add(
"GET",
QUERIES,
status=404,
json={"errors": [{"status": "404", "detail": "Not found."}]},
)
async with Client(mcp_root_server) as client:
with pytest.raises(Exception, match="prowler_list_attack_paths_scans"):
await client.call_tool(
"prowler_list_attack_paths_queries", {"scan_id": "s1"}
)
async def test_a_failure_that_is_not_a_404_keeps_the_shared_message(
mcp_root_server, mock_api_client, mock_router
):
"""Only the 404 means a bad ID. A 403 is a permission the ID cannot fix."""
mock_router.add(
"GET",
QUERIES,
status=403,
json={"errors": [{"status": "403", "detail": "Denied."}]},
)
async with Client(mcp_root_server) as client:
with pytest.raises(Exception, match="prowler_get_current_user"):
await client.call_tool(
"prowler_list_attack_paths_queries", {"scan_id": "s1"}
)
async def test_queries_come_back_as_a_list(
mcp_root_server, mock_api_client, mock_router
):
"""The success path is unchanged."""
mock_router.add(
"GET",
QUERIES,
json=jsonapi_collection(
[
jsonapi_resource(
"attack-paths-queries",
"aws-ec2-instances-internet-exposed",
{
"name": "Internet exposed EC2",
"description": "Find internet-exposed EC2 instances",
"provider": "aws",
"parameters": [],
},
)
]
),
)
async with Client(mcp_root_server) as client:
result = await client.call_tool(
"prowler_list_attack_paths_queries", {"scan_id": "s1"}
)
assert result.data[0]["id"] == "aws-ec2-instances-internet-exposed"
# ------------------------------------------------------------- running a query
RUN = "/api/v1/attack-paths-scans/s1/queries/run"
SCHEMA = "/api/v1/attack-paths-scans/s1/schema"
EMPTY_RESULT = {
"data": {
"type": "attack-paths-query-results",
"id": "s1",
"attributes": {"nodes": [], "relationships": []},
}
}
def _run_args(query_id: str = "aws-ec2-instances-internet-exposed") -> dict[str, str]:
"""Arguments for a query run against the mocked scan."""
return {"scan_id": "s1", "query_id": query_id}
async def test_a_query_that_matched_nothing_is_an_answer_not_a_failure(
mcp_root_server, mock_api_client, mock_router
):
"""Prowler answers a query that matched nothing with 404 and the result body.
Raising on the status called a clean account a failed call and sent the agent
off to re-check arguments that were right.
"""
mock_router.add("POST", RUN, status=404, json=EMPTY_RESULT)
async with Client(mcp_root_server) as client:
result = await client.call_tool_mcp(
"prowler_run_attack_paths_query", _run_args()
)
assert result.isError is False
assert "matched nothing" in result.structuredContent["message"]
async def test_an_empty_result_does_not_come_back_as_an_empty_object(
mcp_root_server, mock_api_client, mock_router
):
"""The serializer drops empty lists, so `{}` is all that would be left."""
mock_router.add("POST", RUN, status=404, json=EMPTY_RESULT)
async with Client(mcp_root_server) as client:
result = await client.call_tool("prowler_run_attack_paths_query", _run_args())
assert result.data != {}
async def test_a_null_graph_is_read_as_an_empty_one(
mcp_root_server, mock_api_client, mock_router
):
"""Prowler can spell the empty graph as `null` rather than as empty lists.
Reading `null` as if it were a graph crashed the parse, turning the same
"nothing matched" answer into an error the agent could not act on.
"""
mock_router.add("POST", RUN, status=404, json={"data": {"attributes": None}})
async with Client(mcp_root_server) as client:
result = await client.call_tool_mcp(
"prowler_run_attack_paths_query", _run_args()
)
assert result.isError is False
assert "matched nothing" in result.structuredContent["message"]
async def test_a_run_against_an_unknown_scan_still_names_the_confusion(
mcp_root_server, mock_api_client, mock_router
):
"""A 404 with no result body is the ID being wrong, not an empty answer."""
mock_router.add(
"POST",
RUN,
status=404,
json={"errors": [{"status": "404", "detail": "Not found."}]},
)
async with Client(mcp_root_server) as client:
with pytest.raises(Exception, match="different resource from regular scans"):
await client.call_tool("prowler_run_attack_paths_query", _run_args())
async def test_a_scan_whose_graph_records_no_schema_says_so(
mcp_root_server, mock_api_client, mock_router
):
"""This 404 is about the graph, not the ID, so it must not blame the ID."""
mock_router.add(
"GET",
SCHEMA,
status=404,
json={"detail": "No cartography schema metadata found for this provider"},
)
async with Client(mcp_root_server) as client:
with pytest.raises(Exception, match="no Cartography schema recorded"):
await client.call_tool(
"prowler_get_attack_paths_cartography_schema", {"scan_id": "s1"}
)
async def test_a_schema_request_for_an_unknown_scan_names_the_confusion(
mcp_root_server, mock_api_client, mock_router
):
"""The other 404 here is the ID, and Prowler writes a JSON:API error for it."""
mock_router.add(
"GET",
SCHEMA,
status=404,
json={"errors": [{"status": "404", "detail": "Not found."}]},
)
async with Client(mcp_root_server) as client:
with pytest.raises(Exception, match="different resource from regular scans"):
await client.call_tool(
"prowler_get_attack_paths_cartography_schema", {"scan_id": "s1"}
)
@@ -0,0 +1,102 @@
"""Tests for the compliance tools.
Both compliance tools answer for exactly one scan. ``scan_id`` names it
directly; ``provider_id`` names it indirectly, as "the latest completed scan of
this provider". Passing both is not a refinement of either -- the scan the
caller named may belong to a different provider entirely -- so it is rejected
rather than resolved by preferring one, which would answer confidently for a
provider nobody asked about.
Tools are driven through an in-memory MCP client so FastMCP resolves the
pydantic ``Field`` defaults and a raised failure arrives the way a client sees
it.
"""
import pytest
from fastmcp import Client
from tests.helpers.jsonapi import jsonapi_collection, jsonapi_resource
SCANS = "/api/v1/scans"
OVERVIEWS = "/api/v1/compliance-overviews"
REQUIREMENTS = f"{OVERVIEWS}/requirements"
TOOLS = [
"prowler_get_compliance_overview",
"prowler_get_compliance_framework_state_details",
]
def arguments(tool: str, **overrides) -> dict:
"""Build the arguments for either tool, which differ only in compliance_id."""
payload = dict(overrides)
if tool.endswith("framework_state_details"):
payload["compliance_id"] = "cis_1.5_aws"
return payload
@pytest.mark.parametrize("tool", TOOLS)
async def test_neither_a_scan_nor_a_provider_is_refused_before_any_request(
mcp_root_server, mock_api_client, mock_router, tool
):
"""There is no scan to answer for, and no way to guess one."""
async with Client(mcp_root_server) as client:
with pytest.raises(Exception, match="must be provided"):
await client.call_tool(tool, arguments(tool))
assert mock_router.paths() == []
@pytest.mark.parametrize("tool", TOOLS)
async def test_a_scan_and_a_provider_together_are_refused_rather_than_reconciled(
mcp_root_server, mock_api_client, mock_router, tool
):
"""Silently keeping the scan would answer for whichever provider owns it.
That report names a scan the caller did ask for, so nothing about it looks
wrong -- while the provider they also named went unread.
"""
async with Client(mcp_root_server) as client:
with pytest.raises(Exception, match="not both"):
await client.call_tool(
tool, arguments(tool, scan_id="s1", provider_id="p1")
)
assert mock_router.paths() == []
@pytest.mark.parametrize("tool", TOOLS)
async def test_a_provider_on_its_own_resolves_to_its_latest_completed_scan(
mcp_root_server, mock_api_client, mock_router, tool
):
"""The indirection is the point of accepting a provider at all."""
mock_router.add(
"GET",
SCANS,
json=jsonapi_collection(
[jsonapi_resource("scans", "s9", {"state": "completed"})]
),
)
mock_router.add("GET", OVERVIEWS, json=jsonapi_collection([]))
mock_router.add("GET", REQUIREMENTS, json=jsonapi_collection([]))
# Each tool reads the compliance state from its own endpoint; both filter it
# by the scan that had to be resolved first.
read = OVERVIEWS if tool.endswith("overview") else REQUIREMENTS
async with Client(mcp_root_server) as client:
await client.call_tool(tool, arguments(tool, provider_id="p1"))
assert mock_router.query_params("GET", SCANS)["filter[provider]"] == "p1"
assert mock_router.query_params("GET", read)["filter[scan_id]"] == "s9"
@pytest.mark.parametrize("tool", TOOLS)
async def test_a_provider_with_no_completed_scan_is_named_as_the_bad_argument(
mcp_root_server, mock_api_client, mock_router, tool
):
"""Nothing has been scanned yet, so there is no compliance state to report."""
mock_router.add("GET", SCANS, json=jsonapi_collection([]))
async with Client(mcp_root_server) as client:
with pytest.raises(Exception, match="No completed scans found for provider p1"):
await client.call_tool(tool, arguments(tool, provider_id="p1"))

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