Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39e903ade3 | ||
|
|
477f3ebda1 | ||
|
|
86e4408f29 | ||
|
|
ae43d21efb | ||
|
|
3ca189a52a | ||
|
|
7c84822fa3 | ||
|
|
51c5fa7168 | ||
|
|
e9121f5f1a | ||
|
|
821fe43efd | ||
|
|
9ffbb4b758 | ||
|
|
4014fcb6c1 | ||
|
|
9c5285adc3 | ||
|
|
f295d290dd | ||
|
|
e5df95c259 | ||
|
|
b6a8af3c54 | ||
|
|
fb7064401b | ||
|
|
8d60f9703a | ||
|
|
ceb601028e | ||
|
|
6422178b76 | ||
|
|
587c47bfe2 | ||
|
|
13a31d9225 | ||
|
|
0715619435 | ||
|
|
1679094f22 |
@@ -30,6 +30,7 @@ jobs:
|
||||
- '3.11'
|
||||
- '3.12'
|
||||
- '3.13'
|
||||
- '3.14'
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -30,6 +30,7 @@ jobs:
|
||||
- '3.11'
|
||||
- '3.12'
|
||||
- '3.13'
|
||||
- '3.14'
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -18333,19 +18333,14 @@ class TestMuteRuleViewSet:
|
||||
assert len(data) == 2
|
||||
assert data[0]["id"] == str(mute_rules_fixture[first_index].id)
|
||||
|
||||
@patch("api.v1.views.chain")
|
||||
@patch("api.v1.views.reaggregate_all_finding_group_summaries_task.si")
|
||||
@patch("api.v1.views.mute_historical_findings_task.si")
|
||||
@patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async")
|
||||
@patch("api.v1.views.transaction.on_commit", side_effect=lambda fn: fn())
|
||||
def test_mute_rules_create_valid(
|
||||
self,
|
||||
_mock_on_commit,
|
||||
mock_mute_signature,
|
||||
mock_reaggregate_signature,
|
||||
mock_chain,
|
||||
mock_mute_task,
|
||||
authenticated_client,
|
||||
findings_fixture,
|
||||
create_test_user,
|
||||
):
|
||||
"""Test creating a valid mute rule."""
|
||||
finding_ids = [str(findings_fixture[0].id)]
|
||||
@@ -18372,24 +18367,20 @@ class TestMuteRuleViewSet:
|
||||
assert response_data["attributes"]["name"] == "New Mute Rule"
|
||||
assert response_data["attributes"]["reason"] == "Security exception approved"
|
||||
|
||||
# Verify the finding was immediately muted
|
||||
from api.models import Finding
|
||||
|
||||
finding = Finding.objects.get(id=findings_fixture[0].id)
|
||||
assert finding.muted is True
|
||||
assert finding.muted_at is not None
|
||||
assert finding.muted_reason == "Security exception approved"
|
||||
assert finding.muted is False
|
||||
assert finding.muted_at is None
|
||||
assert finding.muted_reason is None
|
||||
|
||||
# Verify background task chain was called: mute → reaggregate all
|
||||
mock_mute_signature.assert_called_once()
|
||||
mock_reaggregate_signature.assert_called_once()
|
||||
mock_chain.assert_called_once_with(
|
||||
mock_mute_signature.return_value,
|
||||
mock_reaggregate_signature.return_value,
|
||||
mock_mute_task.assert_called_once_with(
|
||||
kwargs={
|
||||
"tenant_id": str(finding.tenant_id),
|
||||
"mute_rule_id": response_data["id"],
|
||||
"provider_ids": [str(finding.scan.provider_id)],
|
||||
}
|
||||
)
|
||||
mock_chain.return_value.apply_async.assert_called_once()
|
||||
|
||||
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
|
||||
@patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async")
|
||||
def test_mute_rules_create_converts_finding_ids_to_uids(
|
||||
self,
|
||||
mock_task,
|
||||
@@ -18425,7 +18416,7 @@ class TestMuteRuleViewSet:
|
||||
]
|
||||
assert set(mute_rule.finding_uids) == set(expected_uids)
|
||||
|
||||
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
|
||||
@patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async")
|
||||
def test_mute_rules_deduplicates_uids(
|
||||
self,
|
||||
mock_task,
|
||||
@@ -18492,10 +18483,10 @@ class TestMuteRuleViewSet:
|
||||
|
||||
finding1.refresh_from_db()
|
||||
finding2.refresh_from_db()
|
||||
assert finding1.muted is True
|
||||
assert finding2.muted is True
|
||||
assert finding1.muted is False
|
||||
assert finding2.muted is False
|
||||
|
||||
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
|
||||
@patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async")
|
||||
def test_mute_rules_create_overlap_detection_active(
|
||||
self,
|
||||
mock_task,
|
||||
@@ -18528,7 +18519,7 @@ class TestMuteRuleViewSet:
|
||||
"already muted" in error_detail.lower() or "overlap" in error_detail.lower()
|
||||
)
|
||||
|
||||
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
|
||||
@patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async")
|
||||
def test_mute_rules_create_no_overlap_with_inactive(
|
||||
self,
|
||||
mock_task,
|
||||
@@ -18584,7 +18575,7 @@ class TestMuteRuleViewSet:
|
||||
== "/data/attributes/finding_ids"
|
||||
)
|
||||
|
||||
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
|
||||
@patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async")
|
||||
def test_mute_rules_create_invalid_finding_ids(
|
||||
self, mock_task, authenticated_client
|
||||
):
|
||||
|
||||
@@ -244,7 +244,6 @@ from api.v1.serializers import (
|
||||
UserUpdateSerializer,
|
||||
)
|
||||
from botocore.exceptions import ClientError, NoCredentialsError, ParamValidationError
|
||||
from celery import chain
|
||||
from celery.result import AsyncResult
|
||||
from config.custom_logging import BackendLogger
|
||||
from config.env import env
|
||||
@@ -342,8 +341,7 @@ from tasks.tasks import (
|
||||
enqueue_scan_execution_on_commit,
|
||||
get_active_provider_scan,
|
||||
jira_integration_task,
|
||||
mute_historical_findings_task,
|
||||
reaggregate_all_finding_group_summaries_task,
|
||||
mute_findings_in_latest_scans_task,
|
||||
refresh_lighthouse_provider_models_task,
|
||||
)
|
||||
|
||||
@@ -7551,35 +7549,28 @@ class MuteRuleViewSet(BaseRLSViewSet):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# Create the mute rule
|
||||
tenant_id = str(request.tenant_id)
|
||||
finding_ids = serializer.validated_data["finding_ids"]
|
||||
provider_ids = list(
|
||||
dict.fromkeys(
|
||||
Finding.all_objects.filter(
|
||||
id__in=finding_ids, tenant_id=tenant_id
|
||||
).values_list("scan__provider_id", flat=True)
|
||||
)
|
||||
)
|
||||
|
||||
mute_rule = serializer.save()
|
||||
|
||||
tenant_id = str(request.tenant_id)
|
||||
finding_ids = request.data.get("finding_ids", [])
|
||||
|
||||
# Immediately mute the selected findings
|
||||
Finding.all_objects.filter(
|
||||
id__in=finding_ids, tenant_id=tenant_id, muted=False
|
||||
).update(
|
||||
muted=True,
|
||||
muted_at=mute_rule.inserted_at,
|
||||
muted_reason=mute_rule.reason,
|
||||
)
|
||||
|
||||
# Launch background task for historical muting + reaggregation
|
||||
transaction.on_commit(
|
||||
lambda: chain(
|
||||
mute_historical_findings_task.si(
|
||||
tenant_id=tenant_id,
|
||||
mute_rule_id=str(mute_rule.id),
|
||||
),
|
||||
reaggregate_all_finding_group_summaries_task.si(
|
||||
tenant_id=tenant_id,
|
||||
),
|
||||
).apply_async()
|
||||
lambda: mute_findings_in_latest_scans_task.apply_async(
|
||||
kwargs={
|
||||
"tenant_id": tenant_id,
|
||||
"mute_rule_id": str(mute_rule.id),
|
||||
"provider_ids": [str(provider_id) for provider_id in provider_ids],
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# Return the created mute rule
|
||||
serializer = self.get_serializer(mute_rule)
|
||||
return Response(
|
||||
data=serializer.data,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,63 +1,104 @@
|
||||
from collections.abc import Iterable
|
||||
|
||||
from api.db_utils import rls_transaction
|
||||
from api.models import Finding, MuteRule
|
||||
from api.models import Finding, MuteRule, Scan, StateChoices
|
||||
from celery.utils.log import get_task_logger
|
||||
from config.django.base import DJANGO_FINDINGS_BATCH_SIZE
|
||||
from tasks.utils import batched
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
def mute_historical_findings(tenant_id: str, mute_rule_id: str):
|
||||
"""
|
||||
Mute historical findings that match the given mute rule.
|
||||
def _mute_findings_for_rule(
|
||||
*,
|
||||
tenant_id: str,
|
||||
scan_id: str,
|
||||
finding_uids: Iterable[str],
|
||||
muted_at,
|
||||
muted_reason: str,
|
||||
) -> int:
|
||||
finding_uids = list(finding_uids)
|
||||
if not finding_uids:
|
||||
return 0
|
||||
|
||||
This function processes findings in batches, updating their muted status
|
||||
and adding the mute reason.
|
||||
return Finding.all_objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
scan_id=scan_id,
|
||||
uid__in=finding_uids,
|
||||
muted=False,
|
||||
).update(
|
||||
muted=True,
|
||||
muted_at=muted_at,
|
||||
muted_reason=muted_reason,
|
||||
)
|
||||
|
||||
Args:
|
||||
tenant_id (str): The tenant ID for RLS context
|
||||
mute_rule_id (str): The ID of the mute rule to apply
|
||||
|
||||
Returns:
|
||||
dict: Summary of the muting operation with findings_muted count
|
||||
"""
|
||||
findings_muted_count = 0
|
||||
def mute_findings_in_latest_scans(
|
||||
tenant_id: str, mute_rule_id: str, provider_ids: list[str]
|
||||
) -> dict:
|
||||
"""Apply a mute rule to the latest completed scan of each provider."""
|
||||
provider_ids = list(dict.fromkeys(provider_ids))
|
||||
|
||||
# Get the list of UIDs to mute and the reason
|
||||
with rls_transaction(tenant_id):
|
||||
mute_rule = MuteRule.objects.get(id=mute_rule_id, tenant_id=tenant_id)
|
||||
finding_uids = mute_rule.finding_uids
|
||||
mute_reason = mute_rule.reason
|
||||
muted_at = mute_rule.inserted_at
|
||||
|
||||
# Query findings that match the UIDs and are not already muted
|
||||
with rls_transaction(tenant_id):
|
||||
findings_to_mute = Finding.objects.filter(
|
||||
tenant_id=tenant_id, uid__in=finding_uids, muted=False
|
||||
)
|
||||
total_findings = findings_to_mute.count()
|
||||
|
||||
logger.info(
|
||||
f"Processing {total_findings} findings for mute rule {mute_rule_id}"
|
||||
latest_scans = list(
|
||||
Scan.objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
provider_id__in=provider_ids,
|
||||
state=StateChoices.COMPLETED,
|
||||
completed_at__isnull=False,
|
||||
)
|
||||
.order_by("provider_id", "-completed_at", "-inserted_at", "-id")
|
||||
.distinct("provider_id")
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
|
||||
if total_findings > 0:
|
||||
for batch, is_last in batched(
|
||||
findings_to_mute.iterator(), DJANGO_FINDINGS_BATCH_SIZE
|
||||
):
|
||||
batch_ids = [f.id for f in batch]
|
||||
updated_count = Finding.all_objects.filter(
|
||||
id__in=batch_ids, tenant_id=tenant_id
|
||||
).update(
|
||||
muted=True,
|
||||
muted_at=muted_at,
|
||||
muted_reason=mute_reason,
|
||||
)
|
||||
findings_muted_count += updated_count
|
||||
|
||||
logger.info(f"Muted {findings_muted_count} findings for rule {mute_rule_id}")
|
||||
changed_scan_ids = []
|
||||
findings_muted = 0
|
||||
for scan_id in latest_scans:
|
||||
updated = _mute_findings_for_rule(
|
||||
tenant_id=tenant_id,
|
||||
scan_id=str(scan_id),
|
||||
finding_uids=mute_rule.finding_uids,
|
||||
muted_at=mute_rule.inserted_at,
|
||||
muted_reason=mute_rule.reason,
|
||||
)
|
||||
if updated:
|
||||
findings_muted += updated
|
||||
changed_scan_ids.append(str(scan_id))
|
||||
|
||||
logger.info(
|
||||
"Muted %d findings in %d latest scans for rule %s",
|
||||
findings_muted,
|
||||
len(changed_scan_ids),
|
||||
mute_rule_id,
|
||||
)
|
||||
return {
|
||||
"findings_muted": findings_muted_count,
|
||||
"findings_muted": findings_muted,
|
||||
"rule_id": mute_rule_id,
|
||||
"scan_ids": changed_scan_ids,
|
||||
}
|
||||
|
||||
|
||||
def reconcile_scan_mute_rules(tenant_id: str, scan_id: str) -> dict:
|
||||
"""Apply the current enabled mute rules to one completed scan."""
|
||||
findings_muted = 0
|
||||
|
||||
with rls_transaction(tenant_id):
|
||||
mute_rules = MuteRule.objects.filter(tenant_id=tenant_id, enabled=True).values(
|
||||
"finding_uids", "reason", "inserted_at"
|
||||
)
|
||||
|
||||
for mute_rule in mute_rules:
|
||||
findings_muted += _mute_findings_for_rule(
|
||||
tenant_id=tenant_id,
|
||||
scan_id=scan_id,
|
||||
finding_uids=mute_rule["finding_uids"],
|
||||
muted_at=mute_rule["inserted_at"],
|
||||
muted_reason=mute_rule["reason"],
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Reconciled mute rules for scan %s; muted %d findings",
|
||||
scan_id,
|
||||
findings_muted,
|
||||
)
|
||||
return {"findings_muted": findings_muted, "scan_id": str(scan_id)}
|
||||
|
||||
@@ -73,7 +73,10 @@ from tasks.jobs.lighthouse_providers import (
|
||||
check_lighthouse_provider_connection,
|
||||
refresh_lighthouse_provider_models,
|
||||
)
|
||||
from tasks.jobs.muting import mute_historical_findings
|
||||
from tasks.jobs.muting import (
|
||||
mute_findings_in_latest_scans,
|
||||
reconcile_scan_mute_rules,
|
||||
)
|
||||
from tasks.jobs.orphan_recovery import reconcile_orphans
|
||||
from tasks.jobs.report import (
|
||||
STALE_TMP_OUTPUT_MAX_AGE_HOURS,
|
||||
@@ -526,6 +529,7 @@ def perform_scan_task(
|
||||
provider_id=provider_id,
|
||||
checks_to_execute=checks_to_execute,
|
||||
)
|
||||
reconcile_scan_mute_rules(tenant_id, scan_id)
|
||||
_perform_scan_complete_tasks(tenant_id, scan_id, provider_id)
|
||||
return result
|
||||
finally:
|
||||
@@ -635,6 +639,7 @@ def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str):
|
||||
scan_id=str(scan_instance.id),
|
||||
provider_id=provider_id,
|
||||
)
|
||||
reconcile_scan_mute_rules(tenant_id, str(scan_instance.id))
|
||||
_perform_scan_complete_tasks(tenant_id, str(scan_instance.id), provider_id)
|
||||
return result
|
||||
finally:
|
||||
@@ -1188,85 +1193,48 @@ def aggregate_finding_group_summaries_task(tenant_id: str, scan_id: str):
|
||||
return aggregate_finding_group_summaries(tenant_id=tenant_id, scan_id=scan_id)
|
||||
|
||||
|
||||
@shared_task(
|
||||
base=RLSTask, name="reaggregate-all-finding-group-summaries", queue="overview"
|
||||
)
|
||||
@set_tenant(keep_tenant=True)
|
||||
def reaggregate_all_finding_group_summaries_task(tenant_id: str):
|
||||
"""Reaggregate every pre-aggregated summary table for this tenant.
|
||||
def _dispatch_scan_summary_reaggregation(tenant_id: str, scan_ids: list[str]) -> None:
|
||||
if not scan_ids:
|
||||
return
|
||||
|
||||
Mirrors the unbounded scope of `mute_historical_findings_task`: that task
|
||||
rewrites every Finding row whose UID matches a mute rule, with no time
|
||||
limit. To keep the pre-aggregated tables consistent with that update,
|
||||
this task re-runs the same per-scan aggregation pipeline that scan
|
||||
completion runs on the latest completed scan of every (provider, day)
|
||||
pair, rebuilding the tables that power the read endpoints:
|
||||
|
||||
- `ScanSummary` and `DailySeveritySummary` -> `/overviews/findings`,
|
||||
`/overviews/findings-severity`, `/overviews/services`.
|
||||
- `FindingGroupDailySummary` -> `/finding-groups` and
|
||||
`/finding-groups/latest`.
|
||||
- `ScanGroupSummary` -> `/overviews/resource-groups` (resource
|
||||
inventory).
|
||||
- `ScanCategorySummary` -> `/overviews/categories`.
|
||||
- `AttackSurfaceOverview` -> `/overviews/attack-surfaces`.
|
||||
|
||||
Per-scan pipelines are dispatched in parallel via a Celery group so
|
||||
wallclock scales with the worker pool.
|
||||
"""
|
||||
completed_scans = list(
|
||||
Scan.objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
state=StateChoices.COMPLETED,
|
||||
completed_at__isnull=False,
|
||||
)
|
||||
.order_by("-completed_at")
|
||||
.values("id", "completed_at", "provider_id")
|
||||
logger.info(
|
||||
"Reaggregating overview/finding summaries for %d latest scans",
|
||||
len(scan_ids),
|
||||
)
|
||||
|
||||
# Keep the latest scan per (provider, day) pair so the daily summary row
|
||||
# the aggregator writes is the most recent snapshot of that day for that
|
||||
# provider. Iterating from most recent to oldest means the first scan we
|
||||
# see for a given key wins.
|
||||
latest_scans: dict[tuple, str] = {}
|
||||
for scan in completed_scans:
|
||||
key = (scan["provider_id"], scan["completed_at"].date())
|
||||
if key not in latest_scans:
|
||||
latest_scans[key] = str(scan["id"])
|
||||
|
||||
scan_ids = list(latest_scans.values())
|
||||
if scan_ids:
|
||||
logger.info(
|
||||
"Reaggregating overview/finding summaries for %d scans (provider x day)",
|
||||
len(scan_ids),
|
||||
)
|
||||
# DailySeveritySummary reads from ScanSummary, so ScanSummary must be
|
||||
# recomputed first; the other aggregators read Finding directly and
|
||||
# can run in parallel with the severity step.
|
||||
group(
|
||||
chain(
|
||||
perform_scan_summary_task.si(tenant_id=tenant_id, scan_id=scan_id),
|
||||
group(
|
||||
aggregate_daily_severity_task.si(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
),
|
||||
aggregate_finding_group_summaries_task.si(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
),
|
||||
aggregate_scan_resource_group_summaries_task.si(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
),
|
||||
aggregate_scan_category_summaries_task.si(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
),
|
||||
aggregate_attack_surface_task.si(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
),
|
||||
group(
|
||||
chain(
|
||||
perform_scan_summary_task.si(tenant_id=tenant_id, scan_id=scan_id),
|
||||
group(
|
||||
aggregate_daily_severity_task.si(tenant_id=tenant_id, scan_id=scan_id),
|
||||
aggregate_finding_group_summaries_task.si(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
),
|
||||
)
|
||||
for scan_id in scan_ids
|
||||
).apply_async()
|
||||
return {"scans_reaggregated": len(scan_ids)}
|
||||
aggregate_scan_resource_group_summaries_task.si(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
),
|
||||
aggregate_scan_category_summaries_task.si(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
),
|
||||
aggregate_attack_surface_task.si(tenant_id=tenant_id, scan_id=scan_id),
|
||||
),
|
||||
)
|
||||
for scan_id in scan_ids
|
||||
).apply_async()
|
||||
|
||||
|
||||
@shared_task(base=RLSTask, name="findings-mute-latest-scans", queue="overview")
|
||||
@set_tenant(keep_tenant=True)
|
||||
def mute_findings_in_latest_scans_task(
|
||||
tenant_id: str, mute_rule_id: str, provider_ids: list[str]
|
||||
):
|
||||
"""Apply a mute rule to current scans and rebuild only changed summaries."""
|
||||
result = mute_findings_in_latest_scans(
|
||||
tenant_id=tenant_id,
|
||||
mute_rule_id=mute_rule_id,
|
||||
provider_ids=provider_ids,
|
||||
)
|
||||
_dispatch_scan_summary_reaggregation(tenant_id, result["scan_ids"])
|
||||
return result
|
||||
|
||||
|
||||
@shared_task(base=RLSTask, name="lighthouse-connection-check")
|
||||
@@ -1467,25 +1435,3 @@ def generate_compliance_reports_task(tenant_id: str, scan_id: str, provider_id:
|
||||
generate_csa=True,
|
||||
generate_cis=True,
|
||||
)
|
||||
|
||||
|
||||
@shared_task(name="findings-mute-historical")
|
||||
def mute_historical_findings_task(tenant_id: str, mute_rule_id: str):
|
||||
"""
|
||||
Background task to mute all historical findings matching a mute rule.
|
||||
|
||||
This task processes findings in batches to avoid memory issues with large datasets.
|
||||
It updates the Finding.muted, Finding.muted_at, and Finding.muted_reason fields
|
||||
for all findings whose UID is in the mute rule's finding_uids list.
|
||||
|
||||
Args:
|
||||
tenant_id (str): The tenant ID for RLS context.
|
||||
mute_rule_id (str): The primary key of the MuteRule to apply.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing:
|
||||
- 'findings_muted' (int): Total number of findings muted.
|
||||
- 'rule_id' (str): The mute rule ID.
|
||||
- 'status' (str): Final status ('completed').
|
||||
"""
|
||||
return mute_historical_findings(tenant_id, mute_rule_id)
|
||||
|
||||
@@ -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") == ""
|
||||
|
||||
@@ -1,531 +1,205 @@
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from api.models import Finding, MuteRule
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from api.models import Finding, MuteRule, Scan, StateChoices
|
||||
from prowler.lib.check.models import Severity
|
||||
from prowler.lib.outputs.finding import Status
|
||||
from tasks.jobs.muting import mute_historical_findings
|
||||
from tasks.jobs.muting import (
|
||||
mute_findings_in_latest_scans,
|
||||
reconcile_scan_mute_rules,
|
||||
)
|
||||
|
||||
|
||||
def _create_finding(scan: Scan, uid: str) -> Finding:
|
||||
return Finding.objects.create(
|
||||
tenant_id=scan.tenant_id,
|
||||
uid=uid,
|
||||
scan=scan,
|
||||
status=Status.FAIL,
|
||||
status_extended="Test finding",
|
||||
impact=Severity.high,
|
||||
severity=Severity.high,
|
||||
raw_result={},
|
||||
check_id="test_check",
|
||||
check_metadata={"CheckId": "test_check"},
|
||||
muted=False,
|
||||
)
|
||||
|
||||
|
||||
def _create_mute_rule(tenant_id, user, finding_uids, *, enabled=True) -> MuteRule:
|
||||
return MuteRule.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
name=f"Mute rule {uuid4()}",
|
||||
reason="Approved exception",
|
||||
enabled=enabled,
|
||||
created_by=user,
|
||||
finding_uids=finding_uids,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestMuteHistoricalFindings:
|
||||
"""
|
||||
Test suite for the mute_historical_findings function.
|
||||
class TestMuteFindingsInLatestScans:
|
||||
def test_mutes_latest_scan_and_leaves_older_scan_unchanged(
|
||||
self, scans_fixture, create_test_user
|
||||
):
|
||||
latest_scan = scans_fixture[0]
|
||||
older_scan = Scan.objects.create(
|
||||
tenant_id=latest_scan.tenant_id,
|
||||
provider=latest_scan.provider,
|
||||
name="Older scan",
|
||||
trigger=Scan.TriggerChoices.MANUAL,
|
||||
state=StateChoices.COMPLETED,
|
||||
started_at=datetime.now(UTC) - timedelta(days=1),
|
||||
completed_at=datetime.now(UTC) - timedelta(days=1),
|
||||
)
|
||||
uid = "latest-scan-only"
|
||||
older_finding = _create_finding(older_scan, uid)
|
||||
latest_finding = _create_finding(latest_scan, uid)
|
||||
mute_rule = _create_mute_rule(latest_scan.tenant_id, create_test_user, [uid])
|
||||
|
||||
This class tests the batch processing of findings to update their muted status
|
||||
based on MuteRule criteria.
|
||||
"""
|
||||
result = mute_findings_in_latest_scans(
|
||||
str(latest_scan.tenant_id),
|
||||
str(mute_rule.id),
|
||||
[str(latest_scan.provider_id)],
|
||||
)
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def test_user(self, create_test_user):
|
||||
"""Create a test user for mute rule creation."""
|
||||
return create_test_user
|
||||
older_finding.refresh_from_db()
|
||||
latest_finding.refresh_from_db()
|
||||
assert older_finding.muted is False
|
||||
assert latest_finding.muted is True
|
||||
assert latest_finding.muted_at == mute_rule.inserted_at
|
||||
assert latest_finding.muted_reason == mute_rule.reason
|
||||
assert result == {
|
||||
"findings_muted": 1,
|
||||
"rule_id": str(mute_rule.id),
|
||||
"scan_ids": [str(latest_scan.id)],
|
||||
}
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def mute_rule_with_findings(self, tenants_fixture, findings_fixture, test_user):
|
||||
"""
|
||||
Create a mute rule that targets the first finding in the fixture.
|
||||
"""
|
||||
def test_mutes_one_latest_scan_per_provider(self, scans_fixture, create_test_user):
|
||||
first_scan, second_scan, _ = scans_fixture
|
||||
uid = "shared-selected-uid"
|
||||
first_finding = _create_finding(first_scan, uid)
|
||||
second_finding = _create_finding(second_scan, uid)
|
||||
mute_rule = _create_mute_rule(first_scan.tenant_id, create_test_user, [uid])
|
||||
|
||||
result = mute_findings_in_latest_scans(
|
||||
str(first_scan.tenant_id),
|
||||
str(mute_rule.id),
|
||||
[str(first_scan.provider_id), str(second_scan.provider_id)],
|
||||
)
|
||||
|
||||
first_finding.refresh_from_db()
|
||||
second_finding.refresh_from_db()
|
||||
assert first_finding.muted is True
|
||||
assert second_finding.muted is True
|
||||
assert result["findings_muted"] == 2
|
||||
assert set(result["scan_ids"]) == {str(first_scan.id), str(second_scan.id)}
|
||||
|
||||
def test_provider_without_completed_scan_does_nothing(
|
||||
self, tenants_fixture, provider_factory, create_test_user
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
finding = findings_fixture[0]
|
||||
|
||||
mute_rule = MuteRule.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
name="Test Mute Rule",
|
||||
reason="Testing mute functionality",
|
||||
enabled=True,
|
||||
created_by=test_user,
|
||||
finding_uids=[finding.uid],
|
||||
provider = provider_factory()
|
||||
mute_rule = _create_mute_rule(
|
||||
tenant.id, create_test_user, ["future-scan-finding"]
|
||||
)
|
||||
|
||||
return mute_rule
|
||||
result = mute_findings_in_latest_scans(
|
||||
str(tenant.id), str(mute_rule.id), [str(provider.id)]
|
||||
)
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def mute_rule_multiple_findings(self, scans_fixture, test_user):
|
||||
"""
|
||||
Create multiple unmuted findings and a mute rule targeting all of them.
|
||||
"""
|
||||
assert result == {
|
||||
"findings_muted": 0,
|
||||
"rule_id": str(mute_rule.id),
|
||||
"scan_ids": [],
|
||||
}
|
||||
|
||||
def test_retry_does_not_report_changed_scans_twice(
|
||||
self, scans_fixture, create_test_user
|
||||
):
|
||||
scan = scans_fixture[0]
|
||||
tenant_id = scan.tenant_id
|
||||
|
||||
# Create 5 unmuted findings
|
||||
finding_uids = []
|
||||
for i in range(5):
|
||||
finding = Finding.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
uid=f"test_finding_uid_mute_{i}",
|
||||
scan=scan,
|
||||
status=Status.FAIL,
|
||||
status_extended=f"Test status {i}",
|
||||
impact=Severity.high,
|
||||
severity=Severity.high,
|
||||
raw_result={
|
||||
"status": Status.FAIL,
|
||||
"impact": Severity.high,
|
||||
"severity": Severity.high,
|
||||
},
|
||||
check_id=f"test_check_id_{i}",
|
||||
check_metadata={
|
||||
"CheckId": f"test_check_id_{i}",
|
||||
"Description": f"Test description {i}",
|
||||
},
|
||||
muted=False,
|
||||
)
|
||||
finding_uids.append(finding.uid)
|
||||
|
||||
# Create mute rule targeting all findings
|
||||
mute_rule = MuteRule.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
name="Test Multiple Findings Mute Rule",
|
||||
reason="Testing batch muting",
|
||||
enabled=True,
|
||||
created_by=test_user,
|
||||
finding_uids=finding_uids,
|
||||
finding = _create_finding(scan, "idempotent-mute")
|
||||
mute_rule = _create_mute_rule(scan.tenant_id, create_test_user, [finding.uid])
|
||||
args = (
|
||||
str(scan.tenant_id),
|
||||
str(mute_rule.id),
|
||||
[str(scan.provider_id)],
|
||||
)
|
||||
|
||||
return mute_rule, finding_uids
|
||||
first_result = mute_findings_in_latest_scans(*args)
|
||||
second_result = mute_findings_in_latest_scans(*args)
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def mute_rule_already_muted(self, findings_fixture, test_user):
|
||||
"""
|
||||
Create a mute rule that targets an already-muted finding.
|
||||
"""
|
||||
tenant_id = findings_fixture[1].tenant_id
|
||||
already_muted_finding = findings_fixture[1]
|
||||
assert first_result["scan_ids"] == [str(scan.id)]
|
||||
assert second_result["findings_muted"] == 0
|
||||
assert second_result["scan_ids"] == []
|
||||
|
||||
mute_rule = MuteRule.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
name="Test Already Muted Rule",
|
||||
reason="Testing already muted findings",
|
||||
enabled=True,
|
||||
created_by=test_user,
|
||||
finding_uids=[already_muted_finding.uid],
|
||||
def test_does_not_cross_tenant_boundary(
|
||||
self, tenants_fixture, provider_factory, create_test_user
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
other_tenant = tenants_fixture[2]
|
||||
other_provider = provider_factory(tenant=other_tenant)
|
||||
other_scan = Scan.objects.create(
|
||||
tenant_id=other_tenant.id,
|
||||
provider=other_provider,
|
||||
name="Other tenant scan",
|
||||
trigger=Scan.TriggerChoices.MANUAL,
|
||||
state=StateChoices.COMPLETED,
|
||||
started_at=datetime.now(UTC),
|
||||
completed_at=datetime.now(UTC),
|
||||
)
|
||||
other_finding = _create_finding(other_scan, "tenant-isolated-uid")
|
||||
mute_rule = _create_mute_rule(tenant.id, create_test_user, [other_finding.uid])
|
||||
|
||||
result = mute_findings_in_latest_scans(
|
||||
str(tenant.id), str(mute_rule.id), [str(other_provider.id)]
|
||||
)
|
||||
|
||||
return mute_rule
|
||||
other_finding.refresh_from_db()
|
||||
assert other_finding.muted is False
|
||||
assert result["scan_ids"] == []
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def mute_rule_mixed_findings(self, scans_fixture, test_user):
|
||||
"""
|
||||
Create a mute rule with a mix of muted and unmuted findings.
|
||||
"""
|
||||
def test_nonexistent_rule_raises(self, tenants_fixture):
|
||||
with pytest.raises(MuteRule.DoesNotExist):
|
||||
mute_findings_in_latest_scans(str(tenants_fixture[0].id), str(uuid4()), [])
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestReconcileScanMuteRules:
|
||||
def test_applies_only_enabled_rules_to_requested_scan(
|
||||
self, scans_fixture, create_test_user
|
||||
):
|
||||
scan = scans_fixture[0]
|
||||
tenant_id = scan.tenant_id
|
||||
|
||||
# Create 3 unmuted findings
|
||||
unmuted_uids = []
|
||||
for i in range(3):
|
||||
finding = Finding.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
uid=f"unmuted_finding_{i}",
|
||||
scan=scan,
|
||||
status=Status.FAIL,
|
||||
status_extended=f"Unmuted status {i}",
|
||||
impact=Severity.medium,
|
||||
severity=Severity.medium,
|
||||
raw_result={
|
||||
"status": Status.FAIL,
|
||||
"impact": Severity.medium,
|
||||
"severity": Severity.medium,
|
||||
},
|
||||
check_id=f"unmuted_check_{i}",
|
||||
check_metadata={
|
||||
"CheckId": f"unmuted_check_{i}",
|
||||
"Description": f"Unmuted description {i}",
|
||||
},
|
||||
muted=False,
|
||||
)
|
||||
unmuted_uids.append(finding.uid)
|
||||
|
||||
# Create 2 already muted findings
|
||||
muted_uids = []
|
||||
for i in range(2):
|
||||
finding = Finding.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
uid=f"muted_finding_{i}",
|
||||
scan=scan,
|
||||
status=Status.FAIL,
|
||||
status_extended=f"Muted status {i}",
|
||||
impact=Severity.low,
|
||||
severity=Severity.low,
|
||||
raw_result={
|
||||
"status": Status.FAIL,
|
||||
"impact": Severity.low,
|
||||
"severity": Severity.low,
|
||||
},
|
||||
check_id=f"muted_check_{i}",
|
||||
check_metadata={
|
||||
"CheckId": f"muted_check_{i}",
|
||||
"Description": f"Muted description {i}",
|
||||
},
|
||||
muted=True,
|
||||
muted_at=datetime.now(UTC),
|
||||
muted_reason="Already muted",
|
||||
)
|
||||
muted_uids.append(finding.uid)
|
||||
|
||||
# Create mute rule targeting all findings
|
||||
all_uids = unmuted_uids + muted_uids
|
||||
mute_rule = MuteRule.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
name="Test Mixed Findings Rule",
|
||||
reason="Testing mixed muted/unmuted findings",
|
||||
enabled=True,
|
||||
created_by=test_user,
|
||||
finding_uids=all_uids,
|
||||
active_finding = _create_finding(scan, "active-rule-uid")
|
||||
disabled_finding = _create_finding(scan, "disabled-rule-uid")
|
||||
active_rule = _create_mute_rule(
|
||||
scan.tenant_id, create_test_user, [active_finding.uid]
|
||||
)
|
||||
|
||||
return mute_rule, unmuted_uids, muted_uids
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def mute_rule_batch_test(self, scans_fixture, test_user):
|
||||
"""
|
||||
Create enough findings to test batch processing (>1000 for default batch size).
|
||||
"""
|
||||
scan = scans_fixture[0]
|
||||
tenant_id = scan.tenant_id
|
||||
|
||||
# Create 1500 findings to exceed default batch size of 1000
|
||||
finding_uids = []
|
||||
for i in range(1500):
|
||||
finding = Finding.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
uid=f"batch_test_finding_{i}",
|
||||
scan=scan,
|
||||
status=Status.FAIL,
|
||||
status_extended=f"Batch test status {i}",
|
||||
impact=Severity.critical,
|
||||
severity=Severity.critical,
|
||||
raw_result={
|
||||
"status": Status.FAIL,
|
||||
"impact": Severity.critical,
|
||||
"severity": Severity.critical,
|
||||
},
|
||||
check_id=f"batch_test_check_{i}",
|
||||
check_metadata={
|
||||
"CheckId": f"batch_test_check_{i}",
|
||||
"Description": f"Batch test description {i}",
|
||||
},
|
||||
muted=False,
|
||||
)
|
||||
finding_uids.append(finding.uid)
|
||||
|
||||
# Create mute rule targeting all findings
|
||||
mute_rule = MuteRule.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
name="Test Batch Processing Rule",
|
||||
reason="Testing batch processing functionality",
|
||||
enabled=True,
|
||||
created_by=test_user,
|
||||
finding_uids=finding_uids,
|
||||
_create_mute_rule(
|
||||
scan.tenant_id,
|
||||
create_test_user,
|
||||
[disabled_finding.uid],
|
||||
enabled=False,
|
||||
)
|
||||
|
||||
return mute_rule, finding_uids
|
||||
|
||||
def test_mute_historical_findings_single_finding(
|
||||
self, mute_rule_with_findings, findings_fixture
|
||||
):
|
||||
"""
|
||||
Test muting a single historical finding.
|
||||
"""
|
||||
mute_rule = mute_rule_with_findings
|
||||
tenant_id = str(mute_rule.tenant_id)
|
||||
finding = findings_fixture[0]
|
||||
|
||||
# Ensure the finding is not muted before execution
|
||||
finding.refresh_from_db()
|
||||
assert finding.muted is False
|
||||
assert finding.muted_at is None
|
||||
assert finding.muted_reason is None
|
||||
|
||||
# Execute the muting function
|
||||
result = mute_historical_findings(tenant_id, str(mute_rule.id))
|
||||
|
||||
# Verify return value
|
||||
assert result["findings_muted"] == 1
|
||||
assert result["rule_id"] == str(mute_rule.id)
|
||||
|
||||
# Verify the finding was muted
|
||||
finding.refresh_from_db()
|
||||
assert finding.muted is True
|
||||
assert finding.muted_at == mute_rule.inserted_at
|
||||
assert finding.muted_reason == mute_rule.reason
|
||||
|
||||
def test_mute_historical_findings_multiple_findings(
|
||||
self, mute_rule_multiple_findings
|
||||
):
|
||||
"""
|
||||
Test muting multiple historical findings.
|
||||
"""
|
||||
mute_rule, finding_uids = mute_rule_multiple_findings
|
||||
tenant_id = str(mute_rule.tenant_id)
|
||||
|
||||
# Verify all findings are unmuted
|
||||
findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=finding_uids)
|
||||
assert findings.count() == 5
|
||||
for finding in findings:
|
||||
assert finding.muted is False
|
||||
|
||||
# Execute the muting function
|
||||
result = mute_historical_findings(tenant_id, str(mute_rule.id))
|
||||
|
||||
# Verify return value
|
||||
assert result["findings_muted"] == 5
|
||||
assert result["rule_id"] == str(mute_rule.id)
|
||||
|
||||
# Verify all findings were muted
|
||||
findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=finding_uids)
|
||||
for finding in findings:
|
||||
assert finding.muted is True
|
||||
assert finding.muted_at == mute_rule.inserted_at
|
||||
assert finding.muted_reason == mute_rule.reason
|
||||
|
||||
def test_mute_historical_findings_already_muted(
|
||||
self, mute_rule_already_muted, findings_fixture
|
||||
):
|
||||
"""
|
||||
Test that already-muted findings are not counted or updated.
|
||||
"""
|
||||
mute_rule = mute_rule_already_muted
|
||||
tenant_id = str(mute_rule.tenant_id)
|
||||
finding = findings_fixture[1]
|
||||
|
||||
# Verify the finding is already muted
|
||||
finding.refresh_from_db()
|
||||
assert finding.muted is True
|
||||
original_muted_at = finding.muted_at
|
||||
original_muted_reason = finding.muted_reason
|
||||
|
||||
# Execute the muting function
|
||||
result = mute_historical_findings(tenant_id, str(mute_rule.id))
|
||||
|
||||
# Verify no findings were muted
|
||||
assert result["findings_muted"] == 0
|
||||
assert result["rule_id"] == str(mute_rule.id)
|
||||
|
||||
# Verify the finding's mute status did not change
|
||||
finding.refresh_from_db()
|
||||
assert finding.muted is True
|
||||
assert finding.muted_at == original_muted_at
|
||||
assert finding.muted_reason == original_muted_reason
|
||||
|
||||
def test_mute_historical_findings_mixed_status(self, mute_rule_mixed_findings):
|
||||
"""
|
||||
Test muting when some findings are already muted and others are not.
|
||||
"""
|
||||
mute_rule, unmuted_uids, muted_uids = mute_rule_mixed_findings
|
||||
tenant_id = str(mute_rule.tenant_id)
|
||||
|
||||
# Execute the muting function
|
||||
result = mute_historical_findings(tenant_id, str(mute_rule.id))
|
||||
|
||||
# Verify only unmuted findings were counted
|
||||
assert result["findings_muted"] == 3
|
||||
assert result["rule_id"] == str(mute_rule.id)
|
||||
|
||||
# Verify unmuted findings are now muted
|
||||
unmuted_findings = Finding.objects.filter(
|
||||
tenant_id=tenant_id, uid__in=unmuted_uids
|
||||
older_scan = Scan.objects.create(
|
||||
tenant_id=scan.tenant_id,
|
||||
provider=scan.provider,
|
||||
name="Older matching scan",
|
||||
trigger=Scan.TriggerChoices.MANUAL,
|
||||
state=StateChoices.COMPLETED,
|
||||
started_at=datetime.now(UTC) - timedelta(days=1),
|
||||
completed_at=datetime.now(UTC) - timedelta(days=1),
|
||||
)
|
||||
for finding in unmuted_findings:
|
||||
assert finding.muted is True
|
||||
assert finding.muted_at == mute_rule.inserted_at
|
||||
assert finding.muted_reason == mute_rule.reason
|
||||
older_finding = _create_finding(older_scan, active_finding.uid)
|
||||
|
||||
# Verify already-muted findings remained unchanged
|
||||
already_muted_findings = Finding.objects.filter(
|
||||
tenant_id=tenant_id, uid__in=muted_uids
|
||||
)
|
||||
for finding in already_muted_findings:
|
||||
assert finding.muted is True
|
||||
assert finding.muted_reason == "Already muted"
|
||||
result = reconcile_scan_mute_rules(str(scan.tenant_id), str(scan.id))
|
||||
|
||||
def test_mute_historical_findings_nonexistent_rule(self, tenants_fixture):
|
||||
"""
|
||||
Test that a nonexistent mute rule raises ObjectDoesNotExist.
|
||||
"""
|
||||
tenant_id = str(tenants_fixture[0].id)
|
||||
nonexistent_rule_id = str(uuid4())
|
||||
|
||||
with pytest.raises(ObjectDoesNotExist):
|
||||
mute_historical_findings(tenant_id, nonexistent_rule_id)
|
||||
|
||||
def test_mute_historical_findings_no_matching_findings(
|
||||
self, tenants_fixture, test_user
|
||||
):
|
||||
"""
|
||||
Test muting when no findings match the rule's UIDs.
|
||||
"""
|
||||
tenant_id = str(tenants_fixture[0].id)
|
||||
|
||||
# Create a mute rule with non-existent finding UIDs
|
||||
mute_rule = MuteRule.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
name="Test No Match Rule",
|
||||
reason="Testing no matching findings",
|
||||
enabled=True,
|
||||
created_by=test_user,
|
||||
finding_uids=[
|
||||
"nonexistent_uid_1",
|
||||
"nonexistent_uid_2",
|
||||
"nonexistent_uid_3",
|
||||
],
|
||||
)
|
||||
|
||||
# Execute the muting function
|
||||
result = mute_historical_findings(tenant_id, str(mute_rule.id))
|
||||
|
||||
# Verify no findings were muted
|
||||
assert result["findings_muted"] == 0
|
||||
assert result["rule_id"] == str(mute_rule.id)
|
||||
|
||||
def test_mute_historical_findings_batch_processing(self, mute_rule_batch_test):
|
||||
"""
|
||||
Test that large numbers of findings are processed in batches correctly.
|
||||
"""
|
||||
mute_rule, finding_uids = mute_rule_batch_test
|
||||
tenant_id = str(mute_rule.tenant_id)
|
||||
|
||||
# Verify all findings exist and are unmuted
|
||||
findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=finding_uids)
|
||||
assert findings.count() == 1500
|
||||
for finding in findings:
|
||||
assert finding.muted is False
|
||||
|
||||
# Execute the muting function
|
||||
result = mute_historical_findings(tenant_id, str(mute_rule.id))
|
||||
|
||||
# Verify return value
|
||||
assert result["findings_muted"] == 1500
|
||||
assert result["rule_id"] == str(mute_rule.id)
|
||||
|
||||
# Verify all findings were muted
|
||||
findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=finding_uids)
|
||||
for finding in findings:
|
||||
assert finding.muted is True
|
||||
assert finding.muted_at == mute_rule.inserted_at
|
||||
assert finding.muted_reason == mute_rule.reason
|
||||
|
||||
def test_mute_historical_findings_preserves_muted_at_timestamp(
|
||||
self, mute_rule_with_findings, findings_fixture
|
||||
):
|
||||
"""
|
||||
Test that muted_at is set to the rule's inserted_at, not the current time.
|
||||
"""
|
||||
mute_rule = mute_rule_with_findings
|
||||
tenant_id = str(mute_rule.tenant_id)
|
||||
finding = findings_fixture[0]
|
||||
|
||||
# Execute the muting function
|
||||
result = mute_historical_findings(tenant_id, str(mute_rule.id))
|
||||
|
||||
# Verify the finding was muted
|
||||
assert result["findings_muted"] == 1
|
||||
|
||||
# Verify muted_at matches the rule's inserted_at timestamp
|
||||
finding.refresh_from_db()
|
||||
assert finding.muted_at == mute_rule.inserted_at
|
||||
assert finding.muted_at is not None
|
||||
|
||||
def test_mute_historical_findings_partial_match(self, scans_fixture, test_user):
|
||||
"""
|
||||
Test muting when only some of the rule's UIDs exist as findings.
|
||||
"""
|
||||
scan = scans_fixture[0]
|
||||
tenant_id = str(scan.tenant_id)
|
||||
|
||||
# Create 3 findings
|
||||
existing_uids = []
|
||||
for i in range(3):
|
||||
finding = Finding.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
uid=f"partial_match_finding_{i}",
|
||||
scan=scan,
|
||||
status=Status.FAIL,
|
||||
status_extended=f"Partial match status {i}",
|
||||
impact=Severity.high,
|
||||
severity=Severity.high,
|
||||
raw_result={
|
||||
"status": Status.FAIL,
|
||||
"impact": Severity.high,
|
||||
"severity": Severity.high,
|
||||
},
|
||||
check_id=f"partial_match_check_{i}",
|
||||
check_metadata={
|
||||
"CheckId": f"partial_match_check_{i}",
|
||||
"Description": f"Partial match description {i}",
|
||||
},
|
||||
muted=False,
|
||||
)
|
||||
existing_uids.append(finding.uid)
|
||||
|
||||
# Create a mute rule with both existing and non-existing UIDs
|
||||
all_uids = existing_uids + [
|
||||
"nonexistent_uid_1",
|
||||
"nonexistent_uid_2",
|
||||
]
|
||||
mute_rule = MuteRule.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
name="Test Partial Match Rule",
|
||||
reason="Testing partial matching",
|
||||
enabled=True,
|
||||
created_by=test_user,
|
||||
finding_uids=all_uids,
|
||||
)
|
||||
|
||||
# Execute the muting function
|
||||
result = mute_historical_findings(tenant_id, str(mute_rule.id))
|
||||
|
||||
# Verify only existing findings were muted
|
||||
assert result["findings_muted"] == 3
|
||||
assert result["rule_id"] == str(mute_rule.id)
|
||||
|
||||
# Verify the existing findings were muted
|
||||
findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=existing_uids)
|
||||
assert findings.count() == 3
|
||||
for finding in findings:
|
||||
assert finding.muted is True
|
||||
assert finding.muted_at == mute_rule.inserted_at
|
||||
assert finding.muted_reason == mute_rule.reason
|
||||
|
||||
def test_mute_historical_findings_empty_uids(self, tenants_fixture, test_user):
|
||||
"""
|
||||
Test muting when the rule has an empty finding_uids array.
|
||||
"""
|
||||
tenant_id = str(tenants_fixture[0].id)
|
||||
|
||||
# Create a mute rule with empty finding_uids
|
||||
mute_rule = MuteRule.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
name="Test Empty UIDs Rule",
|
||||
reason="Testing empty UIDs",
|
||||
enabled=True,
|
||||
created_by=test_user,
|
||||
finding_uids=[],
|
||||
)
|
||||
|
||||
# Execute the muting function
|
||||
result = mute_historical_findings(tenant_id, str(mute_rule.id))
|
||||
|
||||
# Verify no findings were muted
|
||||
assert result["findings_muted"] == 0
|
||||
assert result["rule_id"] == str(mute_rule.id)
|
||||
|
||||
def test_mute_historical_findings_return_format(self, mute_rule_with_findings):
|
||||
"""
|
||||
Test that the return value has the correct format and fields.
|
||||
"""
|
||||
mute_rule = mute_rule_with_findings
|
||||
tenant_id = str(mute_rule.tenant_id)
|
||||
|
||||
result = mute_historical_findings(tenant_id, str(mute_rule.id))
|
||||
|
||||
# Verify return value structure
|
||||
assert isinstance(result, dict)
|
||||
assert "findings_muted" in result
|
||||
assert "rule_id" in result
|
||||
assert isinstance(result["findings_muted"], int)
|
||||
assert isinstance(result["rule_id"], str)
|
||||
assert result["rule_id"] == str(mute_rule.id)
|
||||
active_finding.refresh_from_db()
|
||||
disabled_finding.refresh_from_db()
|
||||
older_finding.refresh_from_db()
|
||||
assert active_finding.muted is True
|
||||
assert active_finding.muted_at == active_rule.inserted_at
|
||||
assert disabled_finding.muted is False
|
||||
assert older_finding.muted is False
|
||||
assert result == {"findings_muted": 1, "scan_id": str(scan.id)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import httpx
|
||||
@@ -33,10 +33,10 @@ from tasks.tasks import (
|
||||
check_integrations_task,
|
||||
check_lighthouse_provider_connection_task,
|
||||
generate_outputs_task,
|
||||
mute_findings_in_latest_scans_task,
|
||||
perform_attack_paths_scan_task,
|
||||
perform_scan_task,
|
||||
perform_scheduled_scan_task,
|
||||
reaggregate_all_finding_group_summaries_task,
|
||||
refresh_lighthouse_provider_models_task,
|
||||
s3_integration_task,
|
||||
security_hub_integration_task,
|
||||
@@ -2959,6 +2959,7 @@ class TestPerformScheduledScanTask:
|
||||
with (
|
||||
patch("tasks.tasks.perform_prowler_scan", side_effect=_complete_scan),
|
||||
patch("tasks.tasks._perform_scan_complete_tasks"),
|
||||
patch("tasks.tasks.reconcile_scan_mute_rules") as mock_reconcile,
|
||||
self._override_task_request(perform_scheduled_scan_task, id=task_id),
|
||||
):
|
||||
perform_scheduled_scan_task.run(
|
||||
@@ -2982,6 +2983,13 @@ class TestPerformScheduledScanTask:
|
||||
).count()
|
||||
== 1
|
||||
)
|
||||
completed_scan = Scan.objects.get(
|
||||
tenant_id=tenant.id,
|
||||
provider=provider,
|
||||
trigger=Scan.TriggerChoices.SCHEDULED,
|
||||
state=StateChoices.COMPLETED,
|
||||
)
|
||||
mock_reconcile.assert_called_once_with(str(tenant.id), str(completed_scan.id))
|
||||
assert (
|
||||
Scan.objects.filter(
|
||||
tenant_id=tenant.id,
|
||||
@@ -3176,7 +3184,10 @@ class TestPerformScanTask:
|
||||
task=queued_task,
|
||||
)
|
||||
|
||||
events = []
|
||||
|
||||
def _complete_scan(tenant_id, scan_id, provider_id, checks_to_execute=None):
|
||||
events.append("scan")
|
||||
scan_instance = Scan.objects.get(id=scan_id)
|
||||
scan_instance.state = StateChoices.COMPLETED
|
||||
scan_instance.save()
|
||||
@@ -3184,7 +3195,14 @@ class TestPerformScanTask:
|
||||
|
||||
with (
|
||||
patch("tasks.tasks.perform_prowler_scan", side_effect=_complete_scan),
|
||||
patch("tasks.tasks._perform_scan_complete_tasks"),
|
||||
patch(
|
||||
"tasks.tasks.reconcile_scan_mute_rules",
|
||||
side_effect=lambda *_args: events.append("reconcile"),
|
||||
),
|
||||
patch(
|
||||
"tasks.tasks._perform_scan_complete_tasks",
|
||||
side_effect=lambda *_args: events.append("summaries"),
|
||||
),
|
||||
patch("tasks.tasks.perform_scan_task.apply_async") as mock_apply_async,
|
||||
):
|
||||
with django_capture_on_commit_callbacks(execute=True):
|
||||
@@ -3196,6 +3214,7 @@ class TestPerformScanTask:
|
||||
|
||||
queued_task_result.refresh_from_db()
|
||||
assert result == {"status": "ok"}
|
||||
assert events == ["scan", "reconcile", "summaries"]
|
||||
assert queued_task_result.status == states.PENDING
|
||||
mock_apply_async.assert_called_once_with(
|
||||
kwargs={
|
||||
@@ -3241,10 +3260,7 @@ class TestPerformScanTask:
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestReaggregateAllFindingGroupSummaries:
|
||||
def setup_method(self):
|
||||
self.tenant_id = str(uuid.uuid4())
|
||||
|
||||
class TestMuteFindingsInLatestScansTask:
|
||||
@patch("tasks.tasks.chain")
|
||||
@patch("tasks.tasks.group")
|
||||
@patch("tasks.tasks.aggregate_attack_surface_task")
|
||||
@@ -3253,10 +3269,10 @@ class TestReaggregateAllFindingGroupSummaries:
|
||||
@patch("tasks.tasks.aggregate_finding_group_summaries_task")
|
||||
@patch("tasks.tasks.aggregate_daily_severity_task")
|
||||
@patch("tasks.tasks.perform_scan_summary_task")
|
||||
@patch("tasks.tasks.Scan.objects.filter")
|
||||
def test_dispatches_subtasks_for_each_provider_per_day(
|
||||
@patch("tasks.tasks.mute_findings_in_latest_scans")
|
||||
def test_reaggregates_only_changed_scans(
|
||||
self,
|
||||
mock_scan_filter,
|
||||
mock_mute_findings,
|
||||
mock_scan_summary_task,
|
||||
mock_daily_severity_task,
|
||||
mock_finding_group_task,
|
||||
@@ -3265,119 +3281,36 @@ class TestReaggregateAllFindingGroupSummaries:
|
||||
mock_attack_surface_task,
|
||||
mock_group,
|
||||
mock_chain,
|
||||
tenants_fixture,
|
||||
):
|
||||
provider_id_1 = uuid.uuid4()
|
||||
provider_id_2 = uuid.uuid4()
|
||||
scan_id_today_p1 = uuid.uuid4()
|
||||
scan_id_yesterday_p1 = uuid.uuid4()
|
||||
scan_id_today_p2 = uuid.uuid4()
|
||||
today = datetime.now(tz=UTC)
|
||||
yesterday = today - timedelta(days=1)
|
||||
|
||||
mock_outer_group_result = MagicMock()
|
||||
# The first `group()` call wraps the inner parallel step; subsequent
|
||||
# calls wrap the outer per-scan generator.
|
||||
mock_group.side_effect = lambda *args, **kwargs: (
|
||||
list(args[0]) if args and hasattr(args[0], "__iter__") else None,
|
||||
mock_outer_group_result,
|
||||
)[1]
|
||||
|
||||
mock_scan_filter.return_value.order_by.return_value.values.return_value = [
|
||||
{
|
||||
"id": scan_id_today_p1,
|
||||
"completed_at": today,
|
||||
"provider_id": provider_id_1,
|
||||
},
|
||||
{
|
||||
"id": scan_id_today_p2,
|
||||
"completed_at": today,
|
||||
"provider_id": provider_id_2,
|
||||
},
|
||||
{
|
||||
"id": scan_id_yesterday_p1,
|
||||
"completed_at": yesterday,
|
||||
"provider_id": provider_id_1,
|
||||
},
|
||||
]
|
||||
|
||||
result = reaggregate_all_finding_group_summaries_task(tenant_id=self.tenant_id)
|
||||
|
||||
assert result == {"scans_reaggregated": 3}
|
||||
expected_scan_ids = {
|
||||
str(scan_id_today_p1),
|
||||
str(scan_id_today_p2),
|
||||
str(scan_id_yesterday_p1),
|
||||
tenant_id = str(tenants_fixture[0].id)
|
||||
mute_rule_id = str(uuid.uuid4())
|
||||
provider_ids = [str(uuid.uuid4()), str(uuid.uuid4())]
|
||||
scan_ids = [str(uuid.uuid4()), str(uuid.uuid4())]
|
||||
result = {
|
||||
"findings_muted": 2,
|
||||
"rule_id": mute_rule_id,
|
||||
"scan_ids": scan_ids,
|
||||
}
|
||||
for task_mock in (
|
||||
mock_scan_summary_task,
|
||||
mock_daily_severity_task,
|
||||
mock_finding_group_task,
|
||||
mock_resource_group_task,
|
||||
mock_category_task,
|
||||
mock_attack_surface_task,
|
||||
):
|
||||
assert task_mock.si.call_count == 3
|
||||
dispatched = {
|
||||
call.kwargs["scan_id"] for call in task_mock.si.call_args_list
|
||||
}
|
||||
assert dispatched == expected_scan_ids
|
||||
for call in task_mock.si.call_args_list:
|
||||
assert call.kwargs["tenant_id"] == self.tenant_id
|
||||
assert mock_chain.call_count == 3
|
||||
mock_outer_group_result.apply_async.assert_called_once()
|
||||
|
||||
@patch("tasks.tasks.chain")
|
||||
@patch("tasks.tasks.group")
|
||||
@patch("tasks.tasks.aggregate_attack_surface_task")
|
||||
@patch("tasks.tasks.aggregate_scan_category_summaries_task")
|
||||
@patch("tasks.tasks.aggregate_scan_resource_group_summaries_task")
|
||||
@patch("tasks.tasks.aggregate_finding_group_summaries_task")
|
||||
@patch("tasks.tasks.aggregate_daily_severity_task")
|
||||
@patch("tasks.tasks.perform_scan_summary_task")
|
||||
@patch("tasks.tasks.Scan.objects.filter")
|
||||
def test_dedupes_scans_to_latest_per_provider_per_day(
|
||||
self,
|
||||
mock_scan_filter,
|
||||
mock_scan_summary_task,
|
||||
mock_daily_severity_task,
|
||||
mock_finding_group_task,
|
||||
mock_resource_group_task,
|
||||
mock_category_task,
|
||||
mock_attack_surface_task,
|
||||
mock_group,
|
||||
mock_chain,
|
||||
):
|
||||
"""When several scans run on the same day for the same provider, only
|
||||
the latest one is dispatched (matching the daily summary unique key)."""
|
||||
provider_id = uuid.uuid4()
|
||||
latest_scan_today = uuid.uuid4()
|
||||
earlier_scan_today = uuid.uuid4()
|
||||
today_late = datetime.now(tz=UTC)
|
||||
today_early = today_late - timedelta(hours=4)
|
||||
|
||||
mock_mute_findings.return_value = result
|
||||
mock_outer_group_result = MagicMock()
|
||||
mock_group.side_effect = lambda *args, **kwargs: (
|
||||
list(args[0]) if args and hasattr(args[0], "__iter__") else None,
|
||||
mock_outer_group_result,
|
||||
)[1]
|
||||
|
||||
# Returned ordered by `-completed_at`, so the most recent comes first.
|
||||
mock_scan_filter.return_value.order_by.return_value.values.return_value = [
|
||||
{
|
||||
"id": latest_scan_today,
|
||||
"completed_at": today_late,
|
||||
"provider_id": provider_id,
|
||||
},
|
||||
{
|
||||
"id": earlier_scan_today,
|
||||
"completed_at": today_early,
|
||||
"provider_id": provider_id,
|
||||
},
|
||||
]
|
||||
task_result = mute_findings_in_latest_scans_task(
|
||||
tenant_id=tenant_id,
|
||||
mute_rule_id=mute_rule_id,
|
||||
provider_ids=provider_ids,
|
||||
)
|
||||
|
||||
result = reaggregate_all_finding_group_summaries_task(tenant_id=self.tenant_id)
|
||||
|
||||
assert result == {"scans_reaggregated": 1}
|
||||
assert task_result == result
|
||||
mock_mute_findings.assert_called_once_with(
|
||||
tenant_id=tenant_id,
|
||||
mute_rule_id=mute_rule_id,
|
||||
provider_ids=provider_ids,
|
||||
)
|
||||
for task_mock in (
|
||||
mock_scan_summary_task,
|
||||
mock_daily_severity_task,
|
||||
@@ -3386,23 +3319,35 @@ class TestReaggregateAllFindingGroupSummaries:
|
||||
mock_category_task,
|
||||
mock_attack_surface_task,
|
||||
):
|
||||
task_mock.si.assert_called_once_with(
|
||||
tenant_id=self.tenant_id, scan_id=str(latest_scan_today)
|
||||
)
|
||||
mock_chain.assert_called_once()
|
||||
assert task_mock.si.call_count == 2
|
||||
assert {
|
||||
call.kwargs["scan_id"] for call in task_mock.si.call_args_list
|
||||
} == set(scan_ids)
|
||||
assert mock_chain.call_count == 2
|
||||
mock_outer_group_result.apply_async.assert_called_once()
|
||||
|
||||
@patch("tasks.tasks.chain")
|
||||
@patch("tasks.tasks.group")
|
||||
@patch("tasks.tasks.Scan.objects.filter")
|
||||
def test_no_completed_scans_skips_dispatch(
|
||||
self, mock_scan_filter, mock_group, mock_chain
|
||||
@patch("tasks.tasks.mute_findings_in_latest_scans")
|
||||
def test_skips_reaggregation_when_no_scan_changed(
|
||||
self, mock_mute_findings, mock_group, mock_chain, tenants_fixture
|
||||
):
|
||||
mock_scan_filter.return_value.order_by.return_value.values.return_value = []
|
||||
tenant_id = str(tenants_fixture[0].id)
|
||||
mute_rule_id = str(uuid.uuid4())
|
||||
result = {
|
||||
"findings_muted": 0,
|
||||
"rule_id": mute_rule_id,
|
||||
"scan_ids": [],
|
||||
}
|
||||
mock_mute_findings.return_value = result
|
||||
|
||||
result = reaggregate_all_finding_group_summaries_task(tenant_id=self.tenant_id)
|
||||
task_result = mute_findings_in_latest_scans_task(
|
||||
tenant_id=tenant_id,
|
||||
mute_rule_id=mute_rule_id,
|
||||
provider_ids=[],
|
||||
)
|
||||
|
||||
assert result == {"scans_reaggregated": 0}
|
||||
assert task_result == result
|
||||
mock_group.assert_not_called()
|
||||
mock_chain.assert_not_called()
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
Before Width: | Height: | Size: 193 KiB After Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 156 KiB |
|
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:
|
||||
|
||||

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

|
||||
|
||||
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.
|
||||
|
||||
@@ -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}."
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||