Compare commits

...
Author SHA1 Message Date
Hugo P.Brito 39e903ade3 Merge remote-tracking branch 'origin/master' into feat/python-3-14-support 2026-09-02 09:52:58 +01:00
Hugo P.Brito 477f3ebda1 fix(sdk): ensure Python 3.14 binary portability
- Select compatible NumPy and pandas releases on Python 3.14
- Validate binary wheels across supported operating systems
- Preserve existing dependency behavior on older Python versions
2026-09-02 08:32:05 +01: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
Hugo P.Brito 3ca189a52a test(sdk): exercise lazy Okta loading under frozen time 2026-09-01 16:24:53 +01: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
Hugo P.Brito 4014fcb6c1 feat(sdk): add Python 3.14 support
- Extend SDK metadata and CI matrices
- Refresh dependencies and compatibility fixtures
- Add regression coverage and changelog fragment
2026-09-01 15:20:18 +01: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
312 changed files with 24156 additions and 2942 deletions
+1
View File
@@ -30,6 +30,7 @@ jobs:
- '3.11'
- '3.12'
- '3.13'
- '3.14'
steps:
- name: Harden Runner
+91
View File
@@ -102,6 +102,7 @@ jobs:
- '3.11'
- '3.12'
- '3.13'
- '3.14'
package:
- 'prowler'
include:
@@ -119,6 +120,8 @@ jobs:
github.com:443
api.github.com:443
release-assets.githubusercontent.com:443
results-receiver.actions.githubusercontent.com:443
*.blob.core.windows.net:443
pypi.org:443
files.pythonhosted.org:443
@@ -145,6 +148,15 @@ jobs:
- name: Check metadata with the release workflow's twine
run: uvx --from "twine==${TWINE_VERSION}" twine check --strict dist/*
- name: Upload Prowler wheel for portability checks
if: matrix.python-version == '3.14' && matrix.package == 'prowler'
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: prowler-python-3.14-wheel
path: dist/prowler-*.whl
if-no-files-found: error
retention-days: 1
- name: Install the wheel with pip into a clean virtualenv
# Plain pip, --isolated, from outside the repo: consumers never see [tool.uv]
# override-dependencies or constraint-dependencies, so neither does this step.
@@ -162,6 +174,85 @@ jobs:
# from the package. grep fails the step if the summary line never appears.
"${RUNNER_TEMP}/consumer/bin/prowler" aws --list-checks | grep 'available checks'
python-3-14-binary-wheel-portability:
needs: install-from-wheel
runs-on: ${{ matrix.runner }}
timeout-minutes: 10
permissions:
actions: read
contents: read
strategy:
fail-fast: false
matrix:
runner:
- ubuntu-latest
- macos-15-intel
- macos-15
- windows-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
# Block mode is unavailable on GitHub-hosted macOS and Windows runners.
egress-policy: audit
- name: Set up Python 3.14
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: '3.14'
- name: Download built Prowler wheel
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: prowler-python-3.14-wheel
path: dist
- name: Extract NumPy and pandas requirements from wheel metadata
shell: python
run: |
from email.parser import BytesParser
from pathlib import Path
import re
from zipfile import ZipFile
wheel = next(Path("dist").glob("prowler-*.whl"))
with ZipFile(wheel) as archive:
metadata_name = next(
name
for name in archive.namelist()
if name.endswith(".dist-info/METADATA")
)
metadata = BytesParser().parsebytes(archive.read(metadata_name))
requirements = [
requirement
for requirement in metadata.get_all("Requires-Dist", [])
if re.match(r"^(numpy|pandas)(?:\W|$)", requirement, re.IGNORECASE)
]
requirement_names = {
re.match(r"^(numpy|pandas)", requirement, re.IGNORECASE).group(1).lower()
for requirement in requirements
}
if requirement_names != {"numpy", "pandas"}:
raise SystemExit(
f"Expected NumPy and pandas requirements, found: {requirements}"
)
Path("binary-wheel-requirements.txt").write_text(
"\n".join(requirements) + "\n", encoding="utf-8"
)
print("Wheel requirements:", *requirements, sep="\n ")
# huaweicloudsdkcore hard-pins pymongo 4.15.1, which has no cp314 wheel;
# its optional C extensions fall back to a pure-Python build without a compiler.
- name: Require binary distributions for marked NumPy and pandas versions
run: >-
python -m pip download
--only-binary=:all:
--dest binary-wheels
--requirement binary-wheel-requirements.txt
pinned-releases-not-yanked:
needs: changes
if: needs.changes.outputs.run == 'true'
+1
View File
@@ -30,6 +30,7 @@ jobs:
- '3.11'
- '3.12'
- '3.13'
- '3.14'
steps:
- name: Harden Runner
+3 -3
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 |
@@ -285,7 +285,7 @@ Some pre-commit hooks require tools installed on your system:
## Prowler CLI
### Pip package
Prowler CLI is available as a project in [PyPI](https://pypi.org/project/prowler-cloud/). Consequently, it can be installed using pip with Python >=3.10, <3.13:
Prowler CLI is available as a project in [PyPI](https://pypi.org/project/prowler-cloud/). Consequently, it can be installed using pip with Python >=3.10, <3.15:
```console
pip install prowler
@@ -317,7 +317,7 @@ The container images are available here:
### From GitHub
Python >=3.10, <3.13 is required with [uv](https://docs.astral.sh/uv/):
Python >=3.10, <3.15 is required with [uv](https://docs.astral.sh/uv/):
``` console
git clone https://github.com/prowler-cloud/prowler
+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
@@ -0,0 +1 @@
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
@@ -0,0 +1 @@
`POST /api/v1/mute-rules` now updates only each affected provider's latest completed scan and future scans, preventing historical reaggregation from flooding Celery queues
+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)}
+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)}
+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
+71 -2
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,9 +4928,12 @@ dependencies = [
{ name = "stackit-iaas" },
{ name = "stackit-objectstorage" },
{ name = "stackit-resourcemanager" },
{ name = "stackit-ske" },
{ name = "tabulate" },
{ name = "truststore" },
{ name = "tzlocal" },
{ name = "uuid6" },
{ name = "zstandard" },
]
[[package]]
@@ -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"
+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.
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.

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

@@ -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 | `[]` |
@@ -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.42.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:
@@ -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:
@@ -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
@@ -0,0 +1 @@
`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
@@ -0,0 +1 @@
`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
@@ -55,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.
@@ -171,6 +224,17 @@ 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.
+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)
@@ -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,6 +1,7 @@
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
@@ -9,7 +10,7 @@ from prowler_mcp_server.prowler_documentation.search_engine import (
)
# Initialize FastMCP server
docs_mcp_server = FastMCP("prowler-docs")
docs_mcp_server = FastMCP("prowler-docs", mask_error_details=True)
prowler_docs_search_engine = ProwlerDocsSearchEngine()
@@ -56,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,13 +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"
@@ -27,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/"
@@ -43,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.
@@ -53,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
@@ -123,29 +233,22 @@ 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()
@@ -182,29 +285,22 @@ 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()
@@ -274,75 +370,75 @@ 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()
@@ -364,31 +460,38 @@ 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()
@@ -402,8 +505,9 @@ async def get_check_fixer(
) -> 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.
@@ -412,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
@@ -492,28 +593,21 @@ 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()
@@ -543,28 +637,21 @@ 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()
@@ -599,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
@@ -684,27 +768,20 @@ 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()
@@ -728,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}."
)
+78
View File
@@ -7,6 +7,7 @@ reaches a model is text this server produced.
import json
import httpx
import pytest
from fastmcp import Client
from pydantic import BaseModel, ValidationError
@@ -14,7 +15,9 @@ from pydantic import BaseModel, ValidationError
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,
@@ -26,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
@@ -94,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(
@@ -227,3 +289,19 @@ async def test_a_tool_specific_message_survives_masking(
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
+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"
@@ -1,7 +1,9 @@
"""Tests for the Prowler documentation search tool.
"""Tests for the Prowler documentation tools.
Mintlify moved the docs search to a new endpoint that answers with page
sections, so a result is a part of a page and has to read as one.
sections, so a result is a part of a page and has to read as one. And a failed
request must not reach an agent as "the documentation has nothing on this",
which is an answer it would act on, confidently and wrongly.
"""
import json
@@ -9,6 +11,7 @@ import json
from fastmcp import Client
SEARCH = "/api/search/prowler"
DOC = "/getting-started/installation.md"
def search_match(
@@ -107,3 +110,65 @@ async def test_page_size_caps_a_response_the_api_did_not_size(
)
assert len(result.data) == 2
async def test_a_search_that_failed_is_not_reported_as_no_matches(
mcp_root_server, docs_router
):
"""An empty list is an answer. A failed request is not, and must not look like one."""
docs_router.add("POST", SEARCH, status=500, text="upstream error")
async with Client(mcp_root_server) as client:
result = await client.call_tool_mcp("prowler_docs_search", {"term": "install"})
assert result.isError is True
assert result.structuredContent is None
async def test_a_missing_page_fails_and_names_the_tool_that_finds_a_valid_path(
mcp_root_server, docs_router
):
"""A 404 answers the question, and still reaches the agent as an error."""
docs_router.add("GET", DOC, status=404, text="Not Found")
async with Client(mcp_root_server) as client:
result = await client.call_tool_mcp(
"prowler_docs_get_document", {"doc_path": "getting-started/installation"}
)
assert result.isError is True
assert "prowler_docs_search" in result.content[0].text
async def test_a_fetch_that_failed_is_not_reported_as_a_missing_page(
mcp_root_server, docs_router
):
"""Only a 404 answers the question; every other status left it unanswered."""
docs_router.add("GET", DOC, status=503, text="upstream error")
async with Client(mcp_root_server) as client:
result = await client.call_tool_mcp(
"prowler_docs_get_document", {"doc_path": "getting-started/installation"}
)
assert result.isError is True
assert "no page at" not in result.content[0].text
async def test_an_unreadable_body_is_not_reported_as_a_bad_search_term(
mcp_root_server, docs_router
):
"""An edge serving HTML is the site's fault; the caller has no term to fix."""
docs_router.add("POST", SEARCH, status=200, text="<html>edge error page</html>")
async with Client(mcp_root_server) as client:
result = await client.call_tool_mcp("prowler_docs_search", {"term": "install"})
assert result.isError is True
message = result.content[0].text
# Named as the upstream at fault, and explicitly not the caller's arguments,
# which is the story the shared ValueError branch would otherwise tell.
assert "leaves.mintlify.com" in message
assert "changing them will not help" in message
# The body it choked on is upstream text, which this server never relays.
assert "edge error page" not in message
+335
View File
@@ -0,0 +1,335 @@
"""Tests for the Prowler Hub tools.
The Hub sub-server uses its own httpx clients, so its failures never pass through
the Prowler API client. They still have to arrive as tool errors rather than as a
result object, which the protocol, the client and the model all read as a success.
"""
import pytest
from fastmcp import Client
CHECKS = "/api/check"
PROVIDERS = "/api/providers"
COMPLIANCE = "/api/compliance"
CHECK_ID = "s3_bucket_public_access"
HUB_CHECK = f"{CHECKS}/{CHECK_ID}"
def github_check(provider: str, suffix: str = ".py") -> str:
"""The raw.githubusercontent path a check artifact is fetched from."""
return (
f"/prowler-cloud/prowler/refs/heads/master/prowler/providers/{provider}"
f"/services/s3/{CHECK_ID}/{CHECK_ID}{suffix}"
)
GITHUB_CHECK = github_check("aws")
GITHUB_FIXER = github_check("aws", "_fixer.py")
async def test_listing_checks_returns_the_lightweight_shape(
mcp_root_server, hub_router
):
"""The happy path, so the failure tests below are not the only coverage."""
hub_router.add(
"GET",
CHECKS,
json=[
{
"id": "s3_bucket_public_access",
"provider": "aws",
"title": "S3 buckets should block public access",
"severity": "high",
}
],
)
async with Client(mcp_root_server) as client:
result = await client.call_tool("prowler_hub_list_checks", {})
assert result.data["count"] == 1
assert result.data["checks"][0]["id"] == "s3_bucket_public_access"
async def test_an_unknown_check_fails_and_names_the_tool_that_finds_one(
mcp_root_server, hub_router
):
"""A 404 is the caller's mistake, and the fix is a different tool."""
hub_router.add("GET", f"{CHECKS}/nope", status=404)
async with Client(mcp_root_server) as client:
result = await client.call_tool_mcp(
"prowler_hub_get_check_details", {"check_id": "nope"}
)
assert result.isError is True
assert "prowler_hub_semantic_search_checks" in result.content[0].text
async def test_an_unknown_provider_fails_and_lists_the_real_ones(
mcp_root_server, hub_router
):
"""The valid values are already in hand, so withholding them wastes a call."""
hub_router.add(
"GET",
PROVIDERS,
json=[{"id": "aws", "name": "Amazon Web Services", "services": ["s3"]}],
)
async with Client(mcp_root_server) as client:
result = await client.call_tool_mcp(
"prowler_hub_get_provider_services", {"provider_id": "alicloud"}
)
assert result.isError is True
assert "aws" in result.content[0].text
async def test_a_check_without_a_fixer_says_that_is_normal(mcp_root_server, hub_router):
"""Most checks have no auto-remediation, so this must not read as a defect."""
hub_router.add("GET", GITHUB_FIXER, status=404)
hub_router.add("GET", HUB_CHECK, json={"id": CHECK_ID, "provider": "aws"})
async with Client(mcp_root_server) as client:
result = await client.call_tool_mcp(
"prowler_hub_get_check_fixer",
{"provider_id": "aws", "check_id": CHECK_ID},
)
assert result.isError is True
message = result.content[0].text
assert "normal" in message
# The Hub confirmed the check is an aws check, so nothing is left to verify.
assert "prowler_hub_get_check_details" not in message
async def test_a_check_from_another_provider_names_the_provider_that_has_it(
mcp_root_server, hub_router
):
"""The ID exists; only the provider is wrong. Saying otherwise sends the
caller off to search for an ID they already hold."""
hub_router.add("GET", github_check("azure"), status=404)
hub_router.add("GET", HUB_CHECK, json={"id": CHECK_ID, "provider": "aws"})
async with Client(mcp_root_server) as client:
result = await client.call_tool_mcp(
"prowler_hub_get_check_code",
{"provider_id": "azure", "check_id": CHECK_ID},
)
assert result.isError is True
message = result.content[0].text
assert "provider_id='aws'" in message
assert "No check with the ID" not in message
async def test_a_fixer_from_another_provider_is_not_reported_as_a_missing_fixer(
mcp_root_server, hub_router
):
"""'That check has no fixer' about a check the provider never had is a lie
the caller cannot act on."""
hub_router.add("GET", github_check("azure", "_fixer.py"), status=404)
hub_router.add("GET", HUB_CHECK, json={"id": CHECK_ID, "provider": "aws"})
async with Client(mcp_root_server) as client:
result = await client.call_tool_mcp(
"prowler_hub_get_check_fixer",
{"provider_id": "azure", "check_id": CHECK_ID},
)
assert result.isError is True
message = result.content[0].text
assert "provider_id='aws'" in message
assert "auto-remediation" not in message
async def test_a_check_id_that_exists_nowhere_is_still_reported_as_unknown(
mcp_root_server, hub_router
):
"""The Hub not having the ID either is the one case that does justify the
original message."""
hub_router.add("GET", github_check("azure"), status=404)
hub_router.add("GET", HUB_CHECK, status=404)
async with Client(mcp_root_server) as client:
result = await client.call_tool_mcp(
"prowler_hub_get_check_code",
{"provider_id": "azure", "check_id": CHECK_ID},
)
assert result.isError is True
assert "No check with the ID" in result.content[0].text
async def test_an_unreachable_hub_leaves_the_cause_open_rather_than_guessing(
mcp_root_server, hub_router
):
"""With nothing to distinguish the causes, naming one of them is a guess."""
hub_router.add("GET", github_check("azure"), status=404)
hub_router.add("GET", HUB_CHECK, status=503)
async with Client(mcp_root_server) as client:
result = await client.call_tool_mcp(
"prowler_hub_get_check_code",
{"provider_id": "azure", "check_id": CHECK_ID},
)
assert result.isError is True
message = result.content[0].text
assert "No check with the ID" not in message
assert "prowler_hub_get_check_details" in message
async def test_a_check_code_hit_never_asks_the_hub(mcp_root_server, hub_router):
"""The Hub lookup exists to explain a 404. On the happy path it is dead
weight -- a second round trip for every call that already succeeded."""
hub_router.add("GET", GITHUB_CHECK, text="class s3_bucket_public_access: ...")
async with Client(mcp_root_server) as client:
result = await client.call_tool(
"prowler_hub_get_check_code",
{"provider_id": "aws", "check_id": CHECK_ID},
)
assert "class s3_bucket_public_access" in result.data["content"]
assert hub_router.paths() == [f"GET {GITHUB_CHECK}"]
async def test_a_hub_outage_is_reported_rather_than_returned_as_an_empty_list(
mcp_root_server, hub_router
):
"""An empty result set and a failed request are different answers."""
hub_router.add("GET", CHECKS, status=500, json={"detail": "boom"})
async with Client(mcp_root_server) as client:
result = await client.call_tool_mcp("prowler_hub_list_checks", {})
assert result.isError is True
assert result.structuredContent is None
@pytest.mark.parametrize(
("check_id", "routed_as", "sent_as"),
[
("../../evil", f"{CHECKS}/../../evil", b"/api/check/..%2F..%2Fevil"),
("s3/../evil", f"{CHECKS}/s3/../evil", b"/api/check/s3%2F..%2Fevil"),
("..", f"{CHECKS}/..", b"/api/check/%2E%2E"),
(
"s3_x?fields=all",
f"{CHECKS}/s3_x?fields=all",
b"/api/check/s3_x%3Ffields%3Dall",
),
("s3_x#frag", f"{CHECKS}/s3_x#frag", b"/api/check/s3_x%23frag"),
],
ids=["traversal", "mid-path", "dot-segment", "query", "fragment"],
)
async def test_an_id_names_a_check_and_cannot_name_an_endpoint(
mcp_root_server, hub_router, check_id, routed_as, sent_as
):
"""The bug this pins: httpx resolved "../.." away and the request left
/api/check for another endpoint of the Hub."""
hub_router.add("GET", routed_as, status=404)
async with Client(mcp_root_server) as client:
result = await client.call_tool_mcp(
"prowler_hub_get_check_details", {"check_id": check_id}
)
assert hub_router.requests[0].url.raw_path == sent_as
assert result.isError is True
assert "No check with the ID" in result.content[0].text
async def test_a_compliance_id_cannot_name_an_endpoint_either(
mcp_root_server, hub_router
):
"""Every Hub path is built by the same helper, so this holds without its own
guard."""
hub_router.add("GET", f"{COMPLIANCE}/../../evil", status=404)
async with Client(mcp_root_server) as client:
result = await client.call_tool_mcp(
"prowler_hub_get_compliance_details", {"compliance_id": "../../evil"}
)
assert hub_router.requests[0].url.raw_path == b"/api/compliance/..%2F..%2Fevil"
assert result.isError is True
assert "No compliance framework with the ID" in result.content[0].text
async def test_a_check_source_url_confines_the_provider_and_the_check_alike(
mcp_root_server, hub_router
):
"""Both halves of the GitHub raw URL come from the caller, so both are
confined."""
hub_router.add("GET", github_check("../../../../evil"), status=404)
hub_router.add("GET", HUB_CHECK, json={"id": CHECK_ID, "provider": "aws"})
async with Client(mcp_root_server) as client:
result = await client.call_tool_mcp(
"prowler_hub_get_check_code",
{"provider_id": "../../../../evil", "check_id": CHECK_ID},
)
assert (
hub_router.requests[0].url.raw_path
== github_check("..%2F..%2F..%2F..%2Fevil").encode()
)
assert result.isError is True
async def test_a_hub_body_that_is_not_json_is_not_blamed_on_the_arguments(
mcp_root_server, hub_router
):
"""An edge answering 200 with an HTML page decodes to the same
`JSONDecodeError` a malformed argument does, and the two mean opposite
things: nothing in this call can be corrected."""
hub_router.add("GET", CHECKS, text="<html><body>502 Bad Gateway</body></html>")
async with Client(mcp_root_server) as client:
result = await client.call_tool_mcp("prowler_hub_list_checks", {})
assert result.isError is True
message = result.content[0].text
assert "hub.prowler.com" in message
assert "could not read as JSON" in message
assert "Bad Gateway" not in message
assert "Send it as a real object" not in message
async def test_an_unreadable_hub_answer_does_not_become_an_unknown_check(
mcp_root_server, hub_router
):
"""The 404 branch is the only one that may claim the ID does not exist. A
body that could not be read says nothing about the ID."""
hub_router.add("GET", HUB_CHECK, text="<html>not json</html>")
async with Client(mcp_root_server) as client:
result = await client.call_tool_mcp(
"prowler_hub_get_check_details", {"check_id": CHECK_ID}
)
assert result.isError is True
message = result.content[0].text
assert "could not read as JSON" in message
assert "No check with the ID" not in message
async def test_an_unreadable_hub_answer_leaves_a_missing_check_file_unexplained(
mcp_root_server, hub_router
):
"""The Hub is asked which provider owns the check. A body it could not read
is no more of an answer than an outage, so it hedges the same way."""
hub_router.add("GET", github_check("azure"), status=404)
hub_router.add("GET", HUB_CHECK, text="<html>not json</html>")
async with Client(mcp_root_server) as client:
result = await client.call_tool_mcp(
"prowler_hub_get_check_code",
{"provider_id": "azure", "check_id": CHECK_ID},
)
assert result.isError is True
message = result.content[0].text
assert "No check with the ID" not in message
assert "prowler_hub_get_check_details" in message
@@ -0,0 +1 @@
`PROWLER_AWS_PARTITION` environment variable to select the AWS partition used for STS credential validation and scan bootstrap, with a clear error when the account belongs to a different partition
@@ -0,0 +1 @@
CIS Google Workspace Foundations Benchmark v1.4.0 compliance framework
@@ -0,0 +1 @@
`cloudwatch_log_group_agentcore_data_protection_policy_enabled` check for AWS provider, verifying that Bedrock AgentCore log groups mask sensitive data with a CloudWatch Logs data protection policy
@@ -0,0 +1 @@
CloudWatch log metric filter checks no longer crash with `AttributeError` when the account has a metric filter whose log group was not retrieved
@@ -0,0 +1 @@
`ecr_registry_enhanced_scanning_enabled` check for AWS provider, verifying that the ECR registry scan type is enhanced (Amazon Inspector, covering programming language packages and continuous rescanning) instead of basic, and reporting MANUAL when the registry scanning configuration cannot be read
@@ -0,0 +1 @@
`ecr_registry_scan_images_on_push_enabled` no longer passes a registry whose scanning rules are all `MANUAL`, nor describes a `CONTINUOUS_SCAN` registry as scanning on push; each rule's `scanFrequency` is now read instead of inferred from a rule's presence
@@ -0,0 +1 @@
`eks_cluster_vpc_cni_network_policy_enforced` check for AWS provider, flagging EKS clusters whose Amazon VPC CNI managed add-on does not enable Kubernetes network policy enforcement, and reporting MANUAL where the EKS API cannot show the setting
@@ -0,0 +1 @@
`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, or that were dropped because the root organizational unit could not be resolved; a domain-wide failure is still reported as such, with the override noted
@@ -0,0 +1 @@
`rules_*_alert_configured` checks no longer pass a rule whose delivery to the alert center is disabled, the setting behind the benchmark's "Ensure that Alerts is set to On"
@@ -0,0 +1 @@
Google Workspace checks mapped to CIS evaluate the 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
@@ -0,0 +1 @@
`security_login_challenges_configured` and `security_2sv_enforced` unmapped from CIS Google Workspace 4.1.4.1 and CISA SCuBA 0.6 `GWS.COMMONCONTROLS.1.1`, whose Post-SSO verification and phishing-resistant MFA requirements neither check can prove
@@ -0,0 +1 @@
`security_password_policy_strong` no longer fails a domain that never touched the password strength setting: Google enforces strong passwords by default, so an unset value is the secure default and not a missing configuration
@@ -0,0 +1 @@
`guardduty_ai_protection_enabled` check for AWS provider, flagging GuardDuty detectors without AI Protection, which analyzes AWS 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`
@@ -0,0 +1 @@
`guardduty_runtime_monitoring_enabled` check for AWS provider, flagging GuardDuty 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
@@ -0,0 +1 @@
`guardduty_eks_runtime_monitoring_enabled` no longer reports `FAIL` for detectors that use unified Runtime Monitoring; the GuardDuty service now reads the `RUNTIME_MONITORING` feature, which is mutually exclusive with `EKS_RUNTIME_MONITORING` and already covers Amazon EKS
@@ -0,0 +1 @@
`iam_policy_passrole_to_bedrock_agentcore_restricted` check for AWS provider, flagging 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 can run agent code under any role in the account
@@ -0,0 +1 @@
`iam_policy_no_agentcore_workload_access_token_wildcard` check for AWS provider, flagging customer-managed IAM policies that allow `bedrock-agentcore:GetWorkloadAccessToken`, `GetWorkloadAccessTokenForJWT` or `GetWorkloadAccessTokenForUserId` on resources reaching workload identities other than the caller's own, which AWS documents as the only binding on the unverified user ID the token is issued for
@@ -0,0 +1 @@
`iam_role_service_trust_restricts_source_to_account` check for AWS provider, flagging 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
@@ -0,0 +1 @@
`PROWLER_IMAGE_PROVIDER_ALLOWED_PRIVATE_NETWORKS` environment variable so the image provider can reach container registries on allowlisted private networks, keeping every other non-public address blocked
@@ -0,0 +1 @@
Basic authentication fallback in the image provider when a registry rejects the negotiated bearer token, so registries like Harbor that guard catalog listing behind Basic can be enumerated
@@ -0,0 +1 @@
Registry catalog listing when the server answers with a Bearer challenge after negotiating Basic (or anonymous) authentication, switching to a bearer token obtained from the challenge instead of failing
@@ -0,0 +1 @@
`--registry-insecure` now propagates to Trivy via `TRIVY_INSECURE`, so images in registries with self-signed certificates can be pulled and scanned, not just enumerated
@@ -0,0 +1 @@
Registry scans in the Image provider now skip non-image OCI artifacts (Helm charts, cosign signatures, SBOM attestations) and no longer abort the whole scan when Trivy fails on a single discovered image; registry enumeration also runs in parallel instead of one request at a time
@@ -0,0 +1 @@
Checks no longer report `FAIL` when the scanning identity lacks permissions, an API is not enabled or a feature is not licensed; they now emit a single `MANUAL` finding naming what is required, across 28 M365, Azure, AWS and GCP checks
+1
View File
@@ -0,0 +1 @@
Support for Python 3.14
@@ -0,0 +1 @@
`sagemaker_notebook_instance_without_direct_internet_access_configured` check logic to read the `DirectInternetAccess` setting instead of `RootAccess`, failing a notebook instance with direct internet access enabled even when root access is disabled
@@ -241,6 +241,7 @@
"iam_inline_policy_no_administrative_privileges",
"iam_policy_allows_privilege_escalation",
"iam_inline_policy_allows_privilege_escalation",
"iam_policy_passrole_to_bedrock_agentcore_restricted",
"iam_role_administratoraccess_policy",
"iam_user_administrator_access_policy",
"iam_group_administrator_access_policy",
@@ -269,6 +270,7 @@
"iam_user_no_setup_initial_access_key",
"iam_user_two_active_access_key",
"iam_user_console_access_unused",
"iam_policy_no_agentcore_workload_access_token_wildcard",
"bedrock_api_key_no_long_term_credentials"
]
},
@@ -305,6 +307,7 @@
],
"Checks": [
"iam_role_cross_service_confused_deputy_prevention",
"iam_role_service_trust_restricts_source_to_account",
"iam_role_access_not_stale_to_bedrock",
"iam_user_access_not_stale_to_bedrock",
"iam_role_cross_account_readonlyaccess_policy"
@@ -484,7 +487,8 @@
"awslambda_function_no_secrets_in_variables",
"ecs_task_definitions_no_environment_secrets",
"ec2_instance_secrets_user_data",
"cloudwatch_log_group_no_secrets_in_logs"
"cloudwatch_log_group_no_secrets_in_logs",
"cloudwatch_log_group_agentcore_data_protection_policy_enabled"
]
},
{
@@ -878,9 +882,11 @@
"guardduty_s3_protection_enabled",
"guardduty_eks_audit_log_enabled",
"guardduty_eks_runtime_monitoring_enabled",
"guardduty_runtime_monitoring_enabled",
"guardduty_lambda_protection_enabled",
"guardduty_rds_protection_enabled",
"guardduty_ec2_malware_protection_enabled"
"guardduty_ec2_malware_protection_enabled",
"guardduty_ai_protection_enabled"
],
"ConfigRequirements": [
{
@@ -1128,10 +1134,12 @@
"eks_cluster_not_publicly_accessible",
"eks_cluster_private_nodes_enabled",
"eks_cluster_network_policy_enabled",
"eks_cluster_vpc_cni_network_policy_enforced",
"eks_cluster_uses_a_supported_version",
"eks_control_plane_logging_all_types_enabled",
"eks_cluster_kms_cmk_encryption_in_secrets_enabled",
"eks_cluster_deletion_protection_enabled"
"eks_cluster_deletion_protection_enabled",
"ecr_registry_enhanced_scanning_enabled"
]
},
{
@@ -1154,7 +1162,8 @@
"ecs_task_definitions_logging_enabled",
"ecs_task_definitions_no_environment_secrets",
"ecs_task_definitions_host_namespace_not_shared",
"ecs_cluster_container_insights_enabled"
"ecs_cluster_container_insights_enabled",
"ecr_registry_enhanced_scanning_enabled"
]
},
{
@@ -1498,9 +1498,7 @@
{
"Id": "4.1.4.1",
"Description": "Ensure login challenges are enforced",
"Checks": [
"security_login_challenges_configured"
],
"Checks": [],
"Attributes": [
{
"Section": "4 Security",
File diff suppressed because it is too large Load Diff
@@ -9,7 +9,6 @@
"Id": "GWS.COMMONCONTROLS.1.1",
"Description": "Phishing-resistant MFA SHALL be required for all users",
"Checks": [
"security_2sv_enforced",
"security_2sv_hardware_keys_admins"
],
"Attributes": [
+8
View File
@@ -119,6 +119,14 @@ aws:
# aws.cloudwatch_log_group_retention_policy_specific_days_enabled --> by default is 365 days
log_group_retention_days: 365
# aws.cloudwatch_log_group_agentcore_data_protection_policy_enabled
# Log group name prefixes that hold Bedrock AgentCore agent telemetry. This list REPLACES
# the defaults rather than adding to them, so keep both entries below when adding your own
# or the log groups AgentCore creates itself stop being assessed.
agentcore_log_group_name_prefixes:
- "/aws/bedrock-agentcore/"
- "/aws/vendedlogs/bedrock-agentcore/"
# AWS CloudFormation Configuration
# cloudformation_stack_cdktoolkit_bootstrap_version --> by default is 21
recommended_cdk_bootstrap_version: 21
+9
View File
@@ -267,6 +267,15 @@ class AWSProviderConfig(ProviderConfigBase):
f"values accepted by the AWS API: {list(_CLOUDWATCH_RETENTION_DAYS)}."
),
)
agentcore_log_group_name_prefixes: Optional[list[str]] = Field(
default=None,
description=(
"Log group name prefixes that identify Bedrock AgentCore agent "
"telemetry. Set this when AgentCore log delivery is pointed at log "
"groups outside the service defaults; the value replaces the "
"defaults, so list them alongside any prefix of your own."
),
)
recommended_cdk_bootstrap_version: Optional[int] = Field(
default=None,
ge=1,
+123 -11
View File
@@ -1,6 +1,7 @@
import os
import pathlib
from datetime import datetime
from functools import lru_cache
from re import fullmatch
from typing import Optional
@@ -671,7 +672,12 @@ class AwsProvider(Provider):
if mfa:
session = Session(**session_arguments)
session._session.set_default_client_config(session_config)
sts_client = session.client("sts")
sts_region = (
get_env_partition_bootstrap_region()
or session.region_name
or AWS_STS_GLOBAL_ENDPOINT_REGION
)
sts_client = AwsProvider.create_sts_session(session, sts_region)
# TODO: pass values from the input
mfa_info = AwsProvider.input_role_mfa_token_and_code()
@@ -1352,7 +1358,7 @@ class AwsProvider(Provider):
@staticmethod
def test_connection(
profile: str = None,
aws_region: str = AWS_STS_GLOBAL_ENDPOINT_REGION,
aws_region: str = None,
role_arn: str = None,
role_session_name: str = ROLE_SESSION_NAME,
session_duration: int = 3600,
@@ -1369,7 +1375,9 @@ class AwsProvider(Provider):
Args:
profile (str): The AWS profile to use for the session.
aws_region (str): The AWS region to validate the credentials in.
aws_region (str): The AWS region to validate the credentials in. When not
provided, it defaults to the bootstrap region of the partition set in
the PROWLER_AWS_PARTITION environment variable or, if unset, to us-east-1.
role_arn (str): The ARN of the IAM role to assume.
role_session_name (str): The name of the role session.
session_duration (int): The duration of the assumed role session in seconds.
@@ -1412,6 +1420,12 @@ class AwsProvider(Provider):
Connection(is_connected=True, Error=None))
"""
try:
if aws_region is None:
aws_region = (
get_env_partition_bootstrap_region()
or AWS_STS_GLOBAL_ENDPOINT_REGION
)
session = AwsProvider.setup_session(
mfa=mfa_enabled,
profile=profile,
@@ -1430,6 +1444,7 @@ class AwsProvider(Provider):
external_id=external_id,
mfa_enabled=mfa_enabled,
role_session_name=role_session_name,
sts_region=aws_region,
)
assumed_role_credentials = AwsProvider.assume_role(
session,
@@ -1451,6 +1466,13 @@ class AwsProvider(Provider):
if provider_id and caller_identity.account != provider_id:
raise AWSInvalidProviderIdError(file=pathlib.Path(__file__).name)
# Validate that the account belongs to the configured partition, if any
env_partition = os.environ.get("PROWLER_AWS_PARTITION", "").strip()
if env_partition and caller_identity.arn.partition != env_partition:
raise AWSInvalidPartitionError(
message=f"The AWS account is in the {caller_identity.arn.partition} partition, but this deployment is configured for the {env_partition} partition via PROWLER_AWS_PARTITION"
)
return Connection(
is_connected=True,
)
@@ -1591,6 +1613,14 @@ class AwsProvider(Provider):
raise session_token_expired
return Connection(error=session_token_expired)
except AWSInvalidPartitionError as invalid_partition_error:
logger.error(
f"{invalid_partition_error.__class__.__name__}[{invalid_partition_error.__traceback__.tb_lineno}]: {invalid_partition_error}"
)
if raise_on_exception:
raise invalid_partition_error
return Connection(error=invalid_partition_error)
except Exception as error:
logger.critical(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
@@ -1618,14 +1648,9 @@ class AwsProvider(Provider):
sts_client = create_sts_session(session, 'us-west-2')
"""
try:
if os.environ.get("AWS_ENDPOINT_URL"):
sts_endpoint_url = os.environ["AWS_ENDPOINT_URL"]
elif aws_region.startswith("cn-"):
sts_endpoint_url = f"https://sts.{aws_region}.amazonaws.com.cn"
elif aws_region.startswith("eusc-"):
sts_endpoint_url = f"https://sts.{aws_region}.amazonaws.eu"
else:
sts_endpoint_url = f"https://sts.{aws_region}.amazonaws.com"
# Botocore resolves the regional STS endpoint for every partition
# (China, EUSC, GovCloud, ISO); AWS_ENDPOINT_URL overrides it
sts_endpoint_url = os.environ.get("AWS_ENDPOINT_URL") or None
return session.client("sts", aws_region, endpoint_url=sts_endpoint_url)
except Exception as error:
logger.critical(
@@ -1702,6 +1727,80 @@ def read_aws_regions_file() -> dict:
return data
@lru_cache(maxsize=1)
def get_botocore_partition_regions() -> dict:
"""
Get the AWS partitions and their bootstrap region candidates from the
botocore endpoints data.
The region of the partition's global STS endpoint, when declared, is moved
to the front since it is never an opt-in region; the rest are sorted
alphabetically.
Returns:
dict: A dictionary mapping each partition name to its list of regions.
"""
endpoints_data = BotocoreSession().get_data("endpoints")
partition_regions = {}
for partition in endpoints_data["partitions"]:
regions = sorted(partition.get("regions", {}))
sts_service = partition.get("services", {}).get("sts", {})
global_endpoint = sts_service.get("partitionEndpoint")
global_region = (
sts_service.get("endpoints", {})
.get(global_endpoint, {})
.get("credentialScope", {})
.get("region")
)
if global_region in regions:
regions.remove(global_region)
regions.insert(0, global_region)
partition_regions[partition["partition"]] = regions
return partition_regions
def get_env_partition_regions() -> Optional[list]:
"""
Get the bootstrap region candidates for the partition set in the
PROWLER_AWS_PARTITION environment variable.
Returns:
Optional[list]: The regions of the configured partition, preferred
bootstrap region first, or None when the environment variable is
not set.
Raises:
AWSInvalidPartitionError: If the value is not a partition known to botocore.
"""
raw_partition = os.environ.get("PROWLER_AWS_PARTITION", "").strip()
if not raw_partition:
return None
partition_regions = get_botocore_partition_regions()
regions = partition_regions.get(raw_partition)
if not regions:
raise AWSInvalidPartitionError(
message=f"Invalid partition: {raw_partition} set in PROWLER_AWS_PARTITION. Valid partitions: {', '.join(sorted(partition_regions))}"
)
return regions
def get_env_partition_bootstrap_region() -> Optional[str]:
"""
Get the STS bootstrap region for the partition set in the
PROWLER_AWS_PARTITION environment variable.
Returns:
Optional[str]: The preferred bootstrap region of the configured
partition, or None when the environment variable is not set.
Raises:
AWSInvalidPartitionError: If the value is not a partition known to botocore.
"""
regions = get_env_partition_regions()
return regions[0] if regions else None
# TODO: This can be moved to another class since it doesn't need self
def get_aws_region_for_sts(
session_region: str,
@@ -1711,6 +1810,10 @@ def get_aws_region_for_sts(
"""
Get the AWS region for the STS Assume Role operation.
The precedence is: explicit regions, the partition set in the
PROWLER_AWS_PARTITION environment variable, the session region and,
finally, the bootstrap region candidates.
Args:
- session_region (str): The region configured in the AWS session.
- regions (set[str]): The regions passed with the -f/--region/--filter-region option.
@@ -1730,6 +1833,15 @@ def get_aws_region_for_sts(
if region not in excluded_regions:
return region
env_partition_regions = get_env_partition_regions()
if env_partition_regions:
# The configured partition constrains the whole fallback chain: prefer
# a non-excluded region, but never leave the partition
for region in env_partition_regions:
if region not in excluded_regions:
return region
return env_partition_regions[0]
if session_region and session_region not in excluded_regions:
return session_region
@@ -24,10 +24,25 @@ class bedrock_agent_role_least_privilege(Check):
Returns:
A list of ``Check_Report_AWS`` with one entry per agent. The
status is ``FAIL`` when any of the criteria above is violated,
or when the execution role cannot be resolved in IAM.
status is ``FAIL`` when any of the criteria above is violated and
``MANUAL`` when the execution role cannot be resolved in IAM. When
the IAM role inventory itself could not be listed, a single
account-level ``MANUAL`` report is returned instead.
"""
findings = []
if iam_client.roles is None and bedrock_agent_client.agents:
# iam:ListRoles was denied: this is an account-wide condition, so
# emit one account-level MANUAL instead of one per agent.
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.region = iam_client.region
report.resource_id = iam_client.audited_account
report.resource_arn = iam_client.audited_account_arn
report.status = "MANUAL"
report.status_extended = "Cannot evaluate Bedrock Agent execution roles: the IAM roles could not be listed. Verify that the scanning credentials are allowed to call iam:ListRoles."
findings.append(report)
return findings
roles_by_arn = {role.arn: role for role in (iam_client.roles or [])}
for agent in bedrock_agent_client.agents.values():
@@ -39,10 +54,11 @@ class bedrock_agent_role_least_privilege(Check):
role = roles_by_arn.get(agent.role_arn) if agent.role_arn else None
if role is None:
report.status = "FAIL"
report.status = "MANUAL"
report.status_extended = (
f"Bedrock Agent {agent.name} execution role could not be "
f"resolved in IAM and cannot be evaluated for least privilege."
f"resolved in IAM and cannot be evaluated for least privilege; "
f"verify the role manually."
)
findings.append(report)
continue
@@ -15,6 +15,9 @@ class Cloudtrail(AWSService):
super().__init__(__class__.__name__, provider)
self.trail_arn_template = f"arn:{self.audited_partition}:cloudtrail:{self.region}:{self.audited_account}:trail"
self.trails = {}
# True when DescribeTrails was denied in at least one audited region,
# so the trail inventory may be incomplete.
self.trails_unavailable = False
self.__threading_call__(self._get_trails)
if self.trails:
self._get_trail_status()
@@ -79,13 +82,16 @@ class Cloudtrail(AWSService):
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
self.trails_unavailable = True
if not self.trails:
self.trails = None
else:
self.trails_unavailable = True
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
except Exception as error:
self.trails_unavailable = True
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
@@ -13,7 +13,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
class cloudwatch_changes_to_network_acls_alarm_configured(Check):
def execute(self):
"""CloudWatch log metric filter and alarm exist for Network ACL (NACL) change events.
Looks for a CloudWatch Logs metric filter matching the expected pattern on a
log group used by a CloudTrail trail, with at least one alarm on its metric.
- PASS: A matching metric filter with an associated alarm exists.
- FAIL: No matching metric filter, or a filter without an alarm, was found.
- MANUAL: CloudTrail trails, log groups, metric filters or alarms could
not be listed in at least one region, so the absence of a filter/alarm
cannot be asserted.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Evaluate the metric filter and alarm coverage for the account.
Returns:
list[Check_Report_AWS]: A single report for the account.
"""
pattern = build_metric_filter_pattern(
event_names=[
"CreateNetworkAcl",
@@ -34,16 +51,29 @@ class cloudwatch_changes_to_network_acls_alarm_configured(Check):
self.metadata(),
)
if cloudtrail_client.trails is not None:
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
inventory_unavailable = (
cloudtrail_client.trails_unavailable
or logs_client.log_groups_unavailable
or logs_client.metric_filters_unavailable
or cloudwatch_client.metric_alarms_unavailable
)
findings.append(report)
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
# A denied listing in any region means the inventory is incomplete: a
# PASS is still backed by a real filter and alarm, but a FAIL (nothing
# found, or a filter found without its alarm) cannot be trusted.
if report.status == "FAIL" and inventory_unavailable:
report.status = "MANUAL"
report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms."
findings.append(report)
return findings
@@ -13,7 +13,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
class cloudwatch_changes_to_network_gateways_alarm_configured(Check):
def execute(self):
"""CloudWatch Logs metric filter and alarm exist for changes to network gateways.
Looks for a CloudWatch Logs metric filter matching the expected pattern on a
log group used by a CloudTrail trail, with at least one alarm on its metric.
- PASS: A matching metric filter with an associated alarm exists.
- FAIL: No matching metric filter, or a filter without an alarm, was found.
- MANUAL: CloudTrail trails, log groups, metric filters or alarms could
not be listed in at least one region, so the absence of a filter/alarm
cannot be asserted.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Evaluate the metric filter and alarm coverage for the account.
Returns:
list[Check_Report_AWS]: A single report for the account.
"""
pattern = build_metric_filter_pattern(
event_names=[
"CreateCustomerGateway",
@@ -34,16 +51,29 @@ class cloudwatch_changes_to_network_gateways_alarm_configured(Check):
self.metadata(),
)
if cloudtrail_client.trails is not None:
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
inventory_unavailable = (
cloudtrail_client.trails_unavailable
or logs_client.log_groups_unavailable
or logs_client.metric_filters_unavailable
or cloudwatch_client.metric_alarms_unavailable
)
findings.append(report)
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
# A denied listing in any region means the inventory is incomplete: a
# PASS is still backed by a real filter and alarm, but a FAIL (nothing
# found, or a filter found without its alarm) cannot be trusted.
if report.status == "FAIL" and inventory_unavailable:
report.status = "MANUAL"
report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms."
findings.append(report)
return findings
@@ -13,7 +13,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
class cloudwatch_changes_to_network_route_tables_alarm_configured(Check):
def execute(self):
"""Account monitors VPC route table changes with a CloudWatch Logs metric filter and alarm.
Looks for a CloudWatch Logs metric filter matching the expected pattern on a
log group used by a CloudTrail trail, with at least one alarm on its metric.
- PASS: A matching metric filter with an associated alarm exists.
- FAIL: No matching metric filter, or a filter without an alarm, was found.
- MANUAL: CloudTrail trails, log groups, metric filters or alarms could
not be listed in at least one region, so the absence of a filter/alarm
cannot be asserted.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Evaluate the metric filter and alarm coverage for the account.
Returns:
list[Check_Report_AWS]: A single report for the account.
"""
pattern = build_metric_filter_pattern(
event_source="ec2.amazonaws.com",
event_names=[
@@ -36,16 +53,29 @@ class cloudwatch_changes_to_network_route_tables_alarm_configured(Check):
self.metadata(),
)
if cloudtrail_client.trails is not None:
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
inventory_unavailable = (
cloudtrail_client.trails_unavailable
or logs_client.log_groups_unavailable
or logs_client.metric_filters_unavailable
or cloudwatch_client.metric_alarms_unavailable
)
findings.append(report)
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
# A denied listing in any region means the inventory is incomplete: a
# PASS is still backed by a real filter and alarm, but a FAIL (nothing
# found, or a filter found without its alarm) cannot be trusted.
if report.status == "FAIL" and inventory_unavailable:
report.status = "MANUAL"
report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms."
findings.append(report)
return findings
@@ -13,7 +13,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
class cloudwatch_changes_to_vpcs_alarm_configured(Check):
def execute(self):
"""AWS account has a CloudWatch Logs metric filter and alarm for VPC changes.
Looks for a CloudWatch Logs metric filter matching the expected pattern on a
log group used by a CloudTrail trail, with at least one alarm on its metric.
- PASS: A matching metric filter with an associated alarm exists.
- FAIL: No matching metric filter, or a filter without an alarm, was found.
- MANUAL: CloudTrail trails, log groups, metric filters or alarms could
not be listed in at least one region, so the absence of a filter/alarm
cannot be asserted.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Evaluate the metric filter and alarm coverage for the account.
Returns:
list[Check_Report_AWS]: A single report for the account.
"""
pattern = build_metric_filter_pattern(
event_names=[
"CreateVpc",
@@ -39,16 +56,29 @@ class cloudwatch_changes_to_vpcs_alarm_configured(Check):
self.metadata(),
)
if cloudtrail_client.trails is not None:
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
inventory_unavailable = (
cloudtrail_client.trails_unavailable
or logs_client.log_groups_unavailable
or logs_client.metric_filters_unavailable
or cloudwatch_client.metric_alarms_unavailable
)
findings.append(report)
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
# A denied listing in any region means the inventory is incomplete: a
# PASS is still backed by a real filter and alarm, but a FAIL (nothing
# found, or a filter found without its alarm) cannot be trusted.
if report.status == "FAIL" and inventory_unavailable:
report.status = "MANUAL"
report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms."
findings.append(report)
return findings
@@ -0,0 +1,45 @@
{
"Provider": "aws",
"CheckID": "cloudwatch_log_group_agentcore_data_protection_policy_enabled",
"CheckTitle": "Bedrock AgentCore log groups have a CloudWatch Logs data protection policy activated",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices",
"Effects/Data Exposure",
"Sensitive Data Identifications/PII"
],
"ServiceName": "cloudwatch",
"SubServiceName": "logs",
"ResourceIdTemplate": "arn:partition:logs:region:account-id:log-group:log-group-name",
"Severity": "medium",
"ResourceType": "Other",
"ResourceGroup": "monitoring",
"Description": "Log groups holding **Bedrock AgentCore** agent telemetry have an active CloudWatch Logs **data protection policy**, so sensitive data an agent writes to its own logs is masked at ingestion. A policy attached to the log group or inherited from an account-level policy both satisfy this.",
"Risk": "Agents log prompts, tool arguments and retrieved context. Without masking, **PII** and secrets are stored in clear text and readable by every principal holding `logs:GetLogEvents`, widening the audience for regulated data far beyond the agent and defeating filtering applied only at the model boundary.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/mask-sensitive-log-data.html",
"https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/mask-sensitive-log-data-start.html",
"https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/mask-sensitive-log-data-start-account.html",
"https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/protect-sensitive-log-data-types.html",
"https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/observability-configure.html"
],
"Remediation": {
"Code": {
"CLI": "aws logs put-data-protection-policy --log-group-identifier <LOG_GROUP_NAME> --policy-document '{\"Name\":\"agentcore-data-protection-policy\",\"Version\":\"2021-06-01\",\"Statement\":[{\"Sid\":\"audit\",\"DataIdentifier\":[\"arn:aws:dataprotection::aws:data-identifier/EmailAddress\"],\"Operation\":{\"Audit\":{\"FindingsDestination\":{}}}},{\"Sid\":\"redact\",\"DataIdentifier\":[\"arn:aws:dataprotection::aws:data-identifier/EmailAddress\"],\"Operation\":{\"Deidentify\":{\"MaskConfig\":{}}}}]}'",
"NativeIaC": "```yaml\n# CloudFormation: data protection policy on a log group you manage in the template.\n# AgentCore runtime log groups are created by the service, not by CloudFormation --\n# for those, use the CLI command or an AWS::Logs::AccountPolicy instead.\nResources:\n AgentCoreLogGroup:\n Type: AWS::Logs::LogGroup\n Properties:\n LogGroupName: \"<example_resource_name>\"\n DataProtectionPolicy:\n Name: agentcore-data-protection-policy\n Version: '2021-06-01'\n Statement:\n - Sid: audit # CRITICAL: required block, finds the sensitive terms\n DataIdentifier:\n - arn:aws:dataprotection::aws:data-identifier/EmailAddress\n Operation:\n Audit:\n FindingsDestination: {}\n - Sid: redact # CRITICAL: the Deidentify block is what actually masks the data\n DataIdentifier:\n - arn:aws:dataprotection::aws:data-identifier/EmailAddress\n Operation:\n Deidentify:\n MaskConfig: {}\n```",
"Other": "1. In the AWS console, open CloudWatch > Logs > Log groups\n2. Select the AgentCore log group (/aws/bedrock-agentcore/... or /aws/vendedlogs/bedrock-agentcore/...)\n3. Choose Actions > Create data protection policy\n4. Under Managed data identifiers, select the data types your agent can log, for example Address, EmailAddress and PhoneNumber\n5. (Optional) Choose a destination for the audit findings report\n6. Choose Activate data protection\n7. To cover every log group at once, including service-created ones, open Settings > Data protection instead and create an account-level policy",
"Terraform": "```hcl\n# Attaches a data protection policy to an existing AgentCore log group\nresource \"aws_cloudwatch_log_data_protection_policy\" \"<example_resource_name>\" {\n log_group_name = \"<example_resource_name>\"\n\n policy_document = jsonencode({\n Name = \"agentcore-data-protection-policy\"\n Version = \"2021-06-01\"\n Statement = [\n {\n Sid = \"audit\" # CRITICAL: required block, finds the sensitive terms\n DataIdentifier = [\"arn:aws:dataprotection::aws:data-identifier/EmailAddress\"]\n Operation = {\n Audit = {\n FindingsDestination = {}\n }\n }\n },\n {\n Sid = \"redact\" # CRITICAL: the Deidentify block is what actually masks the data\n DataIdentifier = [\"arn:aws:dataprotection::aws:data-identifier/EmailAddress\"]\n Operation = {\n Deidentify = {\n MaskConfig = {}\n }\n }\n }\n ]\n })\n}\n```"
},
"Recommendation": {
"Text": "Attach a data protection policy to every AgentCore log group, choosing the managed data identifiers your agents can plausibly log, or create one account-level policy so service-created log groups are covered as they appear.\n\nMasking is *defense in depth* behind the model-boundary filters: keep restricting `logs:Unmask` so only incident responders can read the original values, and keep the log groups encrypted with a **customer-managed key**.",
"Url": "https://hub.prowler.com/check/cloudwatch_log_group_agentcore_data_protection_policy_enabled"
}
},
"Categories": [
"gen-ai",
"logging"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "Scoped to log groups whose name starts with an AgentCore prefix (configurable via `agentcore_log_group_name_prefixes`, whose value REPLACES the defaults rather than adding to them). AgentCore log delivery can be pointed at an arbitrarily named log group; add those prefixes to the configuration so they are assessed. Not covered by default: gateway, built-in tool and identity spans, and runtime spans for agents created before a Region supported the per-agent span destination, are delivered to the shared `aws/spans` log group, which is account-wide rather than AgentCore-specific. Add `aws/spans` to the configuration to assess it."
}
@@ -0,0 +1,98 @@
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
# Log group name prefixes AgentCore uses for agent telemetry. The runtime creates
# /aws/bedrock-agentcore/runtimes/... itself. Log delivery for memory, gateway and built-in tool
# resources is CONFIGURED by the operator rather than defaulted -- the AgentCore devguide's
# observability-configure page is a put_delivery_source / put_delivery_destination /
# create_delivery procedure -- and it targets /aws/vendedlogs/bedrock-agentcore/... because for
# same-account delivery to a /aws/vendedlogs/ log group the log-delivery service-linked role
# grants write access implicitly, while any other destination needs an explicit resource policy
# or the delivery silently fails. So the prefix is the convention that makes delivery work, which
# is why these two and not others.
DEFAULT_AGENTCORE_LOG_GROUP_PREFIXES = [
"/aws/bedrock-agentcore/",
"/aws/vendedlogs/bedrock-agentcore/",
]
ACTIVATED = "ACTIVATED"
ACCOUNT_DATA_PROTECTION = "ACCOUNT_DATA_PROTECTION"
class cloudwatch_log_group_agentcore_data_protection_policy_enabled(Check):
"""Ensure AgentCore log groups mask sensitive data with a data protection policy.
Agents write prompts, tool arguments and retrieved context to their own log groups. A data
protection policy masks matched data at ingestion, so without one the values are stored in
clear text and readable by every principal holding logs:GetLogEvents.
Scope: log groups whose name starts with an AgentCore prefix. AgentCore log delivery can be
pointed at an arbitrarily named log group, so the prefix list is configurable through
agentcore_log_group_name_prefixes; a configured list REPLACES the defaults rather than
extending them, and an explicitly null value falls back to the defaults.
PASS when the log group has an ACTIVATED policy of its own, or inherits the account-level one.
FAIL when dataProtectionStatus is DELETED, ARCHIVED or DISABLED, or was never reported: all
four mean nothing is being masked at ingestion today.
MANUAL when the log group inventory could not be read, because nothing is then known about any
log group's masking. Only a denied DescribeLogGroups leaves the inventory unknown -- every
other collector failure leaves a readable, possibly partial, inventory.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Execute the AgentCore log group data protection policy check.
Returns:
A list of reports containing the result of the check: one per in-scope
AgentCore log group, or a single account-level report when the log group
inventory could not be read.
"""
findings = []
# An ABSENT or explicitly null value falls back to the defaults; an empty LIST does not.
# `is None` covers both a missing key and a bare `agentcore_log_group_name_prefixes:` in the
# YAML, which parses as None, and both mean "not configured". `or` additionally swallowed an
# explicitly empty list, which IS a configured value and the one way an operator can say "no
# log group is in scope" -- so the fallback overrode the operator and contradicted the
# REPLACES-the-defaults behaviour promised above. An empty tuple is meaningful downstream
# rather than degenerate: str.startswith(()) is False for every name, so nothing is selected,
# which is exactly the request.
configured_prefixes = logs_client.audit_config.get(
"agentcore_log_group_name_prefixes"
)
prefixes = tuple(
DEFAULT_AGENTCORE_LOG_GROUP_PREFIXES
if configured_prefixes is None
else configured_prefixes
)
# An unreadable log group inventory must not read as compliant: without the
# inventory there is no way to tell an AgentCore log group that masks
# sensitive data from one that does not.
if logs_client.log_groups is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "MANUAL"
report.status_extended = "Log groups could not be retrieved, so data protection policies for AgentCore log groups could not be verified."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
return [report]
for log_group in logs_client.log_groups.values():
if not log_group.name.startswith(prefixes):
continue
report = Check_Report_AWS(metadata=self.metadata(), resource=log_group)
if log_group.data_protection_status == ACTIVATED:
report.status = "PASS"
report.status_extended = f"AgentCore log group {log_group.name} has a data protection policy activated."
elif ACCOUNT_DATA_PROTECTION in log_group.inherited_properties:
report.status = "PASS"
report.status_extended = f"AgentCore log group {log_group.name} inherits the account-level data protection policy."
else:
report.status = "FAIL"
report.status_extended = f"AgentCore log group {log_group.name} does not have an active data protection policy, so sensitive data written by the agent is not masked."
findings.append(report)
return findings
@@ -15,7 +15,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
class cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled(
Check
):
def execute(self):
"""CloudWatch Logs metric filter and alarm exist for AWS Config configuration changes.
Looks for a CloudWatch Logs metric filter matching the expected pattern on a
log group used by a CloudTrail trail, with at least one alarm on its metric.
- PASS: A matching metric filter with an associated alarm exists.
- FAIL: No matching metric filter, or a filter without an alarm, was found.
- MANUAL: CloudTrail trails, log groups, metric filters or alarms could
not be listed in at least one region, so the absence of a filter/alarm
cannot be asserted.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Evaluate the metric filter and alarm coverage for the account.
Returns:
list[Check_Report_AWS]: A single report for the account.
"""
pattern = build_metric_filter_pattern(
event_source="config.amazonaws.com",
event_names=[
@@ -35,16 +52,29 @@ class cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_change
self.metadata(),
)
if cloudtrail_client.trails is not None:
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
inventory_unavailable = (
cloudtrail_client.trails_unavailable
or logs_client.log_groups_unavailable
or logs_client.metric_filters_unavailable
or cloudwatch_client.metric_alarms_unavailable
)
findings.append(report)
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
# A denied listing in any region means the inventory is incomplete: a
# PASS is still backed by a real filter and alarm, but a FAIL (nothing
# found, or a filter found without its alarm) cannot be trusted.
if report.status == "FAIL" and inventory_unavailable:
report.status = "MANUAL"
report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms."
findings.append(report)
return findings
@@ -15,7 +15,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
class cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled(
Check
):
def execute(self):
"""CloudWatch Logs metric filter and alarm exist for CloudTrail configuration changes.
Looks for a CloudWatch Logs metric filter matching the expected pattern on a
log group used by a CloudTrail trail, with at least one alarm on its metric.
- PASS: A matching metric filter with an associated alarm exists.
- FAIL: No matching metric filter, or a filter without an alarm, was found.
- MANUAL: CloudTrail trails, log groups, metric filters or alarms could
not be listed in at least one region, so the absence of a filter/alarm
cannot be asserted.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Evaluate the metric filter and alarm coverage for the account.
Returns:
list[Check_Report_AWS]: A single report for the account.
"""
pattern = build_metric_filter_pattern(
event_names=[
"CreateTrail",
@@ -35,16 +52,29 @@ class cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_change
self.metadata(),
)
if cloudtrail_client.trails is not None:
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
inventory_unavailable = (
cloudtrail_client.trails_unavailable
or logs_client.log_groups_unavailable
or logs_client.metric_filters_unavailable
or cloudwatch_client.metric_alarms_unavailable
)
findings.append(report)
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
# A denied listing in any region means the inventory is incomplete: a
# PASS is still backed by a real filter and alarm, but a FAIL (nothing
# found, or a filter found without its alarm) cannot be trusted.
if report.status == "FAIL" and inventory_unavailable:
report.status = "MANUAL"
report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms."
findings.append(report)
return findings
@@ -13,7 +13,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
class cloudwatch_log_metric_filter_authentication_failures(Check):
def execute(self):
"""Account has a CloudWatch Logs metric filter and alarm for AWS Management Console authentication failures.
Looks for a CloudWatch Logs metric filter matching the expected pattern on a
log group used by a CloudTrail trail, with at least one alarm on its metric.
- PASS: A matching metric filter with an associated alarm exists.
- FAIL: No matching metric filter, or a filter without an alarm, was found.
- MANUAL: CloudTrail trails, log groups, metric filters or alarms could
not be listed in at least one region, so the absence of a filter/alarm
cannot be asserted.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Evaluate the metric filter and alarm coverage for the account.
Returns:
list[Check_Report_AWS]: A single report for the account.
"""
pattern = build_metric_filter_pattern(
event_names=["ConsoleLogin"],
extra_clauses=[("errorMessage", "=", "Failed authentication")],
@@ -28,16 +45,29 @@ class cloudwatch_log_metric_filter_authentication_failures(Check):
self.metadata(),
)
if cloudtrail_client.trails is not None:
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
inventory_unavailable = (
cloudtrail_client.trails_unavailable
or logs_client.log_groups_unavailable
or logs_client.metric_filters_unavailable
or cloudwatch_client.metric_alarms_unavailable
)
findings.append(report)
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
# A denied listing in any region means the inventory is incomplete: a
# PASS is still backed by a real filter and alarm, but a FAIL (nothing
# found, or a filter found without its alarm) cannot be trusted.
if report.status == "FAIL" and inventory_unavailable:
report.status = "MANUAL"
report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms."
findings.append(report)
return findings
@@ -13,7 +13,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
class cloudwatch_log_metric_filter_aws_organizations_changes(Check):
def execute(self):
"""CloudWatch Logs metric filter and alarm exist for AWS Organizations changes.
Looks for a CloudWatch Logs metric filter matching the expected pattern on a
log group used by a CloudTrail trail, with at least one alarm on its metric.
- PASS: A matching metric filter with an associated alarm exists.
- FAIL: No matching metric filter, or a filter without an alarm, was found.
- MANUAL: CloudTrail trails, log groups, metric filters or alarms could
not be listed in at least one region, so the absence of a filter/alarm
cannot be asserted.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Evaluate the metric filter and alarm coverage for the account.
Returns:
list[Check_Report_AWS]: A single report for the account.
"""
pattern = build_metric_filter_pattern(
event_source="organizations.amazonaws.com",
event_names=[
@@ -50,16 +67,29 @@ class cloudwatch_log_metric_filter_aws_organizations_changes(Check):
self.metadata(),
)
if cloudtrail_client.trails is not None:
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
inventory_unavailable = (
cloudtrail_client.trails_unavailable
or logs_client.log_groups_unavailable
or logs_client.metric_filters_unavailable
or cloudwatch_client.metric_alarms_unavailable
)
findings.append(report)
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
# A denied listing in any region means the inventory is incomplete: a
# PASS is still backed by a real filter and alarm, but a FAIL (nothing
# found, or a filter found without its alarm) cannot be trusted.
if report.status == "FAIL" and inventory_unavailable:
report.status = "MANUAL"
report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms."
findings.append(report)
return findings
@@ -13,7 +13,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
class cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk(Check):
def execute(self):
"""Account has a CloudWatch log metric filter and alarm for disabling or scheduled deletion of customer-managed KMS keys.
Looks for a CloudWatch Logs metric filter matching the expected pattern on a
log group used by a CloudTrail trail, with at least one alarm on its metric.
- PASS: A matching metric filter with an associated alarm exists.
- FAIL: No matching metric filter, or a filter without an alarm, was found.
- MANUAL: CloudTrail trails, log groups, metric filters or alarms could
not be listed in at least one region, so the absence of a filter/alarm
cannot be asserted.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Evaluate the metric filter and alarm coverage for the account.
Returns:
list[Check_Report_AWS]: A single report for the account.
"""
pattern = build_metric_filter_pattern(
event_source="kms.amazonaws.com",
event_names=["DisableKey", "ScheduleKeyDeletion"],
@@ -28,16 +45,29 @@ class cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk(Chec
self.metadata(),
)
if cloudtrail_client.trails is not None:
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
inventory_unavailable = (
cloudtrail_client.trails_unavailable
or logs_client.log_groups_unavailable
or logs_client.metric_filters_unavailable
or cloudwatch_client.metric_alarms_unavailable
)
findings.append(report)
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
# A denied listing in any region means the inventory is incomplete: a
# PASS is still backed by a real filter and alarm, but a FAIL (nothing
# found, or a filter found without its alarm) cannot be trusted.
if report.status == "FAIL" and inventory_unavailable:
report.status = "MANUAL"
report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms."
findings.append(report)
return findings
@@ -13,7 +13,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
class cloudwatch_log_metric_filter_for_s3_bucket_policy_changes(Check):
def execute(self):
"""CloudWatch log metric filter and alarm exist for S3 bucket policy changes.
Looks for a CloudWatch Logs metric filter matching the expected pattern on a
log group used by a CloudTrail trail, with at least one alarm on its metric.
- PASS: A matching metric filter with an associated alarm exists.
- FAIL: No matching metric filter, or a filter without an alarm, was found.
- MANUAL: CloudTrail trails, log groups, metric filters or alarms could
not be listed in at least one region, so the absence of a filter/alarm
cannot be asserted.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Evaluate the metric filter and alarm coverage for the account.
Returns:
list[Check_Report_AWS]: A single report for the account.
"""
pattern = build_metric_filter_pattern(
event_source="s3.amazonaws.com",
event_names=[
@@ -38,16 +55,29 @@ class cloudwatch_log_metric_filter_for_s3_bucket_policy_changes(Check):
self.metadata(),
)
if cloudtrail_client.trails is not None:
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
inventory_unavailable = (
cloudtrail_client.trails_unavailable
or logs_client.log_groups_unavailable
or logs_client.metric_filters_unavailable
or cloudwatch_client.metric_alarms_unavailable
)
findings.append(report)
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
# A denied listing in any region means the inventory is incomplete: a
# PASS is still backed by a real filter and alarm, but a FAIL (nothing
# found, or a filter found without its alarm) cannot be trusted.
if report.status == "FAIL" and inventory_unavailable:
report.status = "MANUAL"
report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms."
findings.append(report)
return findings
@@ -13,7 +13,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
class cloudwatch_log_metric_filter_policy_changes(Check):
def execute(self):
"""CloudWatch Logs metric filter and alarm exist for IAM policy changes.
Looks for a CloudWatch Logs metric filter matching the expected pattern on a
log group used by a CloudTrail trail, with at least one alarm on its metric.
- PASS: A matching metric filter with an associated alarm exists.
- FAIL: No matching metric filter, or a filter without an alarm, was found.
- MANUAL: CloudTrail trails, log groups, metric filters or alarms could
not be listed in at least one region, so the absence of a filter/alarm
cannot be asserted.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Evaluate the metric filter and alarm coverage for the account.
Returns:
list[Check_Report_AWS]: A single report for the account.
"""
pattern = build_metric_filter_pattern(
event_names=[
"DeleteGroupPolicy",
@@ -44,16 +61,29 @@ class cloudwatch_log_metric_filter_policy_changes(Check):
self.metadata(),
)
if cloudtrail_client.trails is not None:
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
inventory_unavailable = (
cloudtrail_client.trails_unavailable
or logs_client.log_groups_unavailable
or logs_client.metric_filters_unavailable
or cloudwatch_client.metric_alarms_unavailable
)
findings.append(report)
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
# A denied listing in any region means the inventory is incomplete: a
# PASS is still backed by a real filter and alarm, but a FAIL (nothing
# found, or a filter found without its alarm) cannot be trusted.
if report.status == "FAIL" and inventory_unavailable:
report.status = "MANUAL"
report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms."
findings.append(report)
return findings
@@ -12,7 +12,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
class cloudwatch_log_metric_filter_root_usage(Check):
def execute(self):
"""Account has a CloudWatch Logs metric filter and alarm for root account usage.
Looks for a CloudWatch Logs metric filter matching the expected pattern on a
log group used by a CloudTrail trail, with at least one alarm on its metric.
- PASS: A matching metric filter with an associated alarm exists.
- FAIL: No matching metric filter, or a filter without an alarm, was found.
- MANUAL: CloudTrail trails, log groups, metric filters or alarms could
not be listed in at least one region, so the absence of a filter/alarm
cannot be asserted.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Evaluate the metric filter and alarm coverage for the account.
Returns:
list[Check_Report_AWS]: A single report for the account.
"""
pattern = r"\$\.userIdentity\.type\s*=\s*.?Root.+\$\.userIdentity\.invokedBy NOT EXISTS.+\$\.eventType\s*!=\s*.?AwsServiceEvent.?"
findings = []
@@ -24,16 +41,29 @@ class cloudwatch_log_metric_filter_root_usage(Check):
self.metadata(),
)
if cloudtrail_client.trails is not None:
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
inventory_unavailable = (
cloudtrail_client.trails_unavailable
or logs_client.log_groups_unavailable
or logs_client.metric_filters_unavailable
or cloudwatch_client.metric_alarms_unavailable
)
findings.append(report)
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
# A denied listing in any region means the inventory is incomplete: a
# PASS is still backed by a real filter and alarm, but a FAIL (nothing
# found, or a filter found without its alarm) cannot be trusted.
if report.status == "FAIL" and inventory_unavailable:
report.status = "MANUAL"
report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms."
findings.append(report)
return findings
@@ -13,7 +13,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
class cloudwatch_log_metric_filter_security_group_changes(Check):
def execute(self):
"""CloudWatch Logs metric filter and alarm exist for security group changes.
Looks for a CloudWatch Logs metric filter matching the expected pattern on a
log group used by a CloudTrail trail, with at least one alarm on its metric.
- PASS: A matching metric filter with an associated alarm exists.
- FAIL: No matching metric filter, or a filter without an alarm, was found.
- MANUAL: CloudTrail trails, log groups, metric filters or alarms could
not be listed in at least one region, so the absence of a filter/alarm
cannot be asserted.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Evaluate the metric filter and alarm coverage for the account.
Returns:
list[Check_Report_AWS]: A single report for the account.
"""
pattern = build_metric_filter_pattern(
event_names=[
"AuthorizeSecurityGroupIngress",
@@ -34,16 +51,29 @@ class cloudwatch_log_metric_filter_security_group_changes(Check):
self.metadata(),
)
if cloudtrail_client.trails is not None:
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
inventory_unavailable = (
cloudtrail_client.trails_unavailable
or logs_client.log_groups_unavailable
or logs_client.metric_filters_unavailable
or cloudwatch_client.metric_alarms_unavailable
)
findings.append(report)
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
# A denied listing in any region means the inventory is incomplete: a
# PASS is still backed by a real filter and alarm, but a FAIL (nothing
# found, or a filter found without its alarm) cannot be trusted.
if report.status == "FAIL" and inventory_unavailable:
report.status = "MANUAL"
report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms."
findings.append(report)
return findings
@@ -13,7 +13,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
class cloudwatch_log_metric_filter_sign_in_without_mfa(Check):
def execute(self):
"""CloudWatch log metric filter and alarm exist for Management Console sign-in without MFA.
Looks for a CloudWatch Logs metric filter matching the expected pattern on a
log group used by a CloudTrail trail, with at least one alarm on its metric.
- PASS: A matching metric filter with an associated alarm exists.
- FAIL: No matching metric filter, or a filter without an alarm, was found.
- MANUAL: CloudTrail trails, log groups, metric filters or alarms could
not be listed in at least one region, so the absence of a filter/alarm
cannot be asserted.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Evaluate the metric filter and alarm coverage for the account.
Returns:
list[Check_Report_AWS]: A single report for the account.
"""
pattern = build_metric_filter_pattern(
event_names=["ConsoleLogin"],
extra_clauses=[("additionalEventData.MFAUsed", "!=", "Yes")],
@@ -28,16 +45,29 @@ class cloudwatch_log_metric_filter_sign_in_without_mfa(Check):
self.metadata(),
)
if cloudtrail_client.trails is not None:
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
inventory_unavailable = (
cloudtrail_client.trails_unavailable
or logs_client.log_groups_unavailable
or logs_client.metric_filters_unavailable
or cloudwatch_client.metric_alarms_unavailable
)
findings.append(report)
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
# A denied listing in any region means the inventory is incomplete: a
# PASS is still backed by a real filter and alarm, but a FAIL (nothing
# found, or a filter found without its alarm) cannot be trusted.
if report.status == "FAIL" and inventory_unavailable:
report.status = "MANUAL"
report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms."
findings.append(report)
return findings
@@ -12,7 +12,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
class cloudwatch_log_metric_filter_unauthorized_api_calls(Check):
def execute(self):
"""CloudWatch Logs metric filter and alarm exist for unauthorized API calls.
Looks for a CloudWatch Logs metric filter matching the expected pattern on a
log group used by a CloudTrail trail, with at least one alarm on its metric.
- PASS: A matching metric filter with an associated alarm exists.
- FAIL: No matching metric filter, or a filter without an alarm, was found.
- MANUAL: CloudTrail trails, log groups, metric filters or alarms could
not be listed in at least one region, so the absence of a filter/alarm
cannot be asserted.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Evaluate the metric filter and alarm coverage for the account.
Returns:
list[Check_Report_AWS]: A single report for the account.
"""
pattern = r"\$\.errorCode\s*=\s*.?\*UnauthorizedOperation.+\$\.errorCode\s*=\s*.?AccessDenied\*.?"
findings = []
@@ -24,16 +41,29 @@ class cloudwatch_log_metric_filter_unauthorized_api_calls(Check):
self.metadata(),
)
if cloudtrail_client.trails is not None:
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
inventory_unavailable = (
cloudtrail_client.trails_unavailable
or logs_client.log_groups_unavailable
or logs_client.metric_filters_unavailable
or cloudwatch_client.metric_alarms_unavailable
)
findings.append(report)
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
# A denied listing in any region means the inventory is incomplete: a
# PASS is still backed by a real filter and alarm, but a FAIL (nothing
# found, or a filter found without its alarm) cannot be trusted.
if report.status == "FAIL" and inventory_unavailable:
report.status = "MANUAL"
report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms."
findings.append(report)
return findings
@@ -19,6 +19,9 @@ class CloudWatch(AWSService):
# Call AWSService's __init__
super().__init__(__class__.__name__, provider)
self.metric_alarms = []
# True when DescribeAlarms was denied in at least one audited region,
# so the alarm inventory may be incomplete.
self.metric_alarms_unavailable = False
self.__threading_call__(self._describe_alarms)
if self.metric_alarms:
self._list_tags_for_resource()
@@ -56,13 +59,16 @@ class CloudWatch(AWSService):
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
self.metric_alarms_unavailable = True
if not self.metric_alarms:
self.metric_alarms = None
else:
self.metric_alarms_unavailable = True
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
except Exception as error:
self.metric_alarms_unavailable = True
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
@@ -92,6 +98,9 @@ class Logs(AWSService):
# index for cross-service evidence lookups.
self.all_log_groups = {}
self.log_groups = {}
# True when DescribeLogGroups was denied in at least one audited
# region, so the log group inventory may be incomplete.
self.log_groups_unavailable = False
self._log_groups_hydrated = set()
self.log_group_limit = get_resource_scan_limit(
self.audit_config, "max_cloudwatch_log_groups"
@@ -103,6 +112,9 @@ class Logs(AWSService):
self.resource_policies = {}
self.__threading_call__(self._describe_resource_policies)
self.metric_filters = []
# True when DescribeMetricFilters was denied in at least one audited
# region, so the metric filter inventory may be incomplete.
self.metric_filters_unavailable = False
self.__threading_call__(self._describe_metric_filters)
if self.log_groups:
if (
@@ -166,6 +178,9 @@ class Logs(AWSService):
arn=arn,
name=filter["filterName"],
metric=filter["metricTransformations"][0]["metricName"],
metric_namespace=filter["metricTransformations"][0].get(
"metricNamespace"
),
pattern=filter.get("filterPattern", ""),
log_group=log_group,
region=regional_client.region,
@@ -176,18 +191,32 @@ class Logs(AWSService):
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
self.metric_filters_unavailable = True
if not self.metric_filters:
self.metric_filters = None
else:
self.metric_filters_unavailable = True
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
except Exception as error:
self.metric_filters_unavailable = True
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _describe_log_groups(self, regional_client):
"""List the log groups in a region into the complete and the analysed indexes.
A denied DescribeLogGroups sets both indexes to None, but only while nothing has been
collected yet: that None is the state checks read as "inventory unknown", and it must stay
distinguishable from an account that genuinely has no log groups. Any other failure leaves
the indexes as they are, so a partial inventory reads as a smaller one.
dataProtectionStatus and inheritedProperties are stored as reported. An absent
dataProtectionStatus is the API saying the log group has never had a policy, and it is kept
as None rather than a status string so a check can tell "never configured" from DISABLED.
"""
logger.info("CloudWatch Logs - Describing log groups...")
try:
describe_log_groups_paginator = regional_client.get_paginator(
@@ -215,6 +244,12 @@ class Logs(AWSService):
never_expire=never_expire,
kms_id=kms,
creation_time=log_group.get("creationTime"),
data_protection_status=log_group.get(
"dataProtectionStatus"
),
inherited_properties=log_group.get(
"inheritedProperties", []
),
region=regional_client.region,
)
self.all_log_groups[log_group_object.arn] = log_group_object
@@ -224,14 +259,17 @@ class Logs(AWSService):
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
self.log_groups_unavailable = True
if not self.log_groups:
self.all_log_groups = None
self.log_groups = None
else:
self.log_groups_unavailable = True
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
except Exception as error:
self.log_groups_unavailable = True
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
@@ -337,6 +375,11 @@ class LogGroup(BaseModel):
never_expire: bool
kms_id: Optional[str]
creation_time: Optional[int] = None
# None when the log group has never had a data protection policy, otherwise
# ACTIVATED, DELETED, ARCHIVED or DISABLED.
data_protection_status: Optional[str] = None
# Properties inherited from account-level settings, e.g. ACCOUNT_DATA_PROTECTION.
inherited_properties: list[str] = []
region: str
log_streams: dict[str, list[str]] = (
{}
@@ -354,6 +397,7 @@ class MetricFilter(BaseModel):
arn: str
name: str
metric: str
metric_namespace: Optional[str] = None
pattern: str
log_group: Optional[LogGroup] = None
region: str
@@ -48,7 +48,28 @@ def check_cloudwatch_log_metric_filter(
metric_filters: list,
metric_alarms: list,
metadata: dict,
):
) -> Check_Report_AWS | None:
"""Report whether a trail's log group has a matching metric filter and an alarm.
Only metric filters attached to a log group that a CloudTrail trail delivers to
are considered, and only those whose own pattern matches
``metric_filter_pattern``. A filter whose log group was not retrieved is skipped
rather than dereferenced. One compliant filter anywhere short-circuits to PASS;
otherwise the last matching filter found without an alarm is returned as FAIL.
Args:
metric_filter_pattern: regex from ``build_metric_filter_pattern``, matched
against each filter's pattern with ``re.DOTALL``.
trails: CloudTrail trails keyed by ARN; only those with a log group count.
metric_filters: CloudWatch Logs metric filters to evaluate.
metric_alarms: CloudWatch alarms, matched to a filter by metric name and
region, and by namespace when both sides expose one.
metadata: check metadata for the emitted report.
Returns:
A ``Check_Report_AWS`` for the deciding log group, or ``None`` when no
filter matched or an inventory was not collected.
"""
report = None
# 1. Iterate for CloudWatch Log Group in CloudTrail trails
log_groups = []
@@ -58,6 +79,10 @@ def check_cloudwatch_log_metric_filter(
log_groups.append(trail.log_group_arn.split(":")[6])
# 2. Describe metric filters for previous log groups
for metric_filter in metric_filters:
# A filter whose log group was not retrieved cannot be matched against
# the trail log groups, so it cannot satisfy the requirement.
if metric_filter.log_group is None:
continue
if metric_filter.log_group.name in log_groups and re.search(
metric_filter_pattern, metric_filter.pattern, flags=re.DOTALL
):
@@ -66,9 +91,20 @@ def check_cloudwatch_log_metric_filter(
)
report.status = "FAIL"
report.status_extended = f"CloudWatch log group {metric_filter.log_group.name} found with metric filter {metric_filter.name} but no alarms associated."
# 3. Check if there is an alarm for the metric
# 3. Check if there is an alarm for the metric. The alarm must
# watch the same metric name in the same region, and the same
# namespace when both sides expose one — a same-named metric
# in another namespace or region is a different metric.
for alarm in metric_alarms:
if alarm.metric == metric_filter.metric:
if (
alarm.metric == metric_filter.metric
and alarm.region == metric_filter.region
and (
not metric_filter.metric_namespace
or not alarm.name_space
or alarm.name_space == metric_filter.metric_namespace
)
):
report.status = "PASS"
report.status_extended = f"CloudWatch log group {metric_filter.log_group.name} found with metric filter {metric_filter.name} and alarms set."
break
@@ -0,0 +1,41 @@
{
"Provider": "aws",
"CheckID": "ecr_registry_enhanced_scanning_enabled",
"CheckTitle": "ECR registry has enhanced scanning enabled",
"CheckType": [
"Software and Configuration Checks/Vulnerabilities/CVE",
"Software and Configuration Checks/AWS Security Best Practices"
],
"ServiceName": "ecr",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "Other",
"ResourceGroup": "container",
"Description": "Amazon ECR registries with repositories are evaluated for **enhanced scanning**, the Amazon Inspector-powered scan type that covers operating system **and** programming language packages and can rescan images continuously as new CVEs are published. A registry left on **basic** scanning is reported as failing.",
"Risk": "**Basic** scanning matches only OS packages against a static CVE list, so **application dependencies** (npm, PyPI, Maven, Go, .NET) are never assessed. It rescans only on push or on an explicit StartImageScan, at most once per 24 hours, so a CVE published since the last scan stays invisible until someone acts. Vulnerable images keep being pulled, enabling **RCE** and supply chain compromise.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/AmazonECR/latest/userguide/image-scanning-enhanced.html",
"https://docs.aws.amazon.com/inspector/latest/user/scanning-ecr.html",
"https://docs.aws.amazon.com/AmazonECR/latest/userguide/image-scanning.html"
],
"Remediation": {
"Code": {
"CLI": "aws ecr put-registry-scanning-configuration --scan-type ENHANCED --rules 'scanFrequency=CONTINUOUS_SCAN,repositoryFilters=[{filter=*,filterType=WILDCARD}]'",
"NativeIaC": "```yaml\nResources:\n RegistryScanningConfiguration:\n Type: AWS::ECR::RegistryScanningConfiguration\n Properties:\n ScanType: ENHANCED # Critical: BASIC limits the registry to operating-system coverage\n Rules:\n - ScanFrequency: CONTINUOUS_SCAN\n RepositoryFilters:\n - Filter: \"*\" # Critical: coverage comes from the filters, so * is what reaches every repository\n FilterType: WILDCARD\n```",
"Other": "1. Open the AWS Management Console and go to Amazon ECR\n2. In the left menu, click Private registry, then Scanning\n3. Click Edit\n4. Set Scanning type to Enhanced scanning\n5. Under Enhanced scanning, add or keep a repository filter matching every repository that must be scanned. Coverage comes from the filters, not from the frequency: a filter of * covers the whole registry, and any repository no filter matches is left unscanned\n6. Set the scan frequency for those filters, either Continuous scanning or Scan on push\n7. Click Save",
"Terraform": "```hcl\nresource \"aws_ecr_registry_scanning_configuration\" \"<example_resource_name>\" {\n scan_type = \"ENHANCED\"\n\n rule {\n scan_frequency = \"CONTINUOUS_SCAN\"\n repository_filter {\n filter = \"*\"\n filter_type = \"WILDCARD\"\n }\n }\n}\n```"
},
"Recommendation": {
"Text": "Set the registry scan type to **enhanced scanning**, which delegates scanning to **Amazon Inspector** and adds programming language package coverage plus continuous rescanning on top of the operating system coverage that basic scanning provides. Feed the resulting findings into CI/CD gates so vulnerable images are not promoted.",
"Url": "https://hub.prowler.com/check/ecr_registry_enhanced_scanning_enabled"
}
},
"Categories": [
"container-security"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "Scoped to registries that hold at least one repository. The ECR API accepts both the CONTINUOUS_SCAN and SCAN_ON_PUSH frequencies for the ENHANCED scan type, so ENHANCED on its own does not imply continuous rescanning; frequency is a separate axis this check does not assert. It is NOT a coverage guarantee, and this check does not claim one: enhanced scanning is driven by repository filters, so clearing the scan-all filter leaves any repository no filter matches unscanned. What this check asserts is the registry's scan TYPE, not that every repository in it is covered. Registry-wide scan-on-push coverage and its repository filters are asserted by ecr_registry_scan_images_on_push_enabled, which passes for either scan type. Reported as MANUAL when GetRegistryScanningConfiguration could not be read, because an unretrieved scan type is not evidence of compliance."
}
@@ -0,0 +1,43 @@
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.providers.aws.services.ecr.ecr_client import ecr_client
class ecr_registry_enhanced_scanning_enabled(Check):
"""Verify that in-use ECR registries have enhanced scanning enabled.
Enhanced scanning (Amazon Inspector) covers operating system and programming
language packages with continuous rescanning as new CVEs are published, while
basic scanning only covers operating system packages at push time against a
static CVE list. Only registries holding at least one repository are checked.
- PASS: the registry scan type is ENHANCED.
- FAIL: the registry is on another scan type.
- MANUAL: the registry scanning configuration could not be retrieved, so the
scan type is unknown.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Execute the check logic.
Returns:
A list of reports containing the result of the check.
"""
findings = []
for registry in ecr_client.registries.values():
# We want to check the registry if it is in use, hence there are repositories
if len(registry.repositories) != 0:
report = Check_Report_AWS(metadata=self.metadata(), resource=registry)
if registry.scan_type is None:
report.status = "MANUAL"
report.status_extended = f"ECR registry {registry.id} scanning configuration could not be retrieved, check manually if enhanced scanning is enabled."
elif registry.scan_type == "ENHANCED":
report.status = "PASS"
report.status_extended = (
f"ECR registry {registry.id} has enhanced scanning enabled."
)
else:
report.status = "FAIL"
report.status_extended = f"ECR registry {registry.id} has {registry.scan_type} scanning enabled instead of enhanced scanning."
findings.append(report)
return findings
@@ -1,7 +1,7 @@
{
"Provider": "aws",
"CheckID": "ecr_registry_scan_images_on_push_enabled",
"CheckTitle": "ECR registry has image scanning on push enabled for all repositories",
"CheckTitle": "ECR registry has automated image scanning enabled for all repositories",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices",
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices"
@@ -12,8 +12,8 @@
"Severity": "medium",
"ResourceType": "Other",
"ResourceGroup": "container",
"Description": "Amazon ECR registries with repositories are evaluated for image scanning configured as `scan on push` at the registry level, with scan rules that cover all repositories (no restrictive filters), for either **basic** or **enhanced** scanning.",
"Risk": "Absent or filtered `scan on push` lets **vulnerable images** be pushed and deployed without timely detection, enabling exploitation of known CVEs (RCE, privilege escalation), supply chain compromise, and lateral movement - threatening workload integrity and data confidentiality.",
"Description": "Amazon ECR registries with repositories are evaluated for automated image scanning at the registry level -- `scan on push` or `continuous scanning` -- with scan rules that cover all repositories (no restrictive filters), for either **basic** or **enhanced** scanning. A registry whose rules specify only `MANUAL` scanning does not scan pushed images.",
"Risk": "Without automated registry scanning, **vulnerable images** are pushed and deployed without timely detection, enabling exploitation of known CVEs (RCE, privilege escalation), supply chain compromise, and lateral movement - threatening workload integrity and data confidentiality. Repository filters narrow the same risk to whichever repositories they exclude.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/AmazonECR/latest/userguide/image-scanning.html"
@@ -1,27 +1,69 @@
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.providers.aws.services.ecr.ecr_client import ecr_client
# The two frequencies that scan an image without anyone asking. MANUAL is the third value
# GetRegistryScanningConfiguration can return, and it is the default a BASIC registry gets when
# scan on push is not specified, so it is the state this check exists to catch.
AUTOMATED_SCAN_FREQUENCIES = {"SCAN_ON_PUSH", "CONTINUOUS_SCAN"}
class ecr_registry_scan_images_on_push_enabled(Check):
def execute(self):
def execute(self) -> list[Check_Report_AWS]:
"""Execute the check against every ECR registry that holds repositories.
Returns:
A list of reports, one per in-use registry, PASS when its rules cover all
repositories with a frequency in AUTOMATED_SCAN_FREQUENCIES.
"""
findings = []
for registry in ecr_client.registries.values():
# We want to check the registry if it is in use, hence there are repositories
if len(registry.repositories) != 0:
report = Check_Report_AWS(metadata=self.metadata(), resource=registry)
report.status = "FAIL"
report.status_extended = f"ECR registry {registry.id} has {registry.scan_type} scanning without scan on push enabled."
report.status_extended = f"ECR registry {registry.id} has {registry.scan_type} scanning without automated scanning enabled."
if registry.rules:
report.status = "PASS"
report.status_extended = f"ECR registry {registry.id} has {registry.scan_type} scan with scan on push enabled."
filters = True
for rule in registry.rules:
if not rule.scan_filters or "'*'" in str(rule.scan_filters):
filters = False
if filters:
report.status = "FAIL"
report.status_extended = f"ECR registry {registry.id} has {registry.scan_type} scanning with scan on push but with repository filters."
# Read the frequency rather than inferring it from the presence of a rule. A rule
# always carries one -- scanFrequency is required on the shape -- and a MANUAL
# rule is a registry where nothing is scanned until someone runs a scan by hand.
frequencies = {
rule.scan_frequency
for rule in registry.rules
if rule.scan_frequency in AUTOMATED_SCAN_FREQUENCIES
}
if frequencies:
report.status = "PASS"
report.status_extended = f"ECR registry {registry.id} has {registry.scan_type} scanning with {self._describe(frequencies)} for all repositories."
filters = True
for rule in registry.rules:
if not rule.scan_filters or "'*'" in str(rule.scan_filters):
filters = False
if filters:
report.status = "FAIL"
report.status_extended = f"ECR registry {registry.id} has {registry.scan_type} scanning with {self._describe(frequencies)} but with repository filters."
else:
report.status_extended = f"ECR registry {registry.id} has {registry.scan_type} scanning set to manual only, so images are not scanned when they are pushed."
findings.append(report)
return findings
@staticmethod
def _describe(frequencies: set) -> str:
"""Name automated scan frequencies in a fixed order.
Args:
frequencies: The registry's configured frequencies, already narrowed to
AUTOMATED_SCAN_FREQUENCIES.
Returns:
The frequencies in a fixed order, so the message does not vary between runs for
the same registry.
"""
wording = {
"SCAN_ON_PUSH": "scan on push",
"CONTINUOUS_SCAN": "continuous scanning",
}
return " and ".join(
wording[f] for f in ("SCAN_ON_PUSH", "CONTINUOUS_SCAN") if f in frequencies
)

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