feat(bedrock): assess invocation log content and guardrail output filtering (#12663)

This commit is contained in:
Jonathan Nguyen authored and GitHub committed 2026-10-22 01:25:39 +02:00
1 parent 02c8881378
commit 1468596b61
17 files changed
+1222 -9

No files matched your search

@@ -0,0 +1 @@
`bedrock_api_key_no_long_term_credentials` now reports a Bedrock long-term API key that never expires; IAM only returns `ExpirationDate` for keys created with an expiration period, so keys created without one were skipped and produced no finding at all
@@ -0,0 +1 @@
`bedrock_guardrail_sensitive_information_filter_enforced` check for AWS provider, flagging Bedrock guardrails whose PII entities or custom regexes take no action on the output path, so a detected match is reported in the trace and still delivered to the caller
@@ -0,0 +1 @@
`bedrock_model_invocation_logging_text_data_enabled` check for AWS provider, verifying that model invocation logging delivers prompt and response text rather than naming a destination while excluding request and response bodies
@@ -552,7 +552,8 @@
}
],
"Checks": [
"bedrock_guardrail_sensitive_information_filter_enabled"
"bedrock_guardrail_sensitive_information_filter_enabled",
"bedrock_guardrail_sensitive_information_filter_enforced"
]
},
{
@@ -585,6 +586,7 @@
],
"Checks": [
"bedrock_model_invocation_logging_enabled",
"bedrock_model_invocation_logging_text_data_enabled",
"bedrock_model_invocation_logs_encryption_enabled"
]
},
@@ -19,18 +19,33 @@ class bedrock_api_key_no_long_term_credentials(Check):
FAIL (high) for any other active long-term key.
"""
def execute(self):
def execute(self) -> list[Check_Report_AWS]:
"""Execute the check logic.
Returns:
A list of reports containing the result of the check.
"""
findings = []
for api_key in iam_client.service_specific_credentials:
if api_key.service_name != "bedrock.amazonaws.com":
continue
if not api_key.expiration_date:
continue
report = Check_Report_AWS(metadata=self.metadata(), resource=api_key)
now = datetime.now(timezone.utc)
if api_key.expiration_date <= now:
if api_key.expiration_date is None:
# ExpirationDate "is only present for Bedrock API keys ... that were created with
# an expiration period" (IAM ServiceSpecificCredentialMetadata), so its absence is
# the never-expiring key itself, not a key outside this check's scope.
report.status = "FAIL"
report.check_metadata.Severity = Severity.critical
report.status_extended = (
f"Bedrock long-term API key {api_key.id} in user "
f"{api_key.user.name} has no expiration date, so it never expires. "
f"Use short-term Bedrock API keys (session-scoped, valid up to "
f"12 hours) for non-exploratory workloads instead."
)
elif api_key.expiration_date <= now:
report.status = "PASS"
report.status_extended = (
f"Bedrock long-term API key {api_key.id} in user "
@@ -0,0 +1,46 @@
{
"Provider": "aws",
"CheckID": "bedrock_guardrail_sensitive_information_filter_enforced",
"CheckTitle": "Amazon Bedrock guardrail sensitive information filter acts on the output path",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices",
"Software and Configuration Checks/AWS Security Best Practices/Runtime Behavior Analysis",
"Effects/Data Exposure",
"Sensitive Data Identifications/PII"
],
"ServiceName": "bedrock",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "high",
"ResourceType": "Other",
"ResourceGroup": "ai_ml",
"Description": "A **Bedrock guardrail** sensitive information entry whose action is `NONE` reports the detected PII or regex match in the trace and still delivers it to the caller. The evaluation looks for guardrails whose configured PII entities and custom regexes are set to `BLOCK` or `ANONYMIZE` on the output path.",
"Risk": "A filter that detects without acting reads as configured while **PII**, credentials and secrets keep flowing through model output.\n- Reviews and dashboards record the filter as present, so the gap goes unnoticed\n- Leaked tokens and customer records enable unauthorized access, fraud and identity theft",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/bedrock/latest/userguide/guardrails-sensitive-filters.html",
"https://docs.aws.amazon.com/bedrock/latest/APIReference/API_GuardrailPiiEntity.html",
"https://docs.aws.amazon.com/bedrock/latest/APIReference/API_GuardrailRegex.html",
"https://docs.aws.amazon.com/bedrock/latest/userguide/guardrails-components.html"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Sign in to the AWS Management Console and open Amazon Bedrock\n2. Go to Guardrails and select <example_resource_name>\n3. Click Edit (or Open draft) and open Sensitive information filters\n4. For every PII type and regular expression listed, change the action from None to Mask or Block, and leave the output evaluation enabled\n5. Click Save, then publish a new guardrail version and point your agents and model invocations at it\nNote for API and CLI users: UpdateGuardrail replaces the whole guardrail, so it must be a read-modify-write. Call GetGuardrail first, edit only the actions inside the returned sensitiveInformationPolicyConfig, and send every other policy block back unchanged. AWS states \"Include both fields that you want to update as well as fields that you want to keep the same\" -- omitting a block deletes it. Changes land on the working draft only, so publish a new version afterwards.",
"Terraform": ""
},
"Recommendation": {
"Text": "Set every PII entity and custom regular expression in the guardrail sensitive information policy to `BLOCK` or `ANONYMIZE` rather than `NONE`, and keep output evaluation enabled so matches are stopped on the response path. Use `BLOCK` for values that must never appear, `ANONYMIZE` where the surrounding answer is still useful. Confirm the result with an `ApplyGuardrail` call using `source=OUTPUT` and check that the assessment reports the entity as blocked or anonymized.",
"Url": "https://hub.prowler.com/check/bedrock_guardrail_sensitive_information_filter_enforced"
}
},
"Categories": [
"gen-ai"
],
"DependsOn": [],
"RelatedTo": [
"bedrock_guardrail_sensitive_information_filter_enabled"
],
"Notes": "Scoped to guardrails that already carry a sensitive information policy; a guardrail with no such policy is reported by bedrock_guardrail_sensitive_information_filter_enabled instead. GetGuardrail reports `action` for every entry and omits the per-path `outputAction` and `outputEnabled` unless they were set explicitly, in which case `action` governs the output path — verified against a live guardrail whose entries carried only `action: ANONYMIZE` and which masked PII on an ApplyGuardrail call with source=OUTPUT. An entry whose action the API does not report is MANUAL, never PASS. Like the other guardrail checks, this reads the working draft, because the collector calls GetGuardrail without guardrailVersion."
}
@@ -0,0 +1,105 @@
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.providers.aws.services.bedrock.bedrock_client import bedrock_client
ENFORCING_ACTIONS = {"BLOCK", "ANONYMIZE"}
class bedrock_guardrail_sensitive_information_filter_enforced(Check):
"""Ensure guardrail sensitive-information entries block or mask matches on the output path.
A guardrail can carry a full sensitive-information policy whose every PII entity and regex is
set to NONE, which detects the match and returns it in the trace while still delivering the
value to the caller. Presence of the policy therefore does not establish that leakage is
stopped, which is all bedrock_guardrail_sensitive_information_filter_enabled asserts.
Scope: guardrails that already carry a sensitive-information policy. A guardrail with no such
policy is the subject of bedrock_guardrail_sensitive_information_filter_enabled and is not
reported here.
PASS when every entry blocks or masks its matches on the output path.
FAIL when any entry takes no action on the output path, or has output evaluation switched off.
MANUAL when the guardrail configuration could not be read, or an entry's action was not
reported AND no other entry was proven unenforced. A proven leak outranks an incomplete
inventory: this is a universal claim, so one entry known to take no action settles it.
Unreported entries are still named in the FAIL text, so an incomplete inventory is disclosed
rather than dropped.
ENFORCING_ACTIONS holds only BLOCK and ANONYMIZE. NONE reports the detection in the trace and
lets the content through, so an entry set to NONE detects a leak and permits it.
Both name lists are rendered into status_extended, and they are built in the order GetGuardrail
returned piiEntities and regexes. Neither ListGuardrails nor GetGuardrail documents an ordering,
so each render point sorts independently: unsorted output makes one unchanged guardrail produce
two different status_extended strings across scans, which reads as a changed finding.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Execute the check logic.
Returns:
A list of reports containing the result of the check.
"""
findings = []
for guardrail in bedrock_client.guardrails.values():
report = Check_Report_AWS(metadata=self.metadata(), resource=guardrail)
if not guardrail.detail_retrieved:
report.status = "MANUAL"
report.status_extended = (
f"Bedrock Guardrail {guardrail.name} configuration could not be retrieved "
f"({guardrail.detail_error or 'unknown error'}); verify manually that its "
"sensitive information filters block or mask matches on the output path."
)
findings.append(report)
continue
if not guardrail.sensitive_information_entries:
continue
unreported = []
unenforced = []
for entry in guardrail.sensitive_information_entries:
# GetGuardrail omits outputAction unless it was configured explicitly; the
# required `action` governs the output path in that case.
action = entry.output_action or entry.action
if action is None:
unreported.append(entry.name)
elif entry.output_enabled is False or action not in ENFORCING_ACTIONS:
unenforced.append(entry.name)
if unenforced:
unenforced.sort()
unreported.sort()
report.status = "FAIL"
unreported_note = (
f" The action for {', '.join(unreported)} was not reported, so there may be "
"more."
if unreported
else ""
)
report.status_extended = (
f"Bedrock Guardrail {guardrail.name} detects but does not block or mask "
f"{', '.join(unenforced)} on the output path, so matched sensitive "
f"information still reaches the caller.{unreported_note}"
)
elif unreported:
# Sorted here too: a second render point needs its own sort, not an inherited one.
unreported.sort()
report.status = "MANUAL"
report.status_extended = (
f"Bedrock Guardrail {guardrail.name} did not report an action for "
f"{', '.join(unreported)}; verify manually that matches are blocked or "
"masked on the output path."
)
else:
report.status = "PASS"
count = len(guardrail.sensitive_information_entries)
report.status_extended = (
f"Bedrock Guardrail {guardrail.name} blocks or masks "
f"{'its 1 configured sensitive information entry' if count == 1 else f'all {count} configured sensitive information entries'}"
" on the output path."
)
findings.append(report)
return findings
@@ -0,0 +1,43 @@
{
"Provider": "aws",
"CheckID": "bedrock_model_invocation_logging_text_data_enabled",
"CheckTitle": "Bedrock model invocation logging delivers prompt and response text.",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices",
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices"
],
"ServiceName": "bedrock",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "Other",
"ResourceGroup": "ai_ml",
"Description": "Bedrock model invocation logging is checked for text data delivery. A logging configuration can name a destination while excluding request and response bodies, so the destination alone does not establish that prompts and completions are captured.",
"Risk": "Invocation logging that excludes **text data** records that a call happened, but not what was sent or returned.\n\nThat leaves no evidence for the investigations which depend on content itself: **prompt injection**, data exfiltration, and abuse of the model by an authorized caller.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/bedrock/latest/userguide/model-invocation-logging.html#model-invocation-logging-console",
"https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/aws/Bedrock/enable-model-invocation-logging.html",
"https://docs.aws.amazon.com/bedrock/latest/userguide/model-invocation-logging.html"
],
"Remediation": {
"Code": {
"CLI": "aws bedrock put-model-invocation-logging-configuration --logging-config '{\"textDataDeliveryEnabled\":true,\"cloudWatchConfig\":{\"logGroupName\":\"<log_group>\",\"roleArn\":\"<role_arn>\"}}'",
"NativeIaC": "",
"Other": "",
"Terraform": ""
},
"Recommendation": {
"Text": "Set textDataDeliveryEnabled to true on the model invocation logging configuration so prompt and response bodies are delivered to the configured destination.",
"Url": "https://hub.prowler.com/check/bedrock_model_invocation_logging_text_data_enabled"
}
},
"Categories": [
"logging",
"forensics-ready",
"gen-ai"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,62 @@
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.providers.aws.services.bedrock.bedrock_client import bedrock_client
class bedrock_model_invocation_logging_text_data_enabled(Check):
"""Ensure model invocation logging delivers prompt and response text.
Model invocation logging can name a CloudWatch log group or S3 bucket while excluding the
request and response bodies, so a configured destination does not establish that prompts and
completions are captured. This check is scoped to Regions where invocation logging is already
enabled: a Region with logging switched off is the subject of
bedrock_model_invocation_logging_enabled and is not reported here.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Execute the check logic.
Returns:
A list of reports containing the result of the check.
"""
findings = []
for region, logging in bedrock_client.logging_configurations.items():
# Skip-if-absent: only Regions that already deliver logs are in scope.
if not logging.enabled:
continue
report = Check_Report_AWS(metadata=self.metadata(), resource=logging)
report.region = region
# "model-invocation-logging", as both siblings on this configuration use. The account
# id identified no resource -- every Region reported the same id, and the ARN beside it
# already carries the account -- so a reader could not tell two Regions' findings apart
# by resource_id alone.
report.resource_id = "model-invocation-logging"
report.resource_arn = (
bedrock_client._get_model_invocation_logging_arn_template(region)
)
if logging.text_data_delivery_enabled is None:
# The API did not report the flag. Absent is not False, and reporting it as
# compliant would assert content capture that was never confirmed.
report.status = "MANUAL"
report.status_extended = (
f"Bedrock Model Invocation Logging is enabled in region {region} but the "
"text data delivery setting was not returned; verify manually that prompt "
"and response text is delivered."
)
elif logging.text_data_delivery_enabled:
report.status = "PASS"
report.status_extended = (
f"Bedrock Model Invocation Logging in region {region} delivers prompt and "
"response text."
)
else:
report.status = "FAIL"
report.status_extended = (
f"Bedrock Model Invocation Logging in region {region} excludes prompt and "
"response text, so invocation content is not captured."
)
findings.append(report)
return findings
@@ -33,6 +33,13 @@ class Bedrock(AWSService):
)
def _get_model_invocation_logging_configuration(self, regional_client):
"""Read the model invocation logging configuration for a region.
An empty `loggingConfig` is how the API reports logging switched off, so it maps to
`enabled=False` rather than to an absent entry. The four per-data-type delivery flags are
stored as reported, including None: the API omits a flag it was never given, and None
cannot be collapsed to False without asserting an exclusion that was never configured.
"""
logger.info("Bedrock - Getting Model Invocation Logging Configuration...")
try:
logging_config = (
@@ -48,6 +55,18 @@ class Bedrock(AWSService):
).get("logGroupName"),
s3_bucket=logging_config.get("s3Config", {}).get("bucketName"),
enabled=True,
text_data_delivery_enabled=logging_config.get(
"textDataDeliveryEnabled"
),
image_data_delivery_enabled=logging_config.get(
"imageDataDeliveryEnabled"
),
embedding_data_delivery_enabled=logging_config.get(
"embeddingDataDeliveryEnabled"
),
video_data_delivery_enabled=logging_config.get(
"videoDataDeliveryEnabled"
),
)
)
else:
@@ -94,6 +113,17 @@ class Bedrock(AWSService):
)
def _get_guardrail(self, guardrail):
"""Read one guardrail's policies and flatten its sensitive-information entries.
`piiEntities` and `regexes` are flattened into a single list because both carry the same
action/outputAction/outputEnabled triple and both leak the same way; the entry name keeps
the kind visible in findings. Each field is stored as reported, so an action the API did
not return stays None instead of becoming a verdict.
`detail_retrieved` is set only on the success path. A guardrail whose configuration could
not be read therefore carries no filter claims at all, and `detail_error` names the API
error code so a check can report the cause rather than a compliant-looking default.
"""
logger.info("Bedrock - Getting Guardrail...")
try:
guardrail_info = self.regional_clients[guardrail.region].get_guardrail(
@@ -119,8 +149,37 @@ class Bedrock(AWSService):
"filters", []
)
]
sensitive_information_policy = guardrail_info.get(
"sensitiveInformationPolicy", {}
)
for entity in sensitive_information_policy.get("piiEntities", []):
guardrail.sensitive_information_entries.append(
SensitiveInformationEntry(
name=f"PII entity {entity.get('type', 'unknown')}",
action=entity.get("action"),
output_action=entity.get("outputAction"),
output_enabled=entity.get("outputEnabled"),
)
)
for regex in sensitive_information_policy.get("regexes", []):
guardrail.sensitive_information_entries.append(
SensitiveInformationEntry(
name=f"regex {regex.get('name', 'unknown')}",
action=regex.get("action"),
output_action=regex.get("outputAction"),
output_enabled=regex.get("outputEnabled"),
)
)
guardrail.detail_retrieved = True
except ClientError as error:
guardrail.detail_error = error.response["Error"].get(
"Code", error.__class__.__name__
)
logger.error(
f"{guardrail.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
except Exception as error:
guardrail.detail_error = error.__class__.__name__
logger.error(
f"{guardrail.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
@@ -201,6 +260,29 @@ class LoggingConfiguration(BaseModel):
enabled: bool = False
cloudwatch_log_group: Optional[str] = None
s3_bucket: Optional[str] = None
# GetModelInvocationLoggingConfiguration reports which data types are actually delivered.
# A configuration can name a destination while excluding the prompt and response bodies, so
# the destination alone does not establish that invocation content is captured. None means
# the API did not report the flag, which is not the same as False.
text_data_delivery_enabled: Optional[bool] = None
image_data_delivery_enabled: Optional[bool] = None
embedding_data_delivery_enabled: Optional[bool] = None
video_data_delivery_enabled: Optional[bool] = None
class SensitiveInformationEntry(BaseModel):
"""One entry of a guardrail sensitive-information policy: a PII entity or a custom regex.
GetGuardrail always reports `action` (the API marks it required) and omits the per-path
`outputAction` / `outputEnabled` unless they were configured explicitly, in which case
`action` governs both the input and the output path. None therefore means the API did not
report the per-path setting, which is not the same as False or NONE.
"""
name: str
action: Optional[str] = None
output_action: Optional[str] = None
output_enabled: Optional[bool] = None
class ContextualGroundingFilter(BaseModel):
@@ -227,6 +309,8 @@ class Guardrail(BaseModel):
contextual_grounding_filters: list[ContextualGroundingFilter] = []
# False when GetGuardrail failed: absent policy is unknown, not unset.
detail_retrieved: bool = False
detail_error: Optional[str] = None
sensitive_information_entries: list[SensitiveInformationEntry] = []
class CustomModel(BaseModel):
@@ -1,6 +1,7 @@
from datetime import datetime, timedelta, timezone
from unittest import mock
from freezegun import freeze_time
from moto import mock_aws
from prowler.lib.check.models import Severity
@@ -124,10 +125,45 @@ class Test_bedrock_api_key_no_long_term_credentials:
assert result[0].status == "PASS"
assert "has already expired" in result[0].status_extended
# The expiration instant itself belongs to the expired side: at ExpirationDate the
# credential can no longer authenticate. Time is frozen so the key's expiration and the
# check's `now` are the same instant rather than microseconds apart, which is the only
# way this boundary is reachable.
@freeze_time("2026-01-15T12:00:00Z")
@mock_aws
def test_key_without_expiration_date_ignored(self):
def test_key_expiring_at_exactly_now_passes(self):
"""A key whose ExpirationDate is exactly now must PASS as already expired.
The boundary decides which side a strict comparison puts it on. At the expiration instant
the credential can no longer authenticate, so it is not a long-term credential; a `<`
rather than `<=` would report it as still live.
"""
credential = _make_credential(_make_user(), expiration_delta_days=0)
assert credential.expiration_date == datetime.now(timezone.utc)
result = _run_check([credential])
assert len(result) == 1
assert result[0].status == "PASS"
assert "has already expired" in result[0].status_extended
@mock_aws
def test_key_without_expiration_date_fails_critical(self):
"""A key with no expiration date is the never-expiring key, not an out-of-scope one.
IAM's ``ServiceSpecificCredentialMetadata.ExpirationDate`` "is only present for Bedrock
API keys ... that were created with an expiration period", so a key created without one
reports no ExpirationDate at all. Skipping those produced zero findings for exactly the
credential shape this check calls critical.
"""
credential = _make_credential(_make_user(), expiration_delta_days=None)
assert _run_check([credential]) == []
result = _run_check([credential])
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].check_metadata.Severity == Severity.critical
assert "never expires" in result[0].status_extended
assert "short-term Bedrock API keys" in result[0].status_extended
@mock_aws
def test_non_bedrock_service_ignored(self):
@@ -42,6 +42,13 @@ def mock_make_api_call_no_filter(self, operation_name, kwarg):
def mock_make_api_call_with_filter(self, operation_name, kwarg):
"""Serve one guardrail carrying a sensitive information policy, so this check must PASS.
sensitiveInformationPolicy is a structure of piiEntities and regexes, never the boolean this
fixture previously sent. The check only asks whether the key is present, so the boolean did
not change its verdict -- but it also meant no test in the suite exercised the collector
against a response the API can actually return.
"""
if operation_name == "ListGuardrails":
return {
"guardrails": [
@@ -59,7 +66,9 @@ def mock_make_api_call_with_filter(self, operation_name, kwarg):
"guardrailId": "test-id",
"guardrailArn": GUARDRAIL_ARN,
"status": "READY",
"sensitiveInformationPolicy": True,
"sensitiveInformationPolicy": {
"piiEntities": [{"type": "EMAIL", "action": "ANONYMIZE"}]
},
"contentPolicy": {"filters": []},
"blockedInputMessaging": "Sorry, the model cannot answer this question.",
"blockedOutputsMessaging": "Sorry, the model cannot answer this question.",
@@ -0,0 +1,493 @@
from unittest import mock
import botocore
from moto import mock_aws
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
CHECK_MODULE = (
"prowler.providers.aws.services.bedrock."
"bedrock_guardrail_sensitive_information_filter_enforced."
"bedrock_guardrail_sensitive_information_filter_enforced"
)
GUARDRAIL_ARN = (
f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:guardrail/test-id"
)
def _guardrail_detail(sensitive_information_policy=None):
"""Build a GetGuardrail response, omitting sensitiveInformationPolicy when None is passed.
Omission is the point: the API leaves the key out entirely when no policy is configured, and a
guardrail in that state is out of this check's scope. Passing None reproduces that state
faithfully rather than sending an empty dict the API would never return.
"""
detail = {
"name": "test",
"guardrailId": "test-id",
"guardrailArn": GUARDRAIL_ARN,
"status": "READY",
"blockedInputMessaging": "Sorry, the model cannot answer this question.",
"blockedOutputsMessaging": "Sorry, the model cannot answer this question.",
}
if sensitive_information_policy is not None:
detail["sensitiveInformationPolicy"] = sensitive_information_policy
return detail
def _api_call(sensitive_information_policy=None, get_guardrail_error=None):
"""Build a _make_api_call replacement returning one guardrail with the given policy."""
def _mocked(self, operation_name, kwarg):
"""Serve ListGuardrails, GetGuardrail and ListTagsForResource; defer everything else.
GetGuardrail raises when get_guardrail_error was supplied, which is how the
detail-unreadable state is reproduced.
"""
if operation_name == "ListGuardrails":
return {
"guardrails": [
{
"id": "test-id",
"arn": GUARDRAIL_ARN,
"status": "READY",
"name": "test",
}
]
}
if operation_name == "GetGuardrail":
if get_guardrail_error is not None:
raise get_guardrail_error
return _guardrail_detail(sensitive_information_policy)
if operation_name == "ListTagsForResource":
return {"tags": []}
return make_api_call(self, operation_name, kwarg)
return _mocked
SECOND_GUARDRAIL_ARN = (
f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:guardrail/second-id"
)
def _api_call_two_guardrails(self, operation_name, kwarg):
"""One enforcing guardrail and one inert one, so a truncated loop cannot pass."""
if operation_name == "ListGuardrails":
return {
"guardrails": [
{
"id": "test-id",
"arn": GUARDRAIL_ARN,
"status": "READY",
"name": "test",
},
{
"id": "second-id",
"arn": SECOND_GUARDRAIL_ARN,
"status": "READY",
"name": "second",
},
]
}
if operation_name == "GetGuardrail":
if kwarg["guardrailIdentifier"] == "second-id":
detail = _guardrail_detail(
{"piiEntities": [{"type": "EMAIL", "action": "NONE"}]}
)
detail["name"] = "second"
detail["guardrailId"] = "second-id"
detail["guardrailArn"] = SECOND_GUARDRAIL_ARN
return detail
return _guardrail_detail(
{"piiEntities": [{"type": "EMAIL", "action": "ANONYMIZE"}]}
)
if operation_name == "ListTagsForResource":
return {"tags": []}
return make_api_call(self, operation_name, kwarg)
def _run_check(api_call, regions=None):
"""Execute the check against a Bedrock service built from the given _make_api_call stand-in.
The service is constructed inside the patch so its collectors read the stand-in, then injected
as the check's bedrock_client. Returns the list of reports the check produced.
"""
from prowler.providers.aws.services.bedrock.bedrock_service import Bedrock
aws_provider = set_mocked_aws_provider(regions or [AWS_REGION_US_EAST_1])
with mock.patch("botocore.client.BaseClient._make_api_call", new=api_call):
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
f"{CHECK_MODULE}.bedrock_client",
new=Bedrock(aws_provider),
),
):
from prowler.providers.aws.services.bedrock.bedrock_guardrail_sensitive_information_filter_enforced.bedrock_guardrail_sensitive_information_filter_enforced import (
bedrock_guardrail_sensitive_information_filter_enforced,
)
check = bedrock_guardrail_sensitive_information_filter_enforced()
return check.execute()
class Test_bedrock_guardrail_sensitive_information_filter_enforced:
@mock_aws
def test_no_guardrails(self):
"""An account with no guardrails in either audited Region must produce no findings at all.
Not a PASS: there is no resource to make a claim about, and a synthesised PASS per Region
would report enforcement on something that does not exist.
"""
from prowler.providers.aws.services.bedrock.bedrock_service import Bedrock
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(
f"{CHECK_MODULE}.bedrock_client",
new=Bedrock(aws_provider),
),
):
from prowler.providers.aws.services.bedrock.bedrock_guardrail_sensitive_information_filter_enforced.bedrock_guardrail_sensitive_information_filter_enforced import (
bedrock_guardrail_sensitive_information_filter_enforced,
)
check = bedrock_guardrail_sensitive_information_filter_enforced()
assert check.execute() == []
@mock_aws
def test_guardrail_without_sensitive_information_policy_skipped(self):
"""A guardrail with no policy at all is the other check's subject, not this one's."""
assert _run_check(_api_call(sensitive_information_policy=None)) == []
@mock_aws
def test_pii_entities_anonymize_passes(self):
"""Two PII entities both set to ANONYMIZE must PASS: masking stops the value leaving.
Also pins the plural wording and the resource identity, so the PASS message and the
guardrail it is attributed to are both asserted, not just the status.
"""
result = _run_check(
_api_call(
{
"piiEntities": [
{"type": "EMAIL", "action": "ANONYMIZE"},
{"type": "NAME", "action": "ANONYMIZE"},
]
}
)
)
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Bedrock Guardrail test blocks or masks all 2 configured sensitive information entries on the output path."
)
assert result[0].resource_id == "test-id"
assert result[0].resource_arn == GUARDRAIL_ARN
assert result[0].region == AWS_REGION_US_EAST_1
@mock_aws
def test_pii_entity_action_none_fails(self):
"""The proven false PASS: a policy present, detecting, and taking no action."""
result = _run_check(
_api_call({"piiEntities": [{"type": "EMAIL", "action": "NONE"}]})
)
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Bedrock Guardrail test detects but does not block or mask PII entity EMAIL on the output path, so matched sensitive information still reaches the caller."
)
assert result[0].resource_arn == GUARDRAIL_ARN
@mock_aws
def test_one_entity_of_several_set_to_none_fails(self):
"""One NONE entry among enforcing ones must FAIL and name only the NONE entry.
The claim is universal, so a single unenforced entry decides it; naming the compliant
EMAIL entry alongside it would misdirect remediation at the entry that is already correct.
"""
result = _run_check(
_api_call(
{
"piiEntities": [
{"type": "EMAIL", "action": "ANONYMIZE"},
{"type": "AWS_SECRET_KEY", "action": "NONE"},
]
}
)
)
assert len(result) == 1
assert result[0].status == "FAIL"
assert "PII entity AWS_SECRET_KEY" in result[0].status_extended
assert "PII entity EMAIL" not in result[0].status_extended
@mock_aws
def test_regex_action_none_fails(self):
"""A regex set to NONE must FAIL even when every PII entity blocks.
regexes and piiEntities leak identically, so the check must read both arms of the policy;
an implementation that walked only piiEntities would PASS this guardrail.
"""
result = _run_check(
_api_call(
{
"piiEntities": [{"type": "EMAIL", "action": "BLOCK"}],
"regexes": [
{
"name": "internal-token",
"pattern": "tok-[0-9]+",
"action": "NONE",
}
],
}
)
)
assert len(result) == 1
assert result[0].status == "FAIL"
assert "regex internal-token" in result[0].status_extended
@mock_aws
def test_regex_block_passes(self):
"""A lone regex set to BLOCK must PASS, with the singular noun in the message.
A policy carrying only regexes and no piiEntities is a valid configuration, so it must
reach PASS rather than falling through the entity loop into a default verdict.
"""
result = _run_check(
_api_call(
{
"regexes": [
{
"name": "internal-token",
"pattern": "tok-[0-9]+",
"action": "BLOCK",
}
]
}
)
)
assert len(result) == 1
assert result[0].status == "PASS"
# Singular: "all 1 ... entries" was a noun/count disagreement corrected in #12459 --
# match the noun to the count.
assert (
"its 1 configured sensitive information entry" in result[0].status_extended
)
@mock_aws
def test_output_action_none_overrides_enforcing_action(self):
"""outputAction is the output-path setting and wins over `action` when reported."""
result = _run_check(
_api_call(
{
"piiEntities": [
{
"type": "EMAIL",
"action": "ANONYMIZE",
"outputAction": "NONE",
}
]
}
)
)
assert len(result) == 1
assert result[0].status == "FAIL"
assert "PII entity EMAIL" in result[0].status_extended
@mock_aws
def test_output_action_enforcing_with_action_none_passes(self):
"""action NONE with outputAction BLOCK must PASS: the output path is what this check judges.
The converse of test_output_action_none_overrides_enforcing_action. Reading `action` first
would FAIL a guardrail that does block on output, so this pins the precedence in the
direction where getting it wrong produces a false positive.
"""
result = _run_check(
_api_call(
{
"piiEntities": [
{"type": "EMAIL", "action": "NONE", "outputAction": "BLOCK"}
]
}
)
)
assert len(result) == 1
assert result[0].status == "PASS"
@mock_aws
def test_output_evaluation_disabled_fails(self):
"""An enforcing action never runs when output evaluation is switched off."""
result = _run_check(
_api_call(
{
"piiEntities": [
{
"type": "EMAIL",
"action": "BLOCK",
"outputEnabled": False,
}
]
}
)
)
assert len(result) == 1
assert result[0].status == "FAIL"
assert "PII entity EMAIL" in result[0].status_extended
@mock_aws
def test_output_enabled_absent_is_not_treated_as_disabled(self):
"""GetGuardrail omits outputEnabled unless it was set; absent must not read as False."""
result = _run_check(
_api_call({"piiEntities": [{"type": "EMAIL", "action": "BLOCK"}]})
)
assert len(result) == 1
assert result[0].status == "PASS"
@mock_aws
def test_absent_action_is_manual_not_pass(self):
"""An action the API did not report is unknown, not compliant and not a falsy default."""
result = _run_check(_api_call({"piiEntities": [{"type": "EMAIL"}]}))
assert len(result) == 1
assert result[0].status == "MANUAL"
assert (
result[0].status_extended
== "Bedrock Guardrail test did not report an action for PII entity EMAIL; verify manually that matches are blocked or masked on the output path."
)
@mock_aws
def test_proven_leak_outranks_an_unreported_action(self):
"""A guardrail with both an unknown and a NONE entry must FAIL, and still name the unknown.
The original intent here was that such a guardrail "must not silently PASS or FAIL-only" --
the worry being that a bare FAIL would drop the unreported entry. Reporting MANUAL solved
that but created a worse problem: one entry whose action the API omitted hid an entry proven
to take no action, so a provable leak read as merely unknown. This check makes a universal
claim (every entry must block or mask), so one entry known to take no action settles it,
which is the rule the maintainer applied on #12459 -- "definite draft sharing outranks an
incomplete alias inventory". The unknown is still disclosed, in the FAIL message.
"""
result = _run_check(
_api_call(
{
"piiEntities": [
{"type": "EMAIL"},
{"type": "NAME", "action": "NONE"},
]
}
)
)
assert len(result) == 1
assert result[0].status == "FAIL"
assert "PII entity NAME" in result[0].status_extended
assert "PII entity EMAIL" in result[0].status_extended
assert "was not reported" in result[0].status_extended
@mock_aws
def test_fail_message_is_stable_under_entry_reordering(self):
"""The FAIL message must be byte identical when GetGuardrail returns the entries reversed.
GetGuardrail documents no ordering for piiEntities or regexes, so the same unchanged
guardrail can be returned entry-reversed on the next scan. Both rendered lists are built by
append and joined, so without a sort one unchanged guardrail produces two different
status_extended strings, which downstream reads as a finding that changed.
"""
entries = [
{"type": "EMAIL", "action": "NONE"},
{"type": "ADDRESS", "action": "NONE"},
{"type": "NAME"},
{"type": "AGE"},
]
forward = _run_check(_api_call({"piiEntities": entries}))
reversed_ = _run_check(_api_call({"piiEntities": list(reversed(entries))}))
assert len(forward) == 1 and len(reversed_) == 1
# Both lists are asserted to render in sorted order before the two runs are compared: two
# runs that both rendered no entry names at all would be byte identical and prove nothing.
assert forward[0].status == "FAIL" and reversed_[0].status == "FAIL"
message = forward[0].status_extended
assert "PII entity ADDRESS, PII entity EMAIL" in message, message
assert "PII entity AGE, PII entity NAME" in message, message
assert message == reversed_[0].status_extended
@mock_aws
def test_manual_message_is_stable_under_entry_reordering(self):
"""The MANUAL message must be byte identical when the unreported entries arrive reversed.
The MANUAL branch renders the unreported list a second time, so it needs its own sort; a
sort placed only on the FAIL path leaves this message unstable.
"""
entries = [{"type": "NAME"}, {"type": "AGE"}]
forward = _run_check(_api_call({"piiEntities": entries}))
reversed_ = _run_check(_api_call({"piiEntities": list(reversed(entries))}))
assert len(forward) == 1 and len(reversed_) == 1
assert forward[0].status == "MANUAL" and reversed_[0].status == "MANUAL"
message = forward[0].status_extended
assert "PII entity AGE, PII entity NAME" in message, message
assert message == reversed_[0].status_extended
@mock_aws
def test_every_guardrail_is_reported(self):
"""One report per in-scope guardrail: a loop that stops early must not read as clean."""
result = _run_check(_api_call_two_guardrails)
assert len(result) == 2
by_id = {report.resource_id: report for report in result}
assert by_id["test-id"].status == "PASS"
assert by_id["second-id"].status == "FAIL"
assert "PII entity EMAIL" in by_id["second-id"].status_extended
@mock_aws
def test_unreadable_guardrail_detail_is_manual_not_pass(self):
"""GetGuardrail denied must not be reported as a compliant guardrail."""
error = botocore.exceptions.ClientError(
{
"Error": {
"Code": "AccessDeniedException",
"Message": "not authorized to perform: bedrock:GetGuardrail",
}
},
"GetGuardrail",
)
result = _run_check(_api_call(get_guardrail_error=error))
assert len(result) == 1
assert result[0].status == "MANUAL"
assert (
result[0].status_extended
== "Bedrock Guardrail test configuration could not be retrieved (AccessDeniedException); verify manually that its sensitive information filters block or mask matches on the output path."
)
assert result[0].resource_arn == GUARDRAIL_ARN
assert result[0].region == AWS_REGION_US_EAST_1
@@ -0,0 +1,144 @@
from unittest import mock
from prowler.providers.aws.services.bedrock.bedrock_service import LoggingConfiguration
from tests.providers.aws.utils import (
AWS_ACCOUNT_NUMBER,
AWS_REGION_US_EAST_1,
set_mocked_aws_provider,
)
class Test_bedrock_model_invocation_logging_text_data_enabled:
def _run(self, configs):
"""Execute the check with logging_configurations set to the given region -> config map.
Returns the list of reports produced, so a caller can assert both the count and each
verdict.
"""
# An INSTANCE, not the class. Assigning attributes to mock.MagicMock itself mutates it
# for every later test in the session -- it poisoned
# bedrock_model_invocation_logs_encryption_enabled_test with a plain lambda where it
# expected a mock attribute.
bedrock_client = mock.MagicMock()
bedrock_client.logging_configurations = configs
bedrock_client.audited_account = AWS_ACCOUNT_NUMBER
bedrock_client.audited_partition = "aws"
bedrock_client.region = AWS_REGION_US_EAST_1
bedrock_client._get_model_invocation_logging_arn_template = (
lambda region: f"arn:aws:bedrock:{region}:{AWS_ACCOUNT_NUMBER}:model-invocation-logging"
)
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_aws_provider([AWS_REGION_US_EAST_1]),
),
mock.patch(
"prowler.providers.aws.services.bedrock.bedrock_model_invocation_logging_text_data_enabled.bedrock_model_invocation_logging_text_data_enabled.bedrock_client",
bedrock_client,
),
):
from prowler.providers.aws.services.bedrock.bedrock_model_invocation_logging_text_data_enabled.bedrock_model_invocation_logging_text_data_enabled import (
bedrock_model_invocation_logging_text_data_enabled,
)
return bedrock_model_invocation_logging_text_data_enabled().execute()
def test_no_logging_configurations(self):
"""No configuration collected for any Region must produce no findings.
An empty inventory is the state after the collector reached no Region at all, so there is
nothing to make a claim about; a PASS here would assert content capture nowhere observed.
"""
assert self._run({}) == []
def test_logging_disabled_is_out_of_scope(self):
"""A Region with logging off belongs to the sibling check, not this one."""
result = self._run({AWS_REGION_US_EAST_1: LoggingConfiguration(enabled=False)})
assert result == []
def test_text_delivery_enabled(self):
"""Logging on with textDataDeliveryEnabled true must PASS: prompts and responses are captured."""
result = self._run(
{
AWS_REGION_US_EAST_1: LoggingConfiguration(
enabled=True,
cloudwatch_log_group="/aws/bedrock",
text_data_delivery_enabled=True,
)
}
)
assert len(result) == 1
assert result[0].status == "PASS"
assert "delivers prompt and response text" in result[0].status_extended
# The three identity fields were asserted nowhere, so region, resource_id and the ARN
# template could each have been wrong or dropped with the suite still green. resource_id is "model-invocation-logging" to match both siblings on this
# configuration; it used to be the account id, which is identical in every Region and so
# could not distinguish two Regions' findings.
assert result[0].region == AWS_REGION_US_EAST_1
assert result[0].resource_id == "model-invocation-logging"
assert (
result[0].resource_arn
== f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:model-invocation-logging"
)
def test_text_delivery_disabled_is_fail(self):
"""The whole point: a destination is configured but bodies are excluded."""
result = self._run(
{
AWS_REGION_US_EAST_1: LoggingConfiguration(
enabled=True,
cloudwatch_log_group="/aws/bedrock",
text_data_delivery_enabled=False,
)
}
)
assert len(result) == 1
assert result[0].status == "FAIL"
assert "excludes prompt and response text" in result[0].status_extended
assert result[0].region == AWS_REGION_US_EAST_1
assert result[0].resource_id == "model-invocation-logging"
assert (
result[0].resource_arn
== f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:model-invocation-logging"
)
def test_text_delivery_unreported_is_manual_not_pass(self):
"""Absent is not False. An unanswered field must not be read as compliant."""
result = self._run(
{
AWS_REGION_US_EAST_1: LoggingConfiguration(
enabled=True, cloudwatch_log_group="/aws/bedrock"
)
}
)
assert len(result) == 1
assert result[0].status == "MANUAL"
assert result[0].region == AWS_REGION_US_EAST_1
assert result[0].resource_id == "model-invocation-logging"
assert (
result[0].resource_arn
== f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:model-invocation-logging"
)
def test_multiple_regions_counts_and_split(self):
"""Multi-resource: assert the finding count and the PASS/FAIL split."""
result = self._run(
{
AWS_REGION_US_EAST_1: LoggingConfiguration(
enabled=True, text_data_delivery_enabled=True
),
"eu-west-1": LoggingConfiguration(
enabled=True, text_data_delivery_enabled=False
),
"us-west-2": LoggingConfiguration(enabled=False),
}
)
assert len(result) == 2
# Assert the Region-to-status MAPPING, not the multiset. Counting one PASS and one FAIL
# passes just as happily if the two verdicts are swapped, which is the one thing a
# per-Region check can get wrong here. The dict also pins that us-west-2, whose logging is
# off entirely, produces no finding at all rather than a third one.
assert {r.region: r.status for r in result} == {
AWS_REGION_US_EAST_1: "PASS",
"eu-west-1": "FAIL",
}
@@ -22,6 +22,12 @@ GUARDRAIL_ARN = (
def mock_make_api_call(self, operation_name, kwarg):
"""Serve one fully configured guardrail, and defer any other operation to botocore.
The sensitiveInformationPolicy carries one PII entity and one regex, both with an action and
neither with outputAction or outputEnabled, which is the shape GetGuardrail returns when the
per-path settings were never configured.
"""
if operation_name == "ListGuardrails":
return {
"guardrails": [
@@ -48,7 +54,16 @@ def mock_make_api_call(self, operation_name, kwarg):
},
]
},
"sensitiveInformationPolicy": True,
"sensitiveInformationPolicy": {
"piiEntities": [{"type": "EMAIL", "action": "ANONYMIZE"}],
"regexes": [
{
"name": "account-id",
"pattern": "[0-9]{12}",
"action": "BLOCK",
}
],
},
"blockedInputMessaging": "Sorry, the model cannot answer this question.",
"blockedOutputsMessaging": "Sorry, the model cannot answer this question.",
}
@@ -61,6 +76,66 @@ def mock_make_api_call(self, operation_name, kwarg):
return make_api_call(self, operation_name, kwarg)
def mock_make_api_call_guardrail_regex_output_path(self, operation_name, kwarg):
"""A guardrail whose REGEX carries explicit per-path settings.
The shared fixture omits outputAction and outputEnabled on both arms, so an assertion that they
are all None passes whether or not the regex arm reads them -- it cannot discriminate. This
fixture blocks on input and leaks on output, which is the shape that separates the two.
"""
if operation_name == "GetGuardrail":
return {
"guardrailArn": GUARDRAIL_ARN,
"guardrailId": "test-id",
"name": "test",
"status": "READY",
"sensitiveInformationPolicy": {
"regexes": [
{
"name": "internal-token",
"pattern": "tok-[0-9]+",
"action": "BLOCK",
"outputAction": "NONE",
"outputEnabled": True,
}
]
},
"blockedInputMessaging": "no",
"blockedOutputsMessaging": "no",
}
return mock_make_api_call(self, operation_name, kwarg)
def mock_make_api_call_get_guardrail_denied(self, operation_name, kwarg):
"""List the guardrail successfully but deny GetGuardrail.
Reproduces the partial-permission state a caller holding bedrock:ListGuardrails without
bedrock:GetGuardrail sees: the resource is known to exist, its configuration is not.
"""
if operation_name == "ListGuardrails":
return {
"guardrails": [
{
"id": "test-id",
"arn": GUARDRAIL_ARN,
"status": "READY",
"name": "test",
}
]
}
elif operation_name == "GetGuardrail":
raise botocore.exceptions.ClientError(
{
"Error": {
"Code": "AccessDeniedException",
"Message": "not authorized to perform: bedrock:GetGuardrail",
}
},
"GetGuardrail",
)
return make_api_call(self, operation_name, kwarg)
class Test_Bedrock_Service:
@mock_aws
def test_service(self):
@@ -129,6 +204,36 @@ class Test_Bedrock_Service:
)
assert not bedrock.logging_configurations[AWS_REGION_US_EAST_1].enabled
@mock_aws
def test_get_model_invocation_logging_delivery_flags(self):
"""The delivery flags must be carried through, not inferred from the destination.
A configuration can name a log group while excluding the request and response bodies.
Asserting only `enabled` reports content capture that is switched off.
"""
aws_provider = set_mocked_aws_provider(audited_regions=[AWS_REGION_EU_WEST_1])
bedrock_client_eu_west_1 = client("bedrock", region_name="eu-west-1")
bedrock_client_eu_west_1.put_model_invocation_logging_configuration(
loggingConfig={
"cloudWatchConfig": {"logGroupName": "Test", "roleArn": "testrole"},
"textDataDeliveryEnabled": False,
"imageDataDeliveryEnabled": False,
"embeddingDataDeliveryEnabled": True,
"videoDataDeliveryEnabled": False,
}
)
bedrock = Bedrock(aws_provider)
config = bedrock.logging_configurations[AWS_REGION_EU_WEST_1]
assert config.enabled
assert config.text_data_delivery_enabled is False
assert config.image_data_delivery_enabled is False
assert config.embedding_data_delivery_enabled is True
# The fourth flag was the one of four asserted nowhere, so its read could be mis-keyed or
# dropped with the suite green. False rather than True on purpose: it differs from the
# embedding flag beside it, so a swap between the two is caught as well as a read that
# returns None.
assert config.video_data_delivery_enabled is False
@mock.patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call)
@mock_aws
def test_list_guardrails(self):
@@ -143,10 +248,76 @@ class Test_Bedrock_Service:
@mock.patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call)
@mock_aws
def test_get_guardrail(self):
"""A guardrail read without error must record the filters found and no error.
detail_retrieved True with detail_error None is the state that entitles a check to make a
claim about the filters; it is asserted here so the clean read is distinguishable from the
denied read rather than only inferable from it.
"""
aws_provider = set_mocked_aws_provider(audited_regions=[AWS_REGION_US_EAST_1])
bedrock = Bedrock(aws_provider)
assert bedrock.guardrails[GUARDRAIL_ARN].sensitive_information_filter
assert bedrock.guardrails[GUARDRAIL_ARN].prompt_attack_filter_strength == "HIGH"
assert bedrock.guardrails[GUARDRAIL_ARN].detail_retrieved is True
assert bedrock.guardrails[GUARDRAIL_ARN].detail_error is None
@mock.patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call)
@mock_aws
def test_get_guardrail_sensitive_information_entries(self):
"""Every PII entity and regex must be carried through with its per-path action.
The presence of a sensitive information policy says nothing about whether a match is
acted on: an entry whose action is NONE detects the value and still returns it.
"""
aws_provider = set_mocked_aws_provider(audited_regions=[AWS_REGION_US_EAST_1])
bedrock = Bedrock(aws_provider)
entries = bedrock.guardrails[GUARDRAIL_ARN].sensitive_information_entries
assert [entry.name for entry in entries] == [
"PII entity EMAIL",
"regex account-id",
]
assert entries[0].action == "ANONYMIZE"
assert entries[1].action == "BLOCK"
# The API omits the per-path settings unless they were configured explicitly.
assert all(entry.output_action is None for entry in entries)
assert all(entry.output_enabled is None for entry in entries)
@mock.patch(
"botocore.client.BaseClient._make_api_call",
new=mock_make_api_call_guardrail_regex_output_path,
)
@mock_aws
def test_get_guardrail_regex_carries_its_per_path_settings(self):
"""A regex's outputAction and outputEnabled must be read, not only a PII entity's.
Mis-keying either read on the regex arm left all 202 bedrock tests green while a guardrail
that blocks on input and leaks on output flipped FAIL -> PASS in the check above -- its
central claim, output-path enforcement, reversed with nothing failing. The PII arm was
already covered; only the regex arm was not, and the existing assertion could not tell,
because the shared fixture omits these fields on both arms so `all(... is None)` holds
either way.
"""
aws_provider = set_mocked_aws_provider(audited_regions=[AWS_REGION_US_EAST_1])
bedrock = Bedrock(aws_provider)
entries = bedrock.guardrails[GUARDRAIL_ARN].sensitive_information_entries
assert [entry.name for entry in entries] == ["regex internal-token"]
assert entries[0].action == "BLOCK"
assert entries[0].output_action == "NONE"
assert entries[0].output_enabled is True
@mock.patch(
"botocore.client.BaseClient._make_api_call",
new=mock_make_api_call_get_guardrail_denied,
)
@mock_aws
def test_get_guardrail_error_records_unretrieved_detail(self):
"""A guardrail whose detail was never read must be distinguishable from a clean read."""
aws_provider = set_mocked_aws_provider(audited_regions=[AWS_REGION_US_EAST_1])
bedrock = Bedrock(aws_provider)
guardrail = bedrock.guardrails[GUARDRAIL_ARN]
assert guardrail.detail_retrieved is False
assert guardrail.detail_error == "AccessDeniedException"
assert guardrail.sensitive_information_entries == []
@mock.patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call)
@mock_aws