Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39e903ade3 | ||
|
|
477f3ebda1 | ||
|
|
86e4408f29 | ||
|
|
ae43d21efb | ||
|
|
3ca189a52a | ||
|
|
7c84822fa3 | ||
|
|
51c5fa7168 | ||
|
|
e9121f5f1a | ||
|
|
821fe43efd | ||
|
|
9ffbb4b758 | ||
|
|
4014fcb6c1 | ||
|
|
9c5285adc3 | ||
|
|
f295d290dd | ||
|
|
e5df95c259 | ||
|
|
b6a8af3c54 | ||
|
|
fb7064401b | ||
|
|
8d60f9703a | ||
|
|
ceb601028e |
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,63 +1,104 @@
|
||||
from collections.abc import Iterable
|
||||
|
||||
from api.db_utils import rls_transaction
|
||||
from api.models import Finding, MuteRule
|
||||
from api.models import Finding, MuteRule, Scan, StateChoices
|
||||
from celery.utils.log import get_task_logger
|
||||
from config.django.base import DJANGO_FINDINGS_BATCH_SIZE
|
||||
from tasks.utils import batched
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
def mute_historical_findings(tenant_id: str, mute_rule_id: str):
|
||||
"""
|
||||
Mute historical findings that match the given mute rule.
|
||||
def _mute_findings_for_rule(
|
||||
*,
|
||||
tenant_id: str,
|
||||
scan_id: str,
|
||||
finding_uids: Iterable[str],
|
||||
muted_at,
|
||||
muted_reason: str,
|
||||
) -> int:
|
||||
finding_uids = list(finding_uids)
|
||||
if not finding_uids:
|
||||
return 0
|
||||
|
||||
This function processes findings in batches, updating their muted status
|
||||
and adding the mute reason.
|
||||
return Finding.all_objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
scan_id=scan_id,
|
||||
uid__in=finding_uids,
|
||||
muted=False,
|
||||
).update(
|
||||
muted=True,
|
||||
muted_at=muted_at,
|
||||
muted_reason=muted_reason,
|
||||
)
|
||||
|
||||
Args:
|
||||
tenant_id (str): The tenant ID for RLS context
|
||||
mute_rule_id (str): The ID of the mute rule to apply
|
||||
|
||||
Returns:
|
||||
dict: Summary of the muting operation with findings_muted count
|
||||
"""
|
||||
findings_muted_count = 0
|
||||
def mute_findings_in_latest_scans(
|
||||
tenant_id: str, mute_rule_id: str, provider_ids: list[str]
|
||||
) -> dict:
|
||||
"""Apply a mute rule to the latest completed scan of each provider."""
|
||||
provider_ids = list(dict.fromkeys(provider_ids))
|
||||
|
||||
# Get the list of UIDs to mute and the reason
|
||||
with rls_transaction(tenant_id):
|
||||
mute_rule = MuteRule.objects.get(id=mute_rule_id, tenant_id=tenant_id)
|
||||
finding_uids = mute_rule.finding_uids
|
||||
mute_reason = mute_rule.reason
|
||||
muted_at = mute_rule.inserted_at
|
||||
|
||||
# Query findings that match the UIDs and are not already muted
|
||||
with rls_transaction(tenant_id):
|
||||
findings_to_mute = Finding.objects.filter(
|
||||
tenant_id=tenant_id, uid__in=finding_uids, muted=False
|
||||
)
|
||||
total_findings = findings_to_mute.count()
|
||||
|
||||
logger.info(
|
||||
f"Processing {total_findings} findings for mute rule {mute_rule_id}"
|
||||
latest_scans = list(
|
||||
Scan.objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
provider_id__in=provider_ids,
|
||||
state=StateChoices.COMPLETED,
|
||||
completed_at__isnull=False,
|
||||
)
|
||||
.order_by("provider_id", "-completed_at", "-inserted_at", "-id")
|
||||
.distinct("provider_id")
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
|
||||
if total_findings > 0:
|
||||
for batch, is_last in batched(
|
||||
findings_to_mute.iterator(), DJANGO_FINDINGS_BATCH_SIZE
|
||||
):
|
||||
batch_ids = [f.id for f in batch]
|
||||
updated_count = Finding.all_objects.filter(
|
||||
id__in=batch_ids, tenant_id=tenant_id
|
||||
).update(
|
||||
muted=True,
|
||||
muted_at=muted_at,
|
||||
muted_reason=mute_reason,
|
||||
)
|
||||
findings_muted_count += updated_count
|
||||
|
||||
logger.info(f"Muted {findings_muted_count} findings for rule {mute_rule_id}")
|
||||
changed_scan_ids = []
|
||||
findings_muted = 0
|
||||
for scan_id in latest_scans:
|
||||
updated = _mute_findings_for_rule(
|
||||
tenant_id=tenant_id,
|
||||
scan_id=str(scan_id),
|
||||
finding_uids=mute_rule.finding_uids,
|
||||
muted_at=mute_rule.inserted_at,
|
||||
muted_reason=mute_rule.reason,
|
||||
)
|
||||
if updated:
|
||||
findings_muted += updated
|
||||
changed_scan_ids.append(str(scan_id))
|
||||
|
||||
logger.info(
|
||||
"Muted %d findings in %d latest scans for rule %s",
|
||||
findings_muted,
|
||||
len(changed_scan_ids),
|
||||
mute_rule_id,
|
||||
)
|
||||
return {
|
||||
"findings_muted": findings_muted_count,
|
||||
"findings_muted": findings_muted,
|
||||
"rule_id": mute_rule_id,
|
||||
"scan_ids": changed_scan_ids,
|
||||
}
|
||||
|
||||
|
||||
def reconcile_scan_mute_rules(tenant_id: str, scan_id: str) -> dict:
|
||||
"""Apply the current enabled mute rules to one completed scan."""
|
||||
findings_muted = 0
|
||||
|
||||
with rls_transaction(tenant_id):
|
||||
mute_rules = MuteRule.objects.filter(tenant_id=tenant_id, enabled=True).values(
|
||||
"finding_uids", "reason", "inserted_at"
|
||||
)
|
||||
|
||||
for mute_rule in mute_rules:
|
||||
findings_muted += _mute_findings_for_rule(
|
||||
tenant_id=tenant_id,
|
||||
scan_id=scan_id,
|
||||
finding_uids=mute_rule["finding_uids"],
|
||||
muted_at=mute_rule["inserted_at"],
|
||||
muted_reason=mute_rule["reason"],
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Reconciled mute rules for scan %s; muted %d findings",
|
||||
scan_id,
|
||||
findings_muted,
|
||||
)
|
||||
return {"findings_muted": findings_muted, "scan_id": str(scan_id)}
|
||||
|
||||
@@ -73,7 +73,10 @@ from tasks.jobs.lighthouse_providers import (
|
||||
check_lighthouse_provider_connection,
|
||||
refresh_lighthouse_provider_models,
|
||||
)
|
||||
from tasks.jobs.muting import mute_historical_findings
|
||||
from tasks.jobs.muting import (
|
||||
mute_findings_in_latest_scans,
|
||||
reconcile_scan_mute_rules,
|
||||
)
|
||||
from tasks.jobs.orphan_recovery import reconcile_orphans
|
||||
from tasks.jobs.report import (
|
||||
STALE_TMP_OUTPUT_MAX_AGE_HOURS,
|
||||
@@ -526,6 +529,7 @@ def perform_scan_task(
|
||||
provider_id=provider_id,
|
||||
checks_to_execute=checks_to_execute,
|
||||
)
|
||||
reconcile_scan_mute_rules(tenant_id, scan_id)
|
||||
_perform_scan_complete_tasks(tenant_id, scan_id, provider_id)
|
||||
return result
|
||||
finally:
|
||||
@@ -635,6 +639,7 @@ def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str):
|
||||
scan_id=str(scan_instance.id),
|
||||
provider_id=provider_id,
|
||||
)
|
||||
reconcile_scan_mute_rules(tenant_id, str(scan_instance.id))
|
||||
_perform_scan_complete_tasks(tenant_id, str(scan_instance.id), provider_id)
|
||||
return result
|
||||
finally:
|
||||
@@ -1188,85 +1193,48 @@ def aggregate_finding_group_summaries_task(tenant_id: str, scan_id: str):
|
||||
return aggregate_finding_group_summaries(tenant_id=tenant_id, scan_id=scan_id)
|
||||
|
||||
|
||||
@shared_task(
|
||||
base=RLSTask, name="reaggregate-all-finding-group-summaries", queue="overview"
|
||||
)
|
||||
@set_tenant(keep_tenant=True)
|
||||
def reaggregate_all_finding_group_summaries_task(tenant_id: str):
|
||||
"""Reaggregate every pre-aggregated summary table for this tenant.
|
||||
def _dispatch_scan_summary_reaggregation(tenant_id: str, scan_ids: list[str]) -> None:
|
||||
if not scan_ids:
|
||||
return
|
||||
|
||||
Mirrors the unbounded scope of `mute_historical_findings_task`: that task
|
||||
rewrites every Finding row whose UID matches a mute rule, with no time
|
||||
limit. To keep the pre-aggregated tables consistent with that update,
|
||||
this task re-runs the same per-scan aggregation pipeline that scan
|
||||
completion runs on the latest completed scan of every (provider, day)
|
||||
pair, rebuilding the tables that power the read endpoints:
|
||||
|
||||
- `ScanSummary` and `DailySeveritySummary` -> `/overviews/findings`,
|
||||
`/overviews/findings-severity`, `/overviews/services`.
|
||||
- `FindingGroupDailySummary` -> `/finding-groups` and
|
||||
`/finding-groups/latest`.
|
||||
- `ScanGroupSummary` -> `/overviews/resource-groups` (resource
|
||||
inventory).
|
||||
- `ScanCategorySummary` -> `/overviews/categories`.
|
||||
- `AttackSurfaceOverview` -> `/overviews/attack-surfaces`.
|
||||
|
||||
Per-scan pipelines are dispatched in parallel via a Celery group so
|
||||
wallclock scales with the worker pool.
|
||||
"""
|
||||
completed_scans = list(
|
||||
Scan.objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
state=StateChoices.COMPLETED,
|
||||
completed_at__isnull=False,
|
||||
)
|
||||
.order_by("-completed_at")
|
||||
.values("id", "completed_at", "provider_id")
|
||||
logger.info(
|
||||
"Reaggregating overview/finding summaries for %d latest scans",
|
||||
len(scan_ids),
|
||||
)
|
||||
|
||||
# Keep the latest scan per (provider, day) pair so the daily summary row
|
||||
# the aggregator writes is the most recent snapshot of that day for that
|
||||
# provider. Iterating from most recent to oldest means the first scan we
|
||||
# see for a given key wins.
|
||||
latest_scans: dict[tuple, str] = {}
|
||||
for scan in completed_scans:
|
||||
key = (scan["provider_id"], scan["completed_at"].date())
|
||||
if key not in latest_scans:
|
||||
latest_scans[key] = str(scan["id"])
|
||||
|
||||
scan_ids = list(latest_scans.values())
|
||||
if scan_ids:
|
||||
logger.info(
|
||||
"Reaggregating overview/finding summaries for %d scans (provider x day)",
|
||||
len(scan_ids),
|
||||
)
|
||||
# DailySeveritySummary reads from ScanSummary, so ScanSummary must be
|
||||
# recomputed first; the other aggregators read Finding directly and
|
||||
# can run in parallel with the severity step.
|
||||
group(
|
||||
chain(
|
||||
perform_scan_summary_task.si(tenant_id=tenant_id, scan_id=scan_id),
|
||||
group(
|
||||
aggregate_daily_severity_task.si(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
),
|
||||
aggregate_finding_group_summaries_task.si(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
),
|
||||
aggregate_scan_resource_group_summaries_task.si(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
),
|
||||
aggregate_scan_category_summaries_task.si(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
),
|
||||
aggregate_attack_surface_task.si(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
),
|
||||
group(
|
||||
chain(
|
||||
perform_scan_summary_task.si(tenant_id=tenant_id, scan_id=scan_id),
|
||||
group(
|
||||
aggregate_daily_severity_task.si(tenant_id=tenant_id, scan_id=scan_id),
|
||||
aggregate_finding_group_summaries_task.si(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
),
|
||||
)
|
||||
for scan_id in scan_ids
|
||||
).apply_async()
|
||||
return {"scans_reaggregated": len(scan_ids)}
|
||||
aggregate_scan_resource_group_summaries_task.si(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
),
|
||||
aggregate_scan_category_summaries_task.si(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
),
|
||||
aggregate_attack_surface_task.si(tenant_id=tenant_id, scan_id=scan_id),
|
||||
),
|
||||
)
|
||||
for scan_id in scan_ids
|
||||
).apply_async()
|
||||
|
||||
|
||||
@shared_task(base=RLSTask, name="findings-mute-latest-scans", queue="overview")
|
||||
@set_tenant(keep_tenant=True)
|
||||
def mute_findings_in_latest_scans_task(
|
||||
tenant_id: str, mute_rule_id: str, provider_ids: list[str]
|
||||
):
|
||||
"""Apply a mute rule to current scans and rebuild only changed summaries."""
|
||||
result = mute_findings_in_latest_scans(
|
||||
tenant_id=tenant_id,
|
||||
mute_rule_id=mute_rule_id,
|
||||
provider_ids=provider_ids,
|
||||
)
|
||||
_dispatch_scan_summary_reaggregation(tenant_id, result["scan_ids"])
|
||||
return result
|
||||
|
||||
|
||||
@shared_task(base=RLSTask, name="lighthouse-connection-check")
|
||||
@@ -1467,25 +1435,3 @@ def generate_compliance_reports_task(tenant_id: str, scan_id: str, provider_id:
|
||||
generate_csa=True,
|
||||
generate_cis=True,
|
||||
)
|
||||
|
||||
|
||||
@shared_task(name="findings-mute-historical")
|
||||
def mute_historical_findings_task(tenant_id: str, mute_rule_id: str):
|
||||
"""
|
||||
Background task to mute all historical findings matching a mute rule.
|
||||
|
||||
This task processes findings in batches to avoid memory issues with large datasets.
|
||||
It updates the Finding.muted, Finding.muted_at, and Finding.muted_reason fields
|
||||
for all findings whose UID is in the mute rule's finding_uids list.
|
||||
|
||||
Args:
|
||||
tenant_id (str): The tenant ID for RLS context.
|
||||
mute_rule_id (str): The primary key of the MuteRule to apply.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing:
|
||||
- 'findings_muted' (int): Total number of findings muted.
|
||||
- 'rule_id' (str): The mute rule ID.
|
||||
- 'status' (str): Final status ('completed').
|
||||
"""
|
||||
return mute_historical_findings(tenant_id, mute_rule_id)
|
||||
|
||||
@@ -1,531 +1,205 @@
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from api.models import Finding, MuteRule
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from api.models import Finding, MuteRule, Scan, StateChoices
|
||||
from prowler.lib.check.models import Severity
|
||||
from prowler.lib.outputs.finding import Status
|
||||
from tasks.jobs.muting import mute_historical_findings
|
||||
from tasks.jobs.muting import (
|
||||
mute_findings_in_latest_scans,
|
||||
reconcile_scan_mute_rules,
|
||||
)
|
||||
|
||||
|
||||
def _create_finding(scan: Scan, uid: str) -> Finding:
|
||||
return Finding.objects.create(
|
||||
tenant_id=scan.tenant_id,
|
||||
uid=uid,
|
||||
scan=scan,
|
||||
status=Status.FAIL,
|
||||
status_extended="Test finding",
|
||||
impact=Severity.high,
|
||||
severity=Severity.high,
|
||||
raw_result={},
|
||||
check_id="test_check",
|
||||
check_metadata={"CheckId": "test_check"},
|
||||
muted=False,
|
||||
)
|
||||
|
||||
|
||||
def _create_mute_rule(tenant_id, user, finding_uids, *, enabled=True) -> MuteRule:
|
||||
return MuteRule.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
name=f"Mute rule {uuid4()}",
|
||||
reason="Approved exception",
|
||||
enabled=enabled,
|
||||
created_by=user,
|
||||
finding_uids=finding_uids,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestMuteHistoricalFindings:
|
||||
"""
|
||||
Test suite for the mute_historical_findings function.
|
||||
class TestMuteFindingsInLatestScans:
|
||||
def test_mutes_latest_scan_and_leaves_older_scan_unchanged(
|
||||
self, scans_fixture, create_test_user
|
||||
):
|
||||
latest_scan = scans_fixture[0]
|
||||
older_scan = Scan.objects.create(
|
||||
tenant_id=latest_scan.tenant_id,
|
||||
provider=latest_scan.provider,
|
||||
name="Older scan",
|
||||
trigger=Scan.TriggerChoices.MANUAL,
|
||||
state=StateChoices.COMPLETED,
|
||||
started_at=datetime.now(UTC) - timedelta(days=1),
|
||||
completed_at=datetime.now(UTC) - timedelta(days=1),
|
||||
)
|
||||
uid = "latest-scan-only"
|
||||
older_finding = _create_finding(older_scan, uid)
|
||||
latest_finding = _create_finding(latest_scan, uid)
|
||||
mute_rule = _create_mute_rule(latest_scan.tenant_id, create_test_user, [uid])
|
||||
|
||||
This class tests the batch processing of findings to update their muted status
|
||||
based on MuteRule criteria.
|
||||
"""
|
||||
result = mute_findings_in_latest_scans(
|
||||
str(latest_scan.tenant_id),
|
||||
str(mute_rule.id),
|
||||
[str(latest_scan.provider_id)],
|
||||
)
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def test_user(self, create_test_user):
|
||||
"""Create a test user for mute rule creation."""
|
||||
return create_test_user
|
||||
older_finding.refresh_from_db()
|
||||
latest_finding.refresh_from_db()
|
||||
assert older_finding.muted is False
|
||||
assert latest_finding.muted is True
|
||||
assert latest_finding.muted_at == mute_rule.inserted_at
|
||||
assert latest_finding.muted_reason == mute_rule.reason
|
||||
assert result == {
|
||||
"findings_muted": 1,
|
||||
"rule_id": str(mute_rule.id),
|
||||
"scan_ids": [str(latest_scan.id)],
|
||||
}
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def mute_rule_with_findings(self, tenants_fixture, findings_fixture, test_user):
|
||||
"""
|
||||
Create a mute rule that targets the first finding in the fixture.
|
||||
"""
|
||||
def test_mutes_one_latest_scan_per_provider(self, scans_fixture, create_test_user):
|
||||
first_scan, second_scan, _ = scans_fixture
|
||||
uid = "shared-selected-uid"
|
||||
first_finding = _create_finding(first_scan, uid)
|
||||
second_finding = _create_finding(second_scan, uid)
|
||||
mute_rule = _create_mute_rule(first_scan.tenant_id, create_test_user, [uid])
|
||||
|
||||
result = mute_findings_in_latest_scans(
|
||||
str(first_scan.tenant_id),
|
||||
str(mute_rule.id),
|
||||
[str(first_scan.provider_id), str(second_scan.provider_id)],
|
||||
)
|
||||
|
||||
first_finding.refresh_from_db()
|
||||
second_finding.refresh_from_db()
|
||||
assert first_finding.muted is True
|
||||
assert second_finding.muted is True
|
||||
assert result["findings_muted"] == 2
|
||||
assert set(result["scan_ids"]) == {str(first_scan.id), str(second_scan.id)}
|
||||
|
||||
def test_provider_without_completed_scan_does_nothing(
|
||||
self, tenants_fixture, provider_factory, create_test_user
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
finding = findings_fixture[0]
|
||||
|
||||
mute_rule = MuteRule.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
name="Test Mute Rule",
|
||||
reason="Testing mute functionality",
|
||||
enabled=True,
|
||||
created_by=test_user,
|
||||
finding_uids=[finding.uid],
|
||||
provider = provider_factory()
|
||||
mute_rule = _create_mute_rule(
|
||||
tenant.id, create_test_user, ["future-scan-finding"]
|
||||
)
|
||||
|
||||
return mute_rule
|
||||
result = mute_findings_in_latest_scans(
|
||||
str(tenant.id), str(mute_rule.id), [str(provider.id)]
|
||||
)
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def mute_rule_multiple_findings(self, scans_fixture, test_user):
|
||||
"""
|
||||
Create multiple unmuted findings and a mute rule targeting all of them.
|
||||
"""
|
||||
assert result == {
|
||||
"findings_muted": 0,
|
||||
"rule_id": str(mute_rule.id),
|
||||
"scan_ids": [],
|
||||
}
|
||||
|
||||
def test_retry_does_not_report_changed_scans_twice(
|
||||
self, scans_fixture, create_test_user
|
||||
):
|
||||
scan = scans_fixture[0]
|
||||
tenant_id = scan.tenant_id
|
||||
|
||||
# Create 5 unmuted findings
|
||||
finding_uids = []
|
||||
for i in range(5):
|
||||
finding = Finding.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
uid=f"test_finding_uid_mute_{i}",
|
||||
scan=scan,
|
||||
status=Status.FAIL,
|
||||
status_extended=f"Test status {i}",
|
||||
impact=Severity.high,
|
||||
severity=Severity.high,
|
||||
raw_result={
|
||||
"status": Status.FAIL,
|
||||
"impact": Severity.high,
|
||||
"severity": Severity.high,
|
||||
},
|
||||
check_id=f"test_check_id_{i}",
|
||||
check_metadata={
|
||||
"CheckId": f"test_check_id_{i}",
|
||||
"Description": f"Test description {i}",
|
||||
},
|
||||
muted=False,
|
||||
)
|
||||
finding_uids.append(finding.uid)
|
||||
|
||||
# Create mute rule targeting all findings
|
||||
mute_rule = MuteRule.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
name="Test Multiple Findings Mute Rule",
|
||||
reason="Testing batch muting",
|
||||
enabled=True,
|
||||
created_by=test_user,
|
||||
finding_uids=finding_uids,
|
||||
finding = _create_finding(scan, "idempotent-mute")
|
||||
mute_rule = _create_mute_rule(scan.tenant_id, create_test_user, [finding.uid])
|
||||
args = (
|
||||
str(scan.tenant_id),
|
||||
str(mute_rule.id),
|
||||
[str(scan.provider_id)],
|
||||
)
|
||||
|
||||
return mute_rule, finding_uids
|
||||
first_result = mute_findings_in_latest_scans(*args)
|
||||
second_result = mute_findings_in_latest_scans(*args)
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def mute_rule_already_muted(self, findings_fixture, test_user):
|
||||
"""
|
||||
Create a mute rule that targets an already-muted finding.
|
||||
"""
|
||||
tenant_id = findings_fixture[1].tenant_id
|
||||
already_muted_finding = findings_fixture[1]
|
||||
assert first_result["scan_ids"] == [str(scan.id)]
|
||||
assert second_result["findings_muted"] == 0
|
||||
assert second_result["scan_ids"] == []
|
||||
|
||||
mute_rule = MuteRule.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
name="Test Already Muted Rule",
|
||||
reason="Testing already muted findings",
|
||||
enabled=True,
|
||||
created_by=test_user,
|
||||
finding_uids=[already_muted_finding.uid],
|
||||
def test_does_not_cross_tenant_boundary(
|
||||
self, tenants_fixture, provider_factory, create_test_user
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
other_tenant = tenants_fixture[2]
|
||||
other_provider = provider_factory(tenant=other_tenant)
|
||||
other_scan = Scan.objects.create(
|
||||
tenant_id=other_tenant.id,
|
||||
provider=other_provider,
|
||||
name="Other tenant scan",
|
||||
trigger=Scan.TriggerChoices.MANUAL,
|
||||
state=StateChoices.COMPLETED,
|
||||
started_at=datetime.now(UTC),
|
||||
completed_at=datetime.now(UTC),
|
||||
)
|
||||
other_finding = _create_finding(other_scan, "tenant-isolated-uid")
|
||||
mute_rule = _create_mute_rule(tenant.id, create_test_user, [other_finding.uid])
|
||||
|
||||
result = mute_findings_in_latest_scans(
|
||||
str(tenant.id), str(mute_rule.id), [str(other_provider.id)]
|
||||
)
|
||||
|
||||
return mute_rule
|
||||
other_finding.refresh_from_db()
|
||||
assert other_finding.muted is False
|
||||
assert result["scan_ids"] == []
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def mute_rule_mixed_findings(self, scans_fixture, test_user):
|
||||
"""
|
||||
Create a mute rule with a mix of muted and unmuted findings.
|
||||
"""
|
||||
def test_nonexistent_rule_raises(self, tenants_fixture):
|
||||
with pytest.raises(MuteRule.DoesNotExist):
|
||||
mute_findings_in_latest_scans(str(tenants_fixture[0].id), str(uuid4()), [])
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestReconcileScanMuteRules:
|
||||
def test_applies_only_enabled_rules_to_requested_scan(
|
||||
self, scans_fixture, create_test_user
|
||||
):
|
||||
scan = scans_fixture[0]
|
||||
tenant_id = scan.tenant_id
|
||||
|
||||
# Create 3 unmuted findings
|
||||
unmuted_uids = []
|
||||
for i in range(3):
|
||||
finding = Finding.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
uid=f"unmuted_finding_{i}",
|
||||
scan=scan,
|
||||
status=Status.FAIL,
|
||||
status_extended=f"Unmuted status {i}",
|
||||
impact=Severity.medium,
|
||||
severity=Severity.medium,
|
||||
raw_result={
|
||||
"status": Status.FAIL,
|
||||
"impact": Severity.medium,
|
||||
"severity": Severity.medium,
|
||||
},
|
||||
check_id=f"unmuted_check_{i}",
|
||||
check_metadata={
|
||||
"CheckId": f"unmuted_check_{i}",
|
||||
"Description": f"Unmuted description {i}",
|
||||
},
|
||||
muted=False,
|
||||
)
|
||||
unmuted_uids.append(finding.uid)
|
||||
|
||||
# Create 2 already muted findings
|
||||
muted_uids = []
|
||||
for i in range(2):
|
||||
finding = Finding.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
uid=f"muted_finding_{i}",
|
||||
scan=scan,
|
||||
status=Status.FAIL,
|
||||
status_extended=f"Muted status {i}",
|
||||
impact=Severity.low,
|
||||
severity=Severity.low,
|
||||
raw_result={
|
||||
"status": Status.FAIL,
|
||||
"impact": Severity.low,
|
||||
"severity": Severity.low,
|
||||
},
|
||||
check_id=f"muted_check_{i}",
|
||||
check_metadata={
|
||||
"CheckId": f"muted_check_{i}",
|
||||
"Description": f"Muted description {i}",
|
||||
},
|
||||
muted=True,
|
||||
muted_at=datetime.now(UTC),
|
||||
muted_reason="Already muted",
|
||||
)
|
||||
muted_uids.append(finding.uid)
|
||||
|
||||
# Create mute rule targeting all findings
|
||||
all_uids = unmuted_uids + muted_uids
|
||||
mute_rule = MuteRule.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
name="Test Mixed Findings Rule",
|
||||
reason="Testing mixed muted/unmuted findings",
|
||||
enabled=True,
|
||||
created_by=test_user,
|
||||
finding_uids=all_uids,
|
||||
active_finding = _create_finding(scan, "active-rule-uid")
|
||||
disabled_finding = _create_finding(scan, "disabled-rule-uid")
|
||||
active_rule = _create_mute_rule(
|
||||
scan.tenant_id, create_test_user, [active_finding.uid]
|
||||
)
|
||||
|
||||
return mute_rule, unmuted_uids, muted_uids
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def mute_rule_batch_test(self, scans_fixture, test_user):
|
||||
"""
|
||||
Create enough findings to test batch processing (>1000 for default batch size).
|
||||
"""
|
||||
scan = scans_fixture[0]
|
||||
tenant_id = scan.tenant_id
|
||||
|
||||
# Create 1500 findings to exceed default batch size of 1000
|
||||
finding_uids = []
|
||||
for i in range(1500):
|
||||
finding = Finding.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
uid=f"batch_test_finding_{i}",
|
||||
scan=scan,
|
||||
status=Status.FAIL,
|
||||
status_extended=f"Batch test status {i}",
|
||||
impact=Severity.critical,
|
||||
severity=Severity.critical,
|
||||
raw_result={
|
||||
"status": Status.FAIL,
|
||||
"impact": Severity.critical,
|
||||
"severity": Severity.critical,
|
||||
},
|
||||
check_id=f"batch_test_check_{i}",
|
||||
check_metadata={
|
||||
"CheckId": f"batch_test_check_{i}",
|
||||
"Description": f"Batch test description {i}",
|
||||
},
|
||||
muted=False,
|
||||
)
|
||||
finding_uids.append(finding.uid)
|
||||
|
||||
# Create mute rule targeting all findings
|
||||
mute_rule = MuteRule.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
name="Test Batch Processing Rule",
|
||||
reason="Testing batch processing functionality",
|
||||
enabled=True,
|
||||
created_by=test_user,
|
||||
finding_uids=finding_uids,
|
||||
_create_mute_rule(
|
||||
scan.tenant_id,
|
||||
create_test_user,
|
||||
[disabled_finding.uid],
|
||||
enabled=False,
|
||||
)
|
||||
|
||||
return mute_rule, finding_uids
|
||||
|
||||
def test_mute_historical_findings_single_finding(
|
||||
self, mute_rule_with_findings, findings_fixture
|
||||
):
|
||||
"""
|
||||
Test muting a single historical finding.
|
||||
"""
|
||||
mute_rule = mute_rule_with_findings
|
||||
tenant_id = str(mute_rule.tenant_id)
|
||||
finding = findings_fixture[0]
|
||||
|
||||
# Ensure the finding is not muted before execution
|
||||
finding.refresh_from_db()
|
||||
assert finding.muted is False
|
||||
assert finding.muted_at is None
|
||||
assert finding.muted_reason is None
|
||||
|
||||
# Execute the muting function
|
||||
result = mute_historical_findings(tenant_id, str(mute_rule.id))
|
||||
|
||||
# Verify return value
|
||||
assert result["findings_muted"] == 1
|
||||
assert result["rule_id"] == str(mute_rule.id)
|
||||
|
||||
# Verify the finding was muted
|
||||
finding.refresh_from_db()
|
||||
assert finding.muted is True
|
||||
assert finding.muted_at == mute_rule.inserted_at
|
||||
assert finding.muted_reason == mute_rule.reason
|
||||
|
||||
def test_mute_historical_findings_multiple_findings(
|
||||
self, mute_rule_multiple_findings
|
||||
):
|
||||
"""
|
||||
Test muting multiple historical findings.
|
||||
"""
|
||||
mute_rule, finding_uids = mute_rule_multiple_findings
|
||||
tenant_id = str(mute_rule.tenant_id)
|
||||
|
||||
# Verify all findings are unmuted
|
||||
findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=finding_uids)
|
||||
assert findings.count() == 5
|
||||
for finding in findings:
|
||||
assert finding.muted is False
|
||||
|
||||
# Execute the muting function
|
||||
result = mute_historical_findings(tenant_id, str(mute_rule.id))
|
||||
|
||||
# Verify return value
|
||||
assert result["findings_muted"] == 5
|
||||
assert result["rule_id"] == str(mute_rule.id)
|
||||
|
||||
# Verify all findings were muted
|
||||
findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=finding_uids)
|
||||
for finding in findings:
|
||||
assert finding.muted is True
|
||||
assert finding.muted_at == mute_rule.inserted_at
|
||||
assert finding.muted_reason == mute_rule.reason
|
||||
|
||||
def test_mute_historical_findings_already_muted(
|
||||
self, mute_rule_already_muted, findings_fixture
|
||||
):
|
||||
"""
|
||||
Test that already-muted findings are not counted or updated.
|
||||
"""
|
||||
mute_rule = mute_rule_already_muted
|
||||
tenant_id = str(mute_rule.tenant_id)
|
||||
finding = findings_fixture[1]
|
||||
|
||||
# Verify the finding is already muted
|
||||
finding.refresh_from_db()
|
||||
assert finding.muted is True
|
||||
original_muted_at = finding.muted_at
|
||||
original_muted_reason = finding.muted_reason
|
||||
|
||||
# Execute the muting function
|
||||
result = mute_historical_findings(tenant_id, str(mute_rule.id))
|
||||
|
||||
# Verify no findings were muted
|
||||
assert result["findings_muted"] == 0
|
||||
assert result["rule_id"] == str(mute_rule.id)
|
||||
|
||||
# Verify the finding's mute status did not change
|
||||
finding.refresh_from_db()
|
||||
assert finding.muted is True
|
||||
assert finding.muted_at == original_muted_at
|
||||
assert finding.muted_reason == original_muted_reason
|
||||
|
||||
def test_mute_historical_findings_mixed_status(self, mute_rule_mixed_findings):
|
||||
"""
|
||||
Test muting when some findings are already muted and others are not.
|
||||
"""
|
||||
mute_rule, unmuted_uids, muted_uids = mute_rule_mixed_findings
|
||||
tenant_id = str(mute_rule.tenant_id)
|
||||
|
||||
# Execute the muting function
|
||||
result = mute_historical_findings(tenant_id, str(mute_rule.id))
|
||||
|
||||
# Verify only unmuted findings were counted
|
||||
assert result["findings_muted"] == 3
|
||||
assert result["rule_id"] == str(mute_rule.id)
|
||||
|
||||
# Verify unmuted findings are now muted
|
||||
unmuted_findings = Finding.objects.filter(
|
||||
tenant_id=tenant_id, uid__in=unmuted_uids
|
||||
older_scan = Scan.objects.create(
|
||||
tenant_id=scan.tenant_id,
|
||||
provider=scan.provider,
|
||||
name="Older matching scan",
|
||||
trigger=Scan.TriggerChoices.MANUAL,
|
||||
state=StateChoices.COMPLETED,
|
||||
started_at=datetime.now(UTC) - timedelta(days=1),
|
||||
completed_at=datetime.now(UTC) - timedelta(days=1),
|
||||
)
|
||||
for finding in unmuted_findings:
|
||||
assert finding.muted is True
|
||||
assert finding.muted_at == mute_rule.inserted_at
|
||||
assert finding.muted_reason == mute_rule.reason
|
||||
older_finding = _create_finding(older_scan, active_finding.uid)
|
||||
|
||||
# Verify already-muted findings remained unchanged
|
||||
already_muted_findings = Finding.objects.filter(
|
||||
tenant_id=tenant_id, uid__in=muted_uids
|
||||
)
|
||||
for finding in already_muted_findings:
|
||||
assert finding.muted is True
|
||||
assert finding.muted_reason == "Already muted"
|
||||
result = reconcile_scan_mute_rules(str(scan.tenant_id), str(scan.id))
|
||||
|
||||
def test_mute_historical_findings_nonexistent_rule(self, tenants_fixture):
|
||||
"""
|
||||
Test that a nonexistent mute rule raises ObjectDoesNotExist.
|
||||
"""
|
||||
tenant_id = str(tenants_fixture[0].id)
|
||||
nonexistent_rule_id = str(uuid4())
|
||||
|
||||
with pytest.raises(ObjectDoesNotExist):
|
||||
mute_historical_findings(tenant_id, nonexistent_rule_id)
|
||||
|
||||
def test_mute_historical_findings_no_matching_findings(
|
||||
self, tenants_fixture, test_user
|
||||
):
|
||||
"""
|
||||
Test muting when no findings match the rule's UIDs.
|
||||
"""
|
||||
tenant_id = str(tenants_fixture[0].id)
|
||||
|
||||
# Create a mute rule with non-existent finding UIDs
|
||||
mute_rule = MuteRule.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
name="Test No Match Rule",
|
||||
reason="Testing no matching findings",
|
||||
enabled=True,
|
||||
created_by=test_user,
|
||||
finding_uids=[
|
||||
"nonexistent_uid_1",
|
||||
"nonexistent_uid_2",
|
||||
"nonexistent_uid_3",
|
||||
],
|
||||
)
|
||||
|
||||
# Execute the muting function
|
||||
result = mute_historical_findings(tenant_id, str(mute_rule.id))
|
||||
|
||||
# Verify no findings were muted
|
||||
assert result["findings_muted"] == 0
|
||||
assert result["rule_id"] == str(mute_rule.id)
|
||||
|
||||
def test_mute_historical_findings_batch_processing(self, mute_rule_batch_test):
|
||||
"""
|
||||
Test that large numbers of findings are processed in batches correctly.
|
||||
"""
|
||||
mute_rule, finding_uids = mute_rule_batch_test
|
||||
tenant_id = str(mute_rule.tenant_id)
|
||||
|
||||
# Verify all findings exist and are unmuted
|
||||
findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=finding_uids)
|
||||
assert findings.count() == 1500
|
||||
for finding in findings:
|
||||
assert finding.muted is False
|
||||
|
||||
# Execute the muting function
|
||||
result = mute_historical_findings(tenant_id, str(mute_rule.id))
|
||||
|
||||
# Verify return value
|
||||
assert result["findings_muted"] == 1500
|
||||
assert result["rule_id"] == str(mute_rule.id)
|
||||
|
||||
# Verify all findings were muted
|
||||
findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=finding_uids)
|
||||
for finding in findings:
|
||||
assert finding.muted is True
|
||||
assert finding.muted_at == mute_rule.inserted_at
|
||||
assert finding.muted_reason == mute_rule.reason
|
||||
|
||||
def test_mute_historical_findings_preserves_muted_at_timestamp(
|
||||
self, mute_rule_with_findings, findings_fixture
|
||||
):
|
||||
"""
|
||||
Test that muted_at is set to the rule's inserted_at, not the current time.
|
||||
"""
|
||||
mute_rule = mute_rule_with_findings
|
||||
tenant_id = str(mute_rule.tenant_id)
|
||||
finding = findings_fixture[0]
|
||||
|
||||
# Execute the muting function
|
||||
result = mute_historical_findings(tenant_id, str(mute_rule.id))
|
||||
|
||||
# Verify the finding was muted
|
||||
assert result["findings_muted"] == 1
|
||||
|
||||
# Verify muted_at matches the rule's inserted_at timestamp
|
||||
finding.refresh_from_db()
|
||||
assert finding.muted_at == mute_rule.inserted_at
|
||||
assert finding.muted_at is not None
|
||||
|
||||
def test_mute_historical_findings_partial_match(self, scans_fixture, test_user):
|
||||
"""
|
||||
Test muting when only some of the rule's UIDs exist as findings.
|
||||
"""
|
||||
scan = scans_fixture[0]
|
||||
tenant_id = str(scan.tenant_id)
|
||||
|
||||
# Create 3 findings
|
||||
existing_uids = []
|
||||
for i in range(3):
|
||||
finding = Finding.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
uid=f"partial_match_finding_{i}",
|
||||
scan=scan,
|
||||
status=Status.FAIL,
|
||||
status_extended=f"Partial match status {i}",
|
||||
impact=Severity.high,
|
||||
severity=Severity.high,
|
||||
raw_result={
|
||||
"status": Status.FAIL,
|
||||
"impact": Severity.high,
|
||||
"severity": Severity.high,
|
||||
},
|
||||
check_id=f"partial_match_check_{i}",
|
||||
check_metadata={
|
||||
"CheckId": f"partial_match_check_{i}",
|
||||
"Description": f"Partial match description {i}",
|
||||
},
|
||||
muted=False,
|
||||
)
|
||||
existing_uids.append(finding.uid)
|
||||
|
||||
# Create a mute rule with both existing and non-existing UIDs
|
||||
all_uids = existing_uids + [
|
||||
"nonexistent_uid_1",
|
||||
"nonexistent_uid_2",
|
||||
]
|
||||
mute_rule = MuteRule.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
name="Test Partial Match Rule",
|
||||
reason="Testing partial matching",
|
||||
enabled=True,
|
||||
created_by=test_user,
|
||||
finding_uids=all_uids,
|
||||
)
|
||||
|
||||
# Execute the muting function
|
||||
result = mute_historical_findings(tenant_id, str(mute_rule.id))
|
||||
|
||||
# Verify only existing findings were muted
|
||||
assert result["findings_muted"] == 3
|
||||
assert result["rule_id"] == str(mute_rule.id)
|
||||
|
||||
# Verify the existing findings were muted
|
||||
findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=existing_uids)
|
||||
assert findings.count() == 3
|
||||
for finding in findings:
|
||||
assert finding.muted is True
|
||||
assert finding.muted_at == mute_rule.inserted_at
|
||||
assert finding.muted_reason == mute_rule.reason
|
||||
|
||||
def test_mute_historical_findings_empty_uids(self, tenants_fixture, test_user):
|
||||
"""
|
||||
Test muting when the rule has an empty finding_uids array.
|
||||
"""
|
||||
tenant_id = str(tenants_fixture[0].id)
|
||||
|
||||
# Create a mute rule with empty finding_uids
|
||||
mute_rule = MuteRule.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
name="Test Empty UIDs Rule",
|
||||
reason="Testing empty UIDs",
|
||||
enabled=True,
|
||||
created_by=test_user,
|
||||
finding_uids=[],
|
||||
)
|
||||
|
||||
# Execute the muting function
|
||||
result = mute_historical_findings(tenant_id, str(mute_rule.id))
|
||||
|
||||
# Verify no findings were muted
|
||||
assert result["findings_muted"] == 0
|
||||
assert result["rule_id"] == str(mute_rule.id)
|
||||
|
||||
def test_mute_historical_findings_return_format(self, mute_rule_with_findings):
|
||||
"""
|
||||
Test that the return value has the correct format and fields.
|
||||
"""
|
||||
mute_rule = mute_rule_with_findings
|
||||
tenant_id = str(mute_rule.tenant_id)
|
||||
|
||||
result = mute_historical_findings(tenant_id, str(mute_rule.id))
|
||||
|
||||
# Verify return value structure
|
||||
assert isinstance(result, dict)
|
||||
assert "findings_muted" in result
|
||||
assert "rule_id" in result
|
||||
assert isinstance(result["findings_muted"], int)
|
||||
assert isinstance(result["rule_id"], str)
|
||||
assert result["rule_id"] == str(mute_rule.id)
|
||||
active_finding.refresh_from_db()
|
||||
disabled_finding.refresh_from_db()
|
||||
older_finding.refresh_from_db()
|
||||
assert active_finding.muted is True
|
||||
assert active_finding.muted_at == active_rule.inserted_at
|
||||
assert disabled_finding.muted is False
|
||||
assert older_finding.muted is False
|
||||
assert result == {"findings_muted": 1, "scan_id": str(scan.id)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import httpx
|
||||
@@ -33,10 +33,10 @@ from tasks.tasks import (
|
||||
check_integrations_task,
|
||||
check_lighthouse_provider_connection_task,
|
||||
generate_outputs_task,
|
||||
mute_findings_in_latest_scans_task,
|
||||
perform_attack_paths_scan_task,
|
||||
perform_scan_task,
|
||||
perform_scheduled_scan_task,
|
||||
reaggregate_all_finding_group_summaries_task,
|
||||
refresh_lighthouse_provider_models_task,
|
||||
s3_integration_task,
|
||||
security_hub_integration_task,
|
||||
@@ -2959,6 +2959,7 @@ class TestPerformScheduledScanTask:
|
||||
with (
|
||||
patch("tasks.tasks.perform_prowler_scan", side_effect=_complete_scan),
|
||||
patch("tasks.tasks._perform_scan_complete_tasks"),
|
||||
patch("tasks.tasks.reconcile_scan_mute_rules") as mock_reconcile,
|
||||
self._override_task_request(perform_scheduled_scan_task, id=task_id),
|
||||
):
|
||||
perform_scheduled_scan_task.run(
|
||||
@@ -2982,6 +2983,13 @@ class TestPerformScheduledScanTask:
|
||||
).count()
|
||||
== 1
|
||||
)
|
||||
completed_scan = Scan.objects.get(
|
||||
tenant_id=tenant.id,
|
||||
provider=provider,
|
||||
trigger=Scan.TriggerChoices.SCHEDULED,
|
||||
state=StateChoices.COMPLETED,
|
||||
)
|
||||
mock_reconcile.assert_called_once_with(str(tenant.id), str(completed_scan.id))
|
||||
assert (
|
||||
Scan.objects.filter(
|
||||
tenant_id=tenant.id,
|
||||
@@ -3176,7 +3184,10 @@ class TestPerformScanTask:
|
||||
task=queued_task,
|
||||
)
|
||||
|
||||
events = []
|
||||
|
||||
def _complete_scan(tenant_id, scan_id, provider_id, checks_to_execute=None):
|
||||
events.append("scan")
|
||||
scan_instance = Scan.objects.get(id=scan_id)
|
||||
scan_instance.state = StateChoices.COMPLETED
|
||||
scan_instance.save()
|
||||
@@ -3184,7 +3195,14 @@ class TestPerformScanTask:
|
||||
|
||||
with (
|
||||
patch("tasks.tasks.perform_prowler_scan", side_effect=_complete_scan),
|
||||
patch("tasks.tasks._perform_scan_complete_tasks"),
|
||||
patch(
|
||||
"tasks.tasks.reconcile_scan_mute_rules",
|
||||
side_effect=lambda *_args: events.append("reconcile"),
|
||||
),
|
||||
patch(
|
||||
"tasks.tasks._perform_scan_complete_tasks",
|
||||
side_effect=lambda *_args: events.append("summaries"),
|
||||
),
|
||||
patch("tasks.tasks.perform_scan_task.apply_async") as mock_apply_async,
|
||||
):
|
||||
with django_capture_on_commit_callbacks(execute=True):
|
||||
@@ -3196,6 +3214,7 @@ class TestPerformScanTask:
|
||||
|
||||
queued_task_result.refresh_from_db()
|
||||
assert result == {"status": "ok"}
|
||||
assert events == ["scan", "reconcile", "summaries"]
|
||||
assert queued_task_result.status == states.PENDING
|
||||
mock_apply_async.assert_called_once_with(
|
||||
kwargs={
|
||||
@@ -3241,10 +3260,7 @@ class TestPerformScanTask:
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestReaggregateAllFindingGroupSummaries:
|
||||
def setup_method(self):
|
||||
self.tenant_id = str(uuid.uuid4())
|
||||
|
||||
class TestMuteFindingsInLatestScansTask:
|
||||
@patch("tasks.tasks.chain")
|
||||
@patch("tasks.tasks.group")
|
||||
@patch("tasks.tasks.aggregate_attack_surface_task")
|
||||
@@ -3253,10 +3269,10 @@ class TestReaggregateAllFindingGroupSummaries:
|
||||
@patch("tasks.tasks.aggregate_finding_group_summaries_task")
|
||||
@patch("tasks.tasks.aggregate_daily_severity_task")
|
||||
@patch("tasks.tasks.perform_scan_summary_task")
|
||||
@patch("tasks.tasks.Scan.objects.filter")
|
||||
def test_dispatches_subtasks_for_each_provider_per_day(
|
||||
@patch("tasks.tasks.mute_findings_in_latest_scans")
|
||||
def test_reaggregates_only_changed_scans(
|
||||
self,
|
||||
mock_scan_filter,
|
||||
mock_mute_findings,
|
||||
mock_scan_summary_task,
|
||||
mock_daily_severity_task,
|
||||
mock_finding_group_task,
|
||||
@@ -3265,119 +3281,36 @@ class TestReaggregateAllFindingGroupSummaries:
|
||||
mock_attack_surface_task,
|
||||
mock_group,
|
||||
mock_chain,
|
||||
tenants_fixture,
|
||||
):
|
||||
provider_id_1 = uuid.uuid4()
|
||||
provider_id_2 = uuid.uuid4()
|
||||
scan_id_today_p1 = uuid.uuid4()
|
||||
scan_id_yesterday_p1 = uuid.uuid4()
|
||||
scan_id_today_p2 = uuid.uuid4()
|
||||
today = datetime.now(tz=UTC)
|
||||
yesterday = today - timedelta(days=1)
|
||||
|
||||
mock_outer_group_result = MagicMock()
|
||||
# The first `group()` call wraps the inner parallel step; subsequent
|
||||
# calls wrap the outer per-scan generator.
|
||||
mock_group.side_effect = lambda *args, **kwargs: (
|
||||
list(args[0]) if args and hasattr(args[0], "__iter__") else None,
|
||||
mock_outer_group_result,
|
||||
)[1]
|
||||
|
||||
mock_scan_filter.return_value.order_by.return_value.values.return_value = [
|
||||
{
|
||||
"id": scan_id_today_p1,
|
||||
"completed_at": today,
|
||||
"provider_id": provider_id_1,
|
||||
},
|
||||
{
|
||||
"id": scan_id_today_p2,
|
||||
"completed_at": today,
|
||||
"provider_id": provider_id_2,
|
||||
},
|
||||
{
|
||||
"id": scan_id_yesterday_p1,
|
||||
"completed_at": yesterday,
|
||||
"provider_id": provider_id_1,
|
||||
},
|
||||
]
|
||||
|
||||
result = reaggregate_all_finding_group_summaries_task(tenant_id=self.tenant_id)
|
||||
|
||||
assert result == {"scans_reaggregated": 3}
|
||||
expected_scan_ids = {
|
||||
str(scan_id_today_p1),
|
||||
str(scan_id_today_p2),
|
||||
str(scan_id_yesterday_p1),
|
||||
tenant_id = str(tenants_fixture[0].id)
|
||||
mute_rule_id = str(uuid.uuid4())
|
||||
provider_ids = [str(uuid.uuid4()), str(uuid.uuid4())]
|
||||
scan_ids = [str(uuid.uuid4()), str(uuid.uuid4())]
|
||||
result = {
|
||||
"findings_muted": 2,
|
||||
"rule_id": mute_rule_id,
|
||||
"scan_ids": scan_ids,
|
||||
}
|
||||
for task_mock in (
|
||||
mock_scan_summary_task,
|
||||
mock_daily_severity_task,
|
||||
mock_finding_group_task,
|
||||
mock_resource_group_task,
|
||||
mock_category_task,
|
||||
mock_attack_surface_task,
|
||||
):
|
||||
assert task_mock.si.call_count == 3
|
||||
dispatched = {
|
||||
call.kwargs["scan_id"] for call in task_mock.si.call_args_list
|
||||
}
|
||||
assert dispatched == expected_scan_ids
|
||||
for call in task_mock.si.call_args_list:
|
||||
assert call.kwargs["tenant_id"] == self.tenant_id
|
||||
assert mock_chain.call_count == 3
|
||||
mock_outer_group_result.apply_async.assert_called_once()
|
||||
|
||||
@patch("tasks.tasks.chain")
|
||||
@patch("tasks.tasks.group")
|
||||
@patch("tasks.tasks.aggregate_attack_surface_task")
|
||||
@patch("tasks.tasks.aggregate_scan_category_summaries_task")
|
||||
@patch("tasks.tasks.aggregate_scan_resource_group_summaries_task")
|
||||
@patch("tasks.tasks.aggregate_finding_group_summaries_task")
|
||||
@patch("tasks.tasks.aggregate_daily_severity_task")
|
||||
@patch("tasks.tasks.perform_scan_summary_task")
|
||||
@patch("tasks.tasks.Scan.objects.filter")
|
||||
def test_dedupes_scans_to_latest_per_provider_per_day(
|
||||
self,
|
||||
mock_scan_filter,
|
||||
mock_scan_summary_task,
|
||||
mock_daily_severity_task,
|
||||
mock_finding_group_task,
|
||||
mock_resource_group_task,
|
||||
mock_category_task,
|
||||
mock_attack_surface_task,
|
||||
mock_group,
|
||||
mock_chain,
|
||||
):
|
||||
"""When several scans run on the same day for the same provider, only
|
||||
the latest one is dispatched (matching the daily summary unique key)."""
|
||||
provider_id = uuid.uuid4()
|
||||
latest_scan_today = uuid.uuid4()
|
||||
earlier_scan_today = uuid.uuid4()
|
||||
today_late = datetime.now(tz=UTC)
|
||||
today_early = today_late - timedelta(hours=4)
|
||||
|
||||
mock_mute_findings.return_value = result
|
||||
mock_outer_group_result = MagicMock()
|
||||
mock_group.side_effect = lambda *args, **kwargs: (
|
||||
list(args[0]) if args and hasattr(args[0], "__iter__") else None,
|
||||
mock_outer_group_result,
|
||||
)[1]
|
||||
|
||||
# Returned ordered by `-completed_at`, so the most recent comes first.
|
||||
mock_scan_filter.return_value.order_by.return_value.values.return_value = [
|
||||
{
|
||||
"id": latest_scan_today,
|
||||
"completed_at": today_late,
|
||||
"provider_id": provider_id,
|
||||
},
|
||||
{
|
||||
"id": earlier_scan_today,
|
||||
"completed_at": today_early,
|
||||
"provider_id": provider_id,
|
||||
},
|
||||
]
|
||||
task_result = mute_findings_in_latest_scans_task(
|
||||
tenant_id=tenant_id,
|
||||
mute_rule_id=mute_rule_id,
|
||||
provider_ids=provider_ids,
|
||||
)
|
||||
|
||||
result = reaggregate_all_finding_group_summaries_task(tenant_id=self.tenant_id)
|
||||
|
||||
assert result == {"scans_reaggregated": 1}
|
||||
assert task_result == result
|
||||
mock_mute_findings.assert_called_once_with(
|
||||
tenant_id=tenant_id,
|
||||
mute_rule_id=mute_rule_id,
|
||||
provider_ids=provider_ids,
|
||||
)
|
||||
for task_mock in (
|
||||
mock_scan_summary_task,
|
||||
mock_daily_severity_task,
|
||||
@@ -3386,23 +3319,35 @@ class TestReaggregateAllFindingGroupSummaries:
|
||||
mock_category_task,
|
||||
mock_attack_surface_task,
|
||||
):
|
||||
task_mock.si.assert_called_once_with(
|
||||
tenant_id=self.tenant_id, scan_id=str(latest_scan_today)
|
||||
)
|
||||
mock_chain.assert_called_once()
|
||||
assert task_mock.si.call_count == 2
|
||||
assert {
|
||||
call.kwargs["scan_id"] for call in task_mock.si.call_args_list
|
||||
} == set(scan_ids)
|
||||
assert mock_chain.call_count == 2
|
||||
mock_outer_group_result.apply_async.assert_called_once()
|
||||
|
||||
@patch("tasks.tasks.chain")
|
||||
@patch("tasks.tasks.group")
|
||||
@patch("tasks.tasks.Scan.objects.filter")
|
||||
def test_no_completed_scans_skips_dispatch(
|
||||
self, mock_scan_filter, mock_group, mock_chain
|
||||
@patch("tasks.tasks.mute_findings_in_latest_scans")
|
||||
def test_skips_reaggregation_when_no_scan_changed(
|
||||
self, mock_mute_findings, mock_group, mock_chain, tenants_fixture
|
||||
):
|
||||
mock_scan_filter.return_value.order_by.return_value.values.return_value = []
|
||||
tenant_id = str(tenants_fixture[0].id)
|
||||
mute_rule_id = str(uuid.uuid4())
|
||||
result = {
|
||||
"findings_muted": 0,
|
||||
"rule_id": mute_rule_id,
|
||||
"scan_ids": [],
|
||||
}
|
||||
mock_mute_findings.return_value = result
|
||||
|
||||
result = reaggregate_all_finding_group_summaries_task(tenant_id=self.tenant_id)
|
||||
task_result = mute_findings_in_latest_scans_task(
|
||||
tenant_id=tenant_id,
|
||||
mute_rule_id=mute_rule_id,
|
||||
provider_ids=[],
|
||||
)
|
||||
|
||||
assert result == {"scans_reaggregated": 0}
|
||||
assert task_result == result
|
||||
mock_group.assert_not_called()
|
||||
mock_chain.assert_not_called()
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 @@
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "eks_cluster_vpc_cni_network_policy_enforced",
|
||||
"CheckTitle": "EKS cluster enforces Kubernetes network policies through the Amazon VPC CNI add-on",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices/Network Reachability",
|
||||
"TTPs/Lateral Movement"
|
||||
],
|
||||
"ServiceName": "eks",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "AwsEksCluster",
|
||||
"ResourceGroup": "container",
|
||||
"Description": "**Amazon EKS clusters** are evaluated for whether the **Amazon VPC CNI** managed add-on sets `enableNetworkPolicy` to `true`, which is what makes the CNI enforce Kubernetes `NetworkPolicy` resources. The policy objects themselves live in the cluster and are not exposed by the EKS API, so only this enforcement precondition is verified.",
|
||||
"Risk": "Without CNI **network policy enforcement** every `NetworkPolicy` an operator authors is inert, so pods reach every other pod and service in the cluster. That unrestricted east-west path lets one compromised container move **laterally** to sidecars, tool executors and internal APIs, widening the blast radius and easing **data exfiltration**.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/eks/latest/userguide/cni-network-policy.html",
|
||||
"https://docs.aws.amazon.com/eks/latest/APIReference/API_UpdateAddon.html",
|
||||
"https://docs.aws.amazon.com/eks/latest/userguide/updating-an-add-on.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "aws eks update-addon --cluster-name <example_cluster_name> --addon-name vpc-cni --resolve-conflicts PRESERVE --configuration-values '{\"enableNetworkPolicy\":\"true\"}'",
|
||||
"NativeIaC": "```yaml\n# CloudFormation: enable network policy enforcement in the VPC CNI add-on\nResources:\n <example_resource_name>:\n Type: AWS::EKS::Addon\n Properties:\n ClusterName: <example_cluster_name>\n AddonName: vpc-cni\n ResolveConflicts: PRESERVE\n ConfigurationValues: '{\"enableNetworkPolicy\":\"true\"}' # critical: makes the CNI enforce NetworkPolicy resources\n```",
|
||||
"Other": "1. Open the AWS Console and go to EKS > Clusters\n2. Select <your cluster> and open the Add-ons tab\n3. Select the Amazon VPC CNI add-on and click Edit\n4. Expand Optional configuration settings and set enableNetworkPolicy to \"true\" in the configuration values\n5. Click Save changes",
|
||||
"Terraform": "```hcl\n# Enable network policy enforcement in the VPC CNI managed add-on\nresource \"aws_eks_addon\" \"<example_resource_name>\" {\n cluster_name = \"<example_cluster_name>\"\n addon_name = \"vpc-cni\"\n resolve_conflicts_on_update = \"PRESERVE\"\n\n configuration_values = jsonencode({\n enableNetworkPolicy = \"true\" # critical: makes the CNI enforce NetworkPolicy resources\n })\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Set `enableNetworkPolicy` to `true` on the Amazon VPC CNI add-on, then author `NetworkPolicy` resources that allow each workload only its declared dependencies, ideally with `strict` enforcement mode so traffic is denied until the policy is in place. Layer security groups for Pods to reach VPC resources such as databases.",
|
||||
"Url": "https://hub.prowler.com/check/eks_cluster_vpc_cni_network_policy_enforced"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"trust-boundaries",
|
||||
"cluster-security"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "The EKS API exposes only the add-on setting, not the Kubernetes NetworkPolicy resources, so a PASS is the enforcement precondition rather than proof that pod-to-pod traffic is restricted. A FAIL is a statement about the managed add-on, not about the cluster: two documented architectures enforce network policies with this setting false and neither is visible to the EKS API. A third-party policy engine -- the EKS Best Practices Guide recommends Calico and Cilium in its 'ThirdParty Network Policy Engines' section (eks/latest/best-practices/network-security.html) -- would correctly leave it false, since two enforcers are not run together. And a self-managed VPC CNI can be enabled by Helm or by the amazon-vpc-cni ConfigMap key enable-network-policy-controller with the aws-node DaemonSet, two of the three paths AWS documents (eks/latest/userguide/cni-network-policy-configure.html); only the managed add-on path is readable here. A cluster with no vpc-cni managed add-on at all reports MANUAL for the same reason. Detecting Calico or Cilium is deliberately not attempted, because any signal would be a guess presented as a measurement."
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from prowler.lib.check.models import Check, Check_Report_AWS
|
||||
from prowler.providers.aws.services.eks.eks_client import eks_client
|
||||
|
||||
VPC_CNI_ADDON_NAME = "vpc-cni"
|
||||
NETWORK_POLICY_KEY = "enableNetworkPolicy"
|
||||
|
||||
|
||||
def parse_configuration_values(configuration_values: Optional[str]) -> Optional[dict]:
|
||||
"""Decode an EKS add-on `configurationValues` blob.
|
||||
|
||||
Args:
|
||||
configuration_values: The raw JSON string returned by DescribeAddon, which is
|
||||
absent when no configuration has been supplied for the add-on.
|
||||
|
||||
Returns:
|
||||
The decoded mapping, an empty mapping when nothing was supplied, or None when
|
||||
the blob is not a readable JSON object.
|
||||
"""
|
||||
if not configuration_values:
|
||||
return {}
|
||||
try:
|
||||
configuration = json.loads(configuration_values)
|
||||
except ValueError:
|
||||
return None
|
||||
return configuration if isinstance(configuration, dict) else None
|
||||
|
||||
|
||||
def boolean_configuration_value(value: object) -> Optional[bool]:
|
||||
"""Read a VPC CNI boolean setting.
|
||||
|
||||
The add-on configuration schema types these as a string carrying `"format": "boolean"`,
|
||||
so the API returns `"true"` rather than `true`; a JSON boolean is accepted as well
|
||||
because the schema is add-on-version specific and this blob is otherwise untyped.
|
||||
|
||||
Args:
|
||||
value: The value found in the add-on configuration, if any.
|
||||
|
||||
Returns:
|
||||
The boolean it denotes, or None when it is absent or not a recognized boolean.
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str) and value.strip().lower() in ("true", "false"):
|
||||
return value.strip().lower() == "true"
|
||||
return None
|
||||
|
||||
|
||||
class eks_cluster_vpc_cni_network_policy_enforced(Check):
|
||||
"""Ensure the Amazon VPC CNI add-on enforces Kubernetes network policies.
|
||||
|
||||
Kubernetes NetworkPolicy resources live in the cluster and are not exposed by the
|
||||
EKS API. What the API does expose is whether the Amazon VPC CNI managed add-on has
|
||||
network policy enforcement switched on, which is the precondition for any
|
||||
NetworkPolicy to take effect.
|
||||
- PASS: The Amazon VPC CNI add-on sets enableNetworkPolicy to true.
|
||||
- FAIL: The Amazon VPC CNI add-on sets enableNetworkPolicy to false.
|
||||
- MANUAL: The setting cannot be read, or the cluster does not use the Amazon VPC CNI
|
||||
managed add-on.
|
||||
|
||||
TWO WAYS A CLUSTER CAN ENFORCE NETWORK POLICIES WITHOUT THIS SETTING BEING TRUE, so a
|
||||
FAIL is a statement about the managed add-on and not about the cluster. Neither is
|
||||
fixable by reading more of the AWS API: both live in in-cluster state that
|
||||
DescribeAddon cannot see.
|
||||
|
||||
1. A third-party policy engine. The EKS Best Practices Guide recommends Calico and
|
||||
Cilium for requirements the VPC CNI does not cover, such as Layer 7 and DNS
|
||||
hostname rules, in its "ThirdParty Network Policy Engines" section
|
||||
(https://docs.aws.amazon.com/eks/latest/best-practices/network-security.html).
|
||||
A cluster enforcing through one of those would correctly leave this setting false,
|
||||
because two enforcers are not run together.
|
||||
2. A self-managed VPC CNI. AWS documents three ways to enable the feature and only the
|
||||
first is visible here
|
||||
(https://docs.aws.amazon.com/eks/latest/userguide/cni-network-policy-configure.html):
|
||||
`aws eks update-addon --addon-name vpc-cni` with configurationValues; `helm upgrade
|
||||
... aws-vpc-cni`; or the `amazon-vpc-cni` ConfigMap key
|
||||
`enable-network-policy-controller: "true"` together with policy enforcement in the
|
||||
aws-node container of the VPC CNI DaemonSet. The second and third leave the EKS
|
||||
control plane with nothing to report.
|
||||
|
||||
Detecting either is deliberately NOT attempted. Calico and Cilium are invisible to the
|
||||
AWS API, so any signal would be a guess presented as a measurement.
|
||||
"""
|
||||
|
||||
def execute(self) -> list[Check_Report_AWS]:
|
||||
"""Execute the check logic.
|
||||
|
||||
Returns:
|
||||
A list of reports containing the result of the check.
|
||||
"""
|
||||
findings = []
|
||||
for cluster in eks_client.clusters:
|
||||
report = Check_Report_AWS(metadata=self.metadata(), resource=cluster)
|
||||
report.status = "MANUAL"
|
||||
addon = cluster.addons.get(VPC_CNI_ADDON_NAME)
|
||||
|
||||
if addon is None and cluster.addons_discovery_failed:
|
||||
report.status_extended = f"EKS cluster {cluster.name} add-ons could not be listed, so Kubernetes network policy enforcement in the Amazon VPC CNI add-on cannot be determined."
|
||||
elif addon is None:
|
||||
report.status_extended = f"EKS cluster {cluster.name} does not use the Amazon VPC CNI managed add-on, so Kubernetes network policy enforcement cannot be determined from the EKS API. Review the self-managed CNI configuration in the cluster."
|
||||
elif addon.configuration_discovery_failed:
|
||||
report.status_extended = f"EKS cluster {cluster.name} Amazon VPC CNI add-on configuration could not be read, so Kubernetes network policy enforcement cannot be determined."
|
||||
else:
|
||||
configuration = parse_configuration_values(addon.configuration_values)
|
||||
if configuration is None:
|
||||
report.status_extended = f"EKS cluster {cluster.name} Amazon VPC CNI add-on configuration values are not a readable JSON object, so Kubernetes network policy enforcement cannot be determined."
|
||||
else:
|
||||
network_policy_enabled = boolean_configuration_value(
|
||||
configuration.get(NETWORK_POLICY_KEY)
|
||||
)
|
||||
if network_policy_enabled is None:
|
||||
report.status_extended = f"EKS cluster {cluster.name} Amazon VPC CNI add-on does not set {NETWORK_POLICY_KEY} to true or false, so Kubernetes network policy enforcement cannot be determined."
|
||||
elif network_policy_enabled:
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"EKS cluster {cluster.name} enforces Kubernetes network policies through the Amazon VPC CNI add-on. This does not confirm that NetworkPolicy resources restricting pod-to-pod traffic exist in the cluster."
|
||||
else:
|
||||
# States the measurement, not the inference from it. The previous wording --
|
||||
# "cluster does not enforce Kubernetes network policies" -- claimed a cluster
|
||||
# property from an add-on setting, and is false for a cluster enforcing
|
||||
# through Calico or Cilium, or through a self-managed VPC CNI. Both are
|
||||
# documented architectures rather than edge cases; see the class docstring.
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"EKS cluster {cluster.name} Amazon VPC CNI managed add-on does not enforce Kubernetes network policies, since it sets {NETWORK_POLICY_KEY} to false. Enforcement by a third-party policy engine or a self-managed VPC CNI is not visible to the EKS API and is not evaluated."
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -6,14 +6,19 @@ from prowler.lib.logger import logger
|
||||
from prowler.lib.scan_filters.scan_filters import is_resource_filtered
|
||||
from prowler.providers.aws.lib.service.service import AWSService
|
||||
|
||||
# DescribeAddon has no batch form, so only add-ons a check reads are described.
|
||||
COLLECTED_ADDONS = ("vpc-cni",)
|
||||
|
||||
|
||||
class EKS(AWSService):
|
||||
def __init__(self, provider):
|
||||
"""Collect the audited account's EKS clusters, their configuration and their add-ons."""
|
||||
# Call AWSService's __init__
|
||||
super().__init__(__class__.__name__, provider)
|
||||
self.clusters = []
|
||||
self.__threading_call__(self._list_clusters)
|
||||
self._describe_cluster(self.regional_clients)
|
||||
self.__threading_call__(self._describe_cluster_addons, self.clusters)
|
||||
|
||||
def _list_clusters(self, regional_client):
|
||||
logger.info("EKS listing clusters...")
|
||||
@@ -95,12 +100,69 @@ class EKS(AWSService):
|
||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _describe_cluster_addons(self, cluster):
|
||||
"""Attach the add-ons named in COLLECTED_ADDONS, with their configuration, to a cluster.
|
||||
|
||||
ListAddons names every add-on installed on the cluster and DescribeAddon then supplies
|
||||
the ARN and the raw `configurationValues` blob for the ones checks read. A failed listing
|
||||
sets `addons_discovery_failed` on the cluster and a failed describe sets
|
||||
`configuration_discovery_failed` on the add-on, so a check can tell an add-on that is
|
||||
absent from one whose state could not be read instead of reporting both as absent.
|
||||
"""
|
||||
logger.info("EKS describing cluster add-ons...")
|
||||
try:
|
||||
regional_client = self.regional_clients[cluster.region]
|
||||
list_addons_paginator = regional_client.get_paginator("list_addons")
|
||||
for page in list_addons_paginator.paginate(clusterName=cluster.name):
|
||||
for addon_name in page["addons"]:
|
||||
if addon_name in COLLECTED_ADDONS:
|
||||
cluster.addons[addon_name] = EKSAddon(name=addon_name)
|
||||
except Exception as error:
|
||||
cluster.addons_discovery_failed = True
|
||||
logger.error(
|
||||
f"{cluster.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
return
|
||||
|
||||
for addon in cluster.addons.values():
|
||||
try:
|
||||
describe_addon = regional_client.describe_addon(
|
||||
clusterName=cluster.name, addonName=addon.name
|
||||
)
|
||||
addon.arn = describe_addon["addon"].get("addonArn")
|
||||
addon.configuration_values = describe_addon["addon"].get(
|
||||
"configurationValues"
|
||||
)
|
||||
except Exception as error:
|
||||
addon.configuration_discovery_failed = True
|
||||
logger.error(
|
||||
f"{cluster.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
|
||||
class EKSClusterLoggingEntity(BaseModel):
|
||||
types: list[str] = None
|
||||
enabled: bool = None
|
||||
|
||||
|
||||
class EKSAddon(BaseModel):
|
||||
"""An EKS managed add-on, with the configuration values collected for it.
|
||||
|
||||
Attributes:
|
||||
name: The add-on name as returned by ListAddons.
|
||||
arn: The add-on ARN, absent when DescribeAddon could not be read.
|
||||
configuration_values: The raw JSON blob supplied for the add-on, absent when none
|
||||
was supplied or when DescribeAddon could not be read.
|
||||
configuration_discovery_failed: True when DescribeAddon failed, so a check can
|
||||
tell a setting that is unset from one that could not be read.
|
||||
"""
|
||||
|
||||
name: str
|
||||
arn: Optional[str] = None
|
||||
configuration_values: Optional[str] = None
|
||||
configuration_discovery_failed: bool = False
|
||||
|
||||
|
||||
class EKSCluster(BaseModel):
|
||||
name: str
|
||||
arn: str
|
||||
@@ -113,4 +175,6 @@ class EKSCluster(BaseModel):
|
||||
public_access_cidrs: list[str] = []
|
||||
encryptionConfig: bool = None
|
||||
deletion_protection: bool = None
|
||||
addons: dict[str, EKSAddon] = {}
|
||||
addons_discovery_failed: bool = False
|
||||
tags: Optional[list] = []
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "guardduty_ai_protection_enabled",
|
||||
"CheckTitle": "GuardDuty detector has AI Protection enabled",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices/Runtime Behavior Analysis",
|
||||
"TTPs/Credential Access",
|
||||
"Effects/Resource Consumption"
|
||||
],
|
||||
"ServiceName": "guardduty",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "AwsGuardDutyDetector",
|
||||
"ResourceGroup": "security",
|
||||
"Description": "Active **Amazon GuardDuty detectors** are assessed for **AI Protection** being enabled, which analyzes AWS CloudTrail data events from Amazon Bedrock, Amazon Bedrock AgentCore and Amazon SageMaker AI to flag anomalous model invocations, cost harvesting and prompt injection. Detectors that do not report the feature return `MANUAL`, because absence means the Region does not offer it.",
|
||||
"Risk": "Without **AI Protection**, model invocation activity is never baselined, so attackers using **stolen credentials** can invoke foundation models undetected.\n\nThat costs **confidentiality** of prompts and outputs, and **availability** too: expensive prompts harvest inference spend and exhaust quota.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/guardduty/latest/ug/ai-protection.html",
|
||||
"https://docs.aws.amazon.com/guardduty/latest/ug/ai-protection-enable-standalone-account.html",
|
||||
"https://docs.aws.amazon.com/guardduty/latest/ug/findings-ai-protection.html",
|
||||
"https://docs.aws.amazon.com/guardduty/latest/ug/guardduty_regions.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "aws guardduty update-detector --detector-id <detector-id> --features Name=AI_PROTECTION,Status=ENABLED",
|
||||
"NativeIaC": "```yaml\nResources:\n <example_resource_name>:\n Type: AWS::GuardDuty::Detector\n Properties:\n Enable: true\n Features:\n - Name: AI_PROTECTION # Critical: selects the GuardDuty AI Protection plan\n Status: ENABLED # Critical: turns AI Protection on\n```",
|
||||
"Other": "1. Open the AWS Console and go to Amazon GuardDuty\n2. In the Region selector, choose a Region that offers AI Protection\n3. In the navigation pane, choose Protection plans\n4. Choose Configure all enablements, then under AI Protection choose Enable\n5. Choose Save all, then Confirm and save\n6. In an organization, do this from the delegated GuardDuty administrator account and auto-enable it for new accounts",
|
||||
"Terraform": "```hcl\nresource \"aws_guardduty_detector_feature\" \"<example_resource_name>\" {\n detector_id = aws_guardduty_detector.<example_detector_name>.id\n name = \"AI_PROTECTION\" # Critical: GuardDuty AI Protection plan\n status = \"ENABLED\" # Critical: enable the feature\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enable **GuardDuty AI Protection** in every account and Region hosting AI workloads.\n- Enable it org-wide from the delegated GuardDuty administrator and auto-enable it for new accounts\n- Enforce **Amazon Bedrock Guardrails** for prompt attacks, which AI Protection requires to raise prompt injection findings\n- Route findings to AWS Security Hub and review them on a defined cadence",
|
||||
"Url": "https://hub.prowler.com/check/guardduty_ai_protection_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
from prowler.lib.check.models import Check, Check_Report_AWS
|
||||
from prowler.providers.aws.services.guardduty.guardduty_client import guardduty_client
|
||||
|
||||
|
||||
class guardduty_ai_protection_enabled(Check):
|
||||
"""Ensure GuardDuty AI Protection is enabled on every active detector.
|
||||
|
||||
AI Protection analyzes AWS CloudTrail data events from Amazon Bedrock, Amazon
|
||||
Bedrock AgentCore and Amazon SageMaker AI, which makes it the detective control
|
||||
for AI workloads.
|
||||
|
||||
The feature has three observable states rather than two:
|
||||
|
||||
1. Reported as ENABLED: PASS.
|
||||
2. Reported as DISABLED: FAIL.
|
||||
3. Not reported at all: MANUAL. GuardDuty omits features that the Region or the
|
||||
GuardDuty version does not offer, and "could not tell" is not "not
|
||||
compliant". The same account can carry a feature in some Regions and omit it
|
||||
in others, so absence cannot be read as disablement.
|
||||
|
||||
A suspended detector, or one whose GetDetector call failed, is MANUAL as well:
|
||||
the feature state is unknown either way, and guardduty_is_enabled owns the
|
||||
detector-level finding. Regions with no detector at all are left to
|
||||
guardduty_is_enabled entirely.
|
||||
"""
|
||||
|
||||
def execute(self) -> list[Check_Report_AWS]:
|
||||
"""Assess AI Protection on every GuardDuty detector in the account.
|
||||
|
||||
Returns:
|
||||
list[Check_Report_AWS]: one report per detector that exists. PASS when AI
|
||||
Protection is enabled, FAIL when GuardDuty reported the feature
|
||||
disabled, and MANUAL when either the detector state or the feature
|
||||
itself was not reported.
|
||||
"""
|
||||
findings = []
|
||||
for detector in guardduty_client.detectors:
|
||||
if not detector.enabled_in_account:
|
||||
continue
|
||||
|
||||
report = Check_Report_AWS(metadata=self.metadata(), resource=detector)
|
||||
|
||||
if not detector.status:
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = f"GuardDuty detector {detector.id} is not enabled or could not be read, so AI Protection coverage could not be determined."
|
||||
elif detector.ai_protection is None:
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = f"GuardDuty detector {detector.id} does not report the AI Protection feature, so verify manually whether AI Protection is available in region {detector.region}."
|
||||
elif detector.ai_protection:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"GuardDuty detector {detector.id} has AI Protection enabled."
|
||||
)
|
||||
else:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"GuardDuty detector {detector.id} does not have AI Protection enabled."
|
||||
|
||||
findings.append(report)
|
||||
return findings
|
||||
@@ -22,10 +22,10 @@
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "aws guardduty update-detector --detector-id <detector-id> --features name=EKS_RUNTIME_MONITORING,status=ENABLED",
|
||||
"CLI": "aws guardduty update-detector --detector-id <detector-id> --features Name=EKS_RUNTIME_MONITORING,Status=ENABLED",
|
||||
"NativeIaC": "```yaml\nResources:\n <example_resource_name>:\n Type: AWS::GuardDuty::Detector\n Properties:\n Enable: true\n Features:\n - Name: EKS_RUNTIME_MONITORING # Critical: selects EKS Runtime Monitoring feature\n Status: ENABLED # Critical: enables the feature to pass the check\n```",
|
||||
"Other": "1. Open the AWS Console and go to Amazon GuardDuty\n2. In the left pane, select Settings > Runtime monitoring\n3. Under EKS Runtime Monitoring, switch the status to Enabled\n4. Click Save changes",
|
||||
"Terraform": "```hcl\nresource \"aws_guardduty_detector\" \"<example_resource_name>\" {\n enable = true\n\n features {\n name = \"EKS_RUNTIME_MONITORING\" # Critical: selects EKS Runtime Monitoring feature\n status = \"ENABLED\" # Critical: enables the feature to pass the check\n }\n}\n```"
|
||||
"Terraform": "```hcl\nresource \"aws_guardduty_detector\" \"<example_resource_name>\" {\n enable = true\n}\n\nresource \"aws_guardduty_detector_feature\" \"<example_resource_name>\" {\n detector_id = aws_guardduty_detector.<example_resource_name>.id\n name = \"EKS_RUNTIME_MONITORING\" # Critical: selects EKS Runtime Monitoring\n status = \"ENABLED\" # Critical: enables the feature\n\n additional_configuration {\n name = \"EKS_ADDON_MANAGEMENT\"\n status = \"ENABLED\"\n }\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "- Enable **EKS Runtime Monitoring** with automated agent management across all accounts and clusters\n- Enforce **least privilege** for agents and segment cluster access\n- Integrate findings with response workflows and periodically verify runtime coverage",
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "guardduty_runtime_monitoring_enabled",
|
||||
"CheckTitle": "GuardDuty detector has Runtime Monitoring enabled",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices/Runtime Behavior Analysis",
|
||||
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices"
|
||||
],
|
||||
"ServiceName": "guardduty",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "AwsGuardDutyDetector",
|
||||
"ResourceGroup": "security",
|
||||
"Description": "GuardDuty detectors are evaluated for unified **Runtime Monitoring** being enabled. The configuration is at the detector level and relates to visibility into *process execution, file access, and network connections* on Amazon EC2 instances, Amazon ECS on AWS Fargate tasks, and Amazon EKS nodes and containers. The legacy EKS-only feature covers Amazon EKS alone and does not satisfy this check.",
|
||||
"Risk": "Without **Runtime Monitoring**, on-host behavior of EC2, Fargate and EKS workloads is blind to detection. Adversaries can run malware or cryptominers, break out of containers, harvest credentials from instance metadata, tamper with workloads, or pivot to other services, degrading confidentiality, corrupting integrity, and exhausting resources (availability).",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/guardduty/latest/ug/runtime-monitoring.html",
|
||||
"https://docs.aws.amazon.com/guardduty/latest/ug/runtime-monitoring-configuration.html",
|
||||
"https://docs.aws.amazon.com/config/latest/developerguide/guardduty-runtime-monitoring-enabled.html",
|
||||
"https://docs.aws.amazon.com/securityhub/latest/userguide/guardduty-controls.html#guardduty-11"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "aws guardduty update-detector --detector-id <detector-id> --features Name=RUNTIME_MONITORING,Status=ENABLED",
|
||||
"NativeIaC": "```yaml\nResources:\n <example_resource_name>:\n Type: AWS::GuardDuty::Detector\n Properties:\n Enable: true\n Features:\n - Name: RUNTIME_MONITORING # Critical: selects unified Runtime Monitoring, which covers EC2, ECS-Fargate and EKS\n Status: ENABLED # Critical: enables the feature to pass the check\n AdditionalConfiguration:\n - Name: EC2_AGENT_MANAGEMENT\n Status: ENABLED\n - Name: ECS_FARGATE_AGENT_MANAGEMENT\n Status: ENABLED\n - Name: EKS_ADDON_MANAGEMENT\n Status: ENABLED\n```",
|
||||
"Other": "1. Open the AWS Console and go to Amazon GuardDuty\n2. In the left pane, select Protection plans > Runtime Monitoring\n3. Switch Runtime Monitoring to Enabled\n4. Enable automated agent configuration for Amazon EC2, AWS Fargate (Amazon ECS only) and Amazon EKS\n5. Click Save changes\n6. If you were using EKS Runtime Monitoring, migrate to Runtime Monitoring; the two features are mutually exclusive",
|
||||
"Terraform": "```hcl\nresource \"aws_guardduty_detector_feature\" \"<example_resource_name>\" {\n detector_id = aws_guardduty_detector.<example_detector_name>.id\n name = \"RUNTIME_MONITORING\" # Critical: unified feature covering EC2, ECS-Fargate and EKS\n status = \"ENABLED\" # Critical: enables the feature to pass the check\n\n additional_configuration {\n name = \"EC2_AGENT_MANAGEMENT\"\n status = \"ENABLED\"\n }\n\n additional_configuration {\n name = \"ECS_FARGATE_AGENT_MANAGEMENT\"\n status = \"ENABLED\"\n }\n\n additional_configuration {\n name = \"EKS_ADDON_MANAGEMENT\"\n status = \"ENABLED\"\n }\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "- Enable unified **Runtime Monitoring** with automated agent management for Amazon EC2, AWS Fargate (Amazon ECS only) and Amazon EKS across all accounts\n- Migrate from EKS Runtime Monitoring, which covers Amazon EKS only and is mutually exclusive with Runtime Monitoring\n- Review runtime coverage statistics rather than treating enablement as coverage, and route findings to Security Hub or EventBridge for response",
|
||||
"Url": "https://hub.prowler.com/check/guardduty_runtime_monitoring_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
from prowler.lib.check.models import Check, Check_Report_AWS
|
||||
from prowler.providers.aws.services.guardduty.guardduty_client import guardduty_client
|
||||
|
||||
|
||||
class guardduty_runtime_monitoring_enabled(Check):
|
||||
"""Ensure GuardDuty unified Runtime Monitoring is enabled on every active detector.
|
||||
|
||||
Runtime Monitoring covers Amazon EC2 instances, Amazon ECS on AWS Fargate tasks
|
||||
and Amazon EKS nodes and containers. Legacy EKS Runtime Monitoring covers Amazon
|
||||
EKS alone, and its AdditionalConfiguration offers no EC2 or Fargate agent
|
||||
management, so a detector running only the legacy feature has no runtime coverage
|
||||
for EC2 or Fargate workloads and cannot PASS. The two features are mutually
|
||||
exclusive at the API, so the FAIL message names the legacy case to point at
|
||||
migration rather than at first-time enablement. That exclusivity is also why the
|
||||
legacy verdict is reached before the unknown one: a legacy detector is precisely
|
||||
the one whose GetDetector response carries no RUNTIME_MONITORING entry, so testing
|
||||
for the unknown state first would report every legacy detector as undetermined.
|
||||
|
||||
A detector whose own state could not be read is MANUAL rather than absent from the
|
||||
report. Detector.status is True only when GetDetector returned ENABLED and stays
|
||||
None both for a suspended detector and for a GetDetector call that failed, so those
|
||||
two cannot be told apart and neither is a definite absence of runtime coverage.
|
||||
Leaving such a detector out of the findings would leave its Region with nothing to
|
||||
read at all, and an unreported Region reads as a compliant one.
|
||||
|
||||
Regions with no detector at all are left to guardduty_is_enabled entirely, which is
|
||||
also the check that owns the detector-level verdict.
|
||||
|
||||
guardduty_eks_runtime_monitoring_enabled remains the EKS-scoped check.
|
||||
"""
|
||||
|
||||
def execute(self) -> list[Check_Report_AWS]:
|
||||
"""Assess unified Runtime Monitoring on every GuardDuty detector in the account.
|
||||
|
||||
Returns:
|
||||
list[Check_Report_AWS]: one report per detector that exists. PASS when
|
||||
unified Runtime Monitoring is enabled, FAIL when GuardDuty reported the
|
||||
feature disabled or reported only the legacy EKS one, and MANUAL when
|
||||
either the detector state or the feature itself was not reported and no
|
||||
legacy coverage was reported either.
|
||||
"""
|
||||
findings = []
|
||||
for detector in guardduty_client.detectors:
|
||||
if not detector.enabled_in_account:
|
||||
continue
|
||||
|
||||
report = Check_Report_AWS(metadata=self.metadata(), resource=detector)
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"GuardDuty detector {detector.id} does not have Runtime Monitoring enabled."
|
||||
if not detector.status:
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = f"GuardDuty detector {detector.id} is not enabled or could not be read, so Runtime Monitoring coverage could not be determined."
|
||||
elif detector.runtime_monitoring is True:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"GuardDuty detector {detector.id} has Runtime Monitoring enabled."
|
||||
)
|
||||
elif detector.eks_runtime_monitoring:
|
||||
# Ordered ahead of the unknown branch below because a detector running the
|
||||
# legacy feature is the shape that omits RUNTIME_MONITORING entirely: the
|
||||
# two are mutually exclusive at the API. eks_runtime_monitoring is set by
|
||||
# either feature, but the PASS branch above already took the unified case,
|
||||
# so reaching here means EKS_RUNTIME_MONITORING is what enabled it. That is
|
||||
# a known absence of EC2 and Fargate coverage, not an unknown one.
|
||||
report.status_extended = f"GuardDuty detector {detector.id} only has the legacy EKS Runtime Monitoring enabled, leaving Amazon EC2 instances and Amazon ECS on Fargate tasks without runtime coverage."
|
||||
elif detector.runtime_monitoring is None:
|
||||
# GetDetector did not return RUNTIME_MONITORING at all, which is not the
|
||||
# same as returning it DISABLED: a Region that does not offer the unified
|
||||
# feature, or a features array that could not be read, would otherwise be
|
||||
# reported as a definite FAIL. No legacy coverage was reported either, so
|
||||
# nothing is known about this detector's runtime coverage.
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = f"GuardDuty detector {detector.id} did not report the Runtime Monitoring feature, so runtime coverage could not be determined; verify manually."
|
||||
findings.append(report)
|
||||
return findings
|
||||
@@ -63,6 +63,15 @@ class GuardDuty(AWSService):
|
||||
)
|
||||
|
||||
def _get_detector(self, detector):
|
||||
"""Read a detector's status, data sources and features.
|
||||
|
||||
A feature GuardDuty does not return is left as None rather than False, so a
|
||||
Region that does not offer the feature stays distinguishable from one that
|
||||
turned it off.
|
||||
|
||||
Args:
|
||||
detector: Detector object to populate in place.
|
||||
"""
|
||||
logger.info("GuardDuty - getting detector info...")
|
||||
try:
|
||||
if detector.id and detector.enabled_in_account:
|
||||
@@ -108,11 +117,33 @@ class GuardDuty(AWSService):
|
||||
and feat.get("Status", "DISABLED") == "ENABLED"
|
||||
):
|
||||
detector.lambda_protection = True
|
||||
elif (
|
||||
feat.get("Name", "") == "EKS_RUNTIME_MONITORING"
|
||||
and feat.get("Status", "DISABLED") == "ENABLED"
|
||||
elif feat.get("Name", "") == "AI_PROTECTION":
|
||||
# Recorded even when DISABLED, so a Region that offers AI
|
||||
# Protection and turned it off stays distinguishable from one
|
||||
# that never reports the feature.
|
||||
detector.ai_protection = (
|
||||
feat.get("Status", "DISABLED") == "ENABLED"
|
||||
)
|
||||
elif feat.get("Name", "") in (
|
||||
"EKS_RUNTIME_MONITORING",
|
||||
"RUNTIME_MONITORING",
|
||||
):
|
||||
detector.eks_runtime_monitoring = True
|
||||
enabled = feat.get("Status", "DISABLED") == "ENABLED"
|
||||
# Unified Runtime Monitoring (RUNTIME_MONITORING) already
|
||||
# includes threat detection for Amazon EKS resources and is
|
||||
# mutually exclusive with EKS_RUNTIME_MONITORING, so either
|
||||
# feature means the detector has EKS runtime coverage.
|
||||
if enabled:
|
||||
detector.eks_runtime_monitoring = True
|
||||
if feat.get("Name", "") == "RUNTIME_MONITORING":
|
||||
# Only the unified feature covers Amazon EC2 and Amazon
|
||||
# ECS on Fargate, so it is tracked separately. Recorded even
|
||||
# when DISABLED, for the same reason AI_PROTECTION above is:
|
||||
# a Region that offers the feature and turned it off must
|
||||
# stay distinguishable from one that never reported it. A
|
||||
# plain bool cannot express that, and the check would report
|
||||
# a definite FAIL on a Region that has no unified feature.
|
||||
detector.runtime_monitoring = enabled
|
||||
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
@@ -345,8 +376,12 @@ class Detector(BaseModel):
|
||||
rds_protection: bool = False
|
||||
eks_audit_log_protection: bool = False
|
||||
eks_runtime_monitoring: bool = False
|
||||
# None when GuardDuty did not return the feature: unknown, not disabled.
|
||||
runtime_monitoring: Optional[bool] = None
|
||||
lambda_protection: bool = False
|
||||
ec2_malware_protection: bool = False
|
||||
# None when GuardDuty did not return the feature: unknown, not disabled.
|
||||
ai_protection: Optional[bool] = None
|
||||
# Organization configuration fields
|
||||
organization_auto_enable_members: str = "NONE" # NEW, ALL, or NONE
|
||||
organization_config_available: bool = False
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "iam_policy_no_agentcore_workload_access_token_wildcard",
|
||||
"CheckTitle": "Custom IAM policy scopes Bedrock AgentCore workload access token retrieval to workload identity ARNs",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices",
|
||||
"TTPs/Credential Access",
|
||||
"Effects/Data Exposure"
|
||||
],
|
||||
"ServiceName": "iam",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "AwsIamPolicy",
|
||||
"ResourceGroup": "IAM",
|
||||
"Description": "**Customer-managed IAM policies** are examined for `Allow` statements granting `bedrock-agentcore:GetWorkloadAccessToken`, `GetWorkloadAccessTokenForJWT` or `GetWorkloadAccessTokenForUserId` over resources that reach a workload identity other than the caller's own -- `*`, or an AgentCore ARN whose resource field wildcards past `workload-identity-directory`.",
|
||||
"Risk": "A workload access token carries both user and agent identity and unlocks the outbound credential providers holding that user's stored credentials. `GetWorkloadAccessTokenForUserId` takes a caller-supplied user ID the platform does not verify, so AWS documents the IAM scope as the binding: with a wildcard resource, a compromised agent can mint tokens for **other users** of the same agent.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/get-workload-access-token.html",
|
||||
"https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/identity.html",
|
||||
"https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/agent-identity-directory.html",
|
||||
"https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/identity-manage-agent-ids.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "aws iam create-policy-version --policy-arn <POLICY_ARN> --policy-document '{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"bedrock-agentcore:GetWorkloadAccessToken\"],\"Resource\":[\"arn:aws:bedrock-agentcore:<REGION>:<ACCOUNT_ID>:workload-identity-directory/default\",\"arn:aws:bedrock-agentcore:<REGION>:<ACCOUNT_ID>:workload-identity-directory/default/workload-identity/<WORKLOAD_NAME>\"]}]}' --set-as-default",
|
||||
"NativeIaC": "```yaml\n# CloudFormation: scope the token actions to this workload's identity\nResources:\n <example_resource_name>:\n Type: AWS::IAM::ManagedPolicy\n Properties:\n PolicyDocument:\n Version: '2012-10-17'\n Statement:\n - Effect: Allow\n Action:\n - bedrock-agentcore:GetWorkloadAccessToken\n - bedrock-agentcore:GetWorkloadAccessTokenForJWT\n Resource: # FIX: replace '*' with the workload identity this policy is for\n - !Sub 'arn:${AWS::Partition}:bedrock-agentcore:${AWS::Region}:${AWS::AccountId}:workload-identity-directory/default'\n - !Sub 'arn:${AWS::Partition}:bedrock-agentcore:${AWS::Region}:${AWS::AccountId}:workload-identity-directory/default/workload-identity/<example_resource_id>'\n # Where a JWT is always available, deny the unverified user-ID path outright\n - Effect: Deny\n Action: bedrock-agentcore:GetWorkloadAccessTokenForUserId\n Resource: !Sub 'arn:${AWS::Partition}:bedrock-agentcore:${AWS::Region}:${AWS::AccountId}:workload-identity-directory/default'\n```",
|
||||
"Other": "1. In the AWS console, open IAM > Policies and select <example_resource_name>\n2. Choose Edit > JSON\n3. Find every statement whose Action includes bedrock-agentcore:GetWorkloadAccessToken, GetWorkloadAccessTokenForJWT or GetWorkloadAccessTokenForUserId\n4. Replace \"Resource\": \"*\" with the workload identity ARNs the holder legitimately acts for, for example arn:aws:bedrock-agentcore:<REGION>:<ACCOUNT_ID>:workload-identity-directory/default/workload-identity/<WORKLOAD_NAME>\n5. If a JWT identifying the end user is always available, prefer GetWorkloadAccessTokenForJWT and add an explicit Deny for GetWorkloadAccessTokenForUserId\n6. Save as a new default version and re-run the check",
|
||||
"Terraform": "```hcl\n# Scope the token actions to this workload's identity\nresource \"aws_iam_policy\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n policy = jsonencode({\n Version = \"2012-10-17\"\n Statement = [{\n Effect = \"Allow\"\n Action = [\n \"bedrock-agentcore:GetWorkloadAccessToken\",\n \"bedrock-agentcore:GetWorkloadAccessTokenForJWT\",\n ]\n # FIX: replace '*' with the workload identity this policy is for\n Resource = [\n \"arn:aws:bedrock-agentcore:${var.region}:${var.account_id}:workload-identity-directory/default\",\n \"arn:aws:bedrock-agentcore:${var.region}:${var.account_id}:workload-identity-directory/default/workload-identity/<example_resource_id>\",\n ]\n }]\n })\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Scope the workload access token actions to the workload identity ARNs the policy holder acts for, never `*`. AWS states the security binding of `GetWorkloadAccessTokenForUserId` rests on IAM scope, since the platform treats the user ID as an opaque unverified string: **do not grant it broadly via managed policies or wildcard resource statements**. Prefer `GetWorkloadAccessTokenForJWT` and deny the user-ID path where a JWT is always available.",
|
||||
"Url": "https://hub.prowler.com/check/iam_policy_no_agentcore_workload_access_token_wildcard"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"identity-access",
|
||||
"gen-ai"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Scope is the customer-managed population, matching every other iam_policy_* check; inline policies are the subject of the iam_inline_policy_* checks.\n\nThree deliberate limits. A bare Action \"*\" is not read as a grant of these operations, because that is what the administrative-privileges checks report; the Action must carry the bedrock-agentcore prefix, wildcards within it included. The assertion is at ARN-type granularity rather than per workload identity: a resource confined to the workload-identity-directory namespace passes even with a wildcard inside it, because that is the scope the AgentCore console issues. A resource naming another AgentCore type passes for the opposite reason, since these actions accept no such resource and the grant reaches no workload identity.\n\nAn unconditional Deny of the same operation on Resource \"*\" clears the finding; a Deny scoped to one directory does not, because the Allow still reaches every other one."
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
from prowler.lib.check.models import Check, Check_Report_AWS
|
||||
from prowler.providers.aws.services.iam.iam_client import iam_client
|
||||
from prowler.providers.aws.services.iam.lib.policy import iam_pattern_matches
|
||||
|
||||
AGENTCORE_SERVICE_PREFIX = "bedrock-agentcore"
|
||||
# The three operations that hand a caller a workload access token. Confirmed against the
|
||||
# bedrock-agentcore service model: GetWorkloadAccessToken issues a token for the calling
|
||||
# workload, ForJWT exchanges a user JWT, ForUserId names the user directly.
|
||||
WORKLOAD_ACCESS_TOKEN_OPERATIONS = (
|
||||
"GetWorkloadAccessToken",
|
||||
"GetWorkloadAccessTokenForJWT",
|
||||
"GetWorkloadAccessTokenForUserId",
|
||||
)
|
||||
# Every workload identity ARN sits under the workload-identity-directory resource path, both the
|
||||
# directory itself and the workload-identity children beneath it.
|
||||
WORKLOAD_IDENTITY_SEGMENT = "workload-identity-directory"
|
||||
# The leading resource-path segment of every AgentCore type that is NOT a workload identity, from
|
||||
# AWS's machine-readable service reference
|
||||
# (servicereference.us-east-1.amazonaws.com/v1/bedrock-agentcore/bedrock-agentcore.json): 32 resource
|
||||
# types collapsing to 23 distinct leading segments, of which workload-identity-directory is the only
|
||||
# one hosting the two in-scope types.
|
||||
#
|
||||
# SEGMENTS, NOT NAMES, and that distinction is the whole point. Probing concrete resources -- say
|
||||
# token-vault/default, gateway/my-gateway, runtime/my-runtime and a workload identity called
|
||||
# another-workload -- makes their example NAMES load-bearing. A resource field keyed on any other name
|
||||
# then matches none of them, and the check concludes it is confined to the workload-identity
|
||||
# namespace: "...:*prod-*" reaches runtime/prod-chatbot, gateway/prod-chatbot-gw,
|
||||
# memory/prod-chatbot-mem and two distinct workload identities, while reading as reaching none of
|
||||
# them. Names cannot be enumerated -- the AgentCore devguide's own examples are prod-chatbot,
|
||||
# dev-chatbot and customer-support-agent, and its own example policy wildcards on the name -- so no
|
||||
# probe corpus can be completed. Resource TYPES can be enumerated, and are, above.
|
||||
#
|
||||
# This is the same correction already applied to the two short-ARN branches, which stopped pinning
|
||||
# region, account and partition for exactly this reason; here it stops pinning the resource NAME.
|
||||
NON_WORKLOAD_IDENTITY_SEGMENTS = (
|
||||
"ab-test",
|
||||
"batch-evaluate",
|
||||
"browser",
|
||||
"browser-custom",
|
||||
"browser-profile",
|
||||
"capacity-provider",
|
||||
"code-interpreter",
|
||||
"code-interpreter-custom",
|
||||
"configuration-bundle",
|
||||
"dataset",
|
||||
"evaluator",
|
||||
"gateway",
|
||||
"harness",
|
||||
"memory",
|
||||
"online-evaluation-config",
|
||||
"payment-manager",
|
||||
"policy-engine",
|
||||
"recommendation",
|
||||
"registry",
|
||||
"runtime",
|
||||
"token-vault",
|
||||
"tool",
|
||||
)
|
||||
|
||||
|
||||
def _as_list(value) -> list:
|
||||
"""Normalize to list: None -> [], scalar -> [val], list -> list."""
|
||||
if value is None:
|
||||
return []
|
||||
return value if isinstance(value, list) else [value]
|
||||
|
||||
|
||||
def _statements(document: dict) -> list:
|
||||
"""Extract Statement, normalizing single-statement dict to list."""
|
||||
statements = document.get("Statement", [])
|
||||
if not isinstance(statements, list):
|
||||
statements = [statements]
|
||||
return [statement for statement in statements if isinstance(statement, dict)]
|
||||
|
||||
|
||||
def _covered_token_operations(statement: dict) -> set:
|
||||
"""Return the workload access token operations this statement's Action covers.
|
||||
|
||||
Only actions that can name the bedrock-agentcore service are read, and the service field is
|
||||
matched as an IAM pattern rather than compared literally -- `bedrock-*:GetWorkloadAccessToken`
|
||||
reaches the operation, and an exact comparison read it as granting nothing at all.
|
||||
|
||||
A bare "*" is deliberately not treated as a grant of these operations: a statement allowing
|
||||
every action on every resource is what check_admin_access reports, and re-reporting it here
|
||||
would duplicate the administrative-privileges checks rather than add a claim. The
|
||||
`separator != ":"` guard is what preserves that, since "*" partitions to an empty separator.
|
||||
"""
|
||||
covered = set()
|
||||
for action in _as_list(statement.get("Action")):
|
||||
if not isinstance(action, str):
|
||||
continue
|
||||
service, separator, operation = action.strip().partition(":")
|
||||
# A statement allowing EVERY action is the administrative-privileges checks' finding,
|
||||
# whether it is spelled "*" or "*:*"; re-reporting it here would duplicate them. The
|
||||
# separator test covers the bare "*", which partitions to an empty separator.
|
||||
if separator != ":" or (service == "*" and operation == "*"):
|
||||
continue
|
||||
if not iam_pattern_matches(service, AGENTCORE_SERVICE_PREFIX):
|
||||
continue
|
||||
covered.update(
|
||||
token_operation
|
||||
for token_operation in WORKLOAD_ACCESS_TOKEN_OPERATIONS
|
||||
if iam_pattern_matches(operation, token_operation)
|
||||
)
|
||||
return covered
|
||||
|
||||
|
||||
def _token_operations_removed_by(statement: dict) -> set:
|
||||
"""Return the token operations a DENY statement's Action removes.
|
||||
|
||||
Separate from _covered_token_operations on purpose, because the bare-star exclusion that is
|
||||
right on the Allow side inverts on the Deny side. There, skipping ``*`` and ``*:*`` avoids
|
||||
duplicating the administrative-privileges checks; here it meant an unconditional Deny of EVERY
|
||||
action credited nothing, so a policy that grants nothing at all was reported FAIL at high
|
||||
severity -- contradicting this check's own published Notes, which say an unconditional Deny of
|
||||
the operation on Resource "*" clears the finding. The Allow-side guard is deliberately left
|
||||
alone: relaxing it would reverse the settled decision that admin-level grants belong to
|
||||
check_admin_access.
|
||||
|
||||
Deny expressed as NotAction is still NOT read, here or on the Allow side. Inverting it requires
|
||||
resolving the whole action namespace, which is more than this check can claim; not crediting it
|
||||
errs toward reporting rather than toward silence, so a policy denied that way may still FAIL.
|
||||
"""
|
||||
removed = set()
|
||||
for action in _as_list(statement.get("Action")):
|
||||
if not isinstance(action, str):
|
||||
continue
|
||||
service, separator, operation = action.strip().partition(":")
|
||||
if separator != ":":
|
||||
# A bare "*" denies every action, these three among them.
|
||||
if service == "*":
|
||||
removed.update(WORKLOAD_ACCESS_TOKEN_OPERATIONS)
|
||||
continue
|
||||
if not iam_pattern_matches(service, AGENTCORE_SERVICE_PREFIX):
|
||||
continue
|
||||
removed.update(
|
||||
token_operation
|
||||
for token_operation in WORKLOAD_ACCESS_TOKEN_OPERATIONS
|
||||
if iam_pattern_matches(operation, token_operation)
|
||||
)
|
||||
return removed
|
||||
|
||||
|
||||
def _may_reach_segment(resource_field: str, segment: str) -> bool:
|
||||
"""Return True if this resource field could name a resource whose path starts with ``segment``.
|
||||
|
||||
Decided from the field's own shape rather than by matching example resources, so no resource NAME
|
||||
is load-bearing. The two wildcards are NOT interchangeable and that distinction is the whole
|
||||
function: ``*`` spans any run of characters including none, while ``?`` consumes EXACTLY ONE. So
|
||||
the field's HEAD -- everything before its first ``*`` -- is a fixed-length template in which each
|
||||
``?`` stands for one unknown character, and every string the field matches begins with something
|
||||
that template accepts.
|
||||
|
||||
Compare the template to the segment position by position, then decide who supplies the remainder:
|
||||
|
||||
- A position where the template holds a literal that differs from the segment's character rules
|
||||
the segment out entirely, however long the field is.
|
||||
- If the template is at least as long as the segment and no position disagreed, the segment is
|
||||
covered, so the field may reach it.
|
||||
- If the template is SHORTER, only a ``*`` can supply what is missing. Without one the field has
|
||||
a fixed length too short to contain the segment, so it reaches nothing under it.
|
||||
|
||||
THE HEAD IS CUT AT ``*`` ONLY, NEVER AT ``?``, and the two must not be treated alike. ``?``
|
||||
consumes exactly one character, so it belongs to the fixed-length head and is skipped
|
||||
position-by-position above; ``*`` spans an unbounded run, so it terminates the head. Cutting at
|
||||
``?`` as well empties the head of any ``?``-leading field, an empty head is compatible with every
|
||||
segment, and the field then reads as reaching all 22 of them: ``?orkload-identity-directory/*``
|
||||
would be reported while its byte-neighbour ``w?rkload-identity-directory/*`` is not. Measured
|
||||
against an exact oracle over 1345 fields, that spelling costs 265 false FAILs.
|
||||
|
||||
THE COMPLEMENTARY ERROR IS TO RETURN False ON AN EMPTY HEAD, and it is worse. That repairs the
|
||||
``?`` rows and simultaneously turns bare ``*`` and ``*prod-*`` into "reaches nothing", which
|
||||
reinstates the false PASS this segment test exists to remove: 10 oracle mismatches against 0 for
|
||||
the rule as written. So the decision rests on whether a ``*`` is PRESENT, not on whether the head
|
||||
is empty, because ``*`` must keep absorbing everything.
|
||||
|
||||
Deliberately errs toward True on a coarse pair such as head ``browser-custom/x`` against segment
|
||||
``browser``: over-estimating reach can only move a verdict toward reporting, and the reach test
|
||||
that follows still decides the workload-identity question on its own terms.
|
||||
"""
|
||||
head, spans = resource_field.split("*", 1)[0], "*" in resource_field
|
||||
for index in range(min(len(head), len(segment))):
|
||||
if head[index] != "?" and head[index] != segment[index]:
|
||||
return False
|
||||
if len(head) >= len(segment):
|
||||
return True
|
||||
return spans
|
||||
|
||||
|
||||
def _reaches_other_workload_identities(resource: str) -> bool:
|
||||
"""Return True if this resource lets the token actions name a workload identity that
|
||||
is not the caller's own.
|
||||
|
||||
A resource confined to the workload-identity-directory namespace is accepted, wildcards
|
||||
within it included: the AgentCore console itself issues that scope, and the ARN type is
|
||||
the granularity this check asserts. A resource of some other AgentCore type -- a token
|
||||
vault, a gateway -- is accepted too, but for the opposite reason: the token actions
|
||||
accept no such resource, so the grant reaches no workload identity at all.
|
||||
|
||||
EVERY field of the pattern is matched as an IAM pattern, the first one included, because a
|
||||
leading star matches "arn" as surely as it matches anything else. Comparing that field to the
|
||||
literal "arn" instead would read ``*:aws:bedrock-agentcore:us-east-1:123456789012:*`` and
|
||||
``*:*:*:*:*:*`` as naming no workload identity, while their correctly spelled six-field
|
||||
equivalent names every one.
|
||||
|
||||
A pattern with fewer than six fields reaches a workload identity when a star in its LAST
|
||||
spelled-out field can span the fields it never spells out, because IAM wildcards match the
|
||||
colon. That question is answered structurally rather than by probing a concrete ARN, and
|
||||
deliberately so: matching against one ``us-east-1``/``123456789012`` ARN makes the region,
|
||||
account and partition load-bearing, so ``arn:aws:bedrock-agentcore:us-west-2:*`` reads as
|
||||
reaching nothing while the byte-identical ``us-east-1`` spelling is reported. No finite probe
|
||||
corpus fixes that -- an account PREFIX such as
|
||||
``arn:aws:bedrock-agentcore:us-east-1:111122223333*`` has nothing to enumerate. What the fields
|
||||
it does spell out must still do is name an ARN at all: ``arn:aws:s3:*`` is short and starred but
|
||||
names another service.
|
||||
|
||||
``arn:aws:bedrock-agentcore`` and ``arn:aws:bedrock-agentcore:us-east-1`` carry no star, so they
|
||||
match no ARN and reach nothing. The star is what separates them from the cases above, not the
|
||||
length.
|
||||
|
||||
THE RESOURCE FIELD IS DECIDED STRUCTURALLY TOO, by ``_may_reach_segment`` against the enumerable
|
||||
list of AgentCore resource-path segments, and NOT by comparison against concrete example
|
||||
resources. The tempting argument for a small probe corpus is that the namespace test intercepts
|
||||
everything confined to workload-identity-directory before this one runs, so a single probe cannot
|
||||
produce a false verdict. That premise does not hold:
|
||||
|
||||
``arn:aws:bedrock-agentcore:us-east-1:123456789012:*prod-*`` is not confined to the namespace, yet
|
||||
it matches none of a four-resource probe corpus, so the namespace test intercepts it anyway and
|
||||
clears a statement reaching ``runtime/prod-chatbot``, ``gateway/prod-chatbot-gw``,
|
||||
``memory/prod-chatbot-mem``, a token vault, a custom browser, and two distinct workload
|
||||
identities. The pair that shows such a corpus has no defensible boundary: resource field ``*`` is
|
||||
reported while ``*prod-*`` is not, and no rule stated anywhere separates them except "matches one
|
||||
of four example NAMES", which is an artifact of the corpus rather than a property of IAM.
|
||||
|
||||
So the lesson is the one the short-ARN branches already record, one level down: a probe corpus can
|
||||
only decide a question whose answer space it enumerates. Regions, accounts and partitions could
|
||||
not be enumerated there; resource NAMES cannot be enumerated here, since AgentCore creates
|
||||
identities named after the runtime or gateway that made them. Resource TYPES can be, so the test
|
||||
is built on those.
|
||||
"""
|
||||
resource = resource.strip()
|
||||
if resource == "*":
|
||||
return True
|
||||
arn_fields = resource.split(":", 5)
|
||||
if len(arn_fields) < 6:
|
||||
if "*" not in arn_fields[-1]:
|
||||
return False
|
||||
if not iam_pattern_matches(arn_fields[0], "arn"):
|
||||
return False
|
||||
return len(arn_fields) < 3 or iam_pattern_matches(
|
||||
arn_fields[2], AGENTCORE_SERVICE_PREFIX
|
||||
)
|
||||
if not iam_pattern_matches(arn_fields[0], "arn"):
|
||||
return False
|
||||
if not iam_pattern_matches(arn_fields[2], AGENTCORE_SERVICE_PREFIX):
|
||||
return False
|
||||
resource_field = arn_fields[5]
|
||||
# Confined to the workload-identity namespace when it can reach NO resource of another AgentCore
|
||||
# type. The startswith test this replaced asked whether the field begins with the literal
|
||||
# namespace prefix, which is a different question and got the ordering backwards: the confined
|
||||
# workload-identity-* was reported while the strictly broader workload-identity-directory* --
|
||||
# whose reach is a superset of it -- was accepted.
|
||||
#
|
||||
# BOTH TESTS ARE STRUCTURAL, and they have to stay that way together. Comparing a field against
|
||||
# concrete example resources instead lets a field keyed on any name those examples do not use
|
||||
# satisfy both at once: "*prod-*" matches none of the four non-workload-identity probes, so this
|
||||
# branch would call it confined, and it also misses the single another-workload probe below, so
|
||||
# neither test would report it. Making only one of them structural leaves the verdict unchanged,
|
||||
# so do not read this branch as a guard for the one below -- it does not intercept everything
|
||||
# confined to the namespace, and a pattern that is not confined at all can reach it.
|
||||
if not any(
|
||||
_may_reach_segment(resource_field, segment)
|
||||
for segment in NON_WORKLOAD_IDENTITY_SEGMENTS
|
||||
):
|
||||
return False
|
||||
return _may_reach_segment(resource_field, WORKLOAD_IDENTITY_SEGMENT)
|
||||
|
||||
|
||||
def _is_workload_identity_scoped(statement: dict) -> bool:
|
||||
"""Return True if no resource the statement names reaches another workload identity.
|
||||
|
||||
A statement using NotResource names no resource at all -- it grants everything except
|
||||
an excluded list -- so it is not scoped.
|
||||
|
||||
Only Resource and NotResource are read. An Allow-side Condition is NOT evaluated, so a
|
||||
statement narrowed solely by one -- aws:ResourceTag is a condition key on both workload
|
||||
identity resource types -- is still reported. That is deliberate conservatism, the mirror
|
||||
of the Deny-side decision below: a condition is not credited with confining a grant any
|
||||
more than it is credited with removing one. What it costs is that the finding may name a
|
||||
statement an unread condition already scopes, which is why the FAIL text claims only that
|
||||
the RESOURCES do not confine it.
|
||||
"""
|
||||
resources = _as_list(statement.get("Resource"))
|
||||
if not resources:
|
||||
return "NotResource" not in statement
|
||||
return not any(
|
||||
isinstance(resource, str) and _reaches_other_workload_identities(resource)
|
||||
for resource in resources
|
||||
)
|
||||
|
||||
|
||||
def _denied_token_operations(document: dict) -> set:
|
||||
"""Return the token operations an unconditional Deny removes across all resources.
|
||||
|
||||
A conditional Deny is not counted: it only applies when the condition holds, so it
|
||||
does not take the permission away from the request the Allow statement grants.
|
||||
"""
|
||||
denied = set()
|
||||
for statement in _statements(document):
|
||||
if statement.get("Effect") != "Deny" or statement.get("Condition"):
|
||||
continue
|
||||
if any(
|
||||
isinstance(resource, str) and resource.strip() == "*"
|
||||
for resource in _as_list(statement.get("Resource"))
|
||||
):
|
||||
denied.update(_token_operations_removed_by(statement))
|
||||
return denied
|
||||
|
||||
|
||||
def _has_unevaluated_notaction(document: dict) -> bool:
|
||||
"""True if an Allow statement expresses its actions as NotAction.
|
||||
|
||||
NotAction under Effect Allow grants everything EXCEPT what it lists, so a policy using
|
||||
it can grant the very actions this check looks for while carrying no Action key at all.
|
||||
Reading only Action would find nothing and report a clean policy. Inverting NotAction
|
||||
correctly means resolving it against the full action namespace and its interaction with
|
||||
Resource and NotResource, which is more than this check can honestly claim to do -- so
|
||||
the statement is declared unevaluated rather than guessed at.
|
||||
"""
|
||||
for statement in _statements(document):
|
||||
if statement.get("Effect") == "Allow" and "NotAction" in statement:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class iam_policy_no_agentcore_workload_access_token_wildcard(Check):
|
||||
"""Check whether a customer-managed policy scopes AgentCore workload access token retrieval.
|
||||
|
||||
A workload access token identifies the agent to AgentCore, so a policy granting the retrieval
|
||||
operations on every resource lets its holder obtain a token for any workload identity and act as
|
||||
that agent. FAIL when the grant reaches workload identities beyond a named directory; PASS when
|
||||
the resource is confined to the workload-identity-directory namespace, when it names an AgentCore
|
||||
type these actions do not accept, or when an unconditional Deny on ``Resource: "*"`` clears it;
|
||||
MANUAL when the policy document could not be read or expresses a shape this check does not
|
||||
evaluate.
|
||||
|
||||
Caveats:
|
||||
Customer-managed policies only. The assertion is at ARN-type granularity rather than per
|
||||
workload identity, because a wildcard inside the directory namespace is the scope the
|
||||
AgentCore console itself issues. A bare ``Action: "*"`` is left to the
|
||||
administrative-privileges checks.
|
||||
"""
|
||||
|
||||
def execute(self) -> Check_Report_AWS:
|
||||
"""Flag policies granting token ops beyond caller's workload ID.
|
||||
|
||||
MANUAL is used deliberately below, where the document could not be read or expresses a shape
|
||||
this check does not evaluate. An unread document must not report as compliant: the grant it
|
||||
might contain is precisely what is being looked for, so PASS there would assert something never
|
||||
established. This is not off-contract -- 110 upstream checks emit MANUAL and
|
||||
`lib/check/models.py` places no restriction on it.
|
||||
|
||||
THE COST, recorded so it is not rediscovered: `lib/outputs/asff/asff.py` SKIPS findings whose
|
||||
status is MANUAL, because MANUAL is not a valid Security Hub compliance state. A Security Hub
|
||||
consumer therefore sees NOTHING for an unreadable policy, and absence there reads as
|
||||
compliance. CSV and OCSF keep the status, so the information survives in those outputs. That is
|
||||
a gap in one output format, not a reason to report an unread document as PASS or FAIL.
|
||||
"""
|
||||
findings = []
|
||||
for policy in iam_client.policies.values():
|
||||
# Only customer-managed policies: the inline population is a separate check,
|
||||
# and an AWS-managed policy cannot be edited to remediate a finding.
|
||||
if policy.type != "Custom":
|
||||
continue
|
||||
if not policy.attached and not iam_client.provider.scan_unused_services:
|
||||
continue
|
||||
|
||||
report = Check_Report_AWS(metadata=self.metadata(), resource=policy)
|
||||
report.region = iam_client.region
|
||||
|
||||
if not policy.document:
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
f"Custom Policy {policy.name} could not be evaluated because its "
|
||||
"policy document was not retrieved."
|
||||
)
|
||||
findings.append(report)
|
||||
continue
|
||||
|
||||
if _has_unevaluated_notaction(policy.document):
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
f"Custom Policy {policy.name} expresses an Allow statement with "
|
||||
"NotAction, which this check does not evaluate, so its effective grants "
|
||||
"could not be determined; review it manually."
|
||||
)
|
||||
findings.append(report)
|
||||
continue
|
||||
|
||||
denied = _denied_token_operations(policy.document)
|
||||
unscoped = set()
|
||||
for statement in _statements(policy.document):
|
||||
if statement.get("Effect") != "Allow":
|
||||
continue
|
||||
if _is_workload_identity_scoped(statement):
|
||||
continue
|
||||
unscoped.update(_covered_token_operations(statement) - denied)
|
||||
|
||||
if unscoped:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Custom Policy {policy.name} allows "
|
||||
f"{', '.join(sorted(AGENTCORE_SERVICE_PREFIX + ':' + operation for operation in unscoped))} "
|
||||
"on resources outside a workload identity ARN, so its resources do not "
|
||||
"confine token retrieval to the workload's own identity; conditions on "
|
||||
"the statement are not evaluated."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Custom Policy {policy.name} does not allow AgentCore workload access "
|
||||
"token retrieval outside a workload identity ARN."
|
||||
)
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "iam_policy_passrole_to_bedrock_agentcore_restricted",
|
||||
"CheckTitle": "Custom IAM policy restricts iam:PassRole to Bedrock AgentCore to specific roles",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices",
|
||||
"TTPs/Privilege Escalation"
|
||||
],
|
||||
"ServiceName": "iam",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "AwsIamPolicy",
|
||||
"ResourceGroup": "IAM",
|
||||
"Description": "**Customer-managed IAM policies** are examined for `Allow` statements granting `iam:PassRole` over every role -- `Resource` `*`, or an IAM ARN whose resource field is nothing but wildcards -- where the passed role can reach **Bedrock AgentCore**: the statement pins `iam:PassedToService` to an AgentCore principal, or sets no such condition while the policy allows an AgentCore action.",
|
||||
"Risk": "AgentCore runtimes, gateways, code interpreters, browsers and evaluation configs all run under a role the caller names in the create call.\n\nWith `iam:PassRole` unbounded, any principal holding the policy can hand AgentCore **any role in the account**, an administrator role included, then reach that role's permissions through agent code it controls. The role itself needs no change.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/evaluations-prerequisites.html",
|
||||
"https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-permissions.html",
|
||||
"https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_passrole.html",
|
||||
"https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_iam-condition-keys.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "aws iam create-policy-version --policy-arn <POLICY_ARN> --policy-document '{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"iam:PassRole\"],\"Resource\":\"arn:aws:iam::<ACCOUNT_ID>:role/<AGENTCORE_EXECUTION_ROLE_PREFIX>*\",\"Condition\":{\"StringEquals\":{\"iam:PassedToService\":\"bedrock-agentcore.amazonaws.com\"}}}]}' --set-as-default",
|
||||
"NativeIaC": "```yaml\n# CloudFormation: name the roles AgentCore may be handed\nResources:\n <example_resource_name>:\n Type: AWS::IAM::ManagedPolicy\n Properties:\n PolicyDocument:\n Version: '2012-10-17'\n Statement:\n - Effect: Allow\n Action: iam:PassRole\n # FIX: replace '*' with the execution roles AgentCore is meant to run as.\n # A name prefix is enough -- this is the scope AWS's own AgentCore\n # Evaluations reference policy uses.\n Resource: !Sub 'arn:${AWS::Partition}:iam::${AWS::AccountId}:role/<example_resource_id>*'\n Condition:\n StringEquals:\n iam:PassedToService: bedrock-agentcore.amazonaws.com\n```",
|
||||
"Other": "1. In the AWS console, open IAM > Policies and select <example_resource_name>\n2. Choose Edit > JSON\n3. Find every statement whose Action includes iam:PassRole (or iam:* / iam:Pass*) with \"Resource\": \"*\" or an IAM ARN such as arn:aws:iam::<ACCOUNT_ID>:role/*\n4. Replace that resource with the AgentCore execution roles the holder should be able to pass -- a role ARN, or a role-name prefix such as arn:aws:iam::<ACCOUNT_ID>:role/AgentCoreEvaluationRole*\n5. Add Condition StringEquals iam:PassedToService = bedrock-agentcore.amazonaws.com so the roles cannot be handed to any other service\n6. Save as a new default version and re-run the check",
|
||||
"Terraform": "```hcl\n# Name the roles AgentCore may be handed\nresource \"aws_iam_policy\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n policy = jsonencode({\n Version = \"2012-10-17\"\n Statement = [{\n Effect = \"Allow\"\n Action = [\"iam:PassRole\"]\n # FIX: replace '*' with the execution roles AgentCore is meant to run as\n Resource = \"arn:aws:iam::${var.account_id}:role/<example_resource_id>*\"\n Condition = {\n StringEquals = { \"iam:PassedToService\" = \"bedrock-agentcore.amazonaws.com\" }\n }\n }]\n })\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Name the roles that may be passed instead of allowing every role. AWS's own AgentCore Evaluations reference policy shows the shape: `iam:PassRole` on `arn:aws:iam::*:role/AgentCoreEvaluationRole*` under `StringEquals iam:PassedToService = bedrock-agentcore.amazonaws.com`. A role-name prefix satisfies this check; `*` and `role/*` do not. Keep the condition as well, so the same roles cannot be handed to another service.",
|
||||
"Url": "https://hub.prowler.com/check/iam_policy_passrole_to_bedrock_agentcore_restricted"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"identity-access",
|
||||
"gen-ai"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"iam_policy_allows_privilege_escalation"
|
||||
],
|
||||
"Notes": "Companion to iam_policy_allows_privilege_escalation, which reports a full AgentCore create-and-invoke action set but evaluates Action alone, so it reports that combination whatever the PassRole scope and nothing when part of it is absent. This check asserts what that leaves open, how far the PassRole grant reaches, and needs only one AgentCore action beside it.\n\nScope is the customer-managed population. A statement pinning iam:PassedToService to another service is out of scope, and a bare Action \"*\" counts as neither the grant nor the AgentCore action, so administrator policies are left to the administrative-privileges checks.\n\nA resource names every role when no role name escapes it, which is broader than a field of wildcards alone: role/?* and *role* both qualify, as does an ARN whose star spans the account and resource fields. A bounded set passes, since role/? names single-character roles only and role/AgentCoreEvaluationRole* is the scope AWS's own AgentCore Evaluations reference policy uses."
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
import re
|
||||
|
||||
from prowler.lib.check.models import Check, Check_Report_AWS
|
||||
from prowler.providers.aws.services.iam.iam_client import iam_client
|
||||
from prowler.providers.aws.services.iam.lib.policy import iam_pattern_matches
|
||||
|
||||
AGENTCORE_SERVICE_PREFIX = "bedrock-agentcore"
|
||||
AGENTCORE_SERVICE_PRINCIPAL = "bedrock-agentcore.amazonaws.com"
|
||||
# Bedrock AgentCore also reaches IAM through subdomain principals such as
|
||||
# runtime-identity.bedrock-agentcore.amazonaws.com, so a literal value in that family
|
||||
# pins the statement to AgentCore just as the base principal does.
|
||||
AGENTCORE_PRINCIPAL_FAMILY_PATTERN = re.compile(
|
||||
rf"^([a-z0-9-]+\.)?{AGENTCORE_SERVICE_PREFIX}(-[a-z0-9-]+)?\.amazonaws\.com$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Concrete principals used to ask whether a condition value, READ AS AN IAM PATTERN, covers one of
|
||||
# them. That is a different question from the regex above, which asks whether the value IS a family
|
||||
# member, and asking only the second let a wildcard covering the family out of scope entirely. Both
|
||||
# are needed: the regex catches a literal subdomain nobody enumerated here, and the probes catch a
|
||||
# pattern that names no principal literally while reaching several.
|
||||
AGENTCORE_PRINCIPAL_PROBES = (
|
||||
AGENTCORE_SERVICE_PRINCIPAL,
|
||||
f"runtime-identity.{AGENTCORE_SERVICE_PRINCIPAL}",
|
||||
)
|
||||
# Two role names used to ask whether a resource field names EVERY role rather than some of them:
|
||||
# the shortest a role name can be, and the longest. Only "*" can span an arbitrary run of
|
||||
# characters -- "?" matches exactly one -- so a pattern built from literals and "?" alone covers a
|
||||
# bounded set of names and cannot match both probes, while any pattern that does match both leaves
|
||||
# no role name outside it.
|
||||
_SHORTEST_ROLE_NAME = "role/a"
|
||||
_LONGEST_ROLE_NAME = "role/" + "r0le-name-" * 6 + "abcd" # 64 chars, the IAM maximum
|
||||
EVERY_ROLE_RESOURCE_PROBES = (_SHORTEST_ROLE_NAME, _LONGEST_ROLE_NAME)
|
||||
# Operators that COMPARE the request's iam:PassedToService against the statement's own values, so a
|
||||
# value under one of them names a service this statement can hand a role to. An allow-list, because
|
||||
# the deny-list this replaced -- two substring tests for "not" and "ifexists" -- dropped the entire
|
||||
# condition on a one-word operator change, and a dropped condition fell through to the document-wide
|
||||
# fallback in _targets_agentcore as though the statement pinned nothing at all.
|
||||
#
|
||||
# *IfExists and ForAllValues ARE included, unlike _RESTRICTIVE_ATTESTATION_OPERATORS in
|
||||
# kms/lib/enclave.py which rejects both as vacuous-true when the key is absent. The difference is the
|
||||
# question being asked: this function asks which services a statement NAMES, not whether the
|
||||
# statement is restrictive. A value is named whether or not the operator would also admit a request
|
||||
# that omits the key.
|
||||
#
|
||||
# The ARN operators are DELIBERATELY absent, which is why this list is shorter than the trust check's
|
||||
# and not an oversight in it. iam:PassedToService is a STRING-typed key holding a service principal,
|
||||
# and AWS documents it as working with the string operators; an ARN operator on it cannot compare
|
||||
# meaningfully. The trust check needs ArnEquals and ArnLike because aws:SourceArn is ARN-typed. Two
|
||||
# allow-lists differing by the TYPE of the key they read is correct; differing for no stated reason is
|
||||
# what gets flagged, so the reason is here.
|
||||
#
|
||||
# Consequence worth naming: ArnLikeIfExists on iam:PassedToService contributes no value, so the
|
||||
# statement takes the no-pin path and reaches every service -- the same route as carrying no condition
|
||||
# at all. It still FAILs beside an AgentCore action, by that route rather than by being read as a pin.
|
||||
_PASSED_TO_SERVICE_OPERATORS = frozenset(
|
||||
f"{qualifier}{operator}{suffix}".lower()
|
||||
for qualifier in ("", "ForAnyValue:", "ForAllValues:")
|
||||
for operator in ("StringEquals", "StringEqualsIgnoreCase", "StringLike")
|
||||
for suffix in ("", "IfExists")
|
||||
)
|
||||
|
||||
|
||||
def _as_list(value) -> list:
|
||||
"""Normalize to list: None -> [], scalar -> [val], list -> list."""
|
||||
if value is None:
|
||||
return []
|
||||
return value if isinstance(value, list) else [value]
|
||||
|
||||
|
||||
def _statements(document: dict) -> list:
|
||||
"""Extract Statement, normalizing single-statement dict to list."""
|
||||
statements = document.get("Statement", [])
|
||||
if not isinstance(statements, list):
|
||||
statements = [statements]
|
||||
return [statement for statement in statements if isinstance(statement, dict)]
|
||||
|
||||
|
||||
def _covers_passrole(statement: dict) -> bool:
|
||||
"""Return True if the statement's Action covers iam:PassRole.
|
||||
|
||||
The service field is matched as an IAM pattern rather than compared literally, so
|
||||
`*:PassRole` is read as covering it. A bare "*" still does not count: a statement allowing
|
||||
every action on every resource is what the administrative-privileges checks report, and
|
||||
re-reporting it here would duplicate them instead of adding a claim. The `separator != ":"`
|
||||
guard is what preserves that, since "*" partitions to an empty separator.
|
||||
"""
|
||||
for action in _as_list(statement.get("Action")):
|
||||
if not isinstance(action, str):
|
||||
continue
|
||||
service, separator, operation = action.strip().partition(":")
|
||||
# A statement allowing EVERY action is the administrative-privileges checks' finding,
|
||||
# whether it is spelled "*" or "*:*"; the separator test covers the bare "*".
|
||||
if separator != ":" or (service == "*" and operation == "*"):
|
||||
continue
|
||||
if not iam_pattern_matches(service, "iam"):
|
||||
continue
|
||||
if iam_pattern_matches(operation, "PassRole"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _grants_agentcore_action(document: dict) -> bool:
|
||||
"""Return True if the policy allows at least one bedrock-agentcore action.
|
||||
|
||||
This is what puts an otherwise service-agnostic PassRole grant in scope: the same
|
||||
policy can both create an AgentCore resource and choose the role it runs as. A bare
|
||||
"*" is again excluded, so an administrator policy is not pulled in on that basis.
|
||||
|
||||
The service field is matched as an IAM pattern, as it is everywhere else these checks read
|
||||
one. It matters most here: ``bedrock-*:CreateAgentRuntime`` is a plausible thing to write, since
|
||||
one prefix covers bedrock and bedrock-agentcore together. A literal comparison would read it as
|
||||
no AgentCore reach, which takes an unpinned PassRole grant beside it out of scope entirely and
|
||||
clears the policy.
|
||||
"""
|
||||
for statement in _statements(document):
|
||||
if statement.get("Effect") != "Allow":
|
||||
continue
|
||||
for action in _as_list(statement.get("Action")):
|
||||
if not isinstance(action, str):
|
||||
continue
|
||||
service, separator, _ = action.strip().partition(":")
|
||||
if separator == ":" and iam_pattern_matches(
|
||||
service, AGENTCORE_SERVICE_PREFIX
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _passed_to_service_values(statement: dict) -> list:
|
||||
"""Collect the services a statement's iam:PassedToService condition names.
|
||||
|
||||
Read through the allow-list above rather than by rejecting operator names. An operator this code
|
||||
does not recognise must never be silently skipped, because ``_targets_agentcore`` treats "no
|
||||
values" as "reaches every service" and then consults the whole document. So a skipped condition
|
||||
inverts the verdict in BOTH directions, on nothing more than a one-word change of operator:
|
||||
|
||||
- ``StringEqualsIfExists`` naming AgentCore, on ``Resource: "*"``, yields no values, so a policy
|
||||
carrying no other AgentCore action falls out of scope entirely -- a PassRole grant on every
|
||||
role in the account, cleared by a high-severity privilege-escalation check.
|
||||
- ``StringEqualsIfExists`` naming another service yields no values too, so the document-wide
|
||||
fallback pulls the statement back in beside any AgentCore action and reports it, when a
|
||||
statement pinned to sagemaker is out of scope by the same rule spelled ``StringEquals``.
|
||||
|
||||
A negated operator is deliberately not read: it names the services the statement will NOT pass
|
||||
to, and the set it does reach is everything else, which is what "no values" already means here.
|
||||
"""
|
||||
values = []
|
||||
condition = statement.get("Condition", {})
|
||||
if not isinstance(condition, dict):
|
||||
return values
|
||||
for operator, block in condition.items():
|
||||
if not isinstance(operator, str) or not isinstance(block, dict):
|
||||
continue
|
||||
lowered_operator = operator.lower()
|
||||
if lowered_operator not in _PASSED_TO_SERVICE_OPERATORS:
|
||||
continue
|
||||
for key, value in block.items():
|
||||
if isinstance(key, str) and key.lower() == "iam:passedtoservice":
|
||||
values.extend(_as_list(value))
|
||||
return values
|
||||
|
||||
|
||||
def _null_guarded_keys(condition: dict) -> set:
|
||||
"""Return the lowercased condition keys a ``Null: "false"`` test forces to be present.
|
||||
|
||||
The helper the trust check in this PR defines, for the same reason, and byte-identical to it
|
||||
EXCEPT for the value type read -- see below, and see that file's docstring for the other half.
|
||||
Both spellings are handled, the scalar and the list, as kms/lib/enclave.py does.
|
||||
|
||||
A JSON ``false`` counts as well as the string ``"false"``, because on THIS check's surface IAM
|
||||
stores and returns both. Measured on a customer-managed policy, which is exactly this check's
|
||||
population: ``create_policy`` with ``{"Null": {"iam:PassedToService": false}}`` is accepted and
|
||||
``get_policy_version`` returns a Python ``bool``, unconverted. Reading only the string leaves the
|
||||
guard invisible, and the consequence is a false report on AWS's own prescribed hardening: a pin
|
||||
AWS's simulator holds to one service -- absent key ``implicitDeny``, AgentCore ``implicitDeny``
|
||||
-- would be reported as passing every role to AgentCore.
|
||||
|
||||
The trust sibling stays string-only ON PURPOSE, because a trust policy normalizes its scalars
|
||||
and no bool can reach it: the divergence is measured, not drift.
|
||||
|
||||
This also DIVERGES from kms/lib/enclave.py deliberately: that copy tests ``isinstance(value,
|
||||
str)`` only and carries the same blind spot. Diverging toward the correct reading rather than
|
||||
inheriting the house copy's defect.
|
||||
|
||||
EVERY value must be ``false``, not merely one of them, because IAM ORs the values inside a single
|
||||
condition operator. ``Null: {key: ["true","false"]}`` therefore means "key absent OR key present",
|
||||
which is always true and binds NOTHING, yet reading it with ``any`` credited it as a guard and
|
||||
rescued a defeasible pin -- so ADDING the word ``true`` to a guard list improved the score.
|
||||
Measured on IAM's own evaluator with the key omitted: ``allowed`` for ``["true","false"]`` and
|
||||
``["false","true"]``, indistinguishable from carrying no Null block, against ``implicitDeny`` for
|
||||
``"false"``, ``["false"]`` and ``["false","false"]``. Reachable: ``create_policy`` stores a
|
||||
multi-value list and ``get_policy_version`` returns it unchanged, and the trust surface preserves
|
||||
a multi-element list too even though it collapses a single-element one to a scalar.
|
||||
|
||||
The ``candidates and`` guard is not decoration: ``all()`` over an empty list is True, so an empty
|
||||
value list would otherwise be read as the strongest possible guard.
|
||||
|
||||
``0`` is NOT a guard, and it is the VALUE comparison that excludes it, not the type test:
|
||||
``str(0)`` is ``"0"``, which is simply not ``"false"``. The ``isinstance`` merely narrows the
|
||||
accepted types to the two JSON scalars IAM actually returns here. Spelled out because the
|
||||
tempting formulation is the broken one -- ``not candidate`` or ``candidate is False`` reads
|
||||
``0``, ``""``, ``None`` and ``[]`` as guards, since ``isinstance(False, int)`` is True in Python
|
||||
and falsiness is not the question being asked.
|
||||
|
||||
ACCESS ANALYZER DOES NOT CORROBORATE THIS BOUNDARY, so do not cite it as support. Measured: it
|
||||
reports TYPE_MISMATCH_BOOLEAN for ``"FALSE"`` and for ``" false "``, both of which this helper
|
||||
CREDITS, as well as for ``0``, which it does not. Its type-checking is therefore stricter than
|
||||
this helper's casing tolerance in one direction and looser in the other, and the boundary drawn
|
||||
here is this check's own decision rather than an external one. Over-recognising is the dangerous
|
||||
direction, because crediting a guard turns a FAIL into a PASS.
|
||||
"""
|
||||
guarded = set()
|
||||
for operator, block in condition.items():
|
||||
if not isinstance(operator, str) or operator.lower() != "null":
|
||||
continue
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
for key, value in block.items():
|
||||
if not isinstance(key, str):
|
||||
continue
|
||||
candidates = value if isinstance(value, list) else [value]
|
||||
if candidates and all(
|
||||
isinstance(candidate, (str, bool))
|
||||
and str(candidate).strip().lower() == "false"
|
||||
for candidate in candidates
|
||||
):
|
||||
guarded.add(key.lower())
|
||||
return guarded
|
||||
|
||||
|
||||
def _passed_to_service_pin_is_defeasible(statement: dict) -> bool:
|
||||
"""Return True if every operator naming iam:PassedToService can be skipped by the caller.
|
||||
|
||||
An *IfExists operator is not evaluated when the request omits the key, and ForAllValues is
|
||||
vacuous-true for an absent key -- kms/lib/enclave.py records these as the same trap. So a
|
||||
statement whose only pin is one of those reaches every service as well as the one it names,
|
||||
and cannot be treated as confined to it.
|
||||
|
||||
UNLESS the same statement carries ``Null: "false"`` on the same key, which forces the key to be
|
||||
present and removes the skip. Reading that guard is what stops the check penalising the spelling
|
||||
AWS prescribes -- "You should always include the Null condition operator ... with a false value".
|
||||
Without it the hardened form scores worse than the plain one, which is backwards: a caller cannot
|
||||
omit a key the guard requires, so the guard can only narrow the grant.
|
||||
|
||||
Conditions are ANDed, so one non-defeasible operator naming the key holds the request to that
|
||||
key's values whatever else the statement carries; the pin is defeasible only if all of them are.
|
||||
"""
|
||||
defeasible = []
|
||||
condition = statement.get("Condition", {})
|
||||
if not isinstance(condition, dict):
|
||||
return False
|
||||
guarded = "iam:passedtoservice" in _null_guarded_keys(condition)
|
||||
for operator, block in condition.items():
|
||||
if not isinstance(operator, str) or not isinstance(block, dict):
|
||||
continue
|
||||
lowered_operator = operator.lower()
|
||||
if lowered_operator not in _PASSED_TO_SERVICE_OPERATORS:
|
||||
continue
|
||||
if not any(
|
||||
isinstance(key, str) and key.lower() == "iam:passedtoservice"
|
||||
for key in block
|
||||
):
|
||||
continue
|
||||
defeasible.append(
|
||||
(
|
||||
lowered_operator.endswith("ifexists")
|
||||
or lowered_operator.startswith("forallvalues:")
|
||||
)
|
||||
and not guarded
|
||||
)
|
||||
return bool(defeasible) and all(defeasible)
|
||||
|
||||
|
||||
def _names_agentcore(values: list) -> bool:
|
||||
"""Return True if any value can name an AgentCore service principal.
|
||||
|
||||
Two questions, and asking only the second was a false PASS on the exact grant this check exists
|
||||
to catch. Probing the value as a pattern against the ONE base principal, plus a regex asking
|
||||
whether the value IS a family member, left every wildcard that COVERS the family unrecognised:
|
||||
|
||||
*.bedrock-agentcore.amazonaws.com covers 2 known principals read as another service
|
||||
*-identity.bedrock-agentcore.amazonaws.com covers runtime-identity read as another service
|
||||
runtime-identity.* covers runtime-identity read as another service
|
||||
*.bedrock-agentcore.* covers 2 read as another service
|
||||
|
||||
Read that way each would pin iam:PassedToService to something other than AgentCore, taking the
|
||||
statement out of scope -- while both the narrower literal and the no-condition case are reported.
|
||||
Broadening the grant would flip the verdict the safe way round, which is the shape that never
|
||||
self-corrects.
|
||||
|
||||
So the value is now matched as a pattern against several concrete principals, not one, and a
|
||||
literal outside that list is still caught by the family regex. ``?`` covers nothing here on
|
||||
purpose: it matches exactly one character and no principal has a single-character subdomain --
|
||||
that ``?`` cannot match zero characters is pinned by iam/lib/policy_test.py, not assumed here.
|
||||
"""
|
||||
return any(
|
||||
isinstance(value, str)
|
||||
and (
|
||||
AGENTCORE_PRINCIPAL_FAMILY_PATTERN.match(value.strip())
|
||||
or any(
|
||||
iam_pattern_matches(value, probe)
|
||||
for probe in AGENTCORE_PRINCIPAL_PROBES
|
||||
)
|
||||
)
|
||||
for value in values
|
||||
)
|
||||
|
||||
|
||||
def _targets_agentcore(statement: dict, document: dict) -> bool:
|
||||
"""Return True if the statement can hand a role to Bedrock AgentCore.
|
||||
|
||||
Three cases, in the order they are decided:
|
||||
|
||||
1. A condition NAMES AgentCore. The statement is in scope on its own terms, under any operator
|
||||
that compares the key, *IfExists included. Dropping a spelling before its value is read is
|
||||
what would clear a PassRole grant on every role in the account: with no values the statement
|
||||
looks unpinned, so scope falls to a document that allows no AgentCore action.
|
||||
2. A condition pins the key elsewhere and cannot be skipped. The statement cannot reach
|
||||
AgentCore however the rest of the policy is shaped, so it is out of scope -- this is what
|
||||
keeps a grant pinned to sagemaker.amazonaws.com out of the check.
|
||||
3. Anything else -- no condition on the key, or only a defeasible one -- reaches every service,
|
||||
so the rest of the policy decides: in scope when the policy also allows an AgentCore action.
|
||||
"""
|
||||
values = _passed_to_service_values(statement)
|
||||
if _names_agentcore(values):
|
||||
return True
|
||||
if values and not _passed_to_service_pin_is_defeasible(statement):
|
||||
return False
|
||||
return _grants_agentcore_action(document)
|
||||
|
||||
|
||||
def _names_every_role(resource: str) -> bool:
|
||||
"""Return True if this one resource names every role rather than specific roles.
|
||||
|
||||
Decided by matching the resource against the shortest and longest role name a pattern would
|
||||
have to cover, rather than by a regex demanding the resource field be a run of asterisks. That
|
||||
regex was both too narrow and, being anchored on a literal ``arn:``, blind to a wildcarded
|
||||
partition. Four resources naming every role in the account read as specific ones:
|
||||
|
||||
role/?* every role whose name has at least one character
|
||||
*role* every role/... resource there is, since the stars absorb the prefix and the name
|
||||
arn:aws:iam::* the star spans the account and resource fields
|
||||
*:aws:iam::123456789012:role/* a leading star matches "arn"
|
||||
|
||||
``role/?`` still passes and is the case that shows the rule is not "contains a metacharacter":
|
||||
``?`` matches exactly one character -- pinned by iam/lib/policy_test.py rather than assumed here
|
||||
-- so it names single-character roles and nothing else. A name
|
||||
prefix such as ``role/AgentCoreEvaluationRole*`` passes for the same reason -- it covers a set,
|
||||
but not every role -- and that is the scope AWS's own AgentCore Evaluations reference policy
|
||||
uses, so reporting it would report the documented configuration.
|
||||
|
||||
Residual, and it is a judgement not a hole: a pattern of exactly 64 ``?`` would match the long
|
||||
probe and not the short one, so it reads as specific. It names every role whose name is exactly
|
||||
64 characters, which is a set no operator writes by hand.
|
||||
|
||||
A pattern with fewer than six fields is decided structurally, and NOT by probing concrete ARNs.
|
||||
A probe corpus pins the partition and account it happens to carry, which makes both load-bearing
|
||||
in the short branch while the six-field branch ignores them: ``arn:aws:iam::555555555555*`` reads
|
||||
as specific while the identical shape in the probe's own account is reported, and
|
||||
``arn:aws-us-gov:iam::*`` and ``arn:aws-cn:iam::*`` read as specific merely because no probe
|
||||
carries those partitions. No probe corpus can fix an account PREFIX -- there is nothing to
|
||||
enumerate. So: a star in the LAST spelled-out field spans every
|
||||
field after it, because IAM wildcards match the colon, and what remains is that the fields
|
||||
actually spelled out must be able to name a role ARN.
|
||||
|
||||
The region and account positions get an extra test, and both rest on a fixed property of an IAM
|
||||
ARN rather than on a corpus:
|
||||
|
||||
account an account is twelve digits, so a literal that is not twelve digits names no
|
||||
account. This is what keeps ``arn:aws:iam::role/Prod*`` and
|
||||
``arn:aws:iam::12345:role/*`` specific.
|
||||
region every IAM ARN has an EMPTY region, so any region pattern that cannot match the
|
||||
empty string names no region. This is what keeps ``arn:aws:iam:role/Prod*``
|
||||
specific -- the same shape one colon short -- and, at six fields,
|
||||
``arn:aws:iam:us-east-1:123456789012:role/*``.
|
||||
|
||||
BOTH BRANCHES APPLY BOTH TESTS, and the six-field branch not applying them was the defect that
|
||||
reached review. It tested only that the partition and service fields could name an IAM ARN and
|
||||
then probed the resource field, so a fully spelled-out ARN naming a region IAM does not have, or
|
||||
an account of the wrong length, was read as naming every role: a high-severity
|
||||
privilege-escalation FAIL on a pattern matching no role ARN at all. That is the same defect the
|
||||
short branch had already been fixed for, surviving in the branch nobody re-read.
|
||||
|
||||
The two branches phrase the SAME question differently because the field means something
|
||||
different in each, and this is the part that is easy to get wrong:
|
||||
|
||||
short branch the last spelled-out field carries a star that spans every field after it, so
|
||||
only its literal HEAD is pinned to a position. ``?`` is discounted there because
|
||||
it can match the colon and slide the rest of the pattern into a later field --
|
||||
which is what keeps ``arn:aws:iam:?*`` naming every role.
|
||||
six fields the field is delimited on both sides, so the WHOLE field must be able to name a
|
||||
region or an account, and ``?`` is NOT discounted -- it has no colon to match
|
||||
and must consume exactly one character of a region that has none. That is why
|
||||
``arn:aws:iam:?:123456789012:role/*`` names no role while ``arn:aws:iam:?*``
|
||||
names every one.
|
||||
|
||||
The PARTITION position deliberately gets no such test, which is why ``arn:xyz*`` still reads as
|
||||
naming every role. A partition is not a fixed shape -- ``aws``, ``aws-cn``, ``aws-us-gov`` and the
|
||||
iso partitions differ -- and a PREFIX of one cannot be enumerated, which is the same reason the
|
||||
probe corpus was abandoned above. Over-reporting an unspellable partition is the safe direction
|
||||
for a privilege-escalation check; guessing the partition set is not.
|
||||
"""
|
||||
candidate = resource.strip()
|
||||
if candidate == "*":
|
||||
return True
|
||||
arn_fields = candidate.split(":", 5)
|
||||
if len(arn_fields) < 6:
|
||||
if "*" not in arn_fields[-1]:
|
||||
return False
|
||||
if not iam_pattern_matches(arn_fields[0], "arn"):
|
||||
return False
|
||||
if len(arn_fields) > 2 and not iam_pattern_matches(arn_fields[2], "iam"):
|
||||
return False
|
||||
if len(arn_fields) == 4:
|
||||
# The last field sits in the REGION position, and every IAM ARN has an EMPTY region. Any
|
||||
# literal head -- role/Prod in arn:aws:iam:role/Prod* -- can name no region, so the
|
||||
# pattern matches no role ARN however its star spans. Discounting ? keeps
|
||||
# arn:aws:iam:?* naming every role, since ? matches the colon.
|
||||
region_head = arn_fields[3].split("*", 1)[0].replace("?", "")
|
||||
if region_head:
|
||||
return False
|
||||
if len(arn_fields) == 5:
|
||||
# The last field sits in the ACCOUNT position, and an account is twelve digits. A
|
||||
# literal head that is not digits -- role/Prod in arn:aws:iam::role/Prod* -- can name no
|
||||
# account, so the pattern matches no role ARN however its star spans.
|
||||
account_head = arn_fields[4].split("*", 1)[0].replace("?", "")
|
||||
if account_head and not account_head.isdigit():
|
||||
return False
|
||||
return True
|
||||
if not iam_pattern_matches(arn_fields[0], "arn"):
|
||||
return False
|
||||
if not iam_pattern_matches(arn_fields[2], "iam"):
|
||||
return False
|
||||
# Every IAM ARN has an EMPTY region, so a region field that cannot match the empty string names
|
||||
# no region and the pattern reaches no role. Asked as a pattern match against "" rather than by
|
||||
# stripping metacharacters, because that is the whole question here: "" and "*" match it, while
|
||||
# "us-east-1" and "?" do not. ? is deliberately NOT discounted, unlike the short branch above --
|
||||
# this field is delimited on both sides, so there is no colon for it to match and it must consume
|
||||
# one character of a region that has none.
|
||||
if not iam_pattern_matches(arn_fields[3], ""):
|
||||
return False
|
||||
account_field = arn_fields[4]
|
||||
# An account is twelve digits. Two ways a spelled-out field can fail to name one, and the second
|
||||
# is unreachable in the short branch, where a trailing star always spans the field:
|
||||
# a literal head that is not digits role/Prod in arn:aws:iam::role/Prod:...
|
||||
# a starless field of the wrong width 12345, which is digits but names no account
|
||||
account_head = account_field.split("*", 1)[0].replace("?", "")
|
||||
if account_head and not account_head.isdigit():
|
||||
return False
|
||||
if "*" not in account_field and len(account_field) != 12:
|
||||
return False
|
||||
return all(
|
||||
iam_pattern_matches(arn_fields[5], probe)
|
||||
for probe in EVERY_ROLE_RESOURCE_PROBES
|
||||
)
|
||||
|
||||
|
||||
def _allows_any_role(statement: dict) -> bool:
|
||||
"""Return True if the statement's resources name every role rather than named roles."""
|
||||
resources = _as_list(statement.get("Resource"))
|
||||
if not resources:
|
||||
# NotResource grants every resource but the excluded ones, so every role outside
|
||||
# that list stays passable.
|
||||
return "NotResource" in statement
|
||||
return any(
|
||||
isinstance(resource, str) and _names_every_role(resource)
|
||||
for resource in resources
|
||||
)
|
||||
|
||||
|
||||
def _deny_removes_passrole(statement: dict) -> bool:
|
||||
"""Return True if a DENY statement's Action removes iam:PassRole.
|
||||
|
||||
Separate from _covers_passrole on purpose, because the bare-star exclusion that is right on the
|
||||
Allow side inverts on the Deny side. There, skipping ``*`` and ``*:*`` avoids duplicating the
|
||||
administrative-privileges checks; here it meant an unconditional Deny of EVERY action credited
|
||||
nothing, so a policy that grants no PassRole at all was reported FAIL. The Allow-side guard is
|
||||
deliberately left alone: relaxing it would reverse the settled decision that admin-level grants
|
||||
belong to those checks.
|
||||
|
||||
Deny expressed as NotAction is still NOT read. Inverting it needs the whole action namespace,
|
||||
and not crediting it errs toward reporting rather than toward silence.
|
||||
"""
|
||||
for action in _as_list(statement.get("Action")):
|
||||
if not isinstance(action, str):
|
||||
continue
|
||||
service, separator, operation = action.strip().partition(":")
|
||||
if separator != ":":
|
||||
# A bare "*" denies every action, iam:PassRole among them.
|
||||
if service == "*":
|
||||
return True
|
||||
continue
|
||||
if not iam_pattern_matches(service, "iam"):
|
||||
continue
|
||||
if iam_pattern_matches(operation, "PassRole"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _denies_passrole_everywhere(document: dict) -> bool:
|
||||
"""Return True if an unconditional Deny removes iam:PassRole on every resource."""
|
||||
for statement in _statements(document):
|
||||
if statement.get("Effect") != "Deny" or statement.get("Condition"):
|
||||
continue
|
||||
if not _deny_removes_passrole(statement):
|
||||
continue
|
||||
if any(
|
||||
isinstance(resource, str) and resource.strip() == "*"
|
||||
for resource in _as_list(statement.get("Resource"))
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _has_unevaluated_notaction(document: dict) -> bool:
|
||||
"""True if an Allow statement expresses its actions as NotAction.
|
||||
|
||||
NotAction under Effect Allow grants everything EXCEPT what it lists, so a policy using
|
||||
it can grant iam:PassRole while carrying no Action key at all. Reading only Action would
|
||||
find nothing and report a clean policy. Inverting NotAction correctly means resolving it
|
||||
against the full action namespace and its interaction with Resource and NotResource,
|
||||
which is more than this check can honestly claim to do -- so the statement is declared
|
||||
unevaluated rather than guessed at.
|
||||
"""
|
||||
for statement in _statements(document):
|
||||
if statement.get("Effect") == "Allow" and "NotAction" in statement:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class iam_policy_passrole_to_bedrock_agentcore_restricted(Check):
|
||||
"""Check whether a customer-managed policy scopes iam:PassRole to Bedrock AgentCore.
|
||||
|
||||
A statement granting ``iam:PassRole`` on every role beside any Bedrock AgentCore action lets the
|
||||
holder hand an arbitrary role to an agent runtime and assume its permissions, which is a
|
||||
privilege-escalation path. FAIL when the PassRole resource names every role; PASS when it names
|
||||
a bounded set, when the statement pins ``iam:PassedToService`` to another service, or when no
|
||||
AgentCore action accompanies it; MANUAL when the policy document could not be read.
|
||||
|
||||
Caveats:
|
||||
Customer-managed policies only, since inline policies are covered by the
|
||||
``iam_inline_policy_*`` checks and an AWS-managed policy cannot be edited to remediate a
|
||||
finding. A bare ``Action: "*"`` is left to the administrative-privileges checks. Conditions
|
||||
do not rescue an unbounded resource, because ``iam:PassedToService`` binds the service and
|
||||
``iam:AssociatedResourceArn`` the consuming resource, neither the set of roles.
|
||||
"""
|
||||
|
||||
def execute(self) -> Check_Report_AWS:
|
||||
"""Flag policies allowing PassRole to AgentCore on all roles."""
|
||||
findings = []
|
||||
for policy in iam_client.policies.values():
|
||||
# Only customer-managed policies: the inline population is a separate check,
|
||||
# and an AWS-managed policy cannot be edited to remediate a finding.
|
||||
if policy.type != "Custom":
|
||||
continue
|
||||
if not policy.attached and not iam_client.provider.scan_unused_services:
|
||||
continue
|
||||
|
||||
report = Check_Report_AWS(metadata=self.metadata(), resource=policy)
|
||||
report.region = iam_client.region
|
||||
|
||||
if not policy.document:
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
f"Custom Policy {policy.name} could not be evaluated because its "
|
||||
"policy document was not retrieved."
|
||||
)
|
||||
findings.append(report)
|
||||
continue
|
||||
|
||||
if _has_unevaluated_notaction(policy.document):
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
f"Custom Policy {policy.name} expresses an Allow statement with "
|
||||
"NotAction, which this check does not evaluate, so whether it allows "
|
||||
"iam:PassRole could not be determined; review it manually."
|
||||
)
|
||||
findings.append(report)
|
||||
continue
|
||||
|
||||
unrestricted = False
|
||||
if not _denies_passrole_everywhere(policy.document):
|
||||
unrestricted = any(
|
||||
statement.get("Effect") == "Allow"
|
||||
and _covers_passrole(statement)
|
||||
and _allows_any_role(statement)
|
||||
and _targets_agentcore(statement, policy.document)
|
||||
for statement in _statements(policy.document)
|
||||
)
|
||||
|
||||
if unrestricted:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Custom Policy {policy.name} allows iam:PassRole to Bedrock AgentCore "
|
||||
"on every role instead of the specific execution roles AgentCore is "
|
||||
"meant to run as."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Custom Policy {policy.name} does not allow iam:PassRole to Bedrock "
|
||||
"AgentCore on every role."
|
||||
)
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "iam_role_service_trust_restricts_source_to_account",
|
||||
"CheckTitle": "IAM role trust policy confines AWS service principals to a specific source account",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices",
|
||||
"TTPs/Privilege Escalation"
|
||||
],
|
||||
"ServiceName": "iam",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "AwsIamRole",
|
||||
"ResourceGroup": "IAM",
|
||||
"Description": "Trust-policy statements letting an **AWS service principal** call `sts:AssumeRole` confine the request source to one account -- via `aws:SourceAccount`, an account-bearing `aws:SourceArn`, or an organization-scoped source. Scope: statements whose condition binds no account, and trust policies that are not a plain service role. Unconditional service roles go to the related check.",
|
||||
"Risk": "A condition can look protective while binding nothing: a `*IfExists` operator is skipped when the calling service omits the key, a negated operator never matches, and an `aws:SourceArn` that is wildcarded or carries no account field (an S3 bucket ARN) names no account. Any account can then steer the service into assuming the role -- a **cross-service confused deputy** path to its permissions.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html#cross-service-confused-deputy-prevention",
|
||||
"https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_condition-keys.html#condition-keys-sourcearn",
|
||||
"https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_condition-keys.html#condition-keys-sourceaccount",
|
||||
"https://aws.amazon.com/blogs/security/use-scalable-controls-for-aws-services-accessing-your-resources/",
|
||||
"https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_common-scenarios_third-party.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "aws iam update-assume-role-policy --role-name <example_resource_name> --policy-document '{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"<service>.amazonaws.com\"},\"Action\":\"sts:AssumeRole\",\"Condition\":{\"StringEquals\":{\"aws:SourceAccount\":\"<ACCOUNT_ID>\"}}}]}'",
|
||||
"NativeIaC": "```yaml\n# CloudFormation: confine the service-principal trust to this account\nResources:\n <example_resource_name>:\n Type: AWS::IAM::Role\n Properties:\n AssumeRolePolicyDocument:\n Version: '2012-10-17'\n Statement:\n - Effect: Allow\n Principal:\n Service: <service>.amazonaws.com\n Action: sts:AssumeRole\n Condition:\n StringEquals:\n aws:SourceAccount: !Ref AWS::AccountId # binds the call to this account\n ArnLike:\n # Optional but preferred: bind to the calling resource as well.\n # An ARN whose account field is empty (an S3 bucket ARN) or wildcarded\n # does NOT bind the account -- keep aws:SourceAccount in that case.\n aws:SourceArn: !Sub 'arn:${AWS::Partition}:<service>:${AWS::Region}:${AWS::AccountId}:<resource-type>/<resource-name>'\n```",
|
||||
"Other": "1. In the AWS console, go to IAM > Roles\n2. Open <example_resource_name> and select the Trust relationships tab\n3. Click Edit trust policy\n4. For every statement whose Principal is a Service, add a Condition block that binds the source to an account, using either:\n - StringEquals: aws:SourceAccount = <ACCOUNT_ID>, or\n - ArnLike / ArnEquals: aws:SourceArn = an ARN whose account field is <ACCOUNT_ID>\n5. If the aws:SourceArn value has no account field (for example arn:aws:s3:::amzn-s3-demo-bucket), add aws:SourceAccount as well -- the ARN alone cannot bind the account\n6. Do not rely on a *IfExists operator: it is skipped when the calling service omits the key\n7. Save changes and re-run the check",
|
||||
"Terraform": "```hcl\n# Confine the service-principal trust to this account\nresource \"aws_iam_role\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n\n assume_role_policy = jsonencode({\n Version = \"2012-10-17\"\n Statement = [\n {\n Effect = \"Allow\"\n Principal = { Service = \"<service>.amazonaws.com\" }\n Action = \"sts:AssumeRole\"\n Condition = {\n StringEquals = { \"aws:SourceAccount\" = data.aws_caller_identity.current.account_id }\n # Optional but preferred: bind to the calling resource as well.\n ArnLike = { \"aws:SourceArn\" = aws_<service>_<resource>.example.arn }\n }\n }\n ]\n })\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Bind every service-principal trust statement to an account with `aws:SourceAccount`, or with an `aws:SourceArn` whose account field holds the account ID. AWS documents `aws:SourceArn`, `aws:SourceAccount`, `aws:SourceOrgID` and `aws:SourceOrgPaths` as alternatives, so any one of them satisfies this check -- except an ARN with no account field, which needs `aws:SourceAccount` alongside it.",
|
||||
"Url": "https://hub.prowler.com/check/iam_role_service_trust_restricts_source_to_account"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"identity-access",
|
||||
"trust-boundaries"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"iam_role_cross_service_confused_deputy_prevention"
|
||||
],
|
||||
"Notes": "Companion to iam_role_cross_service_confused_deputy_prevention, not a replacement. That check reports a service-principal trust statement carrying no restrictive condition at all, and only on roles it classifies as service roles. This check asserts the clause it leaves open: statements where a condition IS present but confines nothing, and statements no service-role check evaluates. Both can report one role.\n\nA condition confines nothing when it uses a negated operator, an *IfExists or ForAllValues operator with no Null:\"false\" guard on the same key, a wildcarded aws:SourceArn, or an ARN whose account field is empty such as an S3 bucket ARN. A role leaves the other check's population when its trust policy carries a Deny statement, an action outside the assume-role family, or a non-Service principal.\n\nAll four aws:Source* keys count as bindings. sts:ExternalId does not: AWS documents it for third-party access where the third party supplies the value, while a calling service passes source context instead."
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
import re
|
||||
|
||||
from prowler.lib.check.models import Check, Check_Report_AWS
|
||||
from prowler.providers.aws.services.iam.iam_client import iam_client
|
||||
from prowler.providers.aws.services.iam.lib.policy import iam_pattern_matches
|
||||
|
||||
ASSUME_ROLE_ACTION = "sts:AssumeRole"
|
||||
ACCOUNT_ID_PATTERN = re.compile(r"^\d{12}$")
|
||||
ORGANIZATION_ID_PATTERN = re.compile(r"^o-[a-z0-9]{10,32}$")
|
||||
ORGANIZATION_PATH_PATTERN = re.compile(r"^o-[a-z0-9]{10,32}/")
|
||||
|
||||
|
||||
def _as_list(value) -> list:
|
||||
"""Normalize to list: None -> [], scalar -> [val], list -> list.
|
||||
|
||||
The same helper both sibling checks in this PR define. A present-but-null key -- ``"Action":
|
||||
null`` -- makes ``.get("Action", [])`` return None rather than the default, and iterating that
|
||||
raises TypeError out of execute(), discarding every finding for the account rather than one
|
||||
role. IAM will not store such a document, so this is consistency with the siblings and not a
|
||||
security fix.
|
||||
"""
|
||||
if value is None:
|
||||
return []
|
||||
return value if isinstance(value, list) else [value]
|
||||
|
||||
|
||||
def _grants_assume_role(statement: dict) -> bool:
|
||||
"""Return True if the statement's Action covers sts:AssumeRole.
|
||||
|
||||
Exactly that one operation, not the wider assume-role family. ``sts:AssumeRoleWithWebIdentity``
|
||||
and ``sts:AssumeRoleWithSAML`` are how a federated or web identity assumes a role; an AWS service
|
||||
principal uses ``sts:AssumeRole``, so a statement granting only one of the other two is not a
|
||||
service-principal trust grant and is correctly outside this check's population.
|
||||
|
||||
The direction matters and is easy to read backwards: the statement's Action is the PATTERN and
|
||||
``sts:AssumeRole`` is the value, so ``sts:*`` and ``sts:Assume*`` match while
|
||||
``sts:AssumeRoleWithSAML`` does not.
|
||||
|
||||
Matched with the shared IAM matcher this PR already ships, rather than a literal tuple plus a
|
||||
trailing-star test. That pair recognised sts:AssumeRole, sts:* and * and any prefix ending in a
|
||||
star, but nothing else IAM honours: sts:*Role, sts:A*Role, sts:Assume?ole and sts:AssumeRol?
|
||||
each grant the action and each produced NO REPORT at all, because a statement that does not
|
||||
grant assume-role drops out of the evaluated population. Beside a second statement the same
|
||||
miss was worse than silence -- the role reported PASS, asserting it confines every AWS service
|
||||
principal in its trust policy to a specific account.
|
||||
"""
|
||||
for action in _as_list(statement.get("Action")):
|
||||
if not isinstance(action, str):
|
||||
continue
|
||||
if iam_pattern_matches(action, ASSUME_ROLE_ACTION):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _trusts_service_principal(statement: dict) -> bool:
|
||||
"""Return True if the statement trusts at least one AWS service principal.
|
||||
|
||||
A statement that trusts a service principal *alongside* other principal types
|
||||
still qualifies: the service principal is reachable regardless of what else the
|
||||
statement trusts, so it needs the same confused-deputy scoping.
|
||||
"""
|
||||
principal = statement.get("Principal", {})
|
||||
if not isinstance(principal, dict):
|
||||
# Principal: "*" is a string, not a mapping, and trusts every principal there is --
|
||||
# including every service principal. Returning False here dropped the statement from
|
||||
# the population and the role produced no finding at all.
|
||||
return principal == "*"
|
||||
return any(
|
||||
isinstance(service, str) and service
|
||||
for service in _as_list(principal.get("Service"))
|
||||
)
|
||||
|
||||
|
||||
# Operators that COMPARE a request value against the statement's own, so a value under one of them
|
||||
# genuinely pins the request source. An allow-list, because the deny-list this replaced -- two
|
||||
# substring tests for "not" and "ifexists" -- admitted every operator it did not recognise, Null
|
||||
# among them. Modelled on _RESTRICTIVE_ATTESTATION_OPERATORS in kms/lib/enclave.py, which solved the
|
||||
# same problem for attestation keys; the Arn forms are added here because aws:SourceArn is compared
|
||||
# with them. Matched lowercased, which keeps an oddly-cased operator as admissible as it was before.
|
||||
# The IfExists suffix is included, and admitted only under the same Null:"false" guard ForAllValues
|
||||
# needs. kms/lib/enclave.py calls it "the same trap as ForAllValues without a Null:false guard", so
|
||||
# rejecting one outright while rescuing the other was inconsistent on that file's own reading; and
|
||||
# secretsmanager_has_restrictive_resource_policy already ships IfExists paired with Null as its
|
||||
# accepted restrictive form, so the pairing is a shape prowler recognises rather than a new rule.
|
||||
_COMPARING_CONDITION_OPERATORS = frozenset(
|
||||
f"{qualifier}{operator}{suffix}".lower()
|
||||
for qualifier in ("", "ForAnyValue:", "ForAllValues:")
|
||||
for suffix in ("", "IfExists")
|
||||
for operator in (
|
||||
"StringEquals",
|
||||
"StringEqualsIgnoreCase",
|
||||
"StringLike",
|
||||
"ArnEquals",
|
||||
"ArnLike",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _null_guarded_keys(condition: dict) -> set:
|
||||
"""Return the lowercased condition keys a ``Null: "false"`` test forces to be present.
|
||||
|
||||
Both spellings are handled, the scalar and the list, as kms/lib/enclave.py does.
|
||||
|
||||
EVERY value must be ``false``, not merely one of them, because IAM ORs the values inside a single
|
||||
condition operator. ``Null: {key: ["true","false"]}`` means "key absent OR key present", which is
|
||||
always true and binds NOTHING, yet ``any`` credited it and rescued a defeasible pin. This axis is
|
||||
shared with the PassRole sibling and fixed identically there, because a MULTI-element list survives
|
||||
on this surface: ``create_role`` stores ``["true","false"]`` and ``get_role`` returns it as a list.
|
||||
A single-element ``["false"]`` is collapsed to the scalar ``"false"`` here, which is why only the
|
||||
multi-value spelling matters. ``candidates and`` is load-bearing: ``all()`` over an empty list is
|
||||
True, so an empty value list would read as the strongest possible guard.
|
||||
|
||||
TWO AXES, MEASURED SEPARATELY, AND THEY DIVERGE. On VALUE TYPE this stays string-only while the
|
||||
PassRole sibling also reads a JSON boolean, because a trust policy normalizes scalar types and no
|
||||
bool can reach here. On LIST ARITY the two agree, because a multi-element list is preserved on both
|
||||
surfaces. Both halves are stated at both ends rather than left to look like drift, since a helper
|
||||
pair that agrees on one axis and differs on another is exactly what decays when nobody wrote down
|
||||
which axis was which.
|
||||
|
||||
A trust policy NORMALIZES its condition scalars, measured both ways round: ``create_role`` with
|
||||
``{"Null": {"aws:SourceAccount": false}}`` is ACCEPTED, and the document comes back carrying the
|
||||
string ``"false"`` -- from ``list_roles``, which is the call this check's collector actually
|
||||
makes, and identically from ``get_role``. An unquoted ``123456789012`` comes back quoted too.
|
||||
So no bool or int can reach this helper, and reading one would be dead code with no fixture able
|
||||
to exercise it. A customer-managed POLICY document, which is all the sibling reads,
|
||||
PRESERVES both types instead -- two IAM surfaces, two behaviours, which is exactly why this was
|
||||
measured per surface rather than inferred from one.
|
||||
|
||||
If IAM ever stops normalizing here, take the sibling's reading: ``isinstance(candidate, (str,
|
||||
bool)) and str(candidate).strip().lower() == "false"``. That admits a JSON ``false`` and still
|
||||
excludes ``0``, because ``str(0)`` is ``"0"`` -- the value comparison does that work, not the
|
||||
type test. What it must not become is ``not candidate`` or ``candidate is False``, which read
|
||||
``0``, ``""``, ``None`` and ``[]`` as guards; ``isinstance(False, int)`` is True in Python and
|
||||
falsiness is not the question. Over-recognising a guard turns a FAIL into a PASS.
|
||||
"""
|
||||
guarded = set()
|
||||
for operator, block in condition.items():
|
||||
if not isinstance(operator, str) or operator.lower() != "null":
|
||||
continue
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
for key, value in block.items():
|
||||
if not isinstance(key, str):
|
||||
continue
|
||||
candidates = value if isinstance(value, list) else [value]
|
||||
if candidates and all(
|
||||
isinstance(candidate, str) and candidate.strip().lower() == "false"
|
||||
for candidate in candidates
|
||||
):
|
||||
guarded.add(key.lower())
|
||||
return guarded
|
||||
|
||||
|
||||
def _enforced_condition_value_groups(statement: dict, condition_key: str) -> list:
|
||||
"""Collect the values a statement pins to condition_key, GROUPED BY OPERATOR.
|
||||
|
||||
One group per operator, because IAM ANDs the operators in a Condition while ORing the values
|
||||
inside one operator. The grouping follows that structure directly:
|
||||
|
||||
- ACROSS groups, a caller asks whether ANY ONE confines the source. If one operator holds the
|
||||
request to literal account IDs, the request is confined whatever else it must also satisfy --
|
||||
an ANDed operator can only narrow. Pooling operators together would let a broad ``StringLike``
|
||||
beside a pinned ``StringEquals`` widen the verdict, which inverts the semantics.
|
||||
- WITHIN a group, EVERY value must qualify, since IAM lets the request match any one of them.
|
||||
|
||||
This is the per-operator evaluation ``kms/lib/enclave.py`` performs.
|
||||
|
||||
Operators are taken from an ALLOW-LIST, not a deny-list, so an operator this code does not
|
||||
recognise is never credited. Two consequences worth naming:
|
||||
|
||||
- Negated operators invert the match and so pin the source to nothing. They are absent from the
|
||||
allow-list by design.
|
||||
- ``Null`` is a presence test rather than a comparison, so it never contributes a value here. Its
|
||||
role is only as the guard below. A deny-list would admit it and feed the literal string
|
||||
``"false"`` in as though it were an account ID.
|
||||
|
||||
TWO operator families are vacuous on an Allow, and both are credited only under the same guard:
|
||||
|
||||
- ``ForAllValues:*`` "returns true if there are no context keys in the request", which AWS
|
||||
documents with an explicit warning against using it with an Allow effect. This check evaluates
|
||||
Allow statements only, so that is the reachable case.
|
||||
- ``*IfExists`` is not evaluated at all when the request omits the key, which kms/lib/enclave.py
|
||||
names as "the same trap as ForAllValues without a Null:false guard".
|
||||
|
||||
Both are credited only when the same statement carries a ``Null: "false"`` guard on the SAME key,
|
||||
which forces the key to be present and removes the vacuity. The guard defeats it identically for
|
||||
every spelling, so all four of ``ForAllValues:StringEquals``, ``StringEqualsIfExists``,
|
||||
``ArnLikeIfExists`` and ``ForAllValues:StringLikeIfExists`` are treated alike --
|
||||
``secretsmanager_has_restrictive_resource_policy`` already ships IfExists paired with Null as its
|
||||
accepted restrictive form. Refusing the guarded spellings outright would also be wrong because
|
||||
``aws:SourceOrgPaths`` is multivalued, making a set operator the only correct way to write it.
|
||||
|
||||
``ForAnyValue:*`` needs no guard. AWS documents that for no matching context key, or if the key
|
||||
does not exist, it returns false -- so it fails closed on an Allow.
|
||||
"""
|
||||
groups = []
|
||||
condition = statement.get("Condition", {})
|
||||
if not isinstance(condition, dict):
|
||||
return groups
|
||||
null_guarded = _null_guarded_keys(condition)
|
||||
for operator, block in condition.items():
|
||||
if not isinstance(operator, str) or not isinstance(block, dict):
|
||||
continue
|
||||
lowered_operator = operator.lower()
|
||||
if lowered_operator not in _COMPARING_CONDITION_OPERATORS:
|
||||
continue
|
||||
group = []
|
||||
for key, value in block.items():
|
||||
if not isinstance(key, str) or key.lower() != condition_key:
|
||||
continue
|
||||
if (
|
||||
lowered_operator.startswith("forallvalues:")
|
||||
or lowered_operator.endswith("ifexists")
|
||||
) and key.lower() not in null_guarded:
|
||||
continue
|
||||
group.extend(value if isinstance(value, list) else [value])
|
||||
if group:
|
||||
groups.append(group)
|
||||
return groups
|
||||
|
||||
|
||||
def _pins_every_value(statement: dict, condition_key: str, qualifies) -> bool:
|
||||
"""Return True if some one operator holds condition_key to values that all qualify."""
|
||||
return any(
|
||||
all(qualifies(value) for value in group)
|
||||
for group in _enforced_condition_value_groups(statement, condition_key)
|
||||
)
|
||||
|
||||
|
||||
def _is_account_id(value) -> bool:
|
||||
"""Return True if the value is a literal 12-digit account ID."""
|
||||
return isinstance(value, str) and bool(ACCOUNT_ID_PATTERN.match(value))
|
||||
|
||||
|
||||
def _pins_source_account(statement: dict) -> bool:
|
||||
"""Return True if aws:SourceAccount is pinned to literal account IDs only."""
|
||||
return _pins_every_value(statement, "aws:sourceaccount", _is_account_id)
|
||||
|
||||
|
||||
def _arn_carries_account(value) -> bool:
|
||||
"""Return True if the ARN's account field is a literal account ID.
|
||||
|
||||
An ARN whose account field is absent (an S3 bucket ARN) or wildcarded does not
|
||||
confine the caller to one account, so aws:SourceAccount is still required.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
arn_fields = value.split(":")
|
||||
return len(arn_fields) >= 5 and _is_account_id(arn_fields[4])
|
||||
|
||||
|
||||
def _pins_source_arn_to_account(statement: dict) -> bool:
|
||||
"""Return True if some one operator holds every aws:SourceArn value to an account."""
|
||||
return _pins_every_value(statement, "aws:sourcearn", _arn_carries_account)
|
||||
|
||||
|
||||
def _pins_source_organization(statement: dict) -> bool:
|
||||
"""Return True if the source is pinned to an organization or an OU path.
|
||||
|
||||
AWS documents aws:SourceOrgID and aws:SourceOrgPaths as confused-deputy mitigations
|
||||
in their own right, so an organization-scoped statement is not reported.
|
||||
"""
|
||||
return _pins_every_value(
|
||||
statement,
|
||||
"aws:sourceorgid",
|
||||
lambda value: isinstance(value, str)
|
||||
and bool(ORGANIZATION_ID_PATTERN.match(value)),
|
||||
) or _pins_every_value(
|
||||
statement,
|
||||
"aws:sourceorgpaths",
|
||||
lambda value: isinstance(value, str)
|
||||
and bool(ORGANIZATION_PATH_PATTERN.match(value)),
|
||||
)
|
||||
|
||||
|
||||
def _prevents_confused_deputy(statement: dict) -> bool:
|
||||
"""Return True if the statement carries a control AWS documents for *cross-service*
|
||||
confused-deputy prevention.
|
||||
|
||||
sts:ExternalId is deliberately absent. AWS documents it only for third-party access --
|
||||
an external ID is a value the third party supplies -- and an AWS service passes source
|
||||
account and source ARN context, never an external ID. Crediting it here would accept a
|
||||
control the calling service can never satisfy.
|
||||
"""
|
||||
return (
|
||||
_pins_source_account(statement)
|
||||
or _pins_source_arn_to_account(statement)
|
||||
or _pins_source_organization(statement)
|
||||
)
|
||||
|
||||
|
||||
def _has_enforced_condition(statement: dict) -> bool:
|
||||
"""Return True if the statement gates access on at least one enforced condition key.
|
||||
|
||||
A statement with no enforced condition at all places no constraint whatsoever on the
|
||||
caller. That wholly-unconditional state is a different (and more severe) posture than
|
||||
a constraint that is present but does not confine the source, and it is what
|
||||
iam_role_cross_service_confused_deputy_prevention reports.
|
||||
|
||||
This filter DELIBERATELY differs from the allow-list _enforced_condition_value_groups applies, and
|
||||
the two must not be unified. This one asks only whether the statement is gated at all, so a
|
||||
``Null`` presence test counts: it does gate access, even while binding the source to nothing.
|
||||
Pulling such a statement into scope is the safe direction -- it gets evaluated and reported
|
||||
rather than silently skipped. The other function asks what the statement PINS, where a presence
|
||||
test contributes no value and crediting one poisoned the shape test beside it.
|
||||
"""
|
||||
condition = statement.get("Condition", {})
|
||||
if not isinstance(condition, dict):
|
||||
return False
|
||||
for operator, block in condition.items():
|
||||
if not isinstance(operator, str) or not isinstance(block, dict):
|
||||
continue
|
||||
lowered_operator = operator.lower()
|
||||
if "not" in lowered_operator or lowered_operator.endswith("ifexists"):
|
||||
continue
|
||||
if any(isinstance(key, str) for key in block):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_plain_service_trust_policy(statements: list) -> bool:
|
||||
"""Return True if every statement is an Allow of assume-role to services only.
|
||||
|
||||
This is the trust-policy shape that the existing service-role checks assume. A policy
|
||||
that departs from it -- by carrying a Deny statement, an action outside the
|
||||
assume-role family such as sts:SetContext, or a non-service principal -- falls outside
|
||||
their evaluated population entirely, so its service principals go unassessed.
|
||||
"""
|
||||
for statement in statements:
|
||||
if not isinstance(statement, dict):
|
||||
return False
|
||||
if statement.get("Effect") != "Allow" or not _grants_assume_role(statement):
|
||||
return False
|
||||
principal = statement.get("Principal", {})
|
||||
if not isinstance(principal, dict) or set(principal.keys()) != {"Service"}:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class iam_role_service_trust_restricts_source_to_account(Check):
|
||||
"""Check whether a role's service-principal trust confines the request to a source account.
|
||||
|
||||
An AWS service principal permitted to assume a role without a source-account or source-ARN
|
||||
binding exposes the role to the confused-deputy problem: another customer's resource can induce
|
||||
the service to assume it. FAIL when a trust statement carries a condition that confines nothing;
|
||||
PASS when every in-scope statement binds a source; MANUAL for a statement using ``NotAction``,
|
||||
which is the one shape here that cannot be evaluated, since inverting it correctly is more than
|
||||
this check can claim. A trust policy is always present on a role, so there is no unreadable-document
|
||||
branch on this surface, unlike the two policy checks beside it.
|
||||
|
||||
Caveats:
|
||||
Companion to ``iam_role_cross_service_confused_deputy_prevention`` rather than a replacement.
|
||||
That check reports statements with no restrictive condition at all, on roles it classifies as
|
||||
service roles; this one asserts the clause it leaves open, so the wholly unconditional
|
||||
statement produces no finding here and the two can both report one role. Bindings are read
|
||||
from Allow statements only, so a trust policy confined solely through a Deny is still
|
||||
reported.
|
||||
"""
|
||||
|
||||
def execute(self) -> Check_Report_AWS:
|
||||
"""Flag service-principal trust whose present condition binds no account.
|
||||
|
||||
Account bindings are read from Allow statements ONLY. A Deny can also confine the source --
|
||||
`StringNotEquals` on `aws:SourceAccount` denies every account but one -- and this check does
|
||||
not evaluate that, so a trust policy confined solely through a Deny is reported even though
|
||||
it is confined. Rare, and it errs toward reporting rather than toward silence, but it is an
|
||||
unevaluated shape and is declared here rather than left to be inferred, as `NotAction`
|
||||
already is. A Deny still matters for scope: it takes the policy outside the plain-service
|
||||
shape the sibling checks assume, which is what brings the Allow statements beside it into
|
||||
this check's population.
|
||||
|
||||
MANUAL is used deliberately for the NotAction shape, and is not off-contract: 110 upstream
|
||||
checks emit it and `lib/check/models.py` places no restriction on it. THE COST, recorded so it
|
||||
is not rediscovered: `lib/outputs/asff/asff.py` SKIPS MANUAL findings, since MANUAL is not a
|
||||
valid Security Hub compliance state, so a Security Hub consumer sees NOTHING for a trust policy
|
||||
this check could not evaluate, and absence there reads as compliance. CSV and OCSF keep the
|
||||
status. The sibling token-wildcard check carries the same note, for the same reason.
|
||||
"""
|
||||
findings = []
|
||||
for role in iam_client.roles:
|
||||
# Service-linked roles are excluded: their trust relationship is managed by
|
||||
# the service and cannot be edited, so a finding would not be actionable.
|
||||
if "aws-service-role" in role.arn:
|
||||
continue
|
||||
|
||||
trust_policy = role.assume_role_policy or {}
|
||||
statements = trust_policy.get("Statement", [])
|
||||
if not isinstance(statements, list):
|
||||
statements = [statements]
|
||||
|
||||
# NotAction under Effect Allow grants everything except what it lists, so a
|
||||
# trust statement using it can permit sts:AssumeRole while carrying no Action
|
||||
# key. _grants_assume_role reads only Action, so such a statement would drop out
|
||||
# of service_statements below and the role would produce no finding at all.
|
||||
# Inverting NotAction correctly is more than this check can claim, so the role
|
||||
# is declared unevaluated rather than silently skipped.
|
||||
if any(
|
||||
isinstance(statement, dict)
|
||||
and statement.get("Effect") == "Allow"
|
||||
and "NotAction" in statement
|
||||
for statement in statements
|
||||
):
|
||||
report = Check_Report_AWS(metadata=self.metadata(), resource=role)
|
||||
report.region = iam_client.region
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
f"IAM Role {role.name} has a trust policy statement using NotAction, "
|
||||
"which this check does not evaluate, so whether a service principal is "
|
||||
"confined to this account could not be determined; review it manually."
|
||||
)
|
||||
findings.append(report)
|
||||
continue
|
||||
|
||||
service_statements = [
|
||||
statement
|
||||
for statement in statements
|
||||
if isinstance(statement, dict)
|
||||
and statement.get("Effect") == "Allow"
|
||||
and _grants_assume_role(statement)
|
||||
and _trusts_service_principal(statement)
|
||||
]
|
||||
if not service_statements:
|
||||
continue
|
||||
|
||||
# A wholly unconditional service-principal trust statement on an otherwise
|
||||
# plain service role is the fully-unprotected posture that
|
||||
# iam_role_cross_service_confused_deputy_prevention already reports. This
|
||||
# check asserts the narrower clause it does not: that a constraint which IS
|
||||
# present actually confines the source to an account. Statements are therefore
|
||||
# in scope when they carry an enforced condition, or when the trust policy
|
||||
# departs from the plain-service shape and so is evaluated by no other check.
|
||||
is_plain = _is_plain_service_trust_policy(statements)
|
||||
in_scope = [
|
||||
statement
|
||||
for statement in service_statements
|
||||
if _has_enforced_condition(statement) or not is_plain
|
||||
]
|
||||
if not in_scope:
|
||||
continue
|
||||
|
||||
unscoped = [
|
||||
statement
|
||||
for statement in in_scope
|
||||
if not _prevents_confused_deputy(statement)
|
||||
]
|
||||
|
||||
report = Check_Report_AWS(metadata=self.metadata(), resource=role)
|
||||
report.region = iam_client.region
|
||||
if unscoped:
|
||||
# The finding says no condition PINS the key to a literal of the right shape, not
|
||||
# that the statement sets no key. Most inputs reaching here do set one: StringLike
|
||||
# aws:SourceAccount "1234*", an unguarded ForAllValues, and Null "false" all set the
|
||||
# key while pinning nothing. The weaker claim is the one the code supports.
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"IAM Role {role.name} trusts an AWS service principal without confining the "
|
||||
"request source, since no condition pins aws:SourceAccount to a literal "
|
||||
"account ID, aws:SourceArn to an ARN carrying one, or aws:SourceOrgID or "
|
||||
"aws:SourceOrgPaths to an organization."
|
||||
)
|
||||
elif all(
|
||||
_pins_source_account(statement)
|
||||
or _pins_source_arn_to_account(statement)
|
||||
for statement in in_scope
|
||||
):
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"IAM Role {role.name} confines every AWS service principal in its trust "
|
||||
"policy to a specific account."
|
||||
)
|
||||
else:
|
||||
# The organization route reaches PASS through _pins_source_organization, and an
|
||||
# organization may hold hundreds of accounts. Reporting it with the sentence above
|
||||
# told the operator something categorically stronger than was verified, so the two
|
||||
# postures get separate sentences: a reader needs to know which one they have.
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"IAM Role {role.name} confines every AWS service principal in its trust "
|
||||
"policy, but at least one statement is scoped to an organization rather than "
|
||||
"to a single account, so the trusted source may be any account within it."
|
||||
)
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||