Compare commits

...
Author SHA1 Message Date
Hugo P.Brito 39e903ade3 Merge remote-tracking branch 'origin/master' into feat/python-3-14-support 2026-09-02 09:52:58 +01:00
Hugo P.Brito 477f3ebda1 fix(sdk): ensure Python 3.14 binary portability
- Select compatible NumPy and pandas releases on Python 3.14
- Validate binary wheels across supported operating systems
- Preserve existing dependency behavior on older Python versions
2026-09-02 08:32:05 +01:00
Jonathan Nguyen 86e4408f29 feat(ecr): assess enhanced scanning on registries holding repositories (#12660) 2026-09-02 08:53:52 +02:00
Jonathan Nguyen ae43d21efb fix(sagemaker): read DirectInternetAccess instead of RootAccess on notebook instances (#12659) 2026-09-01 18:22:41 +02:00
Hugo P.Brito 3ca189a52a test(sdk): exercise lazy Okta loading under frozen time 2026-09-01 16:24:53 +01:00
Pedro Martín 7c84822fa3 fix(image): skip non-image OCI artifacts in registry scan (#12695) 2026-09-01 17:18:47 +02:00
Daniel Barranquero 51c5fa7168 fix(checks): report MANUAL instead of FAIL on permission and data-availability errors (#12645) 2026-09-01 17:16:56 +02:00
Jonathan Nguyen e9121f5f1a feat(cloudwatch): add agentcore log group data protection policy check (#12662) 2026-09-01 17:04:16 +02:00
Jonathan Nguyen 821fe43efd feat(iam): scope AgentCore PassRole and workload token grants, and flag unbound service trust (#12664) 2026-09-01 17:02:05 +02:00
Jonathan Nguyen 9ffbb4b758 fix(ecr): read each registry scanning rule's frequency instead of assuming scan on push (#12560) 2026-09-01 16:53:58 +02:00
Hugo P.Brito 4014fcb6c1 feat(sdk): add Python 3.14 support
- Extend SDK metadata and CI matrices
- Refresh dependencies and compatibility fixtures
- Add regression coverage and changelog fragment
2026-09-01 15:20:18 +01:00
Jonathan Nguyen 9c5285adc3 fix(cloudwatch): skip metric filters whose log group was not retrieved (#12561) 2026-09-01 14:00:41 +02:00
Pedro Martín f295d290dd feat(image): private network allowlist for SSRF guard (#12678) 2026-09-01 14:00:04 +02:00
Jonathan Nguyen e5df95c259 feat(eks): assess Kubernetes network policy enforcement in the Amazon VPC CNI add-on (#12661) 2026-09-01 13:40:45 +02:00
Jonathan Nguyen b6a8af3c54 feat(guardduty): assess unified Runtime Monitoring and AI Protection (#12564) 2026-09-01 13:18:57 +02:00
Pedro MartínandLydia Vilchez fb7064401b feat(compliance): add CIS 1.4 google workspace compliance (#12513)
Co-authored-by: Lydia Vilchez <lydiavilchezlopez@gmail.com>
2026-09-01 09:35:39 +02:00
Josema Camacho 8d60f9703a fix(api): limit mute rules to current and future scans (#12681) 2026-09-01 09:33:15 +02:00
Pablo Fernandez Guerra (PFE) ceb601028e fix(ui): apply Slack integration design feedback (#12677) 2026-08-31 18:27:11 +02:00
268 changed files with 20070 additions and 2500 deletions
+1
View File
@@ -30,6 +30,7 @@ jobs:
- '3.11'
- '3.12'
- '3.13'
- '3.14'
steps:
- name: Harden Runner
+91
View File
@@ -102,6 +102,7 @@ jobs:
- '3.11'
- '3.12'
- '3.13'
- '3.14'
package:
- 'prowler'
include:
@@ -119,6 +120,8 @@ jobs:
github.com:443
api.github.com:443
release-assets.githubusercontent.com:443
results-receiver.actions.githubusercontent.com:443
*.blob.core.windows.net:443
pypi.org:443
files.pythonhosted.org:443
@@ -145,6 +148,15 @@ jobs:
- name: Check metadata with the release workflow's twine
run: uvx --from "twine==${TWINE_VERSION}" twine check --strict dist/*
- name: Upload Prowler wheel for portability checks
if: matrix.python-version == '3.14' && matrix.package == 'prowler'
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: prowler-python-3.14-wheel
path: dist/prowler-*.whl
if-no-files-found: error
retention-days: 1
- name: Install the wheel with pip into a clean virtualenv
# Plain pip, --isolated, from outside the repo: consumers never see [tool.uv]
# override-dependencies or constraint-dependencies, so neither does this step.
@@ -162,6 +174,85 @@ jobs:
# from the package. grep fails the step if the summary line never appears.
"${RUNNER_TEMP}/consumer/bin/prowler" aws --list-checks | grep 'available checks'
python-3-14-binary-wheel-portability:
needs: install-from-wheel
runs-on: ${{ matrix.runner }}
timeout-minutes: 10
permissions:
actions: read
contents: read
strategy:
fail-fast: false
matrix:
runner:
- ubuntu-latest
- macos-15-intel
- macos-15
- windows-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
# Block mode is unavailable on GitHub-hosted macOS and Windows runners.
egress-policy: audit
- name: Set up Python 3.14
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: '3.14'
- name: Download built Prowler wheel
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: prowler-python-3.14-wheel
path: dist
- name: Extract NumPy and pandas requirements from wheel metadata
shell: python
run: |
from email.parser import BytesParser
from pathlib import Path
import re
from zipfile import ZipFile
wheel = next(Path("dist").glob("prowler-*.whl"))
with ZipFile(wheel) as archive:
metadata_name = next(
name
for name in archive.namelist()
if name.endswith(".dist-info/METADATA")
)
metadata = BytesParser().parsebytes(archive.read(metadata_name))
requirements = [
requirement
for requirement in metadata.get_all("Requires-Dist", [])
if re.match(r"^(numpy|pandas)(?:\W|$)", requirement, re.IGNORECASE)
]
requirement_names = {
re.match(r"^(numpy|pandas)", requirement, re.IGNORECASE).group(1).lower()
for requirement in requirements
}
if requirement_names != {"numpy", "pandas"}:
raise SystemExit(
f"Expected NumPy and pandas requirements, found: {requirements}"
)
Path("binary-wheel-requirements.txt").write_text(
"\n".join(requirements) + "\n", encoding="utf-8"
)
print("Wheel requirements:", *requirements, sep="\n ")
# huaweicloudsdkcore hard-pins pymongo 4.15.1, which has no cp314 wheel;
# its optional C extensions fall back to a pure-Python build without a compiler.
- name: Require binary distributions for marked NumPy and pandas versions
run: >-
python -m pip download
--only-binary=:all:
--dest binary-wheels
--requirement binary-wheel-requirements.txt
pinned-releases-not-yanked:
needs: changes
if: needs.changes.outputs.run == 'true'
+1
View File
@@ -30,6 +30,7 @@ jobs:
- '3.11'
- '3.12'
- '3.13'
- '3.14'
steps:
- name: Harden Runner
+3 -3
View File
@@ -139,7 +139,7 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically
| MongoDB Atlas | 10 | 3 | 1 | 8 | Official | UI, API, CLI |
| LLM | [See `promptfoo` docs.](https://www.promptfoo.dev/docs/red-team/plugins/) | N/A | N/A | N/A | Official | CLI |
| Image | N/A | N/A | N/A | N/A | Official | UI, API, CLI |
| Google Workspace | 65 | 11 | 3 | 6 | Official | UI, API, CLI |
| Google Workspace | 65 | 11 | 4 | 6 | Official | UI, API, CLI |
| OpenStack | 34 | 5 | 1 | 9 | Official | UI, API, CLI |
| Vercel | 26 | 6 | 1 | 8 | Official | UI, API, CLI |
| Okta | 29 | 8 | 2 | 2 | Official | UI, API, CLI |
@@ -285,7 +285,7 @@ Some pre-commit hooks require tools installed on your system:
## Prowler CLI
### Pip package
Prowler CLI is available as a project in [PyPI](https://pypi.org/project/prowler-cloud/). Consequently, it can be installed using pip with Python >=3.10, <3.13:
Prowler CLI is available as a project in [PyPI](https://pypi.org/project/prowler-cloud/). Consequently, it can be installed using pip with Python >=3.10, <3.15:
```console
pip install prowler
@@ -317,7 +317,7 @@ The container images are available here:
### From GitHub
Python >=3.10, <3.13 is required with [uv](https://docs.astral.sh/uv/):
Python >=3.10, <3.15 is required with [uv](https://docs.astral.sh/uv/):
``` console
git clone https://github.com/prowler-cloud/prowler
@@ -0,0 +1 @@
`POST /api/v1/mute-rules` now updates only each affected provider's latest completed scan and future scans, preventing historical reaggregation from flooding Celery queues
+18 -27
View File
@@ -18333,19 +18333,14 @@ class TestMuteRuleViewSet:
assert len(data) == 2
assert data[0]["id"] == str(mute_rules_fixture[first_index].id)
@patch("api.v1.views.chain")
@patch("api.v1.views.reaggregate_all_finding_group_summaries_task.si")
@patch("api.v1.views.mute_historical_findings_task.si")
@patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async")
@patch("api.v1.views.transaction.on_commit", side_effect=lambda fn: fn())
def test_mute_rules_create_valid(
self,
_mock_on_commit,
mock_mute_signature,
mock_reaggregate_signature,
mock_chain,
mock_mute_task,
authenticated_client,
findings_fixture,
create_test_user,
):
"""Test creating a valid mute rule."""
finding_ids = [str(findings_fixture[0].id)]
@@ -18372,24 +18367,20 @@ class TestMuteRuleViewSet:
assert response_data["attributes"]["name"] == "New Mute Rule"
assert response_data["attributes"]["reason"] == "Security exception approved"
# Verify the finding was immediately muted
from api.models import Finding
finding = Finding.objects.get(id=findings_fixture[0].id)
assert finding.muted is True
assert finding.muted_at is not None
assert finding.muted_reason == "Security exception approved"
assert finding.muted is False
assert finding.muted_at is None
assert finding.muted_reason is None
# Verify background task chain was called: mute → reaggregate all
mock_mute_signature.assert_called_once()
mock_reaggregate_signature.assert_called_once()
mock_chain.assert_called_once_with(
mock_mute_signature.return_value,
mock_reaggregate_signature.return_value,
mock_mute_task.assert_called_once_with(
kwargs={
"tenant_id": str(finding.tenant_id),
"mute_rule_id": response_data["id"],
"provider_ids": [str(finding.scan.provider_id)],
}
)
mock_chain.return_value.apply_async.assert_called_once()
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
@patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async")
def test_mute_rules_create_converts_finding_ids_to_uids(
self,
mock_task,
@@ -18425,7 +18416,7 @@ class TestMuteRuleViewSet:
]
assert set(mute_rule.finding_uids) == set(expected_uids)
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
@patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async")
def test_mute_rules_deduplicates_uids(
self,
mock_task,
@@ -18492,10 +18483,10 @@ class TestMuteRuleViewSet:
finding1.refresh_from_db()
finding2.refresh_from_db()
assert finding1.muted is True
assert finding2.muted is True
assert finding1.muted is False
assert finding2.muted is False
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
@patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async")
def test_mute_rules_create_overlap_detection_active(
self,
mock_task,
@@ -18528,7 +18519,7 @@ class TestMuteRuleViewSet:
"already muted" in error_detail.lower() or "overlap" in error_detail.lower()
)
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
@patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async")
def test_mute_rules_create_no_overlap_with_inactive(
self,
mock_task,
@@ -18584,7 +18575,7 @@ class TestMuteRuleViewSet:
== "/data/attributes/finding_ids"
)
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
@patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async")
def test_mute_rules_create_invalid_finding_ids(
self, mock_task, authenticated_client
):
+18 -27
View File
@@ -244,7 +244,6 @@ from api.v1.serializers import (
UserUpdateSerializer,
)
from botocore.exceptions import ClientError, NoCredentialsError, ParamValidationError
from celery import chain
from celery.result import AsyncResult
from config.custom_logging import BackendLogger
from config.env import env
@@ -342,8 +341,7 @@ from tasks.tasks import (
enqueue_scan_execution_on_commit,
get_active_provider_scan,
jira_integration_task,
mute_historical_findings_task,
reaggregate_all_finding_group_summaries_task,
mute_findings_in_latest_scans_task,
refresh_lighthouse_provider_models_task,
)
@@ -7551,35 +7549,28 @@ class MuteRuleViewSet(BaseRLSViewSet):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
# Create the mute rule
tenant_id = str(request.tenant_id)
finding_ids = serializer.validated_data["finding_ids"]
provider_ids = list(
dict.fromkeys(
Finding.all_objects.filter(
id__in=finding_ids, tenant_id=tenant_id
).values_list("scan__provider_id", flat=True)
)
)
mute_rule = serializer.save()
tenant_id = str(request.tenant_id)
finding_ids = request.data.get("finding_ids", [])
# Immediately mute the selected findings
Finding.all_objects.filter(
id__in=finding_ids, tenant_id=tenant_id, muted=False
).update(
muted=True,
muted_at=mute_rule.inserted_at,
muted_reason=mute_rule.reason,
)
# Launch background task for historical muting + reaggregation
transaction.on_commit(
lambda: chain(
mute_historical_findings_task.si(
tenant_id=tenant_id,
mute_rule_id=str(mute_rule.id),
),
reaggregate_all_finding_group_summaries_task.si(
tenant_id=tenant_id,
),
).apply_async()
lambda: mute_findings_in_latest_scans_task.apply_async(
kwargs={
"tenant_id": tenant_id,
"mute_rule_id": str(mute_rule.id),
"provider_ids": [str(provider_id) for provider_id in provider_ids],
}
)
)
# Return the created mute rule
serializer = self.get_serializer(mute_rule)
return Response(
data=serializer.data,
+86 -45
View File
@@ -1,63 +1,104 @@
from collections.abc import Iterable
from api.db_utils import rls_transaction
from api.models import Finding, MuteRule
from api.models import Finding, MuteRule, Scan, StateChoices
from celery.utils.log import get_task_logger
from config.django.base import DJANGO_FINDINGS_BATCH_SIZE
from tasks.utils import batched
logger = get_task_logger(__name__)
def mute_historical_findings(tenant_id: str, mute_rule_id: str):
"""
Mute historical findings that match the given mute rule.
def _mute_findings_for_rule(
*,
tenant_id: str,
scan_id: str,
finding_uids: Iterable[str],
muted_at,
muted_reason: str,
) -> int:
finding_uids = list(finding_uids)
if not finding_uids:
return 0
This function processes findings in batches, updating their muted status
and adding the mute reason.
return Finding.all_objects.filter(
tenant_id=tenant_id,
scan_id=scan_id,
uid__in=finding_uids,
muted=False,
).update(
muted=True,
muted_at=muted_at,
muted_reason=muted_reason,
)
Args:
tenant_id (str): The tenant ID for RLS context
mute_rule_id (str): The ID of the mute rule to apply
Returns:
dict: Summary of the muting operation with findings_muted count
"""
findings_muted_count = 0
def mute_findings_in_latest_scans(
tenant_id: str, mute_rule_id: str, provider_ids: list[str]
) -> dict:
"""Apply a mute rule to the latest completed scan of each provider."""
provider_ids = list(dict.fromkeys(provider_ids))
# Get the list of UIDs to mute and the reason
with rls_transaction(tenant_id):
mute_rule = MuteRule.objects.get(id=mute_rule_id, tenant_id=tenant_id)
finding_uids = mute_rule.finding_uids
mute_reason = mute_rule.reason
muted_at = mute_rule.inserted_at
# Query findings that match the UIDs and are not already muted
with rls_transaction(tenant_id):
findings_to_mute = Finding.objects.filter(
tenant_id=tenant_id, uid__in=finding_uids, muted=False
)
total_findings = findings_to_mute.count()
logger.info(
f"Processing {total_findings} findings for mute rule {mute_rule_id}"
latest_scans = list(
Scan.objects.filter(
tenant_id=tenant_id,
provider_id__in=provider_ids,
state=StateChoices.COMPLETED,
completed_at__isnull=False,
)
.order_by("provider_id", "-completed_at", "-inserted_at", "-id")
.distinct("provider_id")
.values_list("id", flat=True)
)
if total_findings > 0:
for batch, is_last in batched(
findings_to_mute.iterator(), DJANGO_FINDINGS_BATCH_SIZE
):
batch_ids = [f.id for f in batch]
updated_count = Finding.all_objects.filter(
id__in=batch_ids, tenant_id=tenant_id
).update(
muted=True,
muted_at=muted_at,
muted_reason=mute_reason,
)
findings_muted_count += updated_count
logger.info(f"Muted {findings_muted_count} findings for rule {mute_rule_id}")
changed_scan_ids = []
findings_muted = 0
for scan_id in latest_scans:
updated = _mute_findings_for_rule(
tenant_id=tenant_id,
scan_id=str(scan_id),
finding_uids=mute_rule.finding_uids,
muted_at=mute_rule.inserted_at,
muted_reason=mute_rule.reason,
)
if updated:
findings_muted += updated
changed_scan_ids.append(str(scan_id))
logger.info(
"Muted %d findings in %d latest scans for rule %s",
findings_muted,
len(changed_scan_ids),
mute_rule_id,
)
return {
"findings_muted": findings_muted_count,
"findings_muted": findings_muted,
"rule_id": mute_rule_id,
"scan_ids": changed_scan_ids,
}
def reconcile_scan_mute_rules(tenant_id: str, scan_id: str) -> dict:
"""Apply the current enabled mute rules to one completed scan."""
findings_muted = 0
with rls_transaction(tenant_id):
mute_rules = MuteRule.objects.filter(tenant_id=tenant_id, enabled=True).values(
"finding_uids", "reason", "inserted_at"
)
for mute_rule in mute_rules:
findings_muted += _mute_findings_for_rule(
tenant_id=tenant_id,
scan_id=scan_id,
finding_uids=mute_rule["finding_uids"],
muted_at=mute_rule["inserted_at"],
muted_reason=mute_rule["reason"],
)
logger.info(
"Reconciled mute rules for scan %s; muted %d findings",
scan_id,
findings_muted,
)
return {"findings_muted": findings_muted, "scan_id": str(scan_id)}
+45 -99
View File
@@ -73,7 +73,10 @@ from tasks.jobs.lighthouse_providers import (
check_lighthouse_provider_connection,
refresh_lighthouse_provider_models,
)
from tasks.jobs.muting import mute_historical_findings
from tasks.jobs.muting import (
mute_findings_in_latest_scans,
reconcile_scan_mute_rules,
)
from tasks.jobs.orphan_recovery import reconcile_orphans
from tasks.jobs.report import (
STALE_TMP_OUTPUT_MAX_AGE_HOURS,
@@ -526,6 +529,7 @@ def perform_scan_task(
provider_id=provider_id,
checks_to_execute=checks_to_execute,
)
reconcile_scan_mute_rules(tenant_id, scan_id)
_perform_scan_complete_tasks(tenant_id, scan_id, provider_id)
return result
finally:
@@ -635,6 +639,7 @@ def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str):
scan_id=str(scan_instance.id),
provider_id=provider_id,
)
reconcile_scan_mute_rules(tenant_id, str(scan_instance.id))
_perform_scan_complete_tasks(tenant_id, str(scan_instance.id), provider_id)
return result
finally:
@@ -1188,85 +1193,48 @@ def aggregate_finding_group_summaries_task(tenant_id: str, scan_id: str):
return aggregate_finding_group_summaries(tenant_id=tenant_id, scan_id=scan_id)
@shared_task(
base=RLSTask, name="reaggregate-all-finding-group-summaries", queue="overview"
)
@set_tenant(keep_tenant=True)
def reaggregate_all_finding_group_summaries_task(tenant_id: str):
"""Reaggregate every pre-aggregated summary table for this tenant.
def _dispatch_scan_summary_reaggregation(tenant_id: str, scan_ids: list[str]) -> None:
if not scan_ids:
return
Mirrors the unbounded scope of `mute_historical_findings_task`: that task
rewrites every Finding row whose UID matches a mute rule, with no time
limit. To keep the pre-aggregated tables consistent with that update,
this task re-runs the same per-scan aggregation pipeline that scan
completion runs on the latest completed scan of every (provider, day)
pair, rebuilding the tables that power the read endpoints:
- `ScanSummary` and `DailySeveritySummary` -> `/overviews/findings`,
`/overviews/findings-severity`, `/overviews/services`.
- `FindingGroupDailySummary` -> `/finding-groups` and
`/finding-groups/latest`.
- `ScanGroupSummary` -> `/overviews/resource-groups` (resource
inventory).
- `ScanCategorySummary` -> `/overviews/categories`.
- `AttackSurfaceOverview` -> `/overviews/attack-surfaces`.
Per-scan pipelines are dispatched in parallel via a Celery group so
wallclock scales with the worker pool.
"""
completed_scans = list(
Scan.objects.filter(
tenant_id=tenant_id,
state=StateChoices.COMPLETED,
completed_at__isnull=False,
)
.order_by("-completed_at")
.values("id", "completed_at", "provider_id")
logger.info(
"Reaggregating overview/finding summaries for %d latest scans",
len(scan_ids),
)
# Keep the latest scan per (provider, day) pair so the daily summary row
# the aggregator writes is the most recent snapshot of that day for that
# provider. Iterating from most recent to oldest means the first scan we
# see for a given key wins.
latest_scans: dict[tuple, str] = {}
for scan in completed_scans:
key = (scan["provider_id"], scan["completed_at"].date())
if key not in latest_scans:
latest_scans[key] = str(scan["id"])
scan_ids = list(latest_scans.values())
if scan_ids:
logger.info(
"Reaggregating overview/finding summaries for %d scans (provider x day)",
len(scan_ids),
)
# DailySeveritySummary reads from ScanSummary, so ScanSummary must be
# recomputed first; the other aggregators read Finding directly and
# can run in parallel with the severity step.
group(
chain(
perform_scan_summary_task.si(tenant_id=tenant_id, scan_id=scan_id),
group(
aggregate_daily_severity_task.si(
tenant_id=tenant_id, scan_id=scan_id
),
aggregate_finding_group_summaries_task.si(
tenant_id=tenant_id, scan_id=scan_id
),
aggregate_scan_resource_group_summaries_task.si(
tenant_id=tenant_id, scan_id=scan_id
),
aggregate_scan_category_summaries_task.si(
tenant_id=tenant_id, scan_id=scan_id
),
aggregate_attack_surface_task.si(
tenant_id=tenant_id, scan_id=scan_id
),
group(
chain(
perform_scan_summary_task.si(tenant_id=tenant_id, scan_id=scan_id),
group(
aggregate_daily_severity_task.si(tenant_id=tenant_id, scan_id=scan_id),
aggregate_finding_group_summaries_task.si(
tenant_id=tenant_id, scan_id=scan_id
),
)
for scan_id in scan_ids
).apply_async()
return {"scans_reaggregated": len(scan_ids)}
aggregate_scan_resource_group_summaries_task.si(
tenant_id=tenant_id, scan_id=scan_id
),
aggregate_scan_category_summaries_task.si(
tenant_id=tenant_id, scan_id=scan_id
),
aggregate_attack_surface_task.si(tenant_id=tenant_id, scan_id=scan_id),
),
)
for scan_id in scan_ids
).apply_async()
@shared_task(base=RLSTask, name="findings-mute-latest-scans", queue="overview")
@set_tenant(keep_tenant=True)
def mute_findings_in_latest_scans_task(
tenant_id: str, mute_rule_id: str, provider_ids: list[str]
):
"""Apply a mute rule to current scans and rebuild only changed summaries."""
result = mute_findings_in_latest_scans(
tenant_id=tenant_id,
mute_rule_id=mute_rule_id,
provider_ids=provider_ids,
)
_dispatch_scan_summary_reaggregation(tenant_id, result["scan_ids"])
return result
@shared_task(base=RLSTask, name="lighthouse-connection-check")
@@ -1467,25 +1435,3 @@ def generate_compliance_reports_task(tenant_id: str, scan_id: str, provider_id:
generate_csa=True,
generate_cis=True,
)
@shared_task(name="findings-mute-historical")
def mute_historical_findings_task(tenant_id: str, mute_rule_id: str):
"""
Background task to mute all historical findings matching a mute rule.
This task processes findings in batches to avoid memory issues with large datasets.
It updates the Finding.muted, Finding.muted_at, and Finding.muted_reason fields
for all findings whose UID is in the mute rule's finding_uids list.
Args:
tenant_id (str): The tenant ID for RLS context.
mute_rule_id (str): The primary key of the MuteRule to apply.
Returns:
dict: A dictionary containing:
- 'findings_muted' (int): Total number of findings muted.
- 'rule_id' (str): The mute rule ID.
- 'status' (str): Final status ('completed').
"""
return mute_historical_findings(tenant_id, mute_rule_id)
+176 -502
View File
@@ -1,531 +1,205 @@
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from uuid import uuid4
import pytest
from api.models import Finding, MuteRule
from django.core.exceptions import ObjectDoesNotExist
from api.models import Finding, MuteRule, Scan, StateChoices
from prowler.lib.check.models import Severity
from prowler.lib.outputs.finding import Status
from tasks.jobs.muting import mute_historical_findings
from tasks.jobs.muting import (
mute_findings_in_latest_scans,
reconcile_scan_mute_rules,
)
def _create_finding(scan: Scan, uid: str) -> Finding:
return Finding.objects.create(
tenant_id=scan.tenant_id,
uid=uid,
scan=scan,
status=Status.FAIL,
status_extended="Test finding",
impact=Severity.high,
severity=Severity.high,
raw_result={},
check_id="test_check",
check_metadata={"CheckId": "test_check"},
muted=False,
)
def _create_mute_rule(tenant_id, user, finding_uids, *, enabled=True) -> MuteRule:
return MuteRule.objects.create(
tenant_id=tenant_id,
name=f"Mute rule {uuid4()}",
reason="Approved exception",
enabled=enabled,
created_by=user,
finding_uids=finding_uids,
)
@pytest.mark.django_db
class TestMuteHistoricalFindings:
"""
Test suite for the mute_historical_findings function.
class TestMuteFindingsInLatestScans:
def test_mutes_latest_scan_and_leaves_older_scan_unchanged(
self, scans_fixture, create_test_user
):
latest_scan = scans_fixture[0]
older_scan = Scan.objects.create(
tenant_id=latest_scan.tenant_id,
provider=latest_scan.provider,
name="Older scan",
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
started_at=datetime.now(UTC) - timedelta(days=1),
completed_at=datetime.now(UTC) - timedelta(days=1),
)
uid = "latest-scan-only"
older_finding = _create_finding(older_scan, uid)
latest_finding = _create_finding(latest_scan, uid)
mute_rule = _create_mute_rule(latest_scan.tenant_id, create_test_user, [uid])
This class tests the batch processing of findings to update their muted status
based on MuteRule criteria.
"""
result = mute_findings_in_latest_scans(
str(latest_scan.tenant_id),
str(mute_rule.id),
[str(latest_scan.provider_id)],
)
@pytest.fixture(scope="function")
def test_user(self, create_test_user):
"""Create a test user for mute rule creation."""
return create_test_user
older_finding.refresh_from_db()
latest_finding.refresh_from_db()
assert older_finding.muted is False
assert latest_finding.muted is True
assert latest_finding.muted_at == mute_rule.inserted_at
assert latest_finding.muted_reason == mute_rule.reason
assert result == {
"findings_muted": 1,
"rule_id": str(mute_rule.id),
"scan_ids": [str(latest_scan.id)],
}
@pytest.fixture(scope="function")
def mute_rule_with_findings(self, tenants_fixture, findings_fixture, test_user):
"""
Create a mute rule that targets the first finding in the fixture.
"""
def test_mutes_one_latest_scan_per_provider(self, scans_fixture, create_test_user):
first_scan, second_scan, _ = scans_fixture
uid = "shared-selected-uid"
first_finding = _create_finding(first_scan, uid)
second_finding = _create_finding(second_scan, uid)
mute_rule = _create_mute_rule(first_scan.tenant_id, create_test_user, [uid])
result = mute_findings_in_latest_scans(
str(first_scan.tenant_id),
str(mute_rule.id),
[str(first_scan.provider_id), str(second_scan.provider_id)],
)
first_finding.refresh_from_db()
second_finding.refresh_from_db()
assert first_finding.muted is True
assert second_finding.muted is True
assert result["findings_muted"] == 2
assert set(result["scan_ids"]) == {str(first_scan.id), str(second_scan.id)}
def test_provider_without_completed_scan_does_nothing(
self, tenants_fixture, provider_factory, create_test_user
):
tenant = tenants_fixture[0]
finding = findings_fixture[0]
mute_rule = MuteRule.objects.create(
tenant_id=tenant.id,
name="Test Mute Rule",
reason="Testing mute functionality",
enabled=True,
created_by=test_user,
finding_uids=[finding.uid],
provider = provider_factory()
mute_rule = _create_mute_rule(
tenant.id, create_test_user, ["future-scan-finding"]
)
return mute_rule
result = mute_findings_in_latest_scans(
str(tenant.id), str(mute_rule.id), [str(provider.id)]
)
@pytest.fixture(scope="function")
def mute_rule_multiple_findings(self, scans_fixture, test_user):
"""
Create multiple unmuted findings and a mute rule targeting all of them.
"""
assert result == {
"findings_muted": 0,
"rule_id": str(mute_rule.id),
"scan_ids": [],
}
def test_retry_does_not_report_changed_scans_twice(
self, scans_fixture, create_test_user
):
scan = scans_fixture[0]
tenant_id = scan.tenant_id
# Create 5 unmuted findings
finding_uids = []
for i in range(5):
finding = Finding.objects.create(
tenant_id=tenant_id,
uid=f"test_finding_uid_mute_{i}",
scan=scan,
status=Status.FAIL,
status_extended=f"Test status {i}",
impact=Severity.high,
severity=Severity.high,
raw_result={
"status": Status.FAIL,
"impact": Severity.high,
"severity": Severity.high,
},
check_id=f"test_check_id_{i}",
check_metadata={
"CheckId": f"test_check_id_{i}",
"Description": f"Test description {i}",
},
muted=False,
)
finding_uids.append(finding.uid)
# Create mute rule targeting all findings
mute_rule = MuteRule.objects.create(
tenant_id=tenant_id,
name="Test Multiple Findings Mute Rule",
reason="Testing batch muting",
enabled=True,
created_by=test_user,
finding_uids=finding_uids,
finding = _create_finding(scan, "idempotent-mute")
mute_rule = _create_mute_rule(scan.tenant_id, create_test_user, [finding.uid])
args = (
str(scan.tenant_id),
str(mute_rule.id),
[str(scan.provider_id)],
)
return mute_rule, finding_uids
first_result = mute_findings_in_latest_scans(*args)
second_result = mute_findings_in_latest_scans(*args)
@pytest.fixture(scope="function")
def mute_rule_already_muted(self, findings_fixture, test_user):
"""
Create a mute rule that targets an already-muted finding.
"""
tenant_id = findings_fixture[1].tenant_id
already_muted_finding = findings_fixture[1]
assert first_result["scan_ids"] == [str(scan.id)]
assert second_result["findings_muted"] == 0
assert second_result["scan_ids"] == []
mute_rule = MuteRule.objects.create(
tenant_id=tenant_id,
name="Test Already Muted Rule",
reason="Testing already muted findings",
enabled=True,
created_by=test_user,
finding_uids=[already_muted_finding.uid],
def test_does_not_cross_tenant_boundary(
self, tenants_fixture, provider_factory, create_test_user
):
tenant = tenants_fixture[0]
other_tenant = tenants_fixture[2]
other_provider = provider_factory(tenant=other_tenant)
other_scan = Scan.objects.create(
tenant_id=other_tenant.id,
provider=other_provider,
name="Other tenant scan",
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
started_at=datetime.now(UTC),
completed_at=datetime.now(UTC),
)
other_finding = _create_finding(other_scan, "tenant-isolated-uid")
mute_rule = _create_mute_rule(tenant.id, create_test_user, [other_finding.uid])
result = mute_findings_in_latest_scans(
str(tenant.id), str(mute_rule.id), [str(other_provider.id)]
)
return mute_rule
other_finding.refresh_from_db()
assert other_finding.muted is False
assert result["scan_ids"] == []
@pytest.fixture(scope="function")
def mute_rule_mixed_findings(self, scans_fixture, test_user):
"""
Create a mute rule with a mix of muted and unmuted findings.
"""
def test_nonexistent_rule_raises(self, tenants_fixture):
with pytest.raises(MuteRule.DoesNotExist):
mute_findings_in_latest_scans(str(tenants_fixture[0].id), str(uuid4()), [])
@pytest.mark.django_db
class TestReconcileScanMuteRules:
def test_applies_only_enabled_rules_to_requested_scan(
self, scans_fixture, create_test_user
):
scan = scans_fixture[0]
tenant_id = scan.tenant_id
# Create 3 unmuted findings
unmuted_uids = []
for i in range(3):
finding = Finding.objects.create(
tenant_id=tenant_id,
uid=f"unmuted_finding_{i}",
scan=scan,
status=Status.FAIL,
status_extended=f"Unmuted status {i}",
impact=Severity.medium,
severity=Severity.medium,
raw_result={
"status": Status.FAIL,
"impact": Severity.medium,
"severity": Severity.medium,
},
check_id=f"unmuted_check_{i}",
check_metadata={
"CheckId": f"unmuted_check_{i}",
"Description": f"Unmuted description {i}",
},
muted=False,
)
unmuted_uids.append(finding.uid)
# Create 2 already muted findings
muted_uids = []
for i in range(2):
finding = Finding.objects.create(
tenant_id=tenant_id,
uid=f"muted_finding_{i}",
scan=scan,
status=Status.FAIL,
status_extended=f"Muted status {i}",
impact=Severity.low,
severity=Severity.low,
raw_result={
"status": Status.FAIL,
"impact": Severity.low,
"severity": Severity.low,
},
check_id=f"muted_check_{i}",
check_metadata={
"CheckId": f"muted_check_{i}",
"Description": f"Muted description {i}",
},
muted=True,
muted_at=datetime.now(UTC),
muted_reason="Already muted",
)
muted_uids.append(finding.uid)
# Create mute rule targeting all findings
all_uids = unmuted_uids + muted_uids
mute_rule = MuteRule.objects.create(
tenant_id=tenant_id,
name="Test Mixed Findings Rule",
reason="Testing mixed muted/unmuted findings",
enabled=True,
created_by=test_user,
finding_uids=all_uids,
active_finding = _create_finding(scan, "active-rule-uid")
disabled_finding = _create_finding(scan, "disabled-rule-uid")
active_rule = _create_mute_rule(
scan.tenant_id, create_test_user, [active_finding.uid]
)
return mute_rule, unmuted_uids, muted_uids
@pytest.fixture(scope="function")
def mute_rule_batch_test(self, scans_fixture, test_user):
"""
Create enough findings to test batch processing (>1000 for default batch size).
"""
scan = scans_fixture[0]
tenant_id = scan.tenant_id
# Create 1500 findings to exceed default batch size of 1000
finding_uids = []
for i in range(1500):
finding = Finding.objects.create(
tenant_id=tenant_id,
uid=f"batch_test_finding_{i}",
scan=scan,
status=Status.FAIL,
status_extended=f"Batch test status {i}",
impact=Severity.critical,
severity=Severity.critical,
raw_result={
"status": Status.FAIL,
"impact": Severity.critical,
"severity": Severity.critical,
},
check_id=f"batch_test_check_{i}",
check_metadata={
"CheckId": f"batch_test_check_{i}",
"Description": f"Batch test description {i}",
},
muted=False,
)
finding_uids.append(finding.uid)
# Create mute rule targeting all findings
mute_rule = MuteRule.objects.create(
tenant_id=tenant_id,
name="Test Batch Processing Rule",
reason="Testing batch processing functionality",
enabled=True,
created_by=test_user,
finding_uids=finding_uids,
_create_mute_rule(
scan.tenant_id,
create_test_user,
[disabled_finding.uid],
enabled=False,
)
return mute_rule, finding_uids
def test_mute_historical_findings_single_finding(
self, mute_rule_with_findings, findings_fixture
):
"""
Test muting a single historical finding.
"""
mute_rule = mute_rule_with_findings
tenant_id = str(mute_rule.tenant_id)
finding = findings_fixture[0]
# Ensure the finding is not muted before execution
finding.refresh_from_db()
assert finding.muted is False
assert finding.muted_at is None
assert finding.muted_reason is None
# Execute the muting function
result = mute_historical_findings(tenant_id, str(mute_rule.id))
# Verify return value
assert result["findings_muted"] == 1
assert result["rule_id"] == str(mute_rule.id)
# Verify the finding was muted
finding.refresh_from_db()
assert finding.muted is True
assert finding.muted_at == mute_rule.inserted_at
assert finding.muted_reason == mute_rule.reason
def test_mute_historical_findings_multiple_findings(
self, mute_rule_multiple_findings
):
"""
Test muting multiple historical findings.
"""
mute_rule, finding_uids = mute_rule_multiple_findings
tenant_id = str(mute_rule.tenant_id)
# Verify all findings are unmuted
findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=finding_uids)
assert findings.count() == 5
for finding in findings:
assert finding.muted is False
# Execute the muting function
result = mute_historical_findings(tenant_id, str(mute_rule.id))
# Verify return value
assert result["findings_muted"] == 5
assert result["rule_id"] == str(mute_rule.id)
# Verify all findings were muted
findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=finding_uids)
for finding in findings:
assert finding.muted is True
assert finding.muted_at == mute_rule.inserted_at
assert finding.muted_reason == mute_rule.reason
def test_mute_historical_findings_already_muted(
self, mute_rule_already_muted, findings_fixture
):
"""
Test that already-muted findings are not counted or updated.
"""
mute_rule = mute_rule_already_muted
tenant_id = str(mute_rule.tenant_id)
finding = findings_fixture[1]
# Verify the finding is already muted
finding.refresh_from_db()
assert finding.muted is True
original_muted_at = finding.muted_at
original_muted_reason = finding.muted_reason
# Execute the muting function
result = mute_historical_findings(tenant_id, str(mute_rule.id))
# Verify no findings were muted
assert result["findings_muted"] == 0
assert result["rule_id"] == str(mute_rule.id)
# Verify the finding's mute status did not change
finding.refresh_from_db()
assert finding.muted is True
assert finding.muted_at == original_muted_at
assert finding.muted_reason == original_muted_reason
def test_mute_historical_findings_mixed_status(self, mute_rule_mixed_findings):
"""
Test muting when some findings are already muted and others are not.
"""
mute_rule, unmuted_uids, muted_uids = mute_rule_mixed_findings
tenant_id = str(mute_rule.tenant_id)
# Execute the muting function
result = mute_historical_findings(tenant_id, str(mute_rule.id))
# Verify only unmuted findings were counted
assert result["findings_muted"] == 3
assert result["rule_id"] == str(mute_rule.id)
# Verify unmuted findings are now muted
unmuted_findings = Finding.objects.filter(
tenant_id=tenant_id, uid__in=unmuted_uids
older_scan = Scan.objects.create(
tenant_id=scan.tenant_id,
provider=scan.provider,
name="Older matching scan",
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
started_at=datetime.now(UTC) - timedelta(days=1),
completed_at=datetime.now(UTC) - timedelta(days=1),
)
for finding in unmuted_findings:
assert finding.muted is True
assert finding.muted_at == mute_rule.inserted_at
assert finding.muted_reason == mute_rule.reason
older_finding = _create_finding(older_scan, active_finding.uid)
# Verify already-muted findings remained unchanged
already_muted_findings = Finding.objects.filter(
tenant_id=tenant_id, uid__in=muted_uids
)
for finding in already_muted_findings:
assert finding.muted is True
assert finding.muted_reason == "Already muted"
result = reconcile_scan_mute_rules(str(scan.tenant_id), str(scan.id))
def test_mute_historical_findings_nonexistent_rule(self, tenants_fixture):
"""
Test that a nonexistent mute rule raises ObjectDoesNotExist.
"""
tenant_id = str(tenants_fixture[0].id)
nonexistent_rule_id = str(uuid4())
with pytest.raises(ObjectDoesNotExist):
mute_historical_findings(tenant_id, nonexistent_rule_id)
def test_mute_historical_findings_no_matching_findings(
self, tenants_fixture, test_user
):
"""
Test muting when no findings match the rule's UIDs.
"""
tenant_id = str(tenants_fixture[0].id)
# Create a mute rule with non-existent finding UIDs
mute_rule = MuteRule.objects.create(
tenant_id=tenant_id,
name="Test No Match Rule",
reason="Testing no matching findings",
enabled=True,
created_by=test_user,
finding_uids=[
"nonexistent_uid_1",
"nonexistent_uid_2",
"nonexistent_uid_3",
],
)
# Execute the muting function
result = mute_historical_findings(tenant_id, str(mute_rule.id))
# Verify no findings were muted
assert result["findings_muted"] == 0
assert result["rule_id"] == str(mute_rule.id)
def test_mute_historical_findings_batch_processing(self, mute_rule_batch_test):
"""
Test that large numbers of findings are processed in batches correctly.
"""
mute_rule, finding_uids = mute_rule_batch_test
tenant_id = str(mute_rule.tenant_id)
# Verify all findings exist and are unmuted
findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=finding_uids)
assert findings.count() == 1500
for finding in findings:
assert finding.muted is False
# Execute the muting function
result = mute_historical_findings(tenant_id, str(mute_rule.id))
# Verify return value
assert result["findings_muted"] == 1500
assert result["rule_id"] == str(mute_rule.id)
# Verify all findings were muted
findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=finding_uids)
for finding in findings:
assert finding.muted is True
assert finding.muted_at == mute_rule.inserted_at
assert finding.muted_reason == mute_rule.reason
def test_mute_historical_findings_preserves_muted_at_timestamp(
self, mute_rule_with_findings, findings_fixture
):
"""
Test that muted_at is set to the rule's inserted_at, not the current time.
"""
mute_rule = mute_rule_with_findings
tenant_id = str(mute_rule.tenant_id)
finding = findings_fixture[0]
# Execute the muting function
result = mute_historical_findings(tenant_id, str(mute_rule.id))
# Verify the finding was muted
assert result["findings_muted"] == 1
# Verify muted_at matches the rule's inserted_at timestamp
finding.refresh_from_db()
assert finding.muted_at == mute_rule.inserted_at
assert finding.muted_at is not None
def test_mute_historical_findings_partial_match(self, scans_fixture, test_user):
"""
Test muting when only some of the rule's UIDs exist as findings.
"""
scan = scans_fixture[0]
tenant_id = str(scan.tenant_id)
# Create 3 findings
existing_uids = []
for i in range(3):
finding = Finding.objects.create(
tenant_id=tenant_id,
uid=f"partial_match_finding_{i}",
scan=scan,
status=Status.FAIL,
status_extended=f"Partial match status {i}",
impact=Severity.high,
severity=Severity.high,
raw_result={
"status": Status.FAIL,
"impact": Severity.high,
"severity": Severity.high,
},
check_id=f"partial_match_check_{i}",
check_metadata={
"CheckId": f"partial_match_check_{i}",
"Description": f"Partial match description {i}",
},
muted=False,
)
existing_uids.append(finding.uid)
# Create a mute rule with both existing and non-existing UIDs
all_uids = existing_uids + [
"nonexistent_uid_1",
"nonexistent_uid_2",
]
mute_rule = MuteRule.objects.create(
tenant_id=tenant_id,
name="Test Partial Match Rule",
reason="Testing partial matching",
enabled=True,
created_by=test_user,
finding_uids=all_uids,
)
# Execute the muting function
result = mute_historical_findings(tenant_id, str(mute_rule.id))
# Verify only existing findings were muted
assert result["findings_muted"] == 3
assert result["rule_id"] == str(mute_rule.id)
# Verify the existing findings were muted
findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=existing_uids)
assert findings.count() == 3
for finding in findings:
assert finding.muted is True
assert finding.muted_at == mute_rule.inserted_at
assert finding.muted_reason == mute_rule.reason
def test_mute_historical_findings_empty_uids(self, tenants_fixture, test_user):
"""
Test muting when the rule has an empty finding_uids array.
"""
tenant_id = str(tenants_fixture[0].id)
# Create a mute rule with empty finding_uids
mute_rule = MuteRule.objects.create(
tenant_id=tenant_id,
name="Test Empty UIDs Rule",
reason="Testing empty UIDs",
enabled=True,
created_by=test_user,
finding_uids=[],
)
# Execute the muting function
result = mute_historical_findings(tenant_id, str(mute_rule.id))
# Verify no findings were muted
assert result["findings_muted"] == 0
assert result["rule_id"] == str(mute_rule.id)
def test_mute_historical_findings_return_format(self, mute_rule_with_findings):
"""
Test that the return value has the correct format and fields.
"""
mute_rule = mute_rule_with_findings
tenant_id = str(mute_rule.tenant_id)
result = mute_historical_findings(tenant_id, str(mute_rule.id))
# Verify return value structure
assert isinstance(result, dict)
assert "findings_muted" in result
assert "rule_id" in result
assert isinstance(result["findings_muted"], int)
assert isinstance(result["rule_id"], str)
assert result["rule_id"] == str(mute_rule.id)
active_finding.refresh_from_db()
disabled_finding.refresh_from_db()
older_finding.refresh_from_db()
assert active_finding.muted is True
assert active_finding.muted_at == active_rule.inserted_at
assert disabled_finding.muted is False
assert older_finding.muted is False
assert result == {"findings_muted": 1, "scan_id": str(scan.id)}
+69 -124
View File
@@ -1,6 +1,6 @@
import uuid
from contextlib import contextmanager
from datetime import UTC, datetime, timedelta
from datetime import UTC, datetime
from unittest.mock import MagicMock, patch
import httpx
@@ -33,10 +33,10 @@ from tasks.tasks import (
check_integrations_task,
check_lighthouse_provider_connection_task,
generate_outputs_task,
mute_findings_in_latest_scans_task,
perform_attack_paths_scan_task,
perform_scan_task,
perform_scheduled_scan_task,
reaggregate_all_finding_group_summaries_task,
refresh_lighthouse_provider_models_task,
s3_integration_task,
security_hub_integration_task,
@@ -2959,6 +2959,7 @@ class TestPerformScheduledScanTask:
with (
patch("tasks.tasks.perform_prowler_scan", side_effect=_complete_scan),
patch("tasks.tasks._perform_scan_complete_tasks"),
patch("tasks.tasks.reconcile_scan_mute_rules") as mock_reconcile,
self._override_task_request(perform_scheduled_scan_task, id=task_id),
):
perform_scheduled_scan_task.run(
@@ -2982,6 +2983,13 @@ class TestPerformScheduledScanTask:
).count()
== 1
)
completed_scan = Scan.objects.get(
tenant_id=tenant.id,
provider=provider,
trigger=Scan.TriggerChoices.SCHEDULED,
state=StateChoices.COMPLETED,
)
mock_reconcile.assert_called_once_with(str(tenant.id), str(completed_scan.id))
assert (
Scan.objects.filter(
tenant_id=tenant.id,
@@ -3176,7 +3184,10 @@ class TestPerformScanTask:
task=queued_task,
)
events = []
def _complete_scan(tenant_id, scan_id, provider_id, checks_to_execute=None):
events.append("scan")
scan_instance = Scan.objects.get(id=scan_id)
scan_instance.state = StateChoices.COMPLETED
scan_instance.save()
@@ -3184,7 +3195,14 @@ class TestPerformScanTask:
with (
patch("tasks.tasks.perform_prowler_scan", side_effect=_complete_scan),
patch("tasks.tasks._perform_scan_complete_tasks"),
patch(
"tasks.tasks.reconcile_scan_mute_rules",
side_effect=lambda *_args: events.append("reconcile"),
),
patch(
"tasks.tasks._perform_scan_complete_tasks",
side_effect=lambda *_args: events.append("summaries"),
),
patch("tasks.tasks.perform_scan_task.apply_async") as mock_apply_async,
):
with django_capture_on_commit_callbacks(execute=True):
@@ -3196,6 +3214,7 @@ class TestPerformScanTask:
queued_task_result.refresh_from_db()
assert result == {"status": "ok"}
assert events == ["scan", "reconcile", "summaries"]
assert queued_task_result.status == states.PENDING
mock_apply_async.assert_called_once_with(
kwargs={
@@ -3241,10 +3260,7 @@ class TestPerformScanTask:
@pytest.mark.django_db
class TestReaggregateAllFindingGroupSummaries:
def setup_method(self):
self.tenant_id = str(uuid.uuid4())
class TestMuteFindingsInLatestScansTask:
@patch("tasks.tasks.chain")
@patch("tasks.tasks.group")
@patch("tasks.tasks.aggregate_attack_surface_task")
@@ -3253,10 +3269,10 @@ class TestReaggregateAllFindingGroupSummaries:
@patch("tasks.tasks.aggregate_finding_group_summaries_task")
@patch("tasks.tasks.aggregate_daily_severity_task")
@patch("tasks.tasks.perform_scan_summary_task")
@patch("tasks.tasks.Scan.objects.filter")
def test_dispatches_subtasks_for_each_provider_per_day(
@patch("tasks.tasks.mute_findings_in_latest_scans")
def test_reaggregates_only_changed_scans(
self,
mock_scan_filter,
mock_mute_findings,
mock_scan_summary_task,
mock_daily_severity_task,
mock_finding_group_task,
@@ -3265,119 +3281,36 @@ class TestReaggregateAllFindingGroupSummaries:
mock_attack_surface_task,
mock_group,
mock_chain,
tenants_fixture,
):
provider_id_1 = uuid.uuid4()
provider_id_2 = uuid.uuid4()
scan_id_today_p1 = uuid.uuid4()
scan_id_yesterday_p1 = uuid.uuid4()
scan_id_today_p2 = uuid.uuid4()
today = datetime.now(tz=UTC)
yesterday = today - timedelta(days=1)
mock_outer_group_result = MagicMock()
# The first `group()` call wraps the inner parallel step; subsequent
# calls wrap the outer per-scan generator.
mock_group.side_effect = lambda *args, **kwargs: (
list(args[0]) if args and hasattr(args[0], "__iter__") else None,
mock_outer_group_result,
)[1]
mock_scan_filter.return_value.order_by.return_value.values.return_value = [
{
"id": scan_id_today_p1,
"completed_at": today,
"provider_id": provider_id_1,
},
{
"id": scan_id_today_p2,
"completed_at": today,
"provider_id": provider_id_2,
},
{
"id": scan_id_yesterday_p1,
"completed_at": yesterday,
"provider_id": provider_id_1,
},
]
result = reaggregate_all_finding_group_summaries_task(tenant_id=self.tenant_id)
assert result == {"scans_reaggregated": 3}
expected_scan_ids = {
str(scan_id_today_p1),
str(scan_id_today_p2),
str(scan_id_yesterday_p1),
tenant_id = str(tenants_fixture[0].id)
mute_rule_id = str(uuid.uuid4())
provider_ids = [str(uuid.uuid4()), str(uuid.uuid4())]
scan_ids = [str(uuid.uuid4()), str(uuid.uuid4())]
result = {
"findings_muted": 2,
"rule_id": mute_rule_id,
"scan_ids": scan_ids,
}
for task_mock in (
mock_scan_summary_task,
mock_daily_severity_task,
mock_finding_group_task,
mock_resource_group_task,
mock_category_task,
mock_attack_surface_task,
):
assert task_mock.si.call_count == 3
dispatched = {
call.kwargs["scan_id"] for call in task_mock.si.call_args_list
}
assert dispatched == expected_scan_ids
for call in task_mock.si.call_args_list:
assert call.kwargs["tenant_id"] == self.tenant_id
assert mock_chain.call_count == 3
mock_outer_group_result.apply_async.assert_called_once()
@patch("tasks.tasks.chain")
@patch("tasks.tasks.group")
@patch("tasks.tasks.aggregate_attack_surface_task")
@patch("tasks.tasks.aggregate_scan_category_summaries_task")
@patch("tasks.tasks.aggregate_scan_resource_group_summaries_task")
@patch("tasks.tasks.aggregate_finding_group_summaries_task")
@patch("tasks.tasks.aggregate_daily_severity_task")
@patch("tasks.tasks.perform_scan_summary_task")
@patch("tasks.tasks.Scan.objects.filter")
def test_dedupes_scans_to_latest_per_provider_per_day(
self,
mock_scan_filter,
mock_scan_summary_task,
mock_daily_severity_task,
mock_finding_group_task,
mock_resource_group_task,
mock_category_task,
mock_attack_surface_task,
mock_group,
mock_chain,
):
"""When several scans run on the same day for the same provider, only
the latest one is dispatched (matching the daily summary unique key)."""
provider_id = uuid.uuid4()
latest_scan_today = uuid.uuid4()
earlier_scan_today = uuid.uuid4()
today_late = datetime.now(tz=UTC)
today_early = today_late - timedelta(hours=4)
mock_mute_findings.return_value = result
mock_outer_group_result = MagicMock()
mock_group.side_effect = lambda *args, **kwargs: (
list(args[0]) if args and hasattr(args[0], "__iter__") else None,
mock_outer_group_result,
)[1]
# Returned ordered by `-completed_at`, so the most recent comes first.
mock_scan_filter.return_value.order_by.return_value.values.return_value = [
{
"id": latest_scan_today,
"completed_at": today_late,
"provider_id": provider_id,
},
{
"id": earlier_scan_today,
"completed_at": today_early,
"provider_id": provider_id,
},
]
task_result = mute_findings_in_latest_scans_task(
tenant_id=tenant_id,
mute_rule_id=mute_rule_id,
provider_ids=provider_ids,
)
result = reaggregate_all_finding_group_summaries_task(tenant_id=self.tenant_id)
assert result == {"scans_reaggregated": 1}
assert task_result == result
mock_mute_findings.assert_called_once_with(
tenant_id=tenant_id,
mute_rule_id=mute_rule_id,
provider_ids=provider_ids,
)
for task_mock in (
mock_scan_summary_task,
mock_daily_severity_task,
@@ -3386,23 +3319,35 @@ class TestReaggregateAllFindingGroupSummaries:
mock_category_task,
mock_attack_surface_task,
):
task_mock.si.assert_called_once_with(
tenant_id=self.tenant_id, scan_id=str(latest_scan_today)
)
mock_chain.assert_called_once()
assert task_mock.si.call_count == 2
assert {
call.kwargs["scan_id"] for call in task_mock.si.call_args_list
} == set(scan_ids)
assert mock_chain.call_count == 2
mock_outer_group_result.apply_async.assert_called_once()
@patch("tasks.tasks.chain")
@patch("tasks.tasks.group")
@patch("tasks.tasks.Scan.objects.filter")
def test_no_completed_scans_skips_dispatch(
self, mock_scan_filter, mock_group, mock_chain
@patch("tasks.tasks.mute_findings_in_latest_scans")
def test_skips_reaggregation_when_no_scan_changed(
self, mock_mute_findings, mock_group, mock_chain, tenants_fixture
):
mock_scan_filter.return_value.order_by.return_value.values.return_value = []
tenant_id = str(tenants_fixture[0].id)
mute_rule_id = str(uuid.uuid4())
result = {
"findings_muted": 0,
"rule_id": mute_rule_id,
"scan_ids": [],
}
mock_mute_findings.return_value = result
result = reaggregate_all_finding_group_summaries_task(tenant_id=self.tenant_id)
task_result = mute_findings_in_latest_scans_task(
tenant_id=tenant_id,
mute_rule_id=mute_rule_id,
provider_ids=[],
)
assert result == {"scans_reaggregated": 0}
assert task_result == result
mock_group.assert_not_called()
mock_chain.assert_not_called()
+32 -1
View File
@@ -129,12 +129,42 @@ Each check **must** populate the `report.status` and `report.status_extended` fi
- Status field: `report.status`
- `PASS` – Assigned when the check confirms compliance with the configured value.
- `FAIL` – Assigned when the check detects non-compliance with the configured value.
- `MANUAL` – This status must not be used unless manual verification is necessary to determine whether the status (`report.status`) passes (`PASS`) or fails (`FAIL`).
- `MANUAL` – This status must not be used unless manual verification is necessary to determine whether the status (`report.status`) passes (`PASS`) or fails (`FAIL`). This includes the case where Prowler could not retrieve the data needed to evaluate the resource (see below).
- Status extended field: `report.status_extended`
- It **must** end with a period (`.`).
- It **must** include the audited service, the resource, and a concise explanation of the check result, for instance: `EC2 AMI ami-0123456789 is not public.`.
### Permission and Data-Availability Errors Are Not Findings
A `FAIL` must only be emitted when an insecure condition has actually been detected. A check **must never** report `FAIL` because the underlying API call failed: missing permissions or scopes on the scanning identity, an API that is not enabled, a feature that is not licensed, or data that could not be retrieved are scan-configuration problems, not security issues. Reporting them as `FAIL` surfaces a misleading (and often high-severity) finding to the user and skews compliance scores.
When the service layer cannot obtain the data a check depends on, the check must:
1. Emit a single `MANUAL` finding scoped to the widest affected resource (the tenant, account, project or subscription), not one finding per resource. For example, if user registration details cannot be read, emit one tenant-level `MANUAL` instead of one per user.
2. Explain in `status_extended` that the check could not be evaluated and what to fix, naming the permission, scope, API or license required, for instance: `Cannot evaluate credential exposure for privileged users: unable to query Microsoft Defender XDR Advanced Hunting. Verify that the ThreatHunting.Read.All permission is granted to the scanning application.`
3. Leave the check's severity untouched. Do not override `report.check_metadata.Severity` to hide the problem.
The service layer must make the distinction possible: log the error and expose it to checks in a way that cannot be confused with a legitimate empty result. Common patterns already used in Prowler are:
- Defaulting the attribute to `None` (data could not be read) instead of `[]`/`{}` (data was read and is empty), e.g. the `metric_filters is not None` guard in `prowler/providers/aws/services/cloudwatch/lib/metric_filters.py`.
- Keeping an availability flag raised on any denied listing, e.g. `logs_client.metric_filters_unavailable` consumed by the AWS CloudWatch metric filter checks.
- Keeping an error flag or message next to the data, e.g. `entra_client.user_registration_details_error` in M365 or `*_scan_errors` in AWS Bedrock.
- Keeping a set of resources whose lookup failed, e.g. `accessapproval_client.settings_lookup_failed` in GCP.
Make sure the error branch only captures real access errors. A `404`/not-found response frequently means the feature is simply not configured, which **is** a legitimate `FAIL`; a `403` or an unexpected exception is not. An "API not enabled" error is usually a scan-configuration problem too — **except** when the API's activation is itself the control being audited (e.g. GCP Access Approval: with `accessapproval.googleapis.com` disabled the feature provably cannot be enabled, so a definitive API-disabled state is a legitimate `FAIL`, while an undetermined state stays `MANUAL`).
```python
if <service>_client.<data> is None:
report = CheckReport<Provider>(metadata=self.metadata(), resource={})
report.resource_name = "<Tenant/Account-level resource>"
report.resource_id = "<stable-id>"
report.status = "MANUAL"
report.status_extended = "Cannot evaluate <requirement>: <data> could not be retrieved. Verify that <permission/API/license> is granted to the scanning identity."
findings.append(report)
return findings
```
### Prowler's Check Severity Levels
The severity of each check is defined in the metadata file using the `Severity` field. Severity values are always lowercase and must be one of the predefined categories below.
@@ -437,6 +467,7 @@ The metadata structure is enforced in code using a Pydantic model. For reference
- Use clear, actionable, and user-friendly language in `status_extended` to explain the result. Always provide information to identify the resource.
- Use helper functions/utilities for repeated logic to avoid code duplication. Save them in the `lib` folder of the service.
- Handle exceptions gracefully: catch errors per resource, log them, and continue processing other resources.
- Never report `FAIL` because data could not be retrieved (missing permissions, API not enabled, feature not licensed). Emit a single `MANUAL` finding explaining what is required instead; see [Permission and Data-Availability Errors Are Not Findings](#permission-and-data-availability-errors-are-not-findings).
- Document the check with a class and function level docstring explaining what it does, what it checks, and any caveats or provider-specific behaviors.
- Use type hints for the `execute()` method (e.g., `-> list[CheckReport<Provider>]`) for clarity and static analysis.
- Ensure checks are efficient; avoid excessive nested loops. If the complexity is high, consider refactoring the check.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

@@ -51,6 +51,7 @@ The following list includes all the AWS checks with configurable variables that
| `cloudtrail_threat_detection_privilege_escalation` | `threat_detection_privilege_escalation_actions` | List of Strings | See `config.yaml` |
| `cloudtrail_threat_detection_privilege_escalation` | `threat_detection_privilege_escalation_minutes` | Integer | `1440` |
| `cloudtrail_threat_detection_privilege_escalation` | `threat_detection_privilege_escalation_threshold` | Float | `0.2` |
| `cloudwatch_log_group_agentcore_data_protection_policy_enabled` | `agentcore_log_group_name_prefixes` | List of Strings | See `config.yaml` |
| `cloudwatch_log_group_no_secrets_in_logs` | `secrets_ignore_patterns` | List of Strings | `[]` |
| `cloudwatch_log_group_retention_policy_specific_days_enabled` | `log_group_retention_days` | Integer | `365` |
| `codebuild_project_no_secrets_in_variables` | `excluded_sensitive_environment_variables` | List of Strings | `[]` |
@@ -306,9 +306,21 @@ prowler image --registry internal-registry.local --registry-insecure
```
<Warning>
Skipping TLS verification disables certificate validation for registry connections. Use this flag only for trusted internal registries with self-signed certificates.
Skipping TLS verification disables certificate validation for registry connections, including the Trivy image pull (`TRIVY_INSECURE`). Use this flag only for trusted internal registries with self-signed certificates.
</Warning>
#### On-Premises Registries and Private Networks
<VersionBadge version="5.42.0" />
By default, Prowler rejects registry-provided URLs (token endpoints, pagination links) that resolve to non-public addresses, as an SSRF defense. On-premises registries live on private networks by definition, so to scan them declare the trusted ranges explicitly:
```bash
export PROWLER_IMAGE_PROVIDER_ALLOWED_PRIVATE_NETWORKS="192.168.65.254/32,10.20.0.0/16"
```
The value is a comma-separated list of IPs and CIDRs. A resolved address inside an allowlisted range is permitted; every other non-public address stays blocked, so link-local (`169.254.169.254`), loopback, and the rest of the internal network remain protected. The variable applies to registry enumeration and to the connection test. Malformed entries fail at startup, and a non-empty allowlist is logged as a relaxed security control. When unset, behavior is unchanged: only public addresses are followed.
#### Supported Registries
Registry Scan Mode supports the following registry types:
@@ -58,12 +58,14 @@ Two of these read more broadly than they behave, and both are worth understandin
On the consent screen, `chat:write.public` reads as permission to post in any public channel. Prowler never uses it that way: **Prowler only ever posts to the channels authorized on the integration.** The scope exists so that authorizing a public channel does not also require someone to invite the Prowler app to it first.
{/* The Prowler UI deep-links to this heading's anchor, so rewording the heading breaks that link. */}
### Why a Private Channel Is Missing From the Channel List
`groups:read` reveals only the private channels the Prowler app is already a member of. A private channel therefore appears in the channel list only after someone invites `@Prowler` to it in Slack:
`groups:read` reveals only the private channels the Prowler app is already a member of. A private channel therefore appears in the channel list only after someone invites `@Prowler Cloud` to it in Slack:
```text
/invite @Prowler
/invite @Prowler Cloud
```
That invite is issued in Slack, by that channel's own members, and **the invite itself is the permission grant** — no scope bypasses it. Prowler ships no in-product flow to get the app invited, because the decision belongs to the channel's members. After inviting the app, click **Refresh channels** to re-read the list.
@@ -100,14 +102,14 @@ Prowler posts to the channels authorized on the integration. Several channels ca
![Destination channels selection listing public channels and an invited private channel marked Private](/images/prowler-app/slack/channel-picker.png)
2. Select one or more channels. A selected private channel keeps its lock and **Private** identification with the list closed, so the authorized set stays readable at a glance.
2. Select one or more channels. A selected private channel keeps the same **Private** marking with the list closed, so the authorized set stays readable at a glance.
3. Click **Save channels**.
Prowler validates the selection against Slack and derives each channel name itself, so a recorded name can never drift from the channel it belongs to. Once the set is saved, the page reports where Prowler posts and runs the connection check over it.
If the selection reports that no channels are available, the workspace exposes nothing Prowler can see. Create a public channel, or invite `@Prowler` to a private one, then click **Refresh channels**.
If the selection reports that no channels are available, the workspace exposes nothing Prowler can see. Create a public channel, or invite `@Prowler Cloud` to a private one, then click **Refresh channels**.
A workspace can hold more channels than Prowler reads in one go. When that happens, the selection says so and lists what was read: every listed channel is usable, and a channel missing from a partial list is not necessarily one `@Prowler` has to be invited to. Only listed channels can be selected: **Refresh channels** repeats the same bounded read rather than reading further, and the selection's search filters what was already read, so neither surfaces a channel the read left out.
A workspace can hold more channels than Prowler reads in one go. When that happens, the selection says so and lists what was read: every listed channel is usable, and a channel missing from a partial list is not necessarily one `@Prowler Cloud` has to be invited to. Only listed channels can be selected: **Refresh channels** repeats the same bounded read rather than reading further, and the selection's search filters what was already read, so neither surfaces a channel the read left out.
Saving a new selection replaces the authorized set: channels left out of it stop being authorized, and channels added to it are authorized but not yet confirmed. Changing which channels are in the set also resets the integration's connection state, so the check runs again over the new set — reordering the same channels does not. Saving an empty selection leaves the integration with no authorized channels, and **Test connection** cannot be run again until at least one channel is authorized.
@@ -159,7 +161,7 @@ The Slack management page reports the state of the connection and offers these a
| Button | Purpose | Notes |
|--------|---------|-------|
| **Test connection** | Verify the credential and every authorized channel, and confirm the ones not confirmed yet | Posts the confirmation message once per channel and updates the last-checked time. Cannot be run until at least one channel is authorized |
| **Refresh channels** | Re-read the workspace's channel list | Use after inviting `@Prowler` to a private channel |
| **Refresh channels** | Re-read the workspace's channel list | Use after inviting `@Prowler Cloud` to a private channel |
| **Save channels** | Record the selected channels as the integration's authorized set | Enabled once the selection differs from the authorized set |
| **Disconnect** | Remove the integration and attempt to revoke access at Slack | ⚠️ **Cannot be undone** — confirm before disconnecting |
@@ -171,7 +173,7 @@ The Prowler Slack app is not configured for the deployment being used, so no wor
### A Private Channel Does Not Appear in the Channel List
The Prowler app has not been invited to it. In Slack, run `/invite @Prowler` in that channel, then click **Refresh channels**. Membership is the permission: no scope reveals a private channel the app is not in.
The Prowler app has not been invited to it. In Slack, run `/invite @Prowler Cloud` in that channel, then click **Refresh channels**. Membership is the permission: no scope reveals a private channel the app is not in.
### Connection Test Fails
@@ -0,0 +1 @@
CIS Google Workspace Foundations Benchmark v1.4.0 compliance framework
@@ -0,0 +1 @@
`cloudwatch_log_group_agentcore_data_protection_policy_enabled` check for AWS provider, verifying that Bedrock AgentCore log groups mask sensitive data with a CloudWatch Logs data protection policy
@@ -0,0 +1 @@
CloudWatch log metric filter checks no longer crash with `AttributeError` when the account has a metric filter whose log group was not retrieved
@@ -0,0 +1 @@
`ecr_registry_enhanced_scanning_enabled` check for AWS provider, verifying that the ECR registry scan type is enhanced (Amazon Inspector, covering programming language packages and continuous rescanning) instead of basic, and reporting MANUAL when the registry scanning configuration cannot be read
@@ -0,0 +1 @@
`ecr_registry_scan_images_on_push_enabled` no longer passes a registry whose scanning rules are all `MANUAL`, nor describes a `CONTINUOUS_SCAN` registry as scanning on push; each rule's `scanFrequency` is now read instead of inferred from a rule's presence
@@ -0,0 +1 @@
`eks_cluster_vpc_cni_network_policy_enforced` check for AWS provider, flagging EKS clusters whose Amazon VPC CNI managed add-on does not enable Kubernetes network policy enforcement, and reporting MANUAL where the EKS API cannot show the setting
@@ -0,0 +1 @@
`security_2sv_enforced` and `security_2sv_hardware_keys_admins` report MANUAL instead of judging domain-wide values that a group or a sub-organizational unit overrides, or that were dropped because the root organizational unit could not be resolved; a domain-wide failure is still reported as such, with the override noted
@@ -0,0 +1 @@
`rules_*_alert_configured` checks no longer pass a rule whose delivery to the alert center is disabled, the setting behind the benchmark's "Ensure that Alerts is set to On"
@@ -0,0 +1 @@
Google Workspace checks mapped to CIS evaluate the full audit procedure instead of a single condition, so Gmail spoofing actions, 2-Step Verification, password expiration and alert severity left on Google's defaults no longer pass
@@ -0,0 +1 @@
`security_login_challenges_configured` and `security_2sv_enforced` unmapped from CIS Google Workspace 4.1.4.1 and CISA SCuBA 0.6 `GWS.COMMONCONTROLS.1.1`, whose Post-SSO verification and phishing-resistant MFA requirements neither check can prove
@@ -0,0 +1 @@
`security_password_policy_strong` no longer fails a domain that never touched the password strength setting: Google enforces strong passwords by default, so an unset value is the secure default and not a missing configuration
@@ -0,0 +1 @@
`guardduty_ai_protection_enabled` check for AWS provider, flagging GuardDuty detectors without AI Protection, which analyzes AWS CloudTrail data events from Amazon Bedrock, Amazon Bedrock AgentCore and Amazon SageMaker AI; a detector that does not report the feature is `MANUAL` rather than `FAIL`
@@ -0,0 +1 @@
`guardduty_runtime_monitoring_enabled` check for AWS provider, flagging GuardDuty detectors without unified Runtime Monitoring, the only feature that covers Amazon EC2 instances and Amazon ECS on AWS Fargate tasks in addition to Amazon EKS
@@ -0,0 +1 @@
`guardduty_eks_runtime_monitoring_enabled` no longer reports `FAIL` for detectors that use unified Runtime Monitoring; the GuardDuty service now reads the `RUNTIME_MONITORING` feature, which is mutually exclusive with `EKS_RUNTIME_MONITORING` and already covers Amazon EKS
@@ -0,0 +1 @@
`iam_policy_passrole_to_bedrock_agentcore_restricted` check for AWS provider, flagging customer-managed IAM policies that allow `iam:PassRole` over every role where the passed role can reach Bedrock AgentCore, so any principal holding the policy can run agent code under any role in the account
@@ -0,0 +1 @@
`iam_policy_no_agentcore_workload_access_token_wildcard` check for AWS provider, flagging customer-managed IAM policies that allow `bedrock-agentcore:GetWorkloadAccessToken`, `GetWorkloadAccessTokenForJWT` or `GetWorkloadAccessTokenForUserId` on resources reaching workload identities other than the caller's own, which AWS documents as the only binding on the unverified user ID the token is issued for
@@ -0,0 +1 @@
`iam_role_service_trust_restricts_source_to_account` check for AWS provider, flagging IAM roles whose trust policy lets an AWS service principal assume the role without confining the request to a specific source account, including trust policies that `iam_role_cross_service_confused_deputy_prevention` does not evaluate
@@ -0,0 +1 @@
`PROWLER_IMAGE_PROVIDER_ALLOWED_PRIVATE_NETWORKS` environment variable so the image provider can reach container registries on allowlisted private networks, keeping every other non-public address blocked
@@ -0,0 +1 @@
Basic authentication fallback in the image provider when a registry rejects the negotiated bearer token, so registries like Harbor that guard catalog listing behind Basic can be enumerated
@@ -0,0 +1 @@
Registry catalog listing when the server answers with a Bearer challenge after negotiating Basic (or anonymous) authentication, switching to a bearer token obtained from the challenge instead of failing
@@ -0,0 +1 @@
`--registry-insecure` now propagates to Trivy via `TRIVY_INSECURE`, so images in registries with self-signed certificates can be pulled and scanned, not just enumerated
@@ -0,0 +1 @@
Registry scans in the Image provider now skip non-image OCI artifacts (Helm charts, cosign signatures, SBOM attestations) and no longer abort the whole scan when Trivy fails on a single discovered image; registry enumeration also runs in parallel instead of one request at a time
@@ -0,0 +1 @@
Checks no longer report `FAIL` when the scanning identity lacks permissions, an API is not enabled or a feature is not licensed; they now emit a single `MANUAL` finding naming what is required, across 28 M365, Azure, AWS and GCP checks
+1
View File
@@ -0,0 +1 @@
Support for Python 3.14
@@ -0,0 +1 @@
`sagemaker_notebook_instance_without_direct_internet_access_configured` check logic to read the `DirectInternetAccess` setting instead of `RootAccess`, failing a notebook instance with direct internet access enabled even when root access is disabled
@@ -241,6 +241,7 @@
"iam_inline_policy_no_administrative_privileges",
"iam_policy_allows_privilege_escalation",
"iam_inline_policy_allows_privilege_escalation",
"iam_policy_passrole_to_bedrock_agentcore_restricted",
"iam_role_administratoraccess_policy",
"iam_user_administrator_access_policy",
"iam_group_administrator_access_policy",
@@ -269,6 +270,7 @@
"iam_user_no_setup_initial_access_key",
"iam_user_two_active_access_key",
"iam_user_console_access_unused",
"iam_policy_no_agentcore_workload_access_token_wildcard",
"bedrock_api_key_no_long_term_credentials"
]
},
@@ -305,6 +307,7 @@
],
"Checks": [
"iam_role_cross_service_confused_deputy_prevention",
"iam_role_service_trust_restricts_source_to_account",
"iam_role_access_not_stale_to_bedrock",
"iam_user_access_not_stale_to_bedrock",
"iam_role_cross_account_readonlyaccess_policy"
@@ -484,7 +487,8 @@
"awslambda_function_no_secrets_in_variables",
"ecs_task_definitions_no_environment_secrets",
"ec2_instance_secrets_user_data",
"cloudwatch_log_group_no_secrets_in_logs"
"cloudwatch_log_group_no_secrets_in_logs",
"cloudwatch_log_group_agentcore_data_protection_policy_enabled"
]
},
{
@@ -878,9 +882,11 @@
"guardduty_s3_protection_enabled",
"guardduty_eks_audit_log_enabled",
"guardduty_eks_runtime_monitoring_enabled",
"guardduty_runtime_monitoring_enabled",
"guardduty_lambda_protection_enabled",
"guardduty_rds_protection_enabled",
"guardduty_ec2_malware_protection_enabled"
"guardduty_ec2_malware_protection_enabled",
"guardduty_ai_protection_enabled"
],
"ConfigRequirements": [
{
@@ -1128,10 +1134,12 @@
"eks_cluster_not_publicly_accessible",
"eks_cluster_private_nodes_enabled",
"eks_cluster_network_policy_enabled",
"eks_cluster_vpc_cni_network_policy_enforced",
"eks_cluster_uses_a_supported_version",
"eks_control_plane_logging_all_types_enabled",
"eks_cluster_kms_cmk_encryption_in_secrets_enabled",
"eks_cluster_deletion_protection_enabled"
"eks_cluster_deletion_protection_enabled",
"ecr_registry_enhanced_scanning_enabled"
]
},
{
@@ -1154,7 +1162,8 @@
"ecs_task_definitions_logging_enabled",
"ecs_task_definitions_no_environment_secrets",
"ecs_task_definitions_host_namespace_not_shared",
"ecs_cluster_container_insights_enabled"
"ecs_cluster_container_insights_enabled",
"ecr_registry_enhanced_scanning_enabled"
]
},
{
@@ -1498,9 +1498,7 @@
{
"Id": "4.1.4.1",
"Description": "Ensure login challenges are enforced",
"Checks": [
"security_login_challenges_configured"
],
"Checks": [],
"Attributes": [
{
"Section": "4 Security",
File diff suppressed because it is too large Load Diff
@@ -9,7 +9,6 @@
"Id": "GWS.COMMONCONTROLS.1.1",
"Description": "Phishing-resistant MFA SHALL be required for all users",
"Checks": [
"security_2sv_enforced",
"security_2sv_hardware_keys_admins"
],
"Attributes": [
+8
View File
@@ -119,6 +119,14 @@ aws:
# aws.cloudwatch_log_group_retention_policy_specific_days_enabled --> by default is 365 days
log_group_retention_days: 365
# aws.cloudwatch_log_group_agentcore_data_protection_policy_enabled
# Log group name prefixes that hold Bedrock AgentCore agent telemetry. This list REPLACES
# the defaults rather than adding to them, so keep both entries below when adding your own
# or the log groups AgentCore creates itself stop being assessed.
agentcore_log_group_name_prefixes:
- "/aws/bedrock-agentcore/"
- "/aws/vendedlogs/bedrock-agentcore/"
# AWS CloudFormation Configuration
# cloudformation_stack_cdktoolkit_bootstrap_version --> by default is 21
recommended_cdk_bootstrap_version: 21
+9
View File
@@ -267,6 +267,15 @@ class AWSProviderConfig(ProviderConfigBase):
f"values accepted by the AWS API: {list(_CLOUDWATCH_RETENTION_DAYS)}."
),
)
agentcore_log_group_name_prefixes: Optional[list[str]] = Field(
default=None,
description=(
"Log group name prefixes that identify Bedrock AgentCore agent "
"telemetry. Set this when AgentCore log delivery is pointed at log "
"groups outside the service defaults; the value replaces the "
"defaults, so list them alongside any prefix of your own."
),
)
recommended_cdk_bootstrap_version: Optional[int] = Field(
default=None,
ge=1,
@@ -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

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