feat(aws): add bedrock_prompt_encrypted_with_cmk security check (#10905)

This commit is contained in:
Daniel Barranquero
2026-05-11 10:32:44 +02:00
committed by GitHub
parent 0e01e67257
commit 73c0305dc4
14 changed files with 311 additions and 16 deletions
+1
View File
@@ -12,6 +12,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Update Vercel checks to return personalized finding status extended depending on billing plan and classify them with billing-plan categories [(#10663)](https://github.com/prowler-cloud/prowler/pull/10663)
- `bedrock_prompt_management_exists` check for AWS provider [(#10878)](https://github.com/prowler-cloud/prowler/pull/10878)
- 8 Gmail attachment safety and spoofing protection checks for Google Workspace provider using the Cloud Identity Policy API [(#10980)](https://github.com/prowler-cloud/prowler/pull/10980)
- `bedrock_prompt_encrypted_with_cmk` check for AWS provider [(#10905)](https://github.com/prowler-cloud/prowler/pull/10905)
### 🔄 Changed
+2
View File
@@ -6473,6 +6473,7 @@
"backup_recovery_point_encrypted",
"backup_vaults_encrypted",
"bedrock_model_invocation_logs_encryption_enabled",
"bedrock_prompt_encrypted_with_cmk",
"cloudfront_distributions_field_level_encryption_enabled",
"cloudfront_distributions_origin_traffic_encrypted",
"cloudtrail_kms_encryption_enabled",
@@ -6730,6 +6731,7 @@
"backup_recovery_point_encrypted",
"backup_vaults_encrypted",
"bedrock_model_invocation_logs_encryption_enabled",
"bedrock_prompt_encrypted_with_cmk",
"cloudfront_distributions_field_level_encryption_enabled",
"cloudfront_distributions_origin_traffic_encrypted",
"cloudtrail_kms_encryption_enabled",
@@ -1311,6 +1311,7 @@
"glue_development_endpoints_job_bookmark_encryption_enabled",
"glue_ml_transform_encrypted_at_rest",
"bedrock_model_invocation_logs_encryption_enabled",
"bedrock_prompt_encrypted_with_cmk",
"codebuild_project_s3_logs_encrypted",
"codebuild_report_group_export_encrypted"
]
@@ -1767,6 +1767,7 @@
"backup_recovery_point_encrypted",
"backup_vaults_encrypted",
"bedrock_model_invocation_logs_encryption_enabled",
"bedrock_prompt_encrypted_with_cmk",
"cloudfront_distributions_field_level_encryption_enabled",
"cloudfront_distributions_origin_traffic_encrypted",
"cloudtrail_kms_encryption_enabled",
@@ -2115,6 +2115,7 @@
"Checks": [
"backup_vaults_encrypted",
"bedrock_model_invocation_logs_encryption_enabled",
"bedrock_prompt_encrypted_with_cmk",
"cloudtrail_kms_encryption_enabled",
"cloudwatch_log_group_kms_encryption_enabled",
"dynamodb_tables_kms_cmk_encryption_enabled",
@@ -2117,6 +2117,7 @@
"Checks": [
"backup_vaults_encrypted",
"bedrock_model_invocation_logs_encryption_enabled",
"bedrock_prompt_encrypted_with_cmk",
"cloudtrail_kms_encryption_enabled",
"cloudwatch_log_group_kms_encryption_enabled",
"dynamodb_tables_kms_cmk_encryption_enabled",
@@ -903,6 +903,7 @@
"Checks": [
"backup_vaults_encrypted",
"backup_recovery_point_encrypted",
"bedrock_prompt_encrypted_with_cmk",
"cloudtrail_kms_encryption_enabled",
"cloudwatch_log_group_kms_encryption_enabled",
"s3_bucket_kms_encryption",
@@ -0,0 +1,43 @@
{
"Provider": "aws",
"CheckID": "bedrock_prompt_encrypted_with_cmk",
"CheckTitle": "Amazon Bedrock prompt is encrypted at rest with a customer-managed KMS key",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices"
],
"ServiceName": "bedrock",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "Other",
"ResourceGroup": "ai_ml",
"Description": "Bedrock prompts should be encrypted at rest with a **customer-managed KMS key (CMK)** rather than the AWS-owned default key. Prompts can contain sensitive instructions, business logic, and references to downstream tooling that warrant tenant-controlled key material and auditable access via AWS KMS.",
"Risk": "A prompt encrypted only with the AWS-owned default key offers limited tenant control over key access and lifecycle: no customer KMS key policy to govern decrypt permissions, no control over rotation cadence or scheduled deletion, and gaps against frameworks (ISO 27001 A.8.24, NIST CSF PR.DS, KISA-ISMS-P 2.7.2) that require customer-managed keys for sensitive data at rest.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-management.html",
"https://docs.aws.amazon.com/bedrock/latest/APIReference/API_agent_CreatePrompt.html",
"https://docs.aws.amazon.com/bedrock/latest/APIReference/API_agent_UpdatePrompt.html"
],
"Remediation": {
"Code": {
"CLI": "# Retrieve the current DRAFT prompt first and note the existing fields you want to preserve, such as description, defaultVariant, and variants:\naws bedrock-agent get-prompt --prompt-identifier <prompt_id> --prompt-version DRAFT --output json\n# Then update the prompt and include the existing fields you want to keep alongside the CMK change:\naws bedrock-agent update-prompt --prompt-identifier <prompt_id> --name <prompt_name> --description <current_or_new_description> --default-variant <current_default_variant> --variants <current_or_updated_variants_json> --customer-encryption-key-arn <kms_key_arn>",
"NativeIaC": "",
"Other": "1. Open the Amazon Bedrock console\n2. Navigate to Prompt management\n3. Select the prompt\n4. Edit the prompt and choose a customer-managed KMS key for encryption\n5. Save the prompt",
"Terraform": ""
},
"Recommendation": {
"Text": "Encrypt every Bedrock prompt with a **customer-managed KMS key** to retain control over key access, rotation, and lifecycle. When using `update-prompt`, first retrieve the current draft and carry forward the fields you want to preserve, such as the existing description, `defaultVariant`, and `variants`, so the encryption change does not unintentionally overwrite prompt configuration.",
"Url": "https://hub.prowler.com/check/bedrock_prompt_encrypted_with_cmk"
}
},
"Categories": [
"gen-ai",
"encryption"
],
"DependsOn": [],
"RelatedTo": [
"bedrock_prompt_management_exists"
],
"Notes": ""
}
@@ -0,0 +1,32 @@
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.providers.aws.services.bedrock.bedrock_agent_client import (
bedrock_agent_client,
)
class bedrock_prompt_encrypted_with_cmk(Check):
"""Ensure that Bedrock prompts are encrypted with a customer-managed KMS key.
This check evaluates whether each Bedrock prompt is encrypted at rest using
a customer-managed KMS key (CMK) rather than the AWS-owned default key.
- PASS: The Bedrock prompt is encrypted with a customer-managed KMS key.
- FAIL: The Bedrock prompt is not encrypted with a customer-managed KMS key.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Execute the Bedrock prompt CMK encryption check.
Returns:
A list of reports containing the result of the check.
"""
findings = []
for prompt in bedrock_agent_client.prompts.values():
report = Check_Report_AWS(metadata=self.metadata(), resource=prompt)
if prompt.customer_encryption_key_arn:
report.status = "PASS"
report.status_extended = f"Bedrock Prompt {prompt.name} is encrypted with a customer-managed KMS key."
else:
report.status = "FAIL"
report.status_extended = f"Bedrock Prompt {prompt.name} is not encrypted with a customer-managed KMS key."
findings.append(report)
return findings
@@ -34,6 +34,8 @@
"gen-ai"
],
"DependsOn": [],
"RelatedTo": [],
"RelatedTo": [
"bedrock_prompt_encrypted_with_cmk"
],
"Notes": "Results are generated per scanned region. Regions where `ListPrompts` cannot be queried are omitted from the findings."
}
@@ -136,7 +136,10 @@ class Guardrail(BaseModel):
class BedrockAgent(AWSService):
"""Bedrock Agent service class for managing agents and prompts."""
def __init__(self, provider):
"""Initialize the BedrockAgent service."""
# Call AWSService's __init__
super().__init__("bedrock-agent", provider)
self.agents = {}
@@ -144,6 +147,7 @@ class BedrockAgent(AWSService):
self.prompt_scanned_regions: set = set()
self.__threading_call__(self._list_agents)
self.__threading_call__(self._list_prompts)
self.__threading_call__(self._get_prompt, self.prompts.values())
self.__threading_call__(self._list_tags_for_resource, self.agents.values())
def _list_agents(self, regional_client):
@@ -171,29 +175,43 @@ class BedrockAgent(AWSService):
)
def _list_prompts(self, regional_client):
"""List all prompts in a region.
Prompt Management is evaluated as a region-level adoption signal, so
prompt collection is intentionally not filtered by audit_resources.
"""
"""List all prompts in a region."""
logger.info("Bedrock Agent - Listing Prompts...")
try:
paginator = regional_client.get_paginator("list_prompts")
for page in paginator.paginate():
for prompt in page.get("promptSummaries", []):
prompt_arn = prompt.get("arn", "")
self.prompts[prompt_arn] = Prompt(
id=prompt.get("id", ""),
name=prompt.get("name", ""),
arn=prompt_arn,
region=regional_client.region,
)
if not self.audit_resources or (
is_resource_filtered(prompt_arn, self.audit_resources)
):
self.prompts[prompt_arn] = Prompt(
id=prompt.get("id", ""),
name=prompt.get("name", ""),
arn=prompt_arn,
region=regional_client.region,
)
self.prompt_scanned_regions.add(regional_client.region)
except Exception as error:
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _get_prompt(self, prompt):
"""Get detailed prompt information including encryption configuration."""
logger.info("Bedrock Agent - Getting Prompt...")
try:
prompt_info = self.regional_clients[prompt.region].get_prompt(
promptIdentifier=prompt.id
)
prompt.customer_encryption_key_arn = prompt_info.get(
"customerEncryptionKeyArn"
)
except Exception as error:
logger.error(
f"{prompt.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _list_tags_for_resource(self, resource):
"""List tags for a Bedrock Agent resource."""
logger.info("Bedrock Agent - Listing Tags for Resource...")
@@ -212,6 +230,8 @@ class BedrockAgent(AWSService):
class Agent(BaseModel):
"""Model for a Bedrock Agent resource."""
id: str
name: str
arn: str
@@ -227,3 +247,4 @@ class Prompt(BaseModel):
name: str
arn: str
region: str
customer_encryption_key_arn: Optional[str] = None
@@ -0,0 +1,174 @@
from unittest import mock
import botocore
from tests.providers.aws.utils import (
AWS_ACCOUNT_NUMBER,
AWS_REGION_EU_WEST_1,
AWS_REGION_US_EAST_1,
set_mocked_aws_provider,
)
make_api_call = botocore.client.BaseClient._make_api_call
PROMPT_ARN = (
f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:prompt/test-prompt-id"
)
PROMPT_ID = "test-prompt-id"
PROMPT_NAME = "test-prompt"
KMS_KEY_ARN = (
f"arn:aws:kms:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:key/"
"12345678-1234-1234-1234-123456789012"
)
def mock_make_api_call_with_cmk(self, operation_name, kwarg):
"""Mock API call returning a prompt encrypted with a customer-managed KMS key."""
if operation_name == "ListPrompts":
return {
"promptSummaries": [
{
"id": PROMPT_ID,
"name": PROMPT_NAME,
"arn": PROMPT_ARN,
}
]
}
elif operation_name == "GetPrompt":
return {
"id": PROMPT_ID,
"name": PROMPT_NAME,
"arn": PROMPT_ARN,
"customerEncryptionKeyArn": KMS_KEY_ARN,
}
return make_api_call(self, operation_name, kwarg)
def mock_make_api_call_without_cmk(self, operation_name, kwarg):
"""Mock API call returning a prompt without a customer-managed KMS key."""
if operation_name == "ListPrompts":
return {
"promptSummaries": [
{
"id": PROMPT_ID,
"name": PROMPT_NAME,
"arn": PROMPT_ARN,
}
]
}
elif operation_name == "GetPrompt":
return {
"id": PROMPT_ID,
"name": PROMPT_NAME,
"arn": PROMPT_ARN,
}
return make_api_call(self, operation_name, kwarg)
class Test_bedrock_prompt_encrypted_with_cmk:
"""Test suite for the bedrock_prompt_encrypted_with_cmk check."""
@mock.patch(
"botocore.client.BaseClient._make_api_call",
new=lambda self, op, kwarg: make_api_call(self, op, kwarg),
)
def test_no_prompts(self):
"""Test when no prompts exist."""
from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent
aws_provider = set_mocked_aws_provider(
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]
)
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.bedrock.bedrock_prompt_encrypted_with_cmk.bedrock_prompt_encrypted_with_cmk.bedrock_agent_client",
new=BedrockAgent(aws_provider),
),
):
from prowler.providers.aws.services.bedrock.bedrock_prompt_encrypted_with_cmk.bedrock_prompt_encrypted_with_cmk import (
bedrock_prompt_encrypted_with_cmk,
)
check = bedrock_prompt_encrypted_with_cmk()
result = check.execute()
assert len(result) == 0
@mock.patch(
"botocore.client.BaseClient._make_api_call",
new=mock_make_api_call_with_cmk,
)
def test_prompt_encrypted_with_cmk(self):
"""Test when a prompt is encrypted with a customer-managed KMS key."""
from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.bedrock.bedrock_prompt_encrypted_with_cmk.bedrock_prompt_encrypted_with_cmk.bedrock_agent_client",
new=BedrockAgent(aws_provider),
),
):
from prowler.providers.aws.services.bedrock.bedrock_prompt_encrypted_with_cmk.bedrock_prompt_encrypted_with_cmk import (
bedrock_prompt_encrypted_with_cmk,
)
check = bedrock_prompt_encrypted_with_cmk()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"Bedrock Prompt {PROMPT_NAME} is encrypted with a customer-managed KMS key."
)
assert result[0].resource_id == PROMPT_ID
assert result[0].resource_arn == PROMPT_ARN
assert result[0].region == AWS_REGION_US_EAST_1
@mock.patch(
"botocore.client.BaseClient._make_api_call",
new=mock_make_api_call_without_cmk,
)
def test_prompt_not_encrypted_with_cmk(self):
"""Test when a prompt is not encrypted with a customer-managed KMS key."""
from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.bedrock.bedrock_prompt_encrypted_with_cmk.bedrock_prompt_encrypted_with_cmk.bedrock_agent_client",
new=BedrockAgent(aws_provider),
),
):
from prowler.providers.aws.services.bedrock.bedrock_prompt_encrypted_with_cmk.bedrock_prompt_encrypted_with_cmk import (
bedrock_prompt_encrypted_with_cmk,
)
check = bedrock_prompt_encrypted_with_cmk()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Bedrock Prompt {PROMPT_NAME} is not encrypted with a customer-managed KMS key."
)
assert result[0].resource_id == PROMPT_ID
assert result[0].resource_arn == PROMPT_ARN
assert result[0].region == AWS_REGION_US_EAST_1
@@ -406,12 +406,14 @@ class TestBedrockPromptPagination:
regional_client.get_paginator.assert_called_once_with("list_prompts")
paginator.paginate.assert_called_once()
def test_list_prompts_ignores_audit_resources_filter(self):
"""Prompt collection is region-scoped and must ignore audit_resources."""
def test_list_prompts_filters_audit_resources(self):
"""Prompt collection must honor audit_resources when resource ARNs are scoped."""
audit_info = MagicMock()
audit_info.audited_partition = "aws"
audit_info.audited_account = "123456789012"
audit_info.audit_resources = ["arn:aws:s3:::unrelated-resource"]
audit_info.audit_resources = [
"arn:aws:bedrock:us-east-1:123456789012:prompt/prompt-1"
]
regional_client = MagicMock()
regional_client.region = "us-east-1"
@@ -424,7 +426,12 @@ class TestBedrockPromptPagination:
"id": "prompt-1",
"name": "prompt-name-1",
"arn": "arn:aws:bedrock:us-east-1:123456789012:prompt/prompt-1",
}
},
{
"id": "prompt-2",
"name": "prompt-name-2",
"arn": "arn:aws:bedrock:us-east-1:123456789012:prompt/prompt-2",
},
]
}
]
@@ -438,6 +445,14 @@ class TestBedrockPromptPagination:
bedrock_agent_service._list_prompts(regional_client)
assert len(bedrock_agent_service.prompts) == 1
assert (
"arn:aws:bedrock:us-east-1:123456789012:prompt/prompt-1"
in bedrock_agent_service.prompts
)
assert (
"arn:aws:bedrock:us-east-1:123456789012:prompt/prompt-2"
not in bedrock_agent_service.prompts
)
assert "us-east-1" in bedrock_agent_service.prompt_scanned_regions
def test_list_prompts_error_does_not_mark_region_scanned(self):