diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 5f27097cac..84643d604b 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ### 🚀 Added - `bedrock_full_access_policy_attached` check for AWS provider [(#10577)](https://github.com/prowler-cloud/prowler/pull/10577) +- `iam_role_access_not_stale_to_bedrock` and `iam_user_access_not_stale_to_bedrock` checks for AWS provider [(#10536)](https://github.com/prowler-cloud/prowler/pull/10536) --- diff --git a/prowler/compliance/aws/c5_aws.json b/prowler/compliance/aws/c5_aws.json index def79b6aa3..249bd1fb8c 100644 --- a/prowler/compliance/aws/c5_aws.json +++ b/prowler/compliance/aws/c5_aws.json @@ -5282,6 +5282,8 @@ "iam_password_policy_reuse_24", "cognito_user_pool_blocks_potential_malicious_sign_in_attempts", "cognito_user_pool_blocks_compromised_credentials_sign_in_attempts", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", "iam_user_accesskey_unused", "iam_user_console_access_unused", "secretsmanager_secret_unused" @@ -6351,6 +6353,8 @@ "iam_role_administratoraccess_policy", "iam_role_cross_account_readonlyaccess_policy", "iam_rotate_access_key_90_days", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", "iam_user_accesskey_unused", "iam_user_administrator_access_policy", "iam_user_console_access_unused", diff --git a/prowler/compliance/aws/csa_ccm_4.0_aws.json b/prowler/compliance/aws/csa_ccm_4.0_aws.json index 6d0a019c2b..6b6c7e5e7a 100644 --- a/prowler/compliance/aws/csa_ccm_4.0_aws.json +++ b/prowler/compliance/aws/csa_ccm_4.0_aws.json @@ -3098,6 +3098,8 @@ } ], "Checks": [ + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", "iam_user_accesskey_unused", "iam_user_console_access_unused", "iam_user_two_active_access_key" @@ -3436,6 +3438,8 @@ } ], "Checks": [ + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", "iam_user_accesskey_unused", "iam_user_console_access_unused", "iam_user_no_setup_initial_access_key" @@ -3543,6 +3547,8 @@ } ], "Checks": [ + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", "iam_user_accesskey_unused", "iam_user_console_access_unused", "iam_rotate_access_key_90_days", diff --git a/prowler/compliance/aws/ens_rd2022_aws.json b/prowler/compliance/aws/ens_rd2022_aws.json index 1d25aeb3b3..556f0f8868 100644 --- a/prowler/compliance/aws/ens_rd2022_aws.json +++ b/prowler/compliance/aws/ens_rd2022_aws.json @@ -542,6 +542,8 @@ } ], "Checks": [ + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", "iam_user_accesskey_unused", "iam_user_console_access_unused" ] diff --git a/prowler/compliance/aws/fedramp_20x_ksi_low_aws.json b/prowler/compliance/aws/fedramp_20x_ksi_low_aws.json index 60afe9d4fd..b0418f9d45 100644 --- a/prowler/compliance/aws/fedramp_20x_ksi_low_aws.json +++ b/prowler/compliance/aws/fedramp_20x_ksi_low_aws.json @@ -105,6 +105,8 @@ "iam_root_hardware_mfa_enabled", "iam_root_mfa_enabled", "iam_rotate_access_key_90_days", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", "iam_user_accesskey_unused", "iam_user_console_access_unused", "iam_user_hardware_mfa_enabled", @@ -319,6 +321,8 @@ "iam_no_root_access_key", "iam_policy_attached_only_to_group_or_roles", "iam_rotate_access_key_90_days", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", "iam_user_accesskey_unused", "iam_user_console_access_unused", "organizations_delegated_administrators" diff --git a/prowler/compliance/aws/fedramp_low_revision_4_aws.json b/prowler/compliance/aws/fedramp_low_revision_4_aws.json index e06a2a75ad..cf2c0592e1 100644 --- a/prowler/compliance/aws/fedramp_low_revision_4_aws.json +++ b/prowler/compliance/aws/fedramp_low_revision_4_aws.json @@ -37,6 +37,8 @@ "iam_rotate_access_key_90_days", "iam_user_mfa_enabled_console_access", "iam_user_hardware_mfa_enabled", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", "iam_user_accesskey_unused", "iam_user_console_access_unused", "rds_instance_integration_cloudwatch_logs", diff --git a/prowler/compliance/aws/fedramp_moderate_revision_4_aws.json b/prowler/compliance/aws/fedramp_moderate_revision_4_aws.json index 7ca5160ff3..b623f63ba2 100644 --- a/prowler/compliance/aws/fedramp_moderate_revision_4_aws.json +++ b/prowler/compliance/aws/fedramp_moderate_revision_4_aws.json @@ -30,6 +30,8 @@ "iam_rotate_access_key_90_days", "iam_user_mfa_enabled_console_access", "iam_user_mfa_enabled_console_access", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", "iam_user_accesskey_unused", "iam_user_console_access_unused", "securityhub_enabled" @@ -105,6 +107,8 @@ "iam_rotate_access_key_90_days", "iam_user_mfa_enabled_console_access", "iam_user_mfa_enabled_console_access", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", "iam_user_accesskey_unused", "iam_user_console_access_unused" ] @@ -159,6 +163,8 @@ "iam_rotate_access_key_90_days", "iam_user_mfa_enabled_console_access", "iam_user_mfa_enabled_console_access", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", "iam_user_accesskey_unused", "iam_user_console_access_unused" ] @@ -177,6 +183,8 @@ ], "Checks": [ "iam_password_policy_minimum_length_14", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", "iam_user_accesskey_unused", "iam_user_console_access_unused" ] @@ -308,6 +316,8 @@ "iam_customer_attached_policy_no_administrative_privileges", "iam_inline_policy_no_administrative_privileges", "iam_no_root_access_key", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", "iam_user_accesskey_unused", "iam_user_console_access_unused", "awslambda_function_not_publicly_accessible", diff --git a/prowler/compliance/aws/iso27001_2013_aws.json b/prowler/compliance/aws/iso27001_2013_aws.json index a817ec22bd..cf7e5bb0a6 100644 --- a/prowler/compliance/aws/iso27001_2013_aws.json +++ b/prowler/compliance/aws/iso27001_2013_aws.json @@ -867,6 +867,8 @@ } ], "Checks": [ + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", "iam_user_accesskey_unused", "iam_user_console_access_unused" ] diff --git a/prowler/compliance/aws/iso27001_2022_aws.json b/prowler/compliance/aws/iso27001_2022_aws.json index 87fa20c965..b3cbaabf74 100644 --- a/prowler/compliance/aws/iso27001_2022_aws.json +++ b/prowler/compliance/aws/iso27001_2022_aws.json @@ -245,6 +245,8 @@ "iam_policy_attached_only_to_group_or_roles", "iam_user_mfa_enabled_console_access", "iam_root_mfa_enabled", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", "iam_rotate_access_key_90_days", "iam_user_accesskey_unused", "iam_user_console_access_unused", diff --git a/prowler/compliance/aws/mitre_attack_aws.json b/prowler/compliance/aws/mitre_attack_aws.json index e6bcad5870..93545b3870 100644 --- a/prowler/compliance/aws/mitre_attack_aws.json +++ b/prowler/compliance/aws/mitre_attack_aws.json @@ -169,6 +169,8 @@ "iam_customer_unattached_policy_no_administrative_privileges", "iam_inline_policy_no_administrative_privileges", "iam_no_expired_server_certificates_stored", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", "iam_user_accesskey_unused", "iam_user_console_access_unused", "iam_no_root_access_key", diff --git a/prowler/compliance/aws/nis2_aws.json b/prowler/compliance/aws/nis2_aws.json index dc6b75657f..1ee18feba8 100644 --- a/prowler/compliance/aws/nis2_aws.json +++ b/prowler/compliance/aws/nis2_aws.json @@ -1910,6 +1910,8 @@ "Id": "11.5.4", "Description": "The relevant entities shall regularly review the identities for network and information systems and their users and, if no longer needed, deactivate them without delay.", "Checks": [ + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", "iam_user_accesskey_unused", "iam_user_console_access_unused" ], diff --git a/prowler/compliance/aws/nist_800_171_revision_2_aws.json b/prowler/compliance/aws/nist_800_171_revision_2_aws.json index f11f49635d..7767597546 100644 --- a/prowler/compliance/aws/nist_800_171_revision_2_aws.json +++ b/prowler/compliance/aws/nist_800_171_revision_2_aws.json @@ -30,6 +30,8 @@ "iam_no_root_access_key", "iam_user_mfa_enabled_console_access", "iam_user_mfa_enabled_console_access", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", "iam_user_accesskey_unused", "iam_user_console_access_unused", "awslambda_function_not_publicly_accessible", @@ -72,6 +74,8 @@ "iam_no_root_access_key", "iam_user_mfa_enabled_console_access", "iam_user_mfa_enabled_console_access", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", "iam_user_accesskey_unused", "iam_user_console_access_unused", "awslambda_function_not_publicly_accessible", @@ -156,6 +160,8 @@ "iam_customer_attached_policy_no_administrative_privileges", "iam_inline_policy_no_administrative_privileges", "iam_no_root_access_key", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", "iam_user_accesskey_unused", "iam_user_console_access_unused" ] @@ -579,6 +585,8 @@ "Checks": [ "iam_password_policy_reuse_24", "iam_password_policy_expires_passwords_within_90_days_or_less", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", "iam_user_accesskey_unused", "iam_user_console_access_unused" ] diff --git a/prowler/compliance/aws/nist_800_53_revision_4_aws.json b/prowler/compliance/aws/nist_800_53_revision_4_aws.json index 45639cce62..0a38bcb217 100644 --- a/prowler/compliance/aws/nist_800_53_revision_4_aws.json +++ b/prowler/compliance/aws/nist_800_53_revision_4_aws.json @@ -21,6 +21,8 @@ "guardduty_is_enabled", "iam_password_policy_reuse_24", "iam_rotate_access_key_90_days", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", "iam_user_accesskey_unused", "iam_user_console_access_unused", "securityhub_enabled" @@ -39,6 +41,8 @@ } ], "Checks": [ + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", "iam_user_accesskey_unused", "iam_user_console_access_unused" ] @@ -110,6 +114,8 @@ "iam_root_mfa_enabled", "iam_no_root_access_key", "iam_rotate_access_key_90_days", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", "iam_user_accesskey_unused", "iam_user_console_access_unused", "rds_instance_integration_cloudwatch_logs", @@ -230,6 +236,8 @@ "iam_customer_attached_policy_no_administrative_privileges", "iam_inline_policy_no_administrative_privileges", "iam_no_root_access_key", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", "iam_user_accesskey_unused", "iam_user_console_access_unused", "awslambda_function_url_public", diff --git a/prowler/compliance/aws/nist_800_53_revision_5_aws.json b/prowler/compliance/aws/nist_800_53_revision_5_aws.json index a83dc9f331..5f30bada81 100644 --- a/prowler/compliance/aws/nist_800_53_revision_5_aws.json +++ b/prowler/compliance/aws/nist_800_53_revision_5_aws.json @@ -29,6 +29,8 @@ "iam_rotate_access_key_90_days", "iam_user_mfa_enabled_console_access", "iam_user_mfa_enabled_console_access", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", "iam_user_accesskey_unused", "iam_user_console_access_unused", "secretsmanager_automatic_rotation_enabled" @@ -49,6 +51,8 @@ ], "Checks": [ "iam_password_policy_minimum_length_14", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", "iam_user_accesskey_unused", "iam_user_console_access_unused" ] @@ -68,6 +72,8 @@ ], "Checks": [ "iam_password_policy_minimum_length_14", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", "iam_user_accesskey_unused", "iam_user_console_access_unused" ] @@ -87,6 +93,8 @@ ], "Checks": [ "iam_password_policy_minimum_length_14", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", "iam_user_accesskey_unused", "iam_user_console_access_unused" ] @@ -106,6 +114,8 @@ ], "Checks": [ "iam_password_policy_minimum_length_14", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", "iam_user_accesskey_unused", "iam_user_console_access_unused" ] @@ -124,6 +134,8 @@ ], "Checks": [ "iam_password_policy_minimum_length_14", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", "iam_user_accesskey_unused", "iam_user_console_access_unused" ] @@ -233,6 +245,8 @@ } ], "Checks": [ + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", "iam_user_accesskey_unused", "iam_user_console_access_unused" ] @@ -269,6 +283,8 @@ } ], "Checks": [ + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", "iam_user_accesskey_unused", "iam_user_console_access_unused" ] @@ -843,6 +859,8 @@ "iam_rotate_access_key_90_days", "iam_user_mfa_enabled_console_access", "iam_user_mfa_enabled_console_access", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", "iam_user_accesskey_unused", "iam_user_console_access_unused", "secretsmanager_automatic_rotation_enabled" @@ -1177,6 +1195,8 @@ "iam_customer_attached_policy_no_administrative_privileges", "iam_inline_policy_no_administrative_privileges", "iam_no_root_access_key", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", "iam_user_accesskey_unused", "iam_user_console_access_unused", "awslambda_function_not_publicly_accessible", diff --git a/prowler/compliance/aws/nist_csf_1.1_aws.json b/prowler/compliance/aws/nist_csf_1.1_aws.json index b7e5bbe6e5..34e55bde96 100644 --- a/prowler/compliance/aws/nist_csf_1.1_aws.json +++ b/prowler/compliance/aws/nist_csf_1.1_aws.json @@ -575,6 +575,8 @@ "iam_inline_policy_no_administrative_privileges", "iam_no_root_access_key", "iam_rotate_access_key_90_days", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", "iam_user_accesskey_unused", "iam_user_console_access_unused", "secretsmanager_automatic_rotation_enabled" @@ -632,6 +634,8 @@ "iam_customer_attached_policy_no_administrative_privileges", "iam_inline_policy_no_administrative_privileges", "iam_no_root_access_key", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", "iam_user_accesskey_unused", "iam_user_console_access_unused" ] diff --git a/prowler/compliance/aws/nist_csf_2.0_aws.json b/prowler/compliance/aws/nist_csf_2.0_aws.json index 370b4981c4..fc8b1b58ca 100644 --- a/prowler/compliance/aws/nist_csf_2.0_aws.json +++ b/prowler/compliance/aws/nist_csf_2.0_aws.json @@ -705,6 +705,8 @@ "iam_root_mfa_enabled", "iam_no_root_access_key", "iam_user_console_access_unused", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", "iam_user_accesskey_unused", "iam_user_two_active_access_key", "iam_root_credentials_management_enabled", diff --git a/prowler/compliance/aws/pci_3.2.1_aws.json b/prowler/compliance/aws/pci_3.2.1_aws.json index 649f32a94c..ccab1c0813 100644 --- a/prowler/compliance/aws/pci_3.2.1_aws.json +++ b/prowler/compliance/aws/pci_3.2.1_aws.json @@ -1560,6 +1560,8 @@ "Name": "Observe user accounts to verify that any inactive accounts over 90 days old are either removed or disabled", "Description": "Accounts that are not used regularly are often targets of attack since it is less likely that any changes (such as a changed password) will be noticed. As such, these accounts may be more easily exploited and used to access cardholder data.", "Checks": [ + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", "iam_password_policy_reuse_24", "iam_user_accesskey_unused", "iam_user_console_access_unused" diff --git a/prowler/compliance/aws/secnumcloud_3.2_aws.json b/prowler/compliance/aws/secnumcloud_3.2_aws.json index b216e36397..a3f44b5339 100644 --- a/prowler/compliance/aws/secnumcloud_3.2_aws.json +++ b/prowler/compliance/aws/secnumcloud_3.2_aws.json @@ -293,6 +293,8 @@ } ], "Checks": [ + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", "iam_user_accesskey_unused", "iam_user_console_access_unused", "iam_no_expired_server_certificates_stored" @@ -336,6 +338,8 @@ ], "Checks": [ "iam_rotate_access_key_90_days", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", "iam_user_accesskey_unused", "iam_user_console_access_unused", "accessanalyzer_enabled_without_findings" diff --git a/prowler/providers/aws/services/iam/iam_role_access_not_stale_to_bedrock/__init__.py b/prowler/providers/aws/services/iam/iam_role_access_not_stale_to_bedrock/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/iam/iam_role_access_not_stale_to_bedrock/iam_role_access_not_stale_to_bedrock.metadata.json b/prowler/providers/aws/services/iam/iam_role_access_not_stale_to_bedrock/iam_role_access_not_stale_to_bedrock.metadata.json new file mode 100644 index 0000000000..0038a055f4 --- /dev/null +++ b/prowler/providers/aws/services/iam/iam_role_access_not_stale_to_bedrock/iam_role_access_not_stale_to_bedrock.metadata.json @@ -0,0 +1,44 @@ +{ + "Provider": "aws", + "CheckID": "iam_role_access_not_stale_to_bedrock", + "CheckTitle": "Regular Bedrock access ensures IAM roles retain only actively used permissions", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "iam", + "SubServiceName": "", + "ResourceIdTemplate": "arn:partition:iam::account-id:role/resource-id", + "Severity": "medium", + "ResourceType": "AwsIamRole", + "ResourceGroup": "IAM", + "Description": "IAM roles granted **Bedrock** permissions are evaluated for recent service usage.\n\nRoles whose last Bedrock access exceeds the configured threshold (default **60 days**) or that have **never** accessed Bedrock are flagged, indicating stale permissions that should be reviewed.", + "Risk": "Stale Bedrock permissions widen the **blast radius** of a credential compromise.\n\nAn attacker who assumes a role with unused Bedrock permissions can invoke foundation models, exfiltrate data through model responses, or incur significant costs — all without triggering expected usage patterns.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_access-advisor.html", + "https://docs.aws.amazon.com/bedrock/latest/userguide/security-iam.html", + "https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#remove-credentials" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Open the IAM console and select the role\n2. Review the **Access Advisor** tab to confirm Bedrock has not been accessed recently\n3. Remove or detach any policies granting Bedrock permissions that are no longer needed\n4. If the role still requires Bedrock access, verify usage and reduce scope to least privilege", + "Terraform": "" + }, + "Recommendation": { + "Text": "Apply the **principle of least privilege** by regularly reviewing IAM Access Advisor data and revoking Bedrock permissions that are no longer actively used.\n\nEstablish a periodic access review process and automate alerts for stale permissions to maintain a minimal attack surface.", + "Url": "https://hub.prowler.com/check/iam_role_access_not_stale_to_bedrock" + } + }, + "Categories": [ + "identity-access", + "gen-ai" + ], + "DependsOn": [], + "RelatedTo": [ + "bedrock_api_key_no_administrative_privileges", + "bedrock_api_key_no_long_term_credentials" + ], + "Notes": "The staleness threshold is configurable via the `max_unused_bedrock_access_days` audit config key (default: 60 days)." +} diff --git a/prowler/providers/aws/services/iam/iam_role_access_not_stale_to_bedrock/iam_role_access_not_stale_to_bedrock.py b/prowler/providers/aws/services/iam/iam_role_access_not_stale_to_bedrock/iam_role_access_not_stale_to_bedrock.py new file mode 100644 index 0000000000..d0287ab970 --- /dev/null +++ b/prowler/providers/aws/services/iam/iam_role_access_not_stale_to_bedrock/iam_role_access_not_stale_to_bedrock.py @@ -0,0 +1,65 @@ +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 ( + evaluate_bedrock_staleness, + find_bedrock_service, +) + + +class iam_role_access_not_stale_to_bedrock(Check): + """Detect IAM roles with stale Bedrock permissions. + + This check evaluates whether IAM roles with Bedrock service permissions + have actively used those permissions within the configured threshold + (default 60 days). + + - PASS: The role has accessed Bedrock within the allowed period. + - FAIL: The role has Bedrock permissions but has not used them within + the allowed period or has never used them. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the Bedrock access staleness check for IAM roles. + + Iterates over IAM roles, inspecting service last accessed data for + the ``bedrock`` namespace. Roles whose last Bedrock access exceeds + the configured threshold are reported as non-compliant. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + max_unused_bedrock_days = iam_client.audit_config.get( + "max_unused_bedrock_access_days", 60 + ) + + for ( + role_data, + last_accessed_services, + ) in iam_client.role_last_accessed_services.items(): + role_name = role_data[0] + role_arn = role_data[1] + + bedrock_service = find_bedrock_service(last_accessed_services) + if bedrock_service is None: + continue + + report = Check_Report_AWS( + metadata=self.metadata(), + resource={"name": role_name, "arn": role_arn}, + ) + report.resource_id = role_name + report.resource_arn = role_arn + report.region = iam_client.region + if iam_client.roles is not None: + for iam_role in iam_client.roles: + if iam_role.arn == role_arn: + report.resource_tags = iam_role.tags + break + + evaluate_bedrock_staleness( + report, bedrock_service, max_unused_bedrock_days, role_name, "Role" + ) + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/iam/iam_service.py b/prowler/providers/aws/services/iam/iam_service.py index a8469d3b9d..d6cb150178 100644 --- a/prowler/providers/aws/services/iam/iam_service.py +++ b/prowler/providers/aws/services/iam/iam_service.py @@ -1,5 +1,6 @@ import csv from datetime import datetime +from time import sleep from typing import Optional from botocore.client import ClientError @@ -92,6 +93,12 @@ class IAM(AWSService): self._get_access_keys_metadata() self.last_accessed_services = {} self._get_last_accessed_services() + self.role_last_accessed_services = {} + if ( + "iam_role_access_not_stale_to_bedrock" + in provider.audit_metadata.expected_checks + ): + self._get_role_last_accessed_services() self.user_temporary_credentials_usage = {} self._get_user_temporary_credentials_usage() self.organization_features = [] @@ -901,6 +908,74 @@ class IAM(AWSService): f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _get_role_last_accessed_services(self): + """Retrieve service last accessed details for all IAM roles. + + Uses a fire-all-then-collect pattern: all generate calls are + submitted first so the jobs run server-side in parallel, then + results are collected in a second pass. + """ + logger.info("IAM - Getting Role Last Accessed Services ...") + try: + if self.roles is None: + return + + # Phase 1: fire all generate requests + pending_jobs = [] + for role in self.roles: + try: + details = self.client.generate_service_last_accessed_details( + Arn=role.arn + ) + pending_jobs.append((role.name, role.arn, details["JobId"])) + except ClientError as error: + if error.response["Error"]["Code"] == "NoSuchEntity": + logger.warning( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + else: + logger.error( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + logger.error( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + # Phase 2: collect results + max_retries = 60 + for role_name, role_arn, job_id in pending_jobs: + try: + retries = 0 + response = self.client.get_service_last_accessed_details( + JobId=job_id + ) + while response["JobStatus"] == "IN_PROGRESS": + retries += 1 + if retries > max_retries: + logger.warning( + f"{self.region} -- Timeout waiting for service last accessed details for role {role_name}" + ) + break + sleep(1) + response = self.client.get_service_last_accessed_details( + JobId=job_id + ) + if response["JobStatus"] == "COMPLETED": + self.role_last_accessed_services[(role_name, role_arn)] = ( + response.get("ServicesLastAccessed", []) + ) + + except Exception as error: + logger.error( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + except Exception as error: + logger.error( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + def _get_access_keys_metadata(self): logger.info("IAM - Getting Access Keys Metadata ...") try: diff --git a/prowler/providers/aws/services/iam/iam_user_access_not_stale_to_bedrock/__init__.py b/prowler/providers/aws/services/iam/iam_user_access_not_stale_to_bedrock/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/iam/iam_user_access_not_stale_to_bedrock/iam_user_access_not_stale_to_bedrock.metadata.json b/prowler/providers/aws/services/iam/iam_user_access_not_stale_to_bedrock/iam_user_access_not_stale_to_bedrock.metadata.json new file mode 100644 index 0000000000..04547bfbc5 --- /dev/null +++ b/prowler/providers/aws/services/iam/iam_user_access_not_stale_to_bedrock/iam_user_access_not_stale_to_bedrock.metadata.json @@ -0,0 +1,44 @@ +{ + "Provider": "aws", + "CheckID": "iam_user_access_not_stale_to_bedrock", + "CheckTitle": "Regular Bedrock access ensures IAM users retain only actively used permissions", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "iam", + "SubServiceName": "", + "ResourceIdTemplate": "arn:partition:iam::account-id:user/resource-id", + "Severity": "medium", + "ResourceType": "AwsIamUser", + "ResourceGroup": "IAM", + "Description": "IAM users granted **Bedrock** permissions are evaluated for recent service usage.\n\nUsers whose last Bedrock access exceeds the configured threshold (default **60 days**) or that have **never** accessed Bedrock are flagged, indicating stale permissions that should be reviewed.", + "Risk": "Stale Bedrock permissions widen the **blast radius** of a credential compromise.\n\nAn attacker who gains access to a user with unused Bedrock permissions can invoke foundation models, exfiltrate data through model responses, or incur significant costs — all without triggering expected usage patterns.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_access-advisor.html", + "https://docs.aws.amazon.com/bedrock/latest/userguide/security-iam.html", + "https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#remove-credentials" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Open the IAM console and select the user\n2. Review the **Access Advisor** tab to confirm Bedrock has not been accessed recently\n3. Remove or detach any policies granting Bedrock permissions that are no longer needed\n4. If the user still requires Bedrock access, verify usage and reduce scope to least privilege", + "Terraform": "" + }, + "Recommendation": { + "Text": "Apply the **principle of least privilege** by regularly reviewing IAM Access Advisor data and revoking Bedrock permissions that are no longer actively used.\n\nEstablish a periodic access review process and automate alerts for stale permissions to maintain a minimal attack surface.", + "Url": "https://hub.prowler.com/check/iam_user_access_not_stale_to_bedrock" + } + }, + "Categories": [ + "identity-access", + "gen-ai" + ], + "DependsOn": [], + "RelatedTo": [ + "bedrock_api_key_no_administrative_privileges", + "bedrock_api_key_no_long_term_credentials" + ], + "Notes": "The staleness threshold is configurable via the `max_unused_bedrock_access_days` audit config key (default: 60 days)." +} diff --git a/prowler/providers/aws/services/iam/iam_user_access_not_stale_to_bedrock/iam_user_access_not_stale_to_bedrock.py b/prowler/providers/aws/services/iam/iam_user_access_not_stale_to_bedrock/iam_user_access_not_stale_to_bedrock.py new file mode 100644 index 0000000000..09fb215782 --- /dev/null +++ b/prowler/providers/aws/services/iam/iam_user_access_not_stale_to_bedrock/iam_user_access_not_stale_to_bedrock.py @@ -0,0 +1,64 @@ +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 ( + evaluate_bedrock_staleness, + find_bedrock_service, +) + + +class iam_user_access_not_stale_to_bedrock(Check): + """Detect IAM users with stale Bedrock permissions. + + This check evaluates whether IAM users with Bedrock service permissions + have actively used those permissions within the configured threshold + (default 60 days). + + - PASS: The user has accessed Bedrock within the allowed period. + - FAIL: The user has Bedrock permissions but has not used them within + the allowed period or has never used them. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the Bedrock access staleness check for IAM users. + + Iterates over IAM users, inspecting service last accessed data for + the ``bedrock`` namespace. Users whose last Bedrock access exceeds + the configured threshold are reported as non-compliant. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + max_unused_bedrock_days = iam_client.audit_config.get( + "max_unused_bedrock_access_days", 60 + ) + + for ( + user_data, + last_accessed_services, + ) in iam_client.last_accessed_services.items(): + user_name = user_data[0] + user_arn = user_data[1] + + bedrock_service = find_bedrock_service(last_accessed_services) + if bedrock_service is None: + continue + + report = Check_Report_AWS( + metadata=self.metadata(), + resource={"name": user_name, "arn": user_arn}, + ) + report.resource_id = user_name + report.resource_arn = user_arn + report.region = iam_client.region + for iam_user in iam_client.users: + if iam_user.arn == user_arn: + report.resource_tags = iam_user.tags + break + + evaluate_bedrock_staleness( + report, bedrock_service, max_unused_bedrock_days, user_name, "User" + ) + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/iam/lib/policy.py b/prowler/providers/aws/services/iam/lib/policy.py index ecf09d312f..fa3352bd6c 100644 --- a/prowler/providers/aws/services/iam/lib/policy.py +++ b/prowler/providers/aws/services/iam/lib/policy.py @@ -1,9 +1,12 @@ import re +from datetime import datetime, timezone from ipaddress import ip_address, ip_network from typing import Optional, Tuple +from dateutil.parser import parse from py_iam_expand.actions import InvalidActionHandling, expand_actions +from prowler.lib.check.models import Check_Report_AWS from prowler.lib.logger import logger from prowler.providers.aws.aws_provider import read_aws_regions_file @@ -1031,3 +1034,47 @@ def has_codebuild_trusted_principal(trust_policy: dict) -> bool: ) for s in statements ) + + +def find_bedrock_service(last_accessed_services: list[dict]) -> Optional[dict]: + """Return the Bedrock entry from a service last accessed list.""" + for service in last_accessed_services: + if service.get("ServiceNamespace") == "bedrock": + return service + return None + + +def evaluate_bedrock_staleness( + report: Check_Report_AWS, + bedrock_service: dict, + max_days: int, + identity_name: str, + identity_type: str, +) -> None: + """Populate a check report based on Bedrock access recency.""" + last_authenticated = bedrock_service.get("LastAuthenticated") + if last_authenticated is None: + report.status = "FAIL" + report.status_extended = ( + f"IAM {identity_type} {identity_name} has Bedrock permissions " + f"but has never used them." + ) + return + + if isinstance(last_authenticated, str): + last_authenticated = parse(last_authenticated) + + days_since_access = (datetime.now(timezone.utc) - last_authenticated).days + + if days_since_access > max_days: + report.status = "FAIL" + report.status_extended = ( + f"IAM {identity_type} {identity_name} has not accessed Bedrock " + f"in {days_since_access} days (threshold: {max_days} days)." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"IAM {identity_type} {identity_name} accessed Bedrock " + f"{days_since_access} days ago (threshold: {max_days} days)." + ) diff --git a/tests/providers/aws/services/iam/iam_role_access_not_stale_to_bedrock/__init__.py b/tests/providers/aws/services/iam/iam_role_access_not_stale_to_bedrock/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/aws/services/iam/iam_role_access_not_stale_to_bedrock/iam_role_access_not_stale_to_bedrock_test.py b/tests/providers/aws/services/iam/iam_role_access_not_stale_to_bedrock/iam_role_access_not_stale_to_bedrock_test.py new file mode 100644 index 0000000000..ecff542b28 --- /dev/null +++ b/tests/providers/aws/services/iam/iam_role_access_not_stale_to_bedrock/iam_role_access_not_stale_to_bedrock_test.py @@ -0,0 +1,240 @@ +from datetime import datetime, timedelta, timezone +from unittest import mock + +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +IAM_ROLE_NAME = "test-role" +IAM_ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/{IAM_ROLE_NAME}" +ROLE_DATA = (IAM_ROLE_NAME, IAM_ROLE_ARN) + +CHECK_MODULE = ( + "prowler.providers.aws.services.iam." + "iam_role_access_not_stale_to_bedrock.iam_role_access_not_stale_to_bedrock" +) + + +class Test_iam_role_access_not_stale_to_bedrock: + @mock_aws + def test_no_roles_with_bedrock_permissions(self): + """No findings when no roles have Bedrock in last accessed services.""" + from prowler.providers.aws.services.iam.iam_service import IAM + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + iam.role_last_accessed_services = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_role_access_not_stale_to_bedrock.iam_role_access_not_stale_to_bedrock import ( + iam_role_access_not_stale_to_bedrock, + ) + + check = iam_role_access_not_stale_to_bedrock() + assert len(check.execute()) == 0 + + @mock_aws + def test_role_bedrock_access_stale(self): + """FAIL when a role last accessed Bedrock more than 60 days ago.""" + from prowler.providers.aws.services.iam.iam_service import IAM, Role + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + + mock_role = Role( + name=IAM_ROLE_NAME, + arn=IAM_ROLE_ARN, + assume_role_policy={}, + is_service_role=False, + attached_policies=[], + inline_policies=[], + ) + iam.roles = [mock_role] + + last_authenticated = datetime.now(timezone.utc) - timedelta(days=120) + iam.role_last_accessed_services = { + ROLE_DATA: [ + { + "ServiceNamespace": "bedrock", + "ServiceName": "Amazon Bedrock", + "LastAuthenticated": last_authenticated, + }, + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_role_access_not_stale_to_bedrock.iam_role_access_not_stale_to_bedrock import ( + iam_role_access_not_stale_to_bedrock, + ) + + check = iam_role_access_not_stale_to_bedrock() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "has not accessed Bedrock" in result[0].status_extended + assert "Role" in result[0].status_extended + assert IAM_ROLE_NAME in result[0].status_extended + assert result[0].resource_id == IAM_ROLE_NAME + assert result[0].resource_arn == IAM_ROLE_ARN + assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_role_bedrock_access_recent(self): + """PASS when a role accessed Bedrock recently.""" + from prowler.providers.aws.services.iam.iam_service import IAM, Role + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + + mock_role = Role( + name=IAM_ROLE_NAME, + arn=IAM_ROLE_ARN, + assume_role_policy={}, + is_service_role=False, + attached_policies=[], + inline_policies=[], + ) + iam.roles = [mock_role] + + last_authenticated = datetime.now(timezone.utc) - timedelta(days=5) + iam.role_last_accessed_services = { + ROLE_DATA: [ + { + "ServiceNamespace": "bedrock", + "ServiceName": "Amazon Bedrock", + "LastAuthenticated": last_authenticated, + }, + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_role_access_not_stale_to_bedrock.iam_role_access_not_stale_to_bedrock import ( + iam_role_access_not_stale_to_bedrock, + ) + + check = iam_role_access_not_stale_to_bedrock() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "accessed Bedrock" in result[0].status_extended + assert "Role" in result[0].status_extended + assert IAM_ROLE_NAME in result[0].status_extended + + @mock_aws + def test_role_bedrock_never_accessed(self): + """FAIL when a role has Bedrock permissions but never accessed them.""" + from prowler.providers.aws.services.iam.iam_service import IAM, Role + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + + mock_role = Role( + name=IAM_ROLE_NAME, + arn=IAM_ROLE_ARN, + assume_role_policy={}, + is_service_role=False, + attached_policies=[], + inline_policies=[], + ) + iam.roles = [mock_role] + + iam.role_last_accessed_services = { + ROLE_DATA: [ + { + "ServiceNamespace": "bedrock", + "ServiceName": "Amazon Bedrock", + }, + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_role_access_not_stale_to_bedrock.iam_role_access_not_stale_to_bedrock import ( + iam_role_access_not_stale_to_bedrock, + ) + + check = iam_role_access_not_stale_to_bedrock() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "has never used them" in result[0].status_extended + assert "Role" in result[0].status_extended + + @mock_aws + def test_role_tags_are_populated(self): + """Verify resource_tags are populated from the role object.""" + from prowler.providers.aws.services.iam.iam_service import IAM, Role + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + + role_tags = [{"Key": "Team", "Value": "ml-platform"}] + mock_role = Role( + name=IAM_ROLE_NAME, + arn=IAM_ROLE_ARN, + assume_role_policy={}, + is_service_role=False, + attached_policies=[], + inline_policies=[], + tags=role_tags, + ) + iam.roles = [mock_role] + + last_authenticated = datetime.now(timezone.utc) - timedelta(days=5) + iam.role_last_accessed_services = { + ROLE_DATA: [ + { + "ServiceNamespace": "bedrock", + "ServiceName": "Amazon Bedrock", + "LastAuthenticated": last_authenticated, + }, + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_role_access_not_stale_to_bedrock.iam_role_access_not_stale_to_bedrock import ( + iam_role_access_not_stale_to_bedrock, + ) + + check = iam_role_access_not_stale_to_bedrock() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_tags == role_tags diff --git a/tests/providers/aws/services/iam/iam_user_access_not_stale_to_bedrock/__init__.py b/tests/providers/aws/services/iam/iam_user_access_not_stale_to_bedrock/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/aws/services/iam/iam_user_access_not_stale_to_bedrock/iam_user_access_not_stale_to_bedrock_test.py b/tests/providers/aws/services/iam/iam_user_access_not_stale_to_bedrock/iam_user_access_not_stale_to_bedrock_test.py new file mode 100644 index 0000000000..c05b9362c9 --- /dev/null +++ b/tests/providers/aws/services/iam/iam_user_access_not_stale_to_bedrock/iam_user_access_not_stale_to_bedrock_test.py @@ -0,0 +1,449 @@ +from datetime import datetime, timedelta, timezone +from unittest import mock + +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +IAM_USER_NAME = "test-user" +IAM_USER_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:user/{IAM_USER_NAME}" +USER_DATA = (IAM_USER_NAME, IAM_USER_ARN) + +CHECK_MODULE = ( + "prowler.providers.aws.services.iam." + "iam_user_access_not_stale_to_bedrock.iam_user_access_not_stale_to_bedrock" +) + + +class Test_iam_user_access_not_stale_to_bedrock: + @mock_aws + def test_no_users_with_bedrock_permissions(self): + """No findings when no users have Bedrock in last accessed services.""" + from prowler.providers.aws.services.iam.iam_service import IAM + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + iam.last_accessed_services = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_bedrock.iam_user_access_not_stale_to_bedrock import ( + iam_user_access_not_stale_to_bedrock, + ) + + check = iam_user_access_not_stale_to_bedrock() + assert len(check.execute()) == 0 + + @mock_aws + def test_user_without_bedrock_permissions(self): + """User with non-Bedrock services is skipped.""" + from prowler.providers.aws.services.iam.iam_service import IAM, User + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + + mock_user = User( + name=IAM_USER_NAME, + arn=IAM_USER_ARN, + attached_policies=[], + inline_policies=[], + ) + iam.users = [mock_user] + iam.last_accessed_services = { + USER_DATA: [ + {"ServiceNamespace": "iam", "ServiceName": "IAM"}, + {"ServiceNamespace": "s3", "ServiceName": "S3"}, + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_bedrock.iam_user_access_not_stale_to_bedrock import ( + iam_user_access_not_stale_to_bedrock, + ) + + check = iam_user_access_not_stale_to_bedrock() + assert len(check.execute()) == 0 + + @mock_aws + def test_user_bedrock_access_recent(self): + """PASS when user accessed Bedrock within the threshold.""" + from prowler.providers.aws.services.iam.iam_service import IAM, User + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + + mock_user = User( + name=IAM_USER_NAME, + arn=IAM_USER_ARN, + attached_policies=[], + inline_policies=[], + ) + iam.users = [mock_user] + + last_authenticated = datetime.now(timezone.utc) - timedelta(days=10) + iam.last_accessed_services = { + USER_DATA: [ + { + "ServiceNamespace": "bedrock", + "ServiceName": "Amazon Bedrock", + "LastAuthenticated": last_authenticated, + }, + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_bedrock.iam_user_access_not_stale_to_bedrock import ( + iam_user_access_not_stale_to_bedrock, + ) + + check = iam_user_access_not_stale_to_bedrock() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "accessed Bedrock" in result[0].status_extended + assert IAM_USER_NAME in result[0].status_extended + assert result[0].resource_id == IAM_USER_NAME + assert result[0].resource_arn == IAM_USER_ARN + assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_user_bedrock_access_stale(self): + """FAIL when user last accessed Bedrock more than 60 days ago.""" + from prowler.providers.aws.services.iam.iam_service import IAM, User + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + + mock_user = User( + name=IAM_USER_NAME, + arn=IAM_USER_ARN, + attached_policies=[], + inline_policies=[], + ) + iam.users = [mock_user] + + last_authenticated = datetime.now(timezone.utc) - timedelta(days=90) + iam.last_accessed_services = { + USER_DATA: [ + { + "ServiceNamespace": "bedrock", + "ServiceName": "Amazon Bedrock", + "LastAuthenticated": last_authenticated, + }, + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_bedrock.iam_user_access_not_stale_to_bedrock import ( + iam_user_access_not_stale_to_bedrock, + ) + + check = iam_user_access_not_stale_to_bedrock() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "has not accessed Bedrock" in result[0].status_extended + assert "90 days" in result[0].status_extended + assert IAM_USER_NAME in result[0].status_extended + + @mock_aws + def test_user_bedrock_never_accessed(self): + """FAIL when user has Bedrock permissions but has never used them.""" + from prowler.providers.aws.services.iam.iam_service import IAM, User + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + + mock_user = User( + name=IAM_USER_NAME, + arn=IAM_USER_ARN, + attached_policies=[], + inline_policies=[], + ) + iam.users = [mock_user] + + iam.last_accessed_services = { + USER_DATA: [ + { + "ServiceNamespace": "bedrock", + "ServiceName": "Amazon Bedrock", + }, + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_bedrock.iam_user_access_not_stale_to_bedrock import ( + iam_user_access_not_stale_to_bedrock, + ) + + check = iam_user_access_not_stale_to_bedrock() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "has never used them" in result[0].status_extended + + @mock_aws + def test_custom_threshold_via_audit_config(self): + """Custom threshold from audit_config is respected.""" + from prowler.providers.aws.services.iam.iam_service import IAM, User + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + iam.audit_config = {"max_unused_bedrock_access_days": 30} + + mock_user = User( + name=IAM_USER_NAME, + arn=IAM_USER_ARN, + attached_policies=[], + inline_policies=[], + ) + iam.users = [mock_user] + + last_authenticated = datetime.now(timezone.utc) - timedelta(days=45) + iam.last_accessed_services = { + USER_DATA: [ + { + "ServiceNamespace": "bedrock", + "ServiceName": "Amazon Bedrock", + "LastAuthenticated": last_authenticated, + }, + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_bedrock.iam_user_access_not_stale_to_bedrock import ( + iam_user_access_not_stale_to_bedrock, + ) + + check = iam_user_access_not_stale_to_bedrock() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "45 days" in result[0].status_extended + assert "threshold: 30 days" in result[0].status_extended + + @mock_aws + def test_user_bedrock_access_at_exact_threshold(self): + """PASS when user accessed Bedrock exactly at the 60-day boundary.""" + from prowler.providers.aws.services.iam.iam_service import IAM, User + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + + mock_user = User( + name=IAM_USER_NAME, + arn=IAM_USER_ARN, + attached_policies=[], + inline_policies=[], + ) + iam.users = [mock_user] + + last_authenticated = datetime.now(timezone.utc) - timedelta(days=60) + iam.last_accessed_services = { + USER_DATA: [ + { + "ServiceNamespace": "bedrock", + "ServiceName": "Amazon Bedrock", + "LastAuthenticated": last_authenticated, + }, + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_bedrock.iam_user_access_not_stale_to_bedrock import ( + iam_user_access_not_stale_to_bedrock, + ) + + check = iam_user_access_not_stale_to_bedrock() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "60 days ago" in result[0].status_extended + assert "threshold: 60 days" in result[0].status_extended + + @mock_aws + def test_user_bedrock_access_one_day_over_threshold(self): + """FAIL when user accessed Bedrock 61 days ago.""" + from prowler.providers.aws.services.iam.iam_service import IAM, User + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + + mock_user = User( + name=IAM_USER_NAME, + arn=IAM_USER_ARN, + attached_policies=[], + inline_policies=[], + ) + iam.users = [mock_user] + + last_authenticated = datetime.now(timezone.utc) - timedelta(days=61) + iam.last_accessed_services = { + USER_DATA: [ + { + "ServiceNamespace": "bedrock", + "ServiceName": "Amazon Bedrock", + "LastAuthenticated": last_authenticated, + }, + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_bedrock.iam_user_access_not_stale_to_bedrock import ( + iam_user_access_not_stale_to_bedrock, + ) + + check = iam_user_access_not_stale_to_bedrock() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "61 days" in result[0].status_extended + assert "threshold: 60 days" in result[0].status_extended + + @mock_aws + def test_user_bedrock_access_with_string_date(self): + """PASS when LastAuthenticated is an ISO string instead of a datetime object.""" + from prowler.providers.aws.services.iam.iam_service import IAM, User + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + + mock_user = User( + name=IAM_USER_NAME, + arn=IAM_USER_ARN, + attached_policies=[], + inline_policies=[], + ) + iam.users = [mock_user] + + last_access_date = datetime.now(timezone.utc) - timedelta(days=5) + iam.last_accessed_services = { + USER_DATA: [ + { + "ServiceNamespace": "bedrock", + "ServiceName": "Amazon Bedrock", + "LastAuthenticated": last_access_date.isoformat(), + }, + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_bedrock.iam_user_access_not_stale_to_bedrock import ( + iam_user_access_not_stale_to_bedrock, + ) + + check = iam_user_access_not_stale_to_bedrock() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + + @mock_aws + def test_user_tags_are_populated(self): + """Verify resource_tags are populated from the user object.""" + from prowler.providers.aws.services.iam.iam_service import IAM, User + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + + user_tags = [{"Key": "Environment", "Value": "production"}] + mock_user = User( + name=IAM_USER_NAME, + arn=IAM_USER_ARN, + attached_policies=[], + inline_policies=[], + tags=user_tags, + ) + iam.users = [mock_user] + + last_authenticated = datetime.now(timezone.utc) - timedelta(days=10) + iam.last_accessed_services = { + USER_DATA: [ + { + "ServiceNamespace": "bedrock", + "ServiceName": "Amazon Bedrock", + "LastAuthenticated": last_authenticated, + }, + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam), + ): + from prowler.providers.aws.services.iam.iam_user_access_not_stale_to_bedrock.iam_user_access_not_stale_to_bedrock import ( + iam_user_access_not_stale_to_bedrock, + ) + + check = iam_user_access_not_stale_to_bedrock() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_tags == user_tags