From f19106281be6f3834d8b9818f128892f6290b93b Mon Sep 17 00:00:00 2001 From: lydiavilchez <114735608+lydiavilchez@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:00:26 +0200 Subject: [PATCH] feat(aws): add Nitro Enclaves security checks for EC2 and KMS (#12283) --- ...ws-nitro-enclaves-security-checks.added.md | 1 + .../aws/aws_ai_security_framework_aws.json | 13 +- ...cted_framework_reliability_pillar_aws.json | 3 +- ...itected_framework_security_pillar_aws.json | 9 +- prowler/compliance/aws/c5_aws.json | 12 +- prowler/compliance/aws/cis_3.0_aws.json | 9 +- prowler/compliance/aws/ens_rd2022_aws.json | 15 +- .../aws/fedramp_20x_ksi_low_aws.json | 10 +- .../aws/fedramp_moderate_revision_4_aws.json | 37 +- prowler/compliance/aws/ffiec_aws.json | 3 +- prowler/compliance/aws/gdpr_aws.json | 6 +- .../aws/gxp_21_cfr_part_11_aws.json | 8 +- prowler/compliance/aws/hipaa_aws.json | 27 +- prowler/compliance/aws/iso27001_2022_aws.json | 28 +- .../compliance/aws/kisa_isms_p_2023_aws.json | 16 +- .../aws/kisa_isms_p_2023_korean_aws.json | 16 +- prowler/compliance/aws/mitre_attack_aws.json | 18 +- prowler/compliance/aws/nis2_aws.json | 3 +- .../aws/nist_800_171_revision_2_aws.json | 12 +- .../aws/nist_800_53_revision_5_aws.json | 52 +- prowler/compliance/aws/nist_csf_2.0_aws.json | 10 +- prowler/compliance/aws/pci_3.2.1_aws.json | 26 +- prowler/compliance/aws/pci_4.0_aws.json | 140 +++ .../compliance/aws/secnumcloud_3.2_aws.json | 9 +- prowler/compliance/aws/soc2_aws.json | 23 +- .../services/cloudtrail/cloudtrail_service.py | 35 + .../__init__.py | 0 ...oad_host_imdsv2_not_enforced.metadata.json | 41 + ...ntial_workload_host_imdsv2_not_enforced.py | 51 + .../__init__.py | 0 ...al_workload_host_not_running.metadata.json | 38 + ..._confidential_workload_host_not_running.py | 63 ++ .../__init__.py | 0 ...tial_workload_host_public_ip.metadata.json | 38 + ...c2_confidential_workload_host_public_ip.py | 116 +++ .../__init__.py | 0 ...ad_host_unrestricted_ingress.metadata.json | 38 + ...tial_workload_host_unrestricted_ingress.py | 115 +++ .../__init__.py | 0 ...oad_host_vsock_proxy_exposed.metadata.json | 38 + ...ntial_workload_host_vsock_proxy_exposed.py | 103 ++ .../providers/aws/services/ec2/ec2_service.py | 10 + .../providers/aws/services/ec2/lib/enclave.py | 95 ++ .../__init__.py | 0 ..._attestation_bypassable_path.metadata.json | 45 + ...key_enclave_attestation_bypassable_path.py | 108 +++ .../__init__.py | 0 ...tation_no_deployment_binding.metadata.json | 43 + ...clave_attestation_no_deployment_binding.py | 126 +++ .../__init__.py | 0 ...ave_attestation_not_enforced.metadata.json | 40 + ...ms_key_enclave_attestation_not_enforced.py | 90 ++ .../__init__.py | 0 ...ave_attestation_pcr_mismatch.metadata.json | 40 + ...ms_key_enclave_attestation_pcr_mismatch.py | 145 +++ .../__init__.py | 0 ...ve_attestation_unknown_image.metadata.json | 44 + ...s_key_enclave_attestation_unknown_image.py | 272 ++++++ .../__init__.py | 0 ...e_debug_attestation_detected.metadata.json | 44 + ..._key_enclave_debug_attestation_detected.py | 252 +++++ .../providers/aws/services/kms/kms_service.py | 30 + .../aws/services/kms/lib/__init__.py | 0 .../providers/aws/services/kms/lib/enclave.py | 783 ++++++++++++++++ .../providers/aws/services/vpc/vpc_service.py | 23 +- ..._workload_host_imdsv2_not_enforced_test.py | 160 ++++ ...idential_workload_host_not_running_test.py | 229 +++++ ...nfidential_workload_host_public_ip_test.py | 393 ++++++++ ...workload_host_unrestricted_ingress_test.py | 476 ++++++++++ ..._workload_host_vsock_proxy_exposed_test.py | 258 +++++ .../aws/services/ec2/ec2_service_test.py | 54 ++ ...nclave_attestation_bypassable_path_test.py | 521 +++++++++++ ..._attestation_no_deployment_binding_test.py | 505 ++++++++++ ...y_enclave_attestation_not_enforced_test.py | 884 ++++++++++++++++++ ...y_enclave_attestation_pcr_mismatch_test.py | 493 ++++++++++ ..._enclave_attestation_unknown_image_test.py | 445 +++++++++ ...enclave_debug_attestation_detected_test.py | 578 ++++++++++++ .../aws/services/kms/kms_service_test.py | 57 ++ .../aws/services/vpc/vpc_service_test.py | 44 + 79 files changed, 8364 insertions(+), 105 deletions(-) create mode 100644 prowler/changelog.d/aws-nitro-enclaves-security-checks.added.md create mode 100644 prowler/providers/aws/services/ec2/ec2_confidential_workload_host_imdsv2_not_enforced/__init__.py create mode 100644 prowler/providers/aws/services/ec2/ec2_confidential_workload_host_imdsv2_not_enforced/ec2_confidential_workload_host_imdsv2_not_enforced.metadata.json create mode 100644 prowler/providers/aws/services/ec2/ec2_confidential_workload_host_imdsv2_not_enforced/ec2_confidential_workload_host_imdsv2_not_enforced.py create mode 100644 prowler/providers/aws/services/ec2/ec2_confidential_workload_host_not_running/__init__.py create mode 100644 prowler/providers/aws/services/ec2/ec2_confidential_workload_host_not_running/ec2_confidential_workload_host_not_running.metadata.json create mode 100644 prowler/providers/aws/services/ec2/ec2_confidential_workload_host_not_running/ec2_confidential_workload_host_not_running.py create mode 100644 prowler/providers/aws/services/ec2/ec2_confidential_workload_host_public_ip/__init__.py create mode 100644 prowler/providers/aws/services/ec2/ec2_confidential_workload_host_public_ip/ec2_confidential_workload_host_public_ip.metadata.json create mode 100644 prowler/providers/aws/services/ec2/ec2_confidential_workload_host_public_ip/ec2_confidential_workload_host_public_ip.py create mode 100644 prowler/providers/aws/services/ec2/ec2_confidential_workload_host_unrestricted_ingress/__init__.py create mode 100644 prowler/providers/aws/services/ec2/ec2_confidential_workload_host_unrestricted_ingress/ec2_confidential_workload_host_unrestricted_ingress.metadata.json create mode 100644 prowler/providers/aws/services/ec2/ec2_confidential_workload_host_unrestricted_ingress/ec2_confidential_workload_host_unrestricted_ingress.py create mode 100644 prowler/providers/aws/services/ec2/ec2_confidential_workload_host_vsock_proxy_exposed/__init__.py create mode 100644 prowler/providers/aws/services/ec2/ec2_confidential_workload_host_vsock_proxy_exposed/ec2_confidential_workload_host_vsock_proxy_exposed.metadata.json create mode 100644 prowler/providers/aws/services/ec2/ec2_confidential_workload_host_vsock_proxy_exposed/ec2_confidential_workload_host_vsock_proxy_exposed.py create mode 100644 prowler/providers/aws/services/ec2/lib/enclave.py create mode 100644 prowler/providers/aws/services/kms/kms_key_enclave_attestation_bypassable_path/__init__.py create mode 100644 prowler/providers/aws/services/kms/kms_key_enclave_attestation_bypassable_path/kms_key_enclave_attestation_bypassable_path.metadata.json create mode 100644 prowler/providers/aws/services/kms/kms_key_enclave_attestation_bypassable_path/kms_key_enclave_attestation_bypassable_path.py create mode 100644 prowler/providers/aws/services/kms/kms_key_enclave_attestation_no_deployment_binding/__init__.py create mode 100644 prowler/providers/aws/services/kms/kms_key_enclave_attestation_no_deployment_binding/kms_key_enclave_attestation_no_deployment_binding.metadata.json create mode 100644 prowler/providers/aws/services/kms/kms_key_enclave_attestation_no_deployment_binding/kms_key_enclave_attestation_no_deployment_binding.py create mode 100644 prowler/providers/aws/services/kms/kms_key_enclave_attestation_not_enforced/__init__.py create mode 100644 prowler/providers/aws/services/kms/kms_key_enclave_attestation_not_enforced/kms_key_enclave_attestation_not_enforced.metadata.json create mode 100644 prowler/providers/aws/services/kms/kms_key_enclave_attestation_not_enforced/kms_key_enclave_attestation_not_enforced.py create mode 100644 prowler/providers/aws/services/kms/kms_key_enclave_attestation_pcr_mismatch/__init__.py create mode 100644 prowler/providers/aws/services/kms/kms_key_enclave_attestation_pcr_mismatch/kms_key_enclave_attestation_pcr_mismatch.metadata.json create mode 100644 prowler/providers/aws/services/kms/kms_key_enclave_attestation_pcr_mismatch/kms_key_enclave_attestation_pcr_mismatch.py create mode 100644 prowler/providers/aws/services/kms/kms_key_enclave_attestation_unknown_image/__init__.py create mode 100644 prowler/providers/aws/services/kms/kms_key_enclave_attestation_unknown_image/kms_key_enclave_attestation_unknown_image.metadata.json create mode 100644 prowler/providers/aws/services/kms/kms_key_enclave_attestation_unknown_image/kms_key_enclave_attestation_unknown_image.py create mode 100644 prowler/providers/aws/services/kms/kms_key_enclave_debug_attestation_detected/__init__.py create mode 100644 prowler/providers/aws/services/kms/kms_key_enclave_debug_attestation_detected/kms_key_enclave_debug_attestation_detected.metadata.json create mode 100644 prowler/providers/aws/services/kms/kms_key_enclave_debug_attestation_detected/kms_key_enclave_debug_attestation_detected.py create mode 100644 prowler/providers/aws/services/kms/lib/__init__.py create mode 100644 prowler/providers/aws/services/kms/lib/enclave.py create mode 100644 tests/providers/aws/services/ec2/ec2_confidential_workload_host_imdsv2_not_enforced/ec2_confidential_workload_host_imdsv2_not_enforced_test.py create mode 100644 tests/providers/aws/services/ec2/ec2_confidential_workload_host_not_running/ec2_confidential_workload_host_not_running_test.py create mode 100644 tests/providers/aws/services/ec2/ec2_confidential_workload_host_public_ip/ec2_confidential_workload_host_public_ip_test.py create mode 100644 tests/providers/aws/services/ec2/ec2_confidential_workload_host_unrestricted_ingress/ec2_confidential_workload_host_unrestricted_ingress_test.py create mode 100644 tests/providers/aws/services/ec2/ec2_confidential_workload_host_vsock_proxy_exposed/ec2_confidential_workload_host_vsock_proxy_exposed_test.py create mode 100644 tests/providers/aws/services/kms/kms_key_enclave_attestation_bypassable_path/kms_key_enclave_attestation_bypassable_path_test.py create mode 100644 tests/providers/aws/services/kms/kms_key_enclave_attestation_no_deployment_binding/kms_key_enclave_attestation_no_deployment_binding_test.py create mode 100644 tests/providers/aws/services/kms/kms_key_enclave_attestation_not_enforced/kms_key_enclave_attestation_not_enforced_test.py create mode 100644 tests/providers/aws/services/kms/kms_key_enclave_attestation_pcr_mismatch/kms_key_enclave_attestation_pcr_mismatch_test.py create mode 100644 tests/providers/aws/services/kms/kms_key_enclave_attestation_unknown_image/kms_key_enclave_attestation_unknown_image_test.py create mode 100644 tests/providers/aws/services/kms/kms_key_enclave_debug_attestation_detected/kms_key_enclave_debug_attestation_detected_test.py diff --git a/prowler/changelog.d/aws-nitro-enclaves-security-checks.added.md b/prowler/changelog.d/aws-nitro-enclaves-security-checks.added.md new file mode 100644 index 0000000000..e91cfd731a --- /dev/null +++ b/prowler/changelog.d/aws-nitro-enclaves-security-checks.added.md @@ -0,0 +1 @@ +11 AWS Nitro Enclaves security checks providing the first CSPM coverage for confidential computing workloads, covering both host environment (`ec2_confidential_workload_host_*`) and KMS attestation policy (`kms_key_enclave_*`), fully passive via boto3 and CloudTrail LookupEvents diff --git a/prowler/compliance/aws/aws_ai_security_framework_aws.json b/prowler/compliance/aws/aws_ai_security_framework_aws.json index 9b87f7464d..f20baf4735 100644 --- a/prowler/compliance/aws/aws_ai_security_framework_aws.json +++ b/prowler/compliance/aws/aws_ai_security_framework_aws.json @@ -187,10 +187,15 @@ "Section": "Infrastructure Security", "SubSection": "Compute Isolation", "Service": "ec2", - "Type": "Manual" + "Type": "Automated" } ], - "Checks": [] + "Checks": [ + "kms_key_enclave_attestation_not_enforced", + "kms_key_enclave_attestation_bypassable_path", + "kms_key_enclave_attestation_no_deployment_binding", + "kms_key_enclave_attestation_pcr_mismatch" + ] }, { "Id": "AISF-IAM-01", @@ -899,7 +904,9 @@ "Checks": [ "cloudtrail_threat_detection_llm_jacking", "cloudtrail_threat_detection_privilege_escalation", - "cloudtrail_threat_detection_enumeration" + "cloudtrail_threat_detection_enumeration", + "kms_key_enclave_debug_attestation_detected", + "kms_key_enclave_attestation_unknown_image" ] }, { diff --git a/prowler/compliance/aws/aws_well_architected_framework_reliability_pillar_aws.json b/prowler/compliance/aws/aws_well_architected_framework_reliability_pillar_aws.json index db334a690a..607fdc7192 100644 --- a/prowler/compliance/aws/aws_well_architected_framework_reliability_pillar_aws.json +++ b/prowler/compliance/aws/aws_well_architected_framework_reliability_pillar_aws.json @@ -53,7 +53,8 @@ "opensearch_service_domains_audit_logging_enabled", "opensearch_service_domains_cloudwatch_logging_enabled", "rds_instance_enhanced_monitoring_enabled", - "rds_instance_integration_cloudwatch_logs" + "rds_instance_integration_cloudwatch_logs", + "ec2_confidential_workload_host_not_running" ] }, { diff --git a/prowler/compliance/aws/aws_well_architected_framework_security_pillar_aws.json b/prowler/compliance/aws/aws_well_architected_framework_security_pillar_aws.json index 9dd009afb3..41458eea5b 100644 --- a/prowler/compliance/aws/aws_well_architected_framework_security_pillar_aws.json +++ b/prowler/compliance/aws/aws_well_architected_framework_security_pillar_aws.json @@ -324,7 +324,8 @@ "Checks": [ "ec2_instance_imdsv2_enabled", "ec2_instance_profile_attached", - "cloudwatch_cross_account_sharing_disabled" + "cloudwatch_cross_account_sharing_disabled", + "ec2_confidential_workload_host_imdsv2_not_enforced" ] }, { @@ -497,7 +498,8 @@ "sqs_queues_not_publicly_accessible", "ssm_documents_set_as_public", "ec2_securitygroup_allow_wide_open_public_ipv4", - "ec2_ami_public" + "ec2_ami_public", + "ec2_confidential_workload_host_public_ip" ] }, { @@ -833,7 +835,8 @@ "ec2_instance_internet_facing_with_instance_profile", "opensearch_service_domains_updated_to_the_latest_service_software_version", "redshift_cluster_automatic_upgrades", - "ssm_managed_compliant_patching" + "ssm_managed_compliant_patching", + "ec2_confidential_workload_host_imdsv2_not_enforced" ] }, { diff --git a/prowler/compliance/aws/c5_aws.json b/prowler/compliance/aws/c5_aws.json index 269a5ce308..ce26e241e3 100644 --- a/prowler/compliance/aws/c5_aws.json +++ b/prowler/compliance/aws/c5_aws.json @@ -2439,7 +2439,8 @@ "ssm_documents_set_as_public", "vpc_subnet_no_public_ip_by_default", "vpc_subnet_separate_private_public", - "workspaces_vpc_2private_1public_subnets_nat" + "workspaces_vpc_2private_1public_subnets_nat", + "ec2_confidential_workload_host_public_ip" ] }, { @@ -6729,7 +6730,8 @@ "kms_cmk_not_deleted_unintentionally", "kms_cmk_not_multi_region", "kms_key_not_publicly_accessible", - "ec2_ebs_volume_encryption" + "ec2_ebs_volume_encryption", + "kms_key_enclave_attestation_not_enforced" ], "ConfigRequirements": [ { @@ -6840,7 +6842,8 @@ "kms_cmk_rotation_enabled", "kms_key_not_publicly_accessible", "s3_bucket_kms_encryption", - "sns_topics_kms_encryption_at_rest_enabled" + "sns_topics_kms_encryption_at_rest_enabled", + "kms_key_enclave_attestation_not_enforced" ] }, { @@ -7785,7 +7788,8 @@ "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_postgres_5432", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_redis_6379", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_sql_server_1433_1434", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_telnet_23" + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_telnet_23", + "ec2_confidential_workload_host_unrestricted_ingress" ] }, { diff --git a/prowler/compliance/aws/cis_3.0_aws.json b/prowler/compliance/aws/cis_3.0_aws.json index 5540bc40cf..cc8c292e93 100644 --- a/prowler/compliance/aws/cis_3.0_aws.json +++ b/prowler/compliance/aws/cis_3.0_aws.json @@ -1258,7 +1258,8 @@ "Checks": [ "ec2_securitygroup_allow_ingress_from_internet_to_all_ports", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_3389" + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_3389", + "ec2_confidential_workload_host_unrestricted_ingress" ], "Attributes": [ { @@ -1281,7 +1282,8 @@ "Checks": [ "ec2_securitygroup_allow_ingress_from_internet_to_all_ports", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_3389" + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_3389", + "ec2_confidential_workload_host_unrestricted_ingress" ], "Attributes": [ { @@ -1344,7 +1346,8 @@ "Id": "5.6", "Description": "Ensure that EC2 Metadata Service only allows IMDSv2", "Checks": [ - "ec2_instance_imdsv2_enabled" + "ec2_instance_imdsv2_enabled", + "ec2_confidential_workload_host_imdsv2_not_enforced" ], "Attributes": [ { diff --git a/prowler/compliance/aws/ens_rd2022_aws.json b/prowler/compliance/aws/ens_rd2022_aws.json index f81b8a3eea..6edfc39d97 100644 --- a/prowler/compliance/aws/ens_rd2022_aws.json +++ b/prowler/compliance/aws/ens_rd2022_aws.json @@ -2289,7 +2289,8 @@ } ], "Checks": [ - "ec2_securitygroup_allow_ingress_from_internet_to_all_ports" + "ec2_securitygroup_allow_ingress_from_internet_to_all_ports", + "ec2_confidential_workload_host_unrestricted_ingress" ] }, { @@ -4327,7 +4328,9 @@ "Dependencias": [] } ], - "Checks": [] + "Checks": [ + "kms_key_enclave_attestation_not_enforced" + ] }, { "Id": "op.exp.10.aws.cmk.7", @@ -4351,7 +4354,9 @@ "Dependencias": [] } ], - "Checks": [] + "Checks": [ + "kms_key_enclave_attestation_bypassable_path" + ] }, { "Id": "op.exp.10.aws.cmk.8", @@ -4375,7 +4380,9 @@ "Dependencias": [] } ], - "Checks": [] + "Checks": [ + "kms_key_enclave_attestation_pcr_mismatch" + ] }, { "Id": "op.cont.2.aws.az.1", diff --git a/prowler/compliance/aws/fedramp_20x_ksi_low_aws.json b/prowler/compliance/aws/fedramp_20x_ksi_low_aws.json index 15763ef48e..ebc7d696c9 100644 --- a/prowler/compliance/aws/fedramp_20x_ksi_low_aws.json +++ b/prowler/compliance/aws/fedramp_20x_ksi_low_aws.json @@ -79,7 +79,8 @@ "rds_cluster_multi_az", "vpc_subnet_auto_assign_public_ip_disabled", "vpc_default_security_group_restricts_traffic", - "vpc_peering_connection_routing_tables_with_least_privilege" + "vpc_peering_connection_routing_tables_with_least_privilege", + "ec2_confidential_workload_host_imdsv2_not_enforced" ] }, { @@ -202,7 +203,9 @@ "redshift_cluster_audit_logging", "s3_bucket_server_access_logging_enabled", "vpc_flow_logs_enabled", - "wafv2_webacl_logging_enabled" + "wafv2_webacl_logging_enabled", + "kms_key_enclave_debug_attestation_detected", + "kms_key_enclave_attestation_unknown_image" ] }, { @@ -310,7 +313,8 @@ "s3_bucket_secure_transport_policy", "sagemaker_notebook_instance_encryption_enabled", "sns_topics_kms_encryption_at_rest_enabled", - "sqs_queue_server_side_encryption_enabled" + "sqs_queue_server_side_encryption_enabled", + "kms_key_enclave_attestation_not_enforced" ] }, { diff --git a/prowler/compliance/aws/fedramp_moderate_revision_4_aws.json b/prowler/compliance/aws/fedramp_moderate_revision_4_aws.json index 13c4624572..06f81ea08d 100644 --- a/prowler/compliance/aws/fedramp_moderate_revision_4_aws.json +++ b/prowler/compliance/aws/fedramp_moderate_revision_4_aws.json @@ -313,7 +313,9 @@ "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", "s3_bucket_public_access", - "sagemaker_notebook_instance_without_direct_internet_access_configured" + "sagemaker_notebook_instance_without_direct_internet_access_configured", + "ec2_confidential_workload_host_imdsv2_not_enforced", + "kms_key_enclave_attestation_not_enforced" ] }, { @@ -343,7 +345,8 @@ "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_networkacl_allow_ingress_any_port", + "ec2_confidential_workload_host_public_ip" ] }, { @@ -424,7 +427,9 @@ "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", "s3_bucket_public_access", - "sagemaker_notebook_instance_without_direct_internet_access_configured" + "sagemaker_notebook_instance_without_direct_internet_access_configured", + "ec2_confidential_workload_host_imdsv2_not_enforced", + "kms_key_enclave_attestation_bypassable_path" ] }, { @@ -754,7 +759,8 @@ "guardduty_is_enabled", "rds_instance_enhanced_monitoring_enabled", "redshift_cluster_audit_logging", - "securityhub_enabled" + "securityhub_enabled", + "ec2_confidential_workload_host_imdsv2_not_enforced" ], "ConfigRequirements": [ { @@ -821,7 +827,8 @@ ], "Checks": [ "ec2_instance_managed_by_ssm", - "ssm_managed_compliant_patching" + "ssm_managed_compliant_patching", + "ec2_confidential_workload_host_vsock_proxy_exposed" ] }, { @@ -1324,7 +1331,8 @@ "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_networkacl_allow_ingress_any_port", + "ec2_confidential_workload_host_public_ip" ] }, { @@ -1359,7 +1367,8 @@ "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_networkacl_allow_ingress_any_port", + "ec2_confidential_workload_host_public_ip" ] }, { @@ -1422,7 +1431,9 @@ ], "Checks": [ "acm_certificates_expiration_check", - "kms_cmk_rotation_enabled" + "kms_cmk_rotation_enabled", + "kms_key_enclave_attestation_not_enforced", + "kms_key_enclave_attestation_bypassable_path" ] }, { @@ -1484,7 +1495,8 @@ "s3_bucket_default_encryption", "s3_bucket_default_encryption", "sagemaker_notebook_instance_encryption_enabled", - "sns_topics_kms_encryption_at_rest_enabled" + "sns_topics_kms_encryption_at_rest_enabled", + "kms_key_enclave_debug_attestation_detected" ] }, { @@ -1589,7 +1601,9 @@ "ec2_instance_imdsv2_enabled", "guardduty_is_enabled", "redshift_cluster_audit_logging", - "securityhub_enabled" + "securityhub_enabled", + "ec2_confidential_workload_host_imdsv2_not_enforced", + "kms_key_enclave_debug_attestation_detected" ], "ConfigRequirements": [ { @@ -1758,7 +1772,8 @@ } ], "Checks": [ - "cloudtrail_log_file_validation_enabled" + "cloudtrail_log_file_validation_enabled", + "kms_key_enclave_attestation_pcr_mismatch" ] }, { diff --git a/prowler/compliance/aws/ffiec_aws.json b/prowler/compliance/aws/ffiec_aws.json index 4a2a1b943e..f9e72adf0e 100644 --- a/prowler/compliance/aws/ffiec_aws.json +++ b/prowler/compliance/aws/ffiec_aws.json @@ -879,7 +879,8 @@ "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_networkacl_allow_ingress_any_port", + "ec2_confidential_workload_host_vsock_proxy_exposed" ] }, { diff --git a/prowler/compliance/aws/gdpr_aws.json b/prowler/compliance/aws/gdpr_aws.json index a97a11e3dc..1eae6ec040 100644 --- a/prowler/compliance/aws/gdpr_aws.json +++ b/prowler/compliance/aws/gdpr_aws.json @@ -58,7 +58,8 @@ "cloudwatch_log_metric_filter_root_usage", "cloudwatch_log_metric_filter_security_group_changes", "cloudwatch_log_metric_filter_unauthorized_api_calls", - "vpc_flow_logs_enabled" + "vpc_flow_logs_enabled", + "kms_key_enclave_attestation_not_enforced" ], "ConfigRequirements": [ { @@ -139,7 +140,8 @@ "s3_bucket_default_encryption", "s3_bucket_secure_transport_policy", "sagemaker_notebook_instance_encryption_enabled", - "sns_topics_kms_encryption_at_rest_enabled" + "sns_topics_kms_encryption_at_rest_enabled", + "kms_key_enclave_attestation_not_enforced" ] } ] diff --git a/prowler/compliance/aws/gxp_21_cfr_part_11_aws.json b/prowler/compliance/aws/gxp_21_cfr_part_11_aws.json index 76ad0c74a5..f4bae0b9d6 100644 --- a/prowler/compliance/aws/gxp_21_cfr_part_11_aws.json +++ b/prowler/compliance/aws/gxp_21_cfr_part_11_aws.json @@ -109,7 +109,9 @@ "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_networkacl_allow_ingress_any_port", + "ec2_confidential_workload_host_imdsv2_not_enforced", + "ec2_confidential_workload_host_public_ip" ] }, { @@ -195,7 +197,9 @@ "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_networkacl_allow_ingress_any_port", + "ec2_confidential_workload_host_imdsv2_not_enforced", + "ec2_confidential_workload_host_public_ip" ] }, { diff --git a/prowler/compliance/aws/hipaa_aws.json b/prowler/compliance/aws/hipaa_aws.json index 9eb243e6cc..f2cf10c666 100644 --- a/prowler/compliance/aws/hipaa_aws.json +++ b/prowler/compliance/aws/hipaa_aws.json @@ -85,7 +85,8 @@ "sns_topics_kms_encryption_at_rest_enabled", "ec2_networkacl_allow_ingress_any_port", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_networkacl_allow_ingress_any_port", + "ec2_confidential_workload_host_public_ip" ] }, { @@ -115,7 +116,8 @@ "redshift_cluster_audit_logging", "s3_bucket_server_access_logging_enabled", "securityhub_enabled", - "vpc_flow_logs_enabled" + "vpc_flow_logs_enabled", + "kms_key_enclave_debug_attestation_detected" ], "ConfigRequirements": [ { @@ -641,7 +643,10 @@ "s3_bucket_public_access", "s3_bucket_policy_public_write_access", "s3_bucket_public_access", - "sagemaker_notebook_instance_without_direct_internet_access_configured" + "sagemaker_notebook_instance_without_direct_internet_access_configured", + "ec2_confidential_workload_host_public_ip", + "ec2_confidential_workload_host_imdsv2_not_enforced", + "kms_key_enclave_attestation_bypassable_path" ] }, { @@ -715,7 +720,8 @@ "s3_bucket_default_encryption", "s3_bucket_secure_transport_policy", "sagemaker_notebook_instance_encryption_enabled", - "sns_topics_kms_encryption_at_rest_enabled" + "sns_topics_kms_encryption_at_rest_enabled", + "kms_key_enclave_attestation_not_enforced" ] }, { @@ -746,7 +752,8 @@ "redshift_cluster_audit_logging", "s3_bucket_server_access_logging_enabled", "securityhub_enabled", - "vpc_flow_logs_enabled" + "vpc_flow_logs_enabled", + "kms_key_enclave_debug_attestation_detected" ], "ConfigRequirements": [ { @@ -780,7 +787,8 @@ "ec2_ebs_volume_encryption", "s3_bucket_default_encryption", "s3_bucket_secure_transport_policy", - "s3_bucket_object_versioning" + "s3_bucket_object_versioning", + "kms_key_enclave_attestation_pcr_mismatch" ] }, { @@ -801,7 +809,8 @@ "s3_bucket_default_encryption", "s3_bucket_secure_transport_policy", "s3_bucket_object_versioning", - "vpc_flow_logs_enabled" + "vpc_flow_logs_enabled", + "kms_key_enclave_attestation_unknown_image" ] }, { @@ -843,7 +852,9 @@ "s3_bucket_secure_transport_policy", "ec2_networkacl_allow_ingress_any_port", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_networkacl_allow_ingress_any_port", + "ec2_confidential_workload_host_unrestricted_ingress", + "ec2_confidential_workload_host_vsock_proxy_exposed" ] }, { diff --git a/prowler/compliance/aws/iso27001_2022_aws.json b/prowler/compliance/aws/iso27001_2022_aws.json index 563b856317..d245053375 100644 --- a/prowler/compliance/aws/iso27001_2022_aws.json +++ b/prowler/compliance/aws/iso27001_2022_aws.json @@ -1190,7 +1190,8 @@ ], "Checks": [ "guardduty_is_enabled", - "guardduty_no_high_severity_findings" + "guardduty_no_high_severity_findings", + "kms_key_enclave_attestation_pcr_mismatch" ], "ConfigRequirements": [ { @@ -1491,7 +1492,9 @@ "cloudwatch_log_metric_filter_root_usage", "cloudwatch_log_metric_filter_security_group_changes", "cloudwatch_log_metric_filter_sign_in_without_mfa", - "cloudwatch_log_metric_filter_unauthorized_api_calls" + "cloudwatch_log_metric_filter_unauthorized_api_calls", + "kms_key_enclave_debug_attestation_detected", + "kms_key_enclave_attestation_unknown_image" ] }, { @@ -1628,7 +1631,11 @@ "ec2_securitygroup_from_launch_wizard", "ec2_securitygroup_not_used", "ec2_securitygroup_with_many_ingress_egress_rules", - "ec2_transitgateway_auto_accept_vpc_attachments" + "ec2_transitgateway_auto_accept_vpc_attachments", + "ec2_confidential_workload_host_imdsv2_not_enforced", + "ec2_confidential_workload_host_public_ip", + "ec2_confidential_workload_host_unrestricted_ingress", + "ec2_confidential_workload_host_vsock_proxy_exposed" ] }, { @@ -1723,7 +1730,10 @@ "ec2_securitygroup_from_launch_wizard", "ec2_securitygroup_not_used", "ec2_securitygroup_with_many_ingress_egress_rules", - "ec2_transitgateway_auto_accept_vpc_attachments" + "ec2_transitgateway_auto_accept_vpc_attachments", + "ec2_confidential_workload_host_imdsv2_not_enforced", + "ec2_confidential_workload_host_public_ip", + "ec2_confidential_workload_host_unrestricted_ingress" ] }, { @@ -1818,7 +1828,10 @@ "ec2_securitygroup_from_launch_wizard", "ec2_securitygroup_not_used", "ec2_securitygroup_with_many_ingress_egress_rules", - "ec2_transitgateway_auto_accept_vpc_attachments" + "ec2_transitgateway_auto_accept_vpc_attachments", + "ec2_confidential_workload_host_imdsv2_not_enforced", + "ec2_confidential_workload_host_public_ip", + "ec2_confidential_workload_host_unrestricted_ingress" ] }, { @@ -1924,7 +1937,10 @@ "kms_cmk_are_used", "kms_cmk_not_deleted_unintentionally", "kms_cmk_not_multi_region", - "kms_cmk_rotation_enabled" + "kms_cmk_rotation_enabled", + "kms_key_enclave_attestation_not_enforced", + "kms_key_enclave_attestation_bypassable_path", + "kms_key_enclave_attestation_no_deployment_binding" ] }, { diff --git a/prowler/compliance/aws/kisa_isms_p_2023_aws.json b/prowler/compliance/aws/kisa_isms_p_2023_aws.json index 65a6e8c8b8..78b76a5b9b 100644 --- a/prowler/compliance/aws/kisa_isms_p_2023_aws.json +++ b/prowler/compliance/aws/kisa_isms_p_2023_aws.json @@ -1647,7 +1647,9 @@ "vpc_peering_routing_tables_with_least_privilege", "vpc_subnet_no_public_ip_by_default", "vpc_subnet_separate_private_public", - "workspaces_vpc_2private_1public_subnets_nat" + "workspaces_vpc_2private_1public_subnets_nat", + "ec2_confidential_workload_host_public_ip", + "ec2_confidential_workload_host_unrestricted_ingress" ], "Attributes": [ { @@ -1745,7 +1747,9 @@ "sagemaker_notebook_instance_root_access_disabled", "ses_identity_not_publicly_accessible", "ssm_documents_set_as_public", - "vpc_endpoint_connections_trust_boundaries" + "vpc_endpoint_connections_trust_boundaries", + "ec2_confidential_workload_host_imdsv2_not_enforced", + "ec2_confidential_workload_host_unrestricted_ingress" ], "Attributes": [ { @@ -2176,7 +2180,8 @@ "secretsmanager_secret_rotated_periodically", "secretsmanager_secret_unused", "sns_topics_kms_encryption_at_rest_enabled", - "storagegateway_fileshare_encryption_enabled" + "storagegateway_fileshare_encryption_enabled", + "kms_key_enclave_attestation_not_enforced" ], "Attributes": [ { @@ -3368,7 +3373,10 @@ "wafv2_webacl_with_rules", "wellarchitected_workload_no_high_or_medium_risks", "workspaces_volume_encryption_enabled", - "workspaces_vpc_2private_1public_subnets_nat" + "workspaces_vpc_2private_1public_subnets_nat", + "ec2_confidential_workload_host_imdsv2_not_enforced", + "ec2_confidential_workload_host_public_ip", + "ec2_confidential_workload_host_unrestricted_ingress" ], "ConfigRequirements": [ { diff --git a/prowler/compliance/aws/kisa_isms_p_2023_korean_aws.json b/prowler/compliance/aws/kisa_isms_p_2023_korean_aws.json index b99a36d38e..668b6d0c21 100644 --- a/prowler/compliance/aws/kisa_isms_p_2023_korean_aws.json +++ b/prowler/compliance/aws/kisa_isms_p_2023_korean_aws.json @@ -1646,7 +1646,9 @@ "vpc_peering_routing_tables_with_least_privilege", "vpc_subnet_no_public_ip_by_default", "vpc_subnet_separate_private_public", - "workspaces_vpc_2private_1public_subnets_nat" + "workspaces_vpc_2private_1public_subnets_nat", + "ec2_confidential_workload_host_public_ip", + "ec2_confidential_workload_host_unrestricted_ingress" ], "Attributes": [ { @@ -1745,7 +1747,9 @@ "sagemaker_notebook_instance_root_access_disabled", "ses_identity_not_publicly_accessible", "ssm_documents_set_as_public", - "vpc_endpoint_connections_trust_boundaries" + "vpc_endpoint_connections_trust_boundaries", + "ec2_confidential_workload_host_imdsv2_not_enforced", + "ec2_confidential_workload_host_unrestricted_ingress" ], "Attributes": [ { @@ -2178,7 +2182,8 @@ "secretsmanager_secret_rotated_periodically", "secretsmanager_secret_unused", "sns_topics_kms_encryption_at_rest_enabled", - "storagegateway_fileshare_encryption_enabled" + "storagegateway_fileshare_encryption_enabled", + "kms_key_enclave_attestation_not_enforced" ], "Attributes": [ { @@ -3371,7 +3376,10 @@ "wafv2_webacl_with_rules", "wellarchitected_workload_no_high_or_medium_risks", "workspaces_volume_encryption_enabled", - "workspaces_vpc_2private_1public_subnets_nat" + "workspaces_vpc_2private_1public_subnets_nat", + "ec2_confidential_workload_host_imdsv2_not_enforced", + "ec2_confidential_workload_host_public_ip", + "ec2_confidential_workload_host_unrestricted_ingress" ], "ConfigRequirements": [ { diff --git a/prowler/compliance/aws/mitre_attack_aws.json b/prowler/compliance/aws/mitre_attack_aws.json index 3ac8cf0432..8f4fb581f9 100644 --- a/prowler/compliance/aws/mitre_attack_aws.json +++ b/prowler/compliance/aws/mitre_attack_aws.json @@ -33,7 +33,9 @@ "inspector2_is_enabled", "inspector2_active_findings_exist", "awslambda_function_not_publicly_accessible", - "ec2_instance_public_ip" + "ec2_instance_public_ip", + "ec2_confidential_workload_host_public_ip", + "ec2_confidential_workload_host_vsock_proxy_exposed" ], "ConfigRequirements": [ { @@ -224,7 +226,9 @@ "organizations_account_part_of_organizations", "organizations_delegated_administrators", "organizations_scp_check_deny_regions", - "securityhub_enabled" + "securityhub_enabled", + "kms_key_enclave_attestation_bypassable_path", + "kms_key_enclave_attestation_unknown_image" ], "ConfigRequirements": [ { @@ -722,7 +726,8 @@ "securityhub_enabled", "guardduty_is_enabled", "inspector2_is_enabled", - "inspector2_active_findings_exist" + "inspector2_active_findings_exist", + "kms_key_enclave_debug_attestation_detected" ], "ConfigRequirements": [ { @@ -1193,7 +1198,9 @@ "ecs_task_definitions_no_environment_secrets", "eks_cluster_kms_cmk_encryption_in_secrets_enabled", "ssm_document_secrets", - "secretsmanager_automatic_rotation_enabled" + "secretsmanager_automatic_rotation_enabled", + "ec2_confidential_workload_host_imdsv2_not_enforced", + "kms_key_enclave_attestation_not_enforced" ], "ConfigRequirements": [ { @@ -2353,7 +2360,8 @@ "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_postgres_5432", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_redis_6379", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_sql_server_1433_1434", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_telnet_23" + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_telnet_23", + "ec2_confidential_workload_host_unrestricted_ingress" ], "ConfigRequirements": [ { diff --git a/prowler/compliance/aws/nis2_aws.json b/prowler/compliance/aws/nis2_aws.json index 3a4d567d25..995c9a6dac 100644 --- a/prowler/compliance/aws/nis2_aws.json +++ b/prowler/compliance/aws/nis2_aws.json @@ -1345,7 +1345,8 @@ "autoscaling_group_launch_configuration_requires_imdsv2", "ec2_instance_account_imdsv2_enabled", "ec2_instance_imdsv2_enabled", - "ec2_launch_template_imdsv2_required" + "ec2_launch_template_imdsv2_required", + "ec2_confidential_workload_host_imdsv2_not_enforced" ], "Attributes": [ { 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 31c45c6ae3..ae7b9998b3 100644 --- a/prowler/compliance/aws/nist_800_171_revision_2_aws.json +++ b/prowler/compliance/aws/nist_800_171_revision_2_aws.json @@ -552,7 +552,8 @@ "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", "ssm_managed_compliant_patching", - "ec2_securitygroup_default_restrict_traffic" + "ec2_securitygroup_default_restrict_traffic", + "ec2_confidential_workload_host_vsock_proxy_exposed" ] }, { @@ -570,7 +571,8 @@ "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_networkacl_allow_ingress_any_port", + "ec2_confidential_workload_host_vsock_proxy_exposed" ] }, { @@ -1058,7 +1060,8 @@ "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_networkacl_allow_ingress_any_port", + "ec2_confidential_workload_host_public_ip" ] }, { @@ -1119,7 +1122,8 @@ "s3_bucket_default_encryption", "s3_bucket_secure_transport_policy", "sagemaker_notebook_instance_encryption_enabled", - "sns_topics_kms_encryption_at_rest_enabled" + "sns_topics_kms_encryption_at_rest_enabled", + "kms_key_enclave_attestation_not_enforced" ] }, { 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 70864860bf..13a0e0772c 100644 --- a/prowler/compliance/aws/nist_800_53_revision_5_aws.json +++ b/prowler/compliance/aws/nist_800_53_revision_5_aws.json @@ -337,7 +337,9 @@ "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", "s3_bucket_public_access", - "sagemaker_notebook_instance_without_direct_internet_access_configured" + "sagemaker_notebook_instance_without_direct_internet_access_configured", + "ec2_confidential_workload_host_imdsv2_not_enforced", + "kms_key_enclave_attestation_not_enforced" ] }, { @@ -1102,7 +1104,8 @@ "sagemaker_notebook_instance_without_direct_internet_access_configured", "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", - "ec2_networkacl_allow_ingress_any_port" + "ec2_networkacl_allow_ingress_any_port", + "ec2_confidential_workload_host_public_ip" ] }, { @@ -1236,7 +1239,9 @@ "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", "s3_bucket_public_access", - "sagemaker_notebook_instance_without_direct_internet_access_configured" + "sagemaker_notebook_instance_without_direct_internet_access_configured", + "ec2_confidential_workload_host_imdsv2_not_enforced", + "kms_key_enclave_attestation_bypassable_path" ] }, { @@ -2970,7 +2975,8 @@ "s3_account_level_public_access_blocks", "ec2_securitygroup_default_restrict_traffic", "vpc_flow_logs_enabled", - "ec2_networkacl_allow_ingress_any_port" + "ec2_networkacl_allow_ingress_any_port", + "kms_key_enclave_attestation_pcr_mismatch" ] }, { @@ -2986,7 +2992,8 @@ } ], "Checks": [ - "ec2_networkacl_allow_ingress_any_port" + "ec2_networkacl_allow_ingress_any_port", + "ec2_confidential_workload_host_vsock_proxy_exposed" ] }, { @@ -3428,7 +3435,8 @@ "rds_instance_backup_enabled", "rds_instance_multi_az", "redshift_cluster_automated_snapshot", - "s3_bucket_object_versioning" + "s3_bucket_object_versioning", + "ec2_confidential_workload_host_not_running" ] }, { @@ -3639,7 +3647,8 @@ "rds_instance_backup_enabled", "rds_instance_multi_az", "redshift_cluster_automated_snapshot", - "s3_bucket_object_versioning" + "s3_bucket_object_versioning", + "ec2_confidential_workload_host_not_running" ] }, { @@ -5007,7 +5016,8 @@ "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", "s3_bucket_public_access", - "sagemaker_notebook_instance_without_direct_internet_access_configured" + "sagemaker_notebook_instance_without_direct_internet_access_configured", + "ec2_confidential_workload_host_public_ip" ] }, { @@ -5068,7 +5078,8 @@ "s3_bucket_secure_transport_policy", "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", - "ec2_networkacl_allow_ingress_any_port" + "ec2_networkacl_allow_ingress_any_port", + "ec2_confidential_workload_host_unrestricted_ingress" ] }, { @@ -5187,7 +5198,8 @@ "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_networkacl_allow_ingress_any_port", + "ec2_confidential_workload_host_public_ip" ] }, { @@ -5496,7 +5508,8 @@ "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_networkacl_allow_ingress_any_port", + "ec2_confidential_workload_host_unrestricted_ingress" ] }, { @@ -5717,7 +5730,10 @@ } ], "Checks": [ - "kms_cmk_rotation_enabled" + "kms_cmk_rotation_enabled", + "kms_key_enclave_attestation_not_enforced", + "kms_key_enclave_attestation_bypassable_path", + "kms_key_enclave_attestation_no_deployment_binding" ] }, { @@ -5946,7 +5962,10 @@ "s3_bucket_default_encryption", "s3_bucket_default_encryption", "sagemaker_notebook_instance_encryption_enabled", - "sns_topics_kms_encryption_at_rest_enabled" + "sns_topics_kms_encryption_at_rest_enabled", + "kms_key_enclave_attestation_not_enforced", + "kms_key_enclave_attestation_bypassable_path", + "kms_key_enclave_debug_attestation_detected" ] }, { @@ -6406,7 +6425,9 @@ "guardduty_is_enabled", "rds_instance_integration_cloudwatch_logs", "redshift_cluster_audit_logging", - "s3_bucket_server_access_logging_enabled" + "s3_bucket_server_access_logging_enabled", + "kms_key_enclave_debug_attestation_detected", + "kms_key_enclave_attestation_unknown_image" ], "ConfigRequirements": [ { @@ -6826,7 +6847,8 @@ } ], "Checks": [ - "cloudtrail_log_file_validation_enabled" + "cloudtrail_log_file_validation_enabled", + "kms_key_enclave_attestation_pcr_mismatch" ] }, { diff --git a/prowler/compliance/aws/nist_csf_2.0_aws.json b/prowler/compliance/aws/nist_csf_2.0_aws.json index c06eeec11d..832cb5f637 100644 --- a/prowler/compliance/aws/nist_csf_2.0_aws.json +++ b/prowler/compliance/aws/nist_csf_2.0_aws.json @@ -876,7 +876,8 @@ "s3_account_level_public_access_blocks", "s3_bucket_level_public_access_block", "s3_bucket_public_access", - "s3_multi_region_access_point_public_access_block" + "s3_multi_region_access_point_public_access_block", + "ec2_confidential_workload_host_imdsv2_not_enforced" ] }, { @@ -938,7 +939,8 @@ "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_mongodb_27017_27018", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_oracle_1521_2483", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_postgres_5432", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_elasticsearch_kibana_9200_9300_5601" + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_elasticsearch_kibana_9200_9300_5601", + "ec2_confidential_workload_host_unrestricted_ingress" ] }, { @@ -1694,7 +1696,9 @@ "vpc_flow_logs_enabled", "guardduty_is_enabled", "inspector2_is_enabled", - "accessanalyzer_enabled_without_findings" + "accessanalyzer_enabled_without_findings", + "kms_key_enclave_debug_attestation_detected", + "kms_key_enclave_attestation_unknown_image" ], "ConfigRequirements": [ { diff --git a/prowler/compliance/aws/pci_3.2.1_aws.json b/prowler/compliance/aws/pci_3.2.1_aws.json index ca8e968bf9..85fef0b3e5 100644 --- a/prowler/compliance/aws/pci_3.2.1_aws.json +++ b/prowler/compliance/aws/pci_3.2.1_aws.json @@ -76,7 +76,8 @@ "s3_bucket_public_write_acl", "dms_instance_no_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured", - "ec2_networkacl_allow_ingress_tcp_port_3389" + "ec2_networkacl_allow_ingress_tcp_port_3389", + "ec2_confidential_workload_host_unrestricted_ingress" ], "Attributes": [ { @@ -106,7 +107,8 @@ "s3_bucket_public_write_acl", "dms_instance_no_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured", - "ec2_networkacl_allow_ingress_tcp_port_3389" + "ec2_networkacl_allow_ingress_tcp_port_3389", + "ec2_confidential_workload_host_unrestricted_ingress" ], "Attributes": [ { @@ -294,7 +296,9 @@ "ec2_securitygroup_allow_ingress_from_internet_to_all_ports", "ec2_networkacl_allow_ingress_tcp_port_22", "ec2_networkacl_allow_ingress_tcp_port_3389", - "ec2_securitygroup_default_restrict_traffic" + "ec2_securitygroup_default_restrict_traffic", + "ec2_confidential_workload_host_public_ip", + "ec2_confidential_workload_host_unrestricted_ingress" ], "Attributes": [ { @@ -1008,7 +1012,10 @@ "Id": "3.5", "Name": "Document and implement procedures to protect keys used to secure stored cardholder data against disclosure and misuse", "Description": "Note: This requirement applies to keys used to encrypt stored cardholder data, and also applies to key-encrypting keys used to protect data-encrypting keys—such key- encrypting keys must be at least as strong as the data-encrypting key. Cryptographic keys must be strongly protected because those who obtain access will be able to decrypt data. Key-encrypting keys, if used, must be at least as strong as the data-encrypting key in order to ensure proper protection of the key that encrypts the data as well as the data encrypted with that key. The requirement to protect keys from disclosure and misuse applies to both data-encrypting keys and key-encrypting keys. Because one key- encrypting key may grant access to many data- encrypting keys, the key-encrypting keys require strong protection measures.", - "Checks": [], + "Checks": [ + "kms_key_enclave_attestation_not_enforced", + "kms_key_enclave_attestation_bypassable_path" + ], "Attributes": [ { "ItemId": "3.5", @@ -1034,7 +1041,9 @@ "Id": "3.6", "Name": "Fully document and implement all key-management processes and procedures for cryptographic keys used for encryption of cardholder data", "Description": "Note: Numerous industry standards for key management are available from various resources including NIST, which can be found at http://csrc.nist.gov. The manner in which cryptographic keys are managed is a critical part of the continued security of the encryption solution. A good key- management process, whether it is manual or automated as part of the encryption product, is based on industry standards and addresses all key elements at 3.6.1 through 3.6.8. Providing guidance to customers on how to securely transmit, store and update cryptographic keys can help prevent keys from being mismanaged or disclosed to unauthorized entities. This requirement applies to keys used to encrypt stored cardholder data, and any respective key- encrypting keys. Note: Testing Procedure 3.6.a is an additional procedure that only applies if the entity being assessed is a service provider.", - "Checks": [], + "Checks": [ + "kms_key_enclave_attestation_not_enforced" + ], "Attributes": [ { "ItemId": "3.6", @@ -1500,7 +1509,9 @@ "s3_bucket_policy_public_write_access", "s3_bucket_public_write_acl", "dms_instance_no_public_access", - "sagemaker_notebook_instance_without_direct_internet_access_configured" + "sagemaker_notebook_instance_without_direct_internet_access_configured", + "ec2_confidential_workload_host_public_ip", + "kms_key_enclave_attestation_bypassable_path" ], "Attributes": [ { @@ -2131,7 +2142,8 @@ "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", - "redshift_cluster_audit_logging" + "redshift_cluster_audit_logging", + "kms_key_enclave_debug_attestation_detected" ], "Attributes": [ { diff --git a/prowler/compliance/aws/pci_4.0_aws.json b/prowler/compliance/aws/pci_4.0_aws.json index e21b543556..b750d3784a 100644 --- a/prowler/compliance/aws/pci_4.0_aws.json +++ b/prowler/compliance/aws/pci_4.0_aws.json @@ -1603,6 +1603,20 @@ } ] }, + { + "Id": "1.3.1.53", + "Description": "Checks if Nitro Enclave parent instances have a public IP address or reside in a subnet routed to an internet gateway", + "Name": "ec2", + "Checks": [ + "ec2_confidential_workload_host_public_ip" + ], + "Attributes": [ + { + "Section": "1.3.1: Network access to and from the cardholder data environment is restricted. ", + "Service": "ec2" + } + ] + }, { "Id": "1.3.2.1", "Description": "Checks if an Amazon API Gateway API stage is using an AWS WAF web access control list (web ACL)", @@ -2318,6 +2332,20 @@ } ] }, + { + "Id": "1.3.2.53", + "Description": "Checks if security groups attached to Nitro Enclave parent instances allow unrestricted ingress from 0.0.0.0/0 or ::/0", + "Name": "ec2", + "Checks": [ + "ec2_confidential_workload_host_unrestricted_ingress" + ], + "Attributes": [ + { + "Section": "1.3.2: Network access to and from the cardholder data environment is restricted. ", + "Service": "ec2" + } + ] + }, { "Id": "1.4.1.1", "Description": "Checks if an Amazon API Gateway API stage is using an AWS WAF web access control list (web ACL)", @@ -3139,6 +3167,20 @@ } ] }, + { + "Id": "1.4.2.51", + "Description": "Checks if security groups attached to Nitro Enclave parent instances expose vsock-proxy TCP ports to sources outside the VPC", + "Name": "ec2", + "Checks": [ + "ec2_confidential_workload_host_vsock_proxy_exposed" + ], + "Attributes": [ + { + "Section": "1.4.2: Network connections between trusted and untrusted networks are controlled. ", + "Service": "ec2" + } + ] + }, { "Id": "1.4.3.1", "Description": "Checks if an AWS Network Firewall policy is configured with a user defined stateless default action for fragmented packets", @@ -9420,6 +9462,20 @@ } ] }, + { + "Id": "10.4.1.7", + "Description": "Checks if KMS attestation activity originates from enclave images whose PCR values do not match any known record", + "Name": "kms", + "Checks": [ + "kms_key_enclave_attestation_unknown_image" + ], + "Attributes": [ + { + "Section": "10.4.1: Audit logs are reviewed to identify anomalies or suspicious activity. ", + "Service": "kms" + } + ] + }, { "Id": "10.4.2.1", "Description": "Checks if AWS X-Ray tracing is enabled on Amazon API Gateway REST APIs", @@ -11230,6 +11286,20 @@ } ] }, + { + "Id": "2.2.5.18", + "Description": "Checks if Nitro Enclave parent instances enforce IMDSv2 by requiring session tokens for instance metadata requests", + "Name": "ec2", + "Checks": [ + "ec2_confidential_workload_host_imdsv2_not_enforced" + ], + "Attributes": [ + { + "Section": "2.2.5: System components are configured and managed securely. ", + "Service": "ec2" + } + ] + }, { "Id": "2.2.7.1", "Description": "Checks if HTTP to HTTPS redirection is configured on all HTTP listeners of Application Load Balancers", @@ -13184,6 +13254,20 @@ } ] }, + { + "Id": "3.5.1.36", + "Description": "Checks if KMS attestation events record enclaves running in debug mode, identified by all-zero PCR values", + "Name": "kms", + "Checks": [ + "kms_key_enclave_debug_attestation_detected" + ], + "Attributes": [ + { + "Section": "3.5.1: Primary account number (PAN) is secured wherever it is stored. ", + "Service": "kms" + } + ] + }, { "Id": "3.6.1.2.1", "Description": "Checks if AWS Certificate Manager Certificates in your account are marked for expiration within the specified number of days", @@ -13672,6 +13756,20 @@ } ] }, + { + "Id": "3.6.1.10", + "Description": "Checks if KMS key policies used by Nitro Enclave workloads require enclave attestation condition keys", + "Name": "kms", + "Checks": [ + "kms_key_enclave_attestation_not_enforced" + ], + "Attributes": [ + { + "Section": "3.6.1: Cryptographic keys used to protect stored account data are secured. ", + "Service": "kms" + } + ] + }, { "Id": "3.7.1.1", "Description": "Checks if AWS Certificate Manager Certificates in your account are marked for expiration within the specified number of days", @@ -13819,6 +13917,20 @@ } ] }, + { + "Id": "3.7.1.11", + "Description": "Checks if the PCR values authorized in KMS key policies match the customer-maintained golden values for expected enclave images", + "Name": "kms", + "Checks": [ + "kms_key_enclave_attestation_pcr_mismatch" + ], + "Attributes": [ + { + "Section": "3.7.1: Where cryptography is used to protect stored account data, key management processes and procedures covering all aspects of the key lifecycle are defined and implemented. ", + "Service": "kms" + } + ] + }, { "Id": "3.7.2.1", "Description": "Checks if AWS Certificate Manager Certificates in your account are marked for expiration within the specified number of days", @@ -16481,6 +16593,20 @@ } ] }, + { + "Id": "7.2.1.30", + "Description": "Checks if KMS key policies contain an alternative authorization path granting the same operations without requiring enclave attestation", + "Name": "kms", + "Checks": [ + "kms_key_enclave_attestation_bypassable_path" + ], + "Attributes": [ + { + "Section": "7.2.1: Access to system components and data is appropriately defined and assigned. ", + "Service": "kms" + } + ] + }, { "Id": "7.2.2.1", "Description": "Checks if an AWS account is part of AWS Organizations", @@ -19373,6 +19499,20 @@ } ] }, + { + "Id": "8.2.8.25", + "Description": "Checks if Nitro Enclave parent instances enforce IMDSv2, preventing unauthenticated retrieval of the credentials used to call KMS", + "Name": "ec2", + "Checks": [ + "ec2_confidential_workload_host_imdsv2_not_enforced" + ], + "Attributes": [ + { + "Section": "8.2.8: User identification and related accounts for users and administrators are strictly managed throughout an accounts lifecycle. ", + "Service": "ec2" + } + ] + }, { "Id": "8.3.10.1.1", "Description": "Checks if active IAM access keys are rotated (changed) within the number of days specified in maxAccessKeyAge", diff --git a/prowler/compliance/aws/secnumcloud_3.2_aws.json b/prowler/compliance/aws/secnumcloud_3.2_aws.json index 8075dc3610..8d5896d3cd 100644 --- a/prowler/compliance/aws/secnumcloud_3.2_aws.json +++ b/prowler/compliance/aws/secnumcloud_3.2_aws.json @@ -428,7 +428,8 @@ "s3_account_level_public_access_blocks", "s3_bucket_level_public_access_block", "rds_instance_no_public_access", - "ec2_instance_public_ip" + "ec2_instance_public_ip", + "ec2_confidential_workload_host_public_ip" ] }, { @@ -562,7 +563,8 @@ "ecs_task_definitions_no_environment_secrets", "codebuild_project_no_secrets_in_variables", "ssm_document_secrets", - "cloudwatch_log_group_no_secrets_in_logs" + "cloudwatch_log_group_no_secrets_in_logs", + "kms_key_enclave_attestation_not_enforced" ] }, { @@ -1108,7 +1110,8 @@ "vpc_endpoint_connections_trust_boundaries", "vpc_endpoint_services_allowed_principals_trust_boundaries", "elbv2_waf_acl_attached", - "wafv2_webacl_with_rules" + "wafv2_webacl_with_rules", + "ec2_confidential_workload_host_unrestricted_ingress" ] }, { diff --git a/prowler/compliance/aws/soc2_aws.json b/prowler/compliance/aws/soc2_aws.json index c0041a9dae..0e8c9f70ae 100644 --- a/prowler/compliance/aws/soc2_aws.json +++ b/prowler/compliance/aws/soc2_aws.json @@ -225,7 +225,8 @@ } ], "Checks": [ - "s3_bucket_public_access" + "s3_bucket_public_access", + "kms_key_enclave_attestation_not_enforced" ] }, { @@ -262,7 +263,9 @@ "bedrock_full_access_policy_attached", "iam_aws_attached_policy_no_administrative_privileges", "iam_customer_attached_policy_no_administrative_privileges", - "iam_inline_policy_no_administrative_privileges" + "iam_inline_policy_no_administrative_privileges", + "kms_key_enclave_attestation_bypassable_path", + "kms_key_enclave_attestation_no_deployment_binding" ] }, { @@ -322,7 +325,10 @@ "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_postgres_5432", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_redis_6379", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_sql_server_1433_1434", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_telnet_23" + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_telnet_23", + "ec2_confidential_workload_host_public_ip", + "ec2_confidential_workload_host_unrestricted_ingress", + "ec2_confidential_workload_host_vsock_proxy_exposed" ] }, { @@ -396,7 +402,8 @@ "guardduty_is_enabled", "securityhub_enabled", "ec2_instance_managed_by_ssm", - "ssm_managed_compliant_patching" + "ssm_managed_compliant_patching", + "kms_key_enclave_attestation_pcr_mismatch" ], "ConfigRequirements": [ { @@ -446,7 +453,10 @@ "ec2_instance_imdsv2_enabled", "guardduty_is_enabled", "apigateway_restapi_logging_enabled", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22" + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", + "ec2_confidential_workload_host_imdsv2_not_enforced", + "kms_key_enclave_debug_attestation_detected", + "kms_key_enclave_attestation_unknown_image" ], "ConfigRequirements": [ { @@ -645,7 +655,8 @@ "stepfunctions_statemachine_logging_enabled", "waf_global_webacl_logging_enabled", "wafv2_webacl_logging_enabled", - "wafv2_webacl_rule_logging_enabled" + "wafv2_webacl_rule_logging_enabled", + "ec2_confidential_workload_host_not_running" ] }, { diff --git a/prowler/providers/aws/services/cloudtrail/cloudtrail_service.py b/prowler/providers/aws/services/cloudtrail/cloudtrail_service.py index d146dc14c7..92caa6fb9b 100644 --- a/prowler/providers/aws/services/cloudtrail/cloudtrail_service.py +++ b/prowler/providers/aws/services/cloudtrail/cloudtrail_service.py @@ -207,6 +207,41 @@ class Cloudtrail(AWSService): f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _lookup_events_page(self, region, event_name, minutes): + """Return ``(events, truncated, error)`` for a single lookup_events page. + + ``LookupEvents`` is a **per-region** API: even for a multi-region trail, + events recorded in region X are only retrievable by calling + ``LookupEvents`` against region X. This helper is therefore + region-scoped; callers iterate over the audited regions themselves. + + - ``events``: list of raw event dicts (empty on no match or on error). + - ``truncated``: True when the API returned a ``NextToken`` (coverage + in this page is bounded; more events exist in the window). + - ``error``: ``None`` on success; a short string when the call raised + so callers can report incomplete visibility instead of interpreting + an empty response as "no matches" (matches the fail-closed pattern). + """ + logger.info("CloudTrail - Lookup Events (single-page)...") + try: + regional_client = self.regional_clients[region] + except KeyError as error: + logger.error(f"CloudTrail - unknown region '{region}': {error}") + return [], False, f"unknown region '{region}'" + try: + response = regional_client.lookup_events( + LookupAttributes=[ + {"AttributeKey": "EventName", "AttributeValue": event_name} + ], + StartTime=datetime.now() - timedelta(minutes=minutes), + ) + return response.get("Events") or [], bool(response.get("NextToken")), None + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return [], False, error.__class__.__name__ + def _list_tags_for_resource(self): logger.info("CloudTrail - List Tags...") try: diff --git a/prowler/providers/aws/services/ec2/ec2_confidential_workload_host_imdsv2_not_enforced/__init__.py b/prowler/providers/aws/services/ec2/ec2_confidential_workload_host_imdsv2_not_enforced/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/ec2/ec2_confidential_workload_host_imdsv2_not_enforced/ec2_confidential_workload_host_imdsv2_not_enforced.metadata.json b/prowler/providers/aws/services/ec2/ec2_confidential_workload_host_imdsv2_not_enforced/ec2_confidential_workload_host_imdsv2_not_enforced.metadata.json new file mode 100644 index 0000000000..3859d836a6 --- /dev/null +++ b/prowler/providers/aws/services/ec2/ec2_confidential_workload_host_imdsv2_not_enforced/ec2_confidential_workload_host_imdsv2_not_enforced.metadata.json @@ -0,0 +1,41 @@ +{ + "Provider": "aws", + "CheckID": "ec2_confidential_workload_host_imdsv2_not_enforced", + "CheckTitle": "Confidential-workload host enforces IMDSv2", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "TTPs/Credential Access" + ], + "ServiceName": "ec2", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "AwsEc2Instance", + "ResourceGroup": "compute", + "Description": "Instances hosting **Nitro Enclave** workloads (`EnclaveOptions.Enabled=true`) are evaluated for **IMDSv2 enforcement** on the metadata service (`HttpTokens=required`). This check assesses the host environment; it does not audit the enclave itself.", + "Risk": "IMDSv1 exposes **temporary IAM credentials** to SSRF and workload-compromise paths on the host, undermining the confidentiality of the identity the enclave workload depends on.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-IMDS-new-instances.html", + "https://docs.aws.amazon.com/enclaves/latest/user/nitro-enclave-concepts.html" + ], + "Remediation": { + "Code": { + "CLI": "aws ec2 modify-instance-metadata-options --instance-id --http-tokens required --http-endpoint enabled", + "NativeIaC": "```yaml\nResources:\n example_resource:\n Type: AWS::EC2::Instance\n Properties:\n EnclaveOptions: { Enabled: true }\n MetadataOptions:\n HttpTokens: required # critical: enforce IMDSv2\n```", + "Other": "1. Open the EC2 console.\n2. Select the confidential-workload host and choose Actions > Instance settings > Modify instance metadata options.\n3. Set IMDS to Enabled and IMDSv2 to Required.\n4. Save.", + "Terraform": "```hcl\nresource \"aws_instance\" \"example_resource\" {\n enclave_options { enabled = true }\n metadata_options {\n http_tokens = \"required\" # critical: enforce IMDSv2\n http_endpoint = \"enabled\"\n }\n}\n```" + }, + "Recommendation": { + "Text": "Apply **defense in depth** on identity surfaces: require **IMDSv2** on every confidential-workload host so credentials cannot be lifted from the metadata service via SSRF or a compromised workload on the host.", + "Url": "https://hub.prowler.com/check/ec2_confidential_workload_host_imdsv2_not_enforced" + } + }, + "Categories": [ + "identity-access", + "ec2-imdsv1" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/aws/services/ec2/ec2_confidential_workload_host_imdsv2_not_enforced/ec2_confidential_workload_host_imdsv2_not_enforced.py b/prowler/providers/aws/services/ec2/ec2_confidential_workload_host_imdsv2_not_enforced/ec2_confidential_workload_host_imdsv2_not_enforced.py new file mode 100644 index 0000000000..fec787fe37 --- /dev/null +++ b/prowler/providers/aws/services/ec2/ec2_confidential_workload_host_imdsv2_not_enforced/ec2_confidential_workload_host_imdsv2_not_enforced.py @@ -0,0 +1,51 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.ec2.ec2_client import ec2_client +from prowler.providers.aws.services.ec2.lib.enclave import ( + HOST_TRUST_MODEL_BOILERPLATE, + is_enclave_parent, +) + + +class ec2_confidential_workload_host_imdsv2_not_enforced(Check): + """Ensure confidential-workload host instances enforce IMDSv2. + + The confidential-workload host (an EC2 instance with + ``EnclaveOptions.Enabled=true``) inherits and passes its IAM identity to + every Nitro Enclave it operates. When the host still accepts IMDSv1, SSRF + and other workload-compromise paths on the host expose the temporary + credentials the enclave workload depends on. This check assesses the host + environment; it does not audit the enclave itself. + + - PASS: The host has ``HttpTokens=required`` (IMDSv2 enforced). + - FAIL: The host still allows IMDSv1 (``HttpTokens=optional``). + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the confidential-workload host IMDSv2 enforcement check. + + Iterates confidential-workload hosts (instances with enclaves enabled) + and reports whether each one enforces IMDSv2 on the metadata service. + + Returns: + list[Check_Report_AWS]: One report per confidential-workload host. + """ + findings = [] + for instance in ec2_client.instances: + if not is_enclave_parent(instance): + continue + report = Check_Report_AWS(metadata=self.metadata(), resource=instance) + if instance.http_tokens == "required": + report.status = "PASS" + report.status_extended = ( + f"Confidential-workload host {instance.id} enforces " + f"IMDSv2. {HOST_TRUST_MODEL_BOILERPLATE}" + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Confidential-workload host {instance.id} does not " + f"enforce IMDSv2 (HttpTokens={instance.http_tokens}). " + f"{HOST_TRUST_MODEL_BOILERPLATE}" + ) + findings.append(report) + return findings diff --git a/prowler/providers/aws/services/ec2/ec2_confidential_workload_host_not_running/__init__.py b/prowler/providers/aws/services/ec2/ec2_confidential_workload_host_not_running/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/ec2/ec2_confidential_workload_host_not_running/ec2_confidential_workload_host_not_running.metadata.json b/prowler/providers/aws/services/ec2/ec2_confidential_workload_host_not_running/ec2_confidential_workload_host_not_running.metadata.json new file mode 100644 index 0000000000..d892b898fe --- /dev/null +++ b/prowler/providers/aws/services/ec2/ec2_confidential_workload_host_not_running/ec2_confidential_workload_host_not_running.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "aws", + "CheckID": "ec2_confidential_workload_host_not_running", + "CheckTitle": "Nitro Enclave parent instance is in the running state", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "ec2", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "AwsEc2Instance", + "ResourceGroup": "compute", + "Description": "**Nitro Enclave** parent instances are evaluated against the EC2 lifecycle: the instance state must not be `stopped`, `shutting-down`, or `terminated`. Enclaves are destroyed when their parent stops, so any consumer depending on enclave availability breaks if the parent moves to a terminal state. Transient states (`pending`, `stopping`) are reported as PASS with a note.", + "Risk": "A parent in a terminal state means the enclave no longer exists. Any downstream workload that assumes an active enclave silently loses its **availability** guarantee and may fall back to a less-protected path outside the trust boundary.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-lifecycle.html" + ], + "Remediation": { + "Code": { + "CLI": "aws ec2 start-instances --instance-ids ", + "NativeIaC": "", + "Other": "1. Investigate why the parent stopped (manual action, ASG scale-in, spot interruption).\n2. If the outage was intentional, decommission any downstream expectation of enclave availability.\n3. Otherwise, start the instance and relaunch the enclave through `nitro-cli run-enclave` on boot.\n4. Wrap enclave workloads in Auto Scaling Groups with lifecycle hooks that recreate enclaves after any instance replacement.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Treat enclave availability as an **operational contract**: keep parents running (or explicitly retire the downstream dependency), and prefer managed lifecycle patterns (Auto Scaling Groups, lifecycle hooks) that recreate the enclave whenever the parent is replaced.", + "Url": "https://hub.prowler.com/check/ec2_confidential_workload_host_not_running" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/aws/services/ec2/ec2_confidential_workload_host_not_running/ec2_confidential_workload_host_not_running.py b/prowler/providers/aws/services/ec2/ec2_confidential_workload_host_not_running/ec2_confidential_workload_host_not_running.py new file mode 100644 index 0000000000..ba0352beed --- /dev/null +++ b/prowler/providers/aws/services/ec2/ec2_confidential_workload_host_not_running/ec2_confidential_workload_host_not_running.py @@ -0,0 +1,63 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.ec2.ec2_client import ec2_client +from prowler.providers.aws.services.ec2.lib.enclave import ( + HOST_TRUST_MODEL_BOILERPLATE, +) + +_TERMINAL_STATES = frozenset({"stopped", "shutting-down", "terminated"}) +_TRANSIENT_STATES = frozenset({"pending", "stopping"}) + + +class ec2_confidential_workload_host_not_running(Check): + """Ensure Nitro Enclave parent instances are in the running state. + + Enclaves are destroyed when their parent EC2 instance stops or terminates. + An enclave-enabled instance that has moved to ``stopped``, + ``shutting-down``, or ``terminated`` therefore signals either an + unexpected outage or a lifecycle change that any consumer relying on + enclave availability will fail against. Transient lifecycle states + (``pending``, ``stopping``) are reported as PASS with a note. + + - PASS: The enclave-enabled instance is running or in a transient state. + - FAIL: The instance is stopped/shutting-down/terminated. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the Nitro Enclave parent state check. + + Iterates over every enclave-enabled EC2 instance (including terminal + states, unlike the shared ``is_enclave_parent`` helper) and reports + whether the instance is still in a running lifecycle state. + + Returns: + list[Check_Report_AWS]: One report per enclave-enabled instance. + """ + findings = [] + for instance in ec2_client.instances: + if not instance.enclaves_enabled: + continue + + report = Check_Report_AWS(metadata=self.metadata(), resource=instance) + if instance.state in _TERMINAL_STATES: + report.status = "FAIL" + report.status_extended = ( + f"Nitro Enclave parent instance {instance.id} is in state " + f"'{instance.state}'. Enclaves are destroyed when the parent " + f"stops; any expectation of enclave availability is violated. " + f"{HOST_TRUST_MODEL_BOILERPLATE}" + ) + elif instance.state in _TRANSIENT_STATES: + report.status = "PASS" + report.status_extended = ( + f"Nitro Enclave parent instance {instance.id} is in transient " + f"state '{instance.state}'; re-evaluate after the lifecycle " + f"transition settles. {HOST_TRUST_MODEL_BOILERPLATE}" + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Nitro Enclave parent instance {instance.id} is in state " + f"'{instance.state}'. {HOST_TRUST_MODEL_BOILERPLATE}" + ) + findings.append(report) + return findings diff --git a/prowler/providers/aws/services/ec2/ec2_confidential_workload_host_public_ip/__init__.py b/prowler/providers/aws/services/ec2/ec2_confidential_workload_host_public_ip/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/ec2/ec2_confidential_workload_host_public_ip/ec2_confidential_workload_host_public_ip.metadata.json b/prowler/providers/aws/services/ec2/ec2_confidential_workload_host_public_ip/ec2_confidential_workload_host_public_ip.metadata.json new file mode 100644 index 0000000000..9b7aa5acbe --- /dev/null +++ b/prowler/providers/aws/services/ec2/ec2_confidential_workload_host_public_ip/ec2_confidential_workload_host_public_ip.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "aws", + "CheckID": "ec2_confidential_workload_host_public_ip", + "CheckTitle": "Confidential-workload host is not exposed to the internet", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "ec2", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "AwsEc2Instance", + "ResourceGroup": "compute", + "Description": "Instances hosting **Nitro Enclave** workloads (`EnclaveOptions.Enabled=true`) are evaluated for direct internet reachability. The host must not carry a public IP and must not sit in a subnet whose route table sends `0.0.0.0/0` or `::/0` to an Internet Gateway. NAT Gateway routes are not flagged. This check assesses the host environment; it does not audit the enclave itself.", + "Risk": "A publicly reachable host expands the workload **attack surface** unnecessarily and increases the exposure of the enclave's **I/O path over vsock**. Public reachability is rarely required for enclave workloads.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/enclaves/latest/user/nitro-enclave-concepts.html" + ], + "Remediation": { + "Code": { + "CLI": "aws ec2 disassociate-address --association-id ", + "NativeIaC": "```yaml\nResources:\n example_resource:\n Type: AWS::EC2::Instance\n Properties:\n EnclaveOptions: { Enabled: true }\n SubnetId: # critical: private subnet only\n NetworkInterfaces:\n - AssociatePublicIpAddress: false # critical: no public IP\n DeviceIndex: 0\n SubnetId: \n```", + "Other": "1. Launch confidential-workload hosts only in private subnets whose route tables have no 0.0.0.0/0 route to an Internet Gateway.\n2. Do not assign an Elastic IP or auto-assigned public IPv4/IPv6 to hosts.\n3. Route outbound internet traffic through a NAT Gateway if the workload needs egress.", + "Terraform": "```hcl\nresource \"aws_instance\" \"example_resource\" {\n enclave_options { enabled = true }\n subnet_id = var.private_subnet_id\n associate_public_ip_address = false # critical: no public IP\n}\n```" + }, + "Recommendation": { + "Text": "Apply the **minimum-exposure** principle: place confidential-workload hosts on **private subnets only** and route required outbound traffic via managed egress (NAT). Keep the host invisible from the public internet.", + "Url": "https://hub.prowler.com/check/ec2_confidential_workload_host_public_ip" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/aws/services/ec2/ec2_confidential_workload_host_public_ip/ec2_confidential_workload_host_public_ip.py b/prowler/providers/aws/services/ec2/ec2_confidential_workload_host_public_ip/ec2_confidential_workload_host_public_ip.py new file mode 100644 index 0000000000..3e2d37d4ad --- /dev/null +++ b/prowler/providers/aws/services/ec2/ec2_confidential_workload_host_public_ip/ec2_confidential_workload_host_public_ip.py @@ -0,0 +1,116 @@ +from ipaddress import IPv6Address + +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.ec2.ec2_client import ec2_client +from prowler.providers.aws.services.ec2.lib.enclave import ( + HOST_TRUST_MODEL_BOILERPLATE, + is_enclave_parent, +) +from prowler.providers.aws.services.vpc.vpc_client import vpc_client + + +class ec2_confidential_workload_host_public_ip(Check): + """Ensure confidential-workload hosts are not internet-reachable. + + A confidential-workload host (an EC2 instance with + ``EnclaveOptions.Enabled=true``) that is reachable from the public + Internet — via a public IPv4, a globally-routable IPv6 on its ENI, or a + subnet whose route table sends ``0.0.0.0/0`` or ``::/0`` to an Internet + Gateway — expands the host attack surface unnecessarily. This check + assesses the host environment; it does not audit the enclave itself. + + - PASS: The host has no public IPv4/IPv6 and its subnet is not public. + - FAIL: Any of those signals is present. + - MANUAL: ENI or subnet referenced by the instance is not observable in + the service cache; the visible surface shows no exposure but coverage + is incomplete (fail-closed). + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the confidential-workload host public-IP exposure check. + + For each confidential-workload host, checks the instance's public IPv4, + every attached ENI for a globally-routable IPv6 address, and its + subnet's route table for a default route to an Internet Gateway + (IPv4 ``0.0.0.0/0`` or IPv6 ``::/0``). NAT-gateway routes are not + flagged. When ENI or subnet resolution fails from the service cache, + the check emits MANUAL rather than PASS to avoid a false negative. + + Returns: + list[Check_Report_AWS]: One report per confidential-workload host. + """ + findings = [] + for instance in ec2_client.instances: + if not is_enclave_parent(instance): + continue + + report = Check_Report_AWS(metadata=self.metadata(), resource=instance) + has_public_ipv4 = bool(instance.public_ip) + + # Track dependencies that could not be resolved. A missing ENI or + # subnet means the exposure signal for that path is unobservable, + # not "no exposure" — surface it as MANUAL to avoid a false PASS. + missing_deps: list = [] + + global_ipv6_addresses = [] + for eni_id in instance.network_interfaces or []: + eni = ec2_client.network_interfaces.get(eni_id) + if eni is None: + missing_deps.append(f"ENI {eni_id}") + continue + for address in eni.public_ip_addresses or []: + if isinstance(address, IPv6Address): + global_ipv6_addresses.append(str(address)) + + subnet = vpc_client.vpc_subnets.get(instance.subnet_id) + if instance.subnet_id and subnet is None: + missing_deps.append(f"subnet {instance.subnet_id}") + in_public_ipv4_subnet = bool(subnet and subnet.public) + in_public_ipv6_subnet = bool(subnet and subnet.public_ipv6) + + if ( + not has_public_ipv4 + and not global_ipv6_addresses + and not in_public_ipv4_subnet + and not in_public_ipv6_subnet + ): + if missing_deps: + report.status = "MANUAL" + report.status_extended = ( + f"Confidential-workload host {instance.id} exposure " + f"cannot be fully verified: dependency data not " + f"observable for " + + ", ".join(missing_deps) + + f". No public IP/subnet observed on the visible " + f"surface. {HOST_TRUST_MODEL_BOILERPLATE}" + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Confidential-workload host {instance.id} has no " + f"public IP and is not in a public subnet. " + f"{HOST_TRUST_MODEL_BOILERPLATE}" + ) + else: + reasons = [] + if has_public_ipv4: + reasons.append(f"public IPv4 {instance.public_ip}") + if global_ipv6_addresses: + reasons.append( + "global IPv6 address on its ENI (" + + ", ".join(global_ipv6_addresses) + + ")" + ) + if in_public_ipv4_subnet: + reasons.append("its subnet routes 0.0.0.0/0 to an internet gateway") + if in_public_ipv6_subnet: + reasons.append("its subnet routes ::/0 to an internet gateway") + report.status = "FAIL" + report.status_extended = ( + f"Confidential-workload host {instance.id} is " + f"internet-exposed: " + + " and ".join(reasons) + + f". {HOST_TRUST_MODEL_BOILERPLATE}" + ) + findings.append(report) + return findings diff --git a/prowler/providers/aws/services/ec2/ec2_confidential_workload_host_unrestricted_ingress/__init__.py b/prowler/providers/aws/services/ec2/ec2_confidential_workload_host_unrestricted_ingress/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/ec2/ec2_confidential_workload_host_unrestricted_ingress/ec2_confidential_workload_host_unrestricted_ingress.metadata.json b/prowler/providers/aws/services/ec2/ec2_confidential_workload_host_unrestricted_ingress/ec2_confidential_workload_host_unrestricted_ingress.metadata.json new file mode 100644 index 0000000000..a91b14ea80 --- /dev/null +++ b/prowler/providers/aws/services/ec2/ec2_confidential_workload_host_unrestricted_ingress/ec2_confidential_workload_host_unrestricted_ingress.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "aws", + "CheckID": "ec2_confidential_workload_host_unrestricted_ingress", + "CheckTitle": "Confidential-workload host does not expose non-standard ports to the internet", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "ec2", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "AwsEc2Instance", + "ResourceGroup": "compute", + "Description": "Hosts of **Nitro Enclave** workloads are evaluated for unrestricted ingress (`0.0.0.0/0` or `::/0`) on TCP/UDP ports outside a configurable allow-list (`enclave_sg_allow_ports`, default `[22, 80, 443]`). Aggregates ingress across every security group attached to the host. Assesses the host environment only.", + "Risk": "Overly permissive security groups expose the host to **lateral movement** and to attackers probing high-port services. The host's I/O path is the enclave's I/O path over vsock, so exposed proxy ports on the host translate to exposed enclave communication channels.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/vpc/latest/userguide/security-group-rules.html" + ], + "Remediation": { + "Code": { + "CLI": "aws ec2 revoke-security-group-ingress --group-id --protocol tcp --port --cidr 0.0.0.0/0", + "NativeIaC": "```yaml\nResources:\n example_resource:\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: confidential-workload host ingress\n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 443\n ToPort: 443\n CidrIp: 0.0.0.0/0 # critical: keep public ingress to the allow-listed ports only\n```", + "Other": "1. Enumerate the security groups attached to each confidential-workload host.\n2. For each rule that allows 0.0.0.0/0 or ::/0 ingress on a port outside {22, 80, 443}, restrict the source to a private CIDR or a peer security group.\n3. Prefer SG-to-SG references over CIDR-based ingress for internal services.", + "Terraform": "```hcl\nresource \"aws_security_group_rule\" \"example_resource\" {\n type = \"ingress\"\n from_port = 443\n to_port = 443\n protocol = \"tcp\"\n cidr_blocks = [\"0.0.0.0/0\"] # critical: only for allow-listed ports\n security_group_id = var.host_sg_id\n}\n```" + }, + "Recommendation": { + "Text": "Apply **least-exposure** to confidential-workload hosts: restrict internet-facing ingress to the minimum set of standard ports the workload actually needs, and prefer security-group-to-security-group references for internal services.", + "Url": "https://hub.prowler.com/check/ec2_confidential_workload_host_unrestricted_ingress" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/aws/services/ec2/ec2_confidential_workload_host_unrestricted_ingress/ec2_confidential_workload_host_unrestricted_ingress.py b/prowler/providers/aws/services/ec2/ec2_confidential_workload_host_unrestricted_ingress/ec2_confidential_workload_host_unrestricted_ingress.py new file mode 100644 index 0000000000..278ba70224 --- /dev/null +++ b/prowler/providers/aws/services/ec2/ec2_confidential_workload_host_unrestricted_ingress/ec2_confidential_workload_host_unrestricted_ingress.py @@ -0,0 +1,115 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.ec2.ec2_client import ec2_client +from prowler.providers.aws.services.ec2.lib.enclave import ( + HOST_TRUST_MODEL_BOILERPLATE, + UNRESTRICTED_INGRESS_SUMMARY_THRESHOLD, + is_enclave_parent, + rule_world_facing_port_range, +) + + +class ec2_confidential_workload_host_unrestricted_ingress(Check): + """Ensure confidential-workload hosts do not expose non-standard ports. + + Security groups attached to the host are evaluated for ingress rules that + permit ``0.0.0.0/0`` or ``::/0`` on TCP/UDP ports outside a configurable + allow-list (``enclave_sg_allow_ports``; defaults to ``[22, 80, 443]``). + This check assesses the host environment; it does not audit the enclave + itself. + + - PASS: No security-group rule exposes a non-allow-listed port to the world. + - FAIL: At least one rule opens a non-allow-listed port to the world. + - MANUAL: SGs referenced by the instance are not observable in the service + cache (e.g., collection failure) — no exposure verified on the visible + SGs, but visibility is incomplete (fail-closed). + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the confidential-workload host unrestricted-ingress check. + + For each confidential-workload host, iterates over every ingress rule + of every attached security group and flags rules that allow world + ingress on TCP/UDP ports outside the configured allow-list. Rules with + no port bounds (e.g., ``IpProtocol=-1``) are flagged as ``all``. + Non-allow-listed ranges larger than + ``UNRESTRICTED_INGRESS_SUMMARY_THRESHOLD`` (10) are collapsed into a + ``from-to`` label to keep the finding message actionable. + + Returns: + list[Check_Report_AWS]: One report per confidential-workload host. + """ + findings = [] + allow_ports = set( + ec2_client.audit_config.get("enclave_sg_allow_ports", [22, 80, 443]) + ) + # Rebuilt once per scan; the SG dict does not change while iterating + # instances. + observed_sg_ids = {sg.id for sg in ec2_client.security_groups.values()} + + for instance in ec2_client.instances: + if not is_enclave_parent(instance): + continue + + report = Check_Report_AWS(metadata=self.metadata(), resource=instance) + exposed_ports = set() + + # SGs referenced by the instance but not resolvable from the + # service layer — treated as missing visibility (MANUAL) rather + # than "no exposure". ec2_client.security_groups is indexed by + # ARN, so match on the SG's .id attribute. + missing_sgs = [ + sg_id + for sg_id in (instance.security_groups or []) + if sg_id not in observed_sg_ids + ] + + for sg in ec2_client.security_groups.values(): + if sg.id not in instance.security_groups: + continue + for rule in sg.ingress_rules: + port_range = rule_world_facing_port_range( + rule, protocols=("tcp", "udp") + ) + if port_range is None: + continue + if port_range == "all": + exposed_ports.add("all") + continue + from_port, to_port = port_range + non_allowed = set(range(from_port, to_port + 1)) - allow_ports + if not non_allowed: + continue + if len(non_allowed) > UNRESTRICTED_INGRESS_SUMMARY_THRESHOLD: + exposed_ports.add(f"{from_port}-{to_port}") + else: + exposed_ports.update(non_allowed) + + if exposed_ports: + numeric_ports = sorted(p for p in exposed_ports if isinstance(p, int)) + labels = sorted(p for p in exposed_ports if isinstance(p, str)) + ports_str = ", ".join(str(p) for p in numeric_ports + labels) + report.status = "FAIL" + report.status_extended = ( + f"Confidential-workload host {instance.id} has security " + f"groups that expose non-allow-listed ports to the " + f"internet: {ports_str}. {HOST_TRUST_MODEL_BOILERPLATE}" + ) + elif missing_sgs: + report.status = "MANUAL" + report.status_extended = ( + f"Confidential-workload host {instance.id} ingress " + f"cannot be fully verified: security group(s) " + + ", ".join(missing_sgs) + + f" referenced by the instance were not observable. " + f"No non-allow-listed exposure found on the visible SGs. " + f"{HOST_TRUST_MODEL_BOILERPLATE}" + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Confidential-workload host {instance.id} exposes only " + f"allow-listed ports {sorted(allow_ports)} (if any) to " + f"the internet. {HOST_TRUST_MODEL_BOILERPLATE}" + ) + findings.append(report) + return findings diff --git a/prowler/providers/aws/services/ec2/ec2_confidential_workload_host_vsock_proxy_exposed/__init__.py b/prowler/providers/aws/services/ec2/ec2_confidential_workload_host_vsock_proxy_exposed/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/ec2/ec2_confidential_workload_host_vsock_proxy_exposed/ec2_confidential_workload_host_vsock_proxy_exposed.metadata.json b/prowler/providers/aws/services/ec2/ec2_confidential_workload_host_vsock_proxy_exposed/ec2_confidential_workload_host_vsock_proxy_exposed.metadata.json new file mode 100644 index 0000000000..b5740a2dae --- /dev/null +++ b/prowler/providers/aws/services/ec2/ec2_confidential_workload_host_vsock_proxy_exposed/ec2_confidential_workload_host_vsock_proxy_exposed.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "aws", + "CheckID": "ec2_confidential_workload_host_vsock_proxy_exposed", + "CheckTitle": "Confidential-workload host does not expose likely vsock-proxy TCP ports to the internet", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "ec2", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "AwsEc2Instance", + "ResourceGroup": "compute", + "Description": "Hosts of **Nitro Enclave** workloads are evaluated for unrestricted ingress on TCP ports commonly used by *vsock-proxy* applications (`enclave_vsock_ports`, default `[5000, 8000-8090, 9000]`). vsock is AF_VSOCK, but proxy applications bridge to TCP; this heuristic targets the bridge. Assesses the host environment only.", + "Risk": "Publicly reachable vsock-proxy TCP ports let an attacker interact with the proxy and potentially reach the enclave communication channel the host was expected to keep private.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/enclaves/latest/user/nitro-enclave-concepts.html" + ], + "Remediation": { + "Code": { + "CLI": "aws ec2 revoke-security-group-ingress --group-id --protocol tcp --port --cidr 0.0.0.0/0", + "NativeIaC": "```yaml\nResources:\n example_resource:\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: confidential-workload host ingress\n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 8000\n ToPort: 8090\n CidrIp: 10.0.0.0/16 # critical: private CIDR only, not 0.0.0.0/0\n```", + "Other": "1. Confirm which TCP ports the vsock-proxy actually needs on the host.\n2. Restrict ingress to those ports to internal CIDRs or peer security groups only.\n3. Adjust `enclave_vsock_ports` in the audit config if the deployment uses a non-default proxy scheme.", + "Terraform": "```hcl\nresource \"aws_security_group_rule\" \"example_resource\" {\n type = \"ingress\"\n from_port = 8000\n to_port = 8090\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/16\"] # critical: private CIDR only\n security_group_id = var.host_sg_id\n}\n```" + }, + "Recommendation": { + "Text": "Keep any TCP bridge to vsock off the public internet on confidential-workload hosts. Because this control is heuristic, review flagged ports against the actual proxy scheme before broad remediation.", + "Url": "https://hub.prowler.com/check/ec2_confidential_workload_host_vsock_proxy_exposed" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Heuristic check: vsock is AF_VSOCK, but vsock-proxy applications commonly bridge to TCP; false positives are possible for non-vsock services on the same ports." +} diff --git a/prowler/providers/aws/services/ec2/ec2_confidential_workload_host_vsock_proxy_exposed/ec2_confidential_workload_host_vsock_proxy_exposed.py b/prowler/providers/aws/services/ec2/ec2_confidential_workload_host_vsock_proxy_exposed/ec2_confidential_workload_host_vsock_proxy_exposed.py new file mode 100644 index 0000000000..e92df9149a --- /dev/null +++ b/prowler/providers/aws/services/ec2/ec2_confidential_workload_host_vsock_proxy_exposed/ec2_confidential_workload_host_vsock_proxy_exposed.py @@ -0,0 +1,103 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.ec2.ec2_client import ec2_client +from prowler.providers.aws.services.ec2.lib.enclave import ( + HOST_TRUST_MODEL_BOILERPLATE, + is_enclave_parent, + rule_world_facing_port_range, +) + + +class ec2_confidential_workload_host_vsock_proxy_exposed(Check): + """Ensure confidential-workload hosts do not expose likely vsock-proxy TCP ports. + + vsock itself is ``AF_VSOCK`` and is not reachable over TCP/IP, but common + vsock-proxy applications (for example the Nitro Enclaves SDK proxy) + bridge between vsock and TCP by listening on ports on the host. If those + TCP ports are exposed to the internet, an attacker can interact with the + proxy and potentially reach the enclave communication channel. The port + set is configurable via ``enclave_vsock_ports`` and defaults to + ``[5000, 8000-8090, 9000]``. This is a heuristic control. This check + assesses the host environment; it does not audit the enclave itself. + + - PASS: No security-group rule opens a heuristic vsock-proxy port to the world. + - FAIL: At least one such port is exposed to ``0.0.0.0/0`` or ``::/0``. + - MANUAL: SGs referenced by the instance are not observable in the service + cache; no exposure on the visible SGs but coverage is incomplete + (fail-closed). + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the confidential-workload host vsock-proxy exposure check. + + For every confidential-workload host, iterates the ingress rules of + every attached security group and flags heuristic vsock-proxy TCP + ports that allow ``0.0.0.0/0`` or ``::/0``. + + Returns: + list[Check_Report_AWS]: One report per confidential-workload host. + """ + findings = [] + vsock_ports = set( + ec2_client.audit_config.get( + "enclave_vsock_ports", + [5000, *range(8000, 8091), 9000], + ) + ) + + for instance in ec2_client.instances: + if not is_enclave_parent(instance): + continue + + report = Check_Report_AWS(metadata=self.metadata(), resource=instance) + exposed_ports = set() + + observed_sg_ids = {sg.id for sg in ec2_client.security_groups.values()} + missing_sgs = [ + sg_id + for sg_id in (instance.security_groups or []) + if sg_id not in observed_sg_ids + ] + + for sg in ec2_client.security_groups.values(): + if sg.id not in instance.security_groups: + continue + for rule in sg.ingress_rules: + port_range = rule_world_facing_port_range(rule) + if port_range is None: + continue + if port_range == "all": + exposed_ports.update(vsock_ports) + continue + from_port, to_port = port_range + exposed_ports.update( + vsock_ports.intersection(range(from_port, to_port + 1)) + ) + + if exposed_ports: + report.status = "FAIL" + report.status_extended = ( + f"Confidential-workload host {instance.id} exposes " + f"likely vsock-proxy TCP ports {sorted(exposed_ports)} " + f"to the internet. This check is heuristic and may " + f"false-positive on non-vsock services on the same " + f"ports. {HOST_TRUST_MODEL_BOILERPLATE}" + ) + elif missing_sgs: + report.status = "MANUAL" + report.status_extended = ( + f"Confidential-workload host {instance.id} vsock-proxy " + f"exposure cannot be fully verified: security group(s) " + + ", ".join(missing_sgs) + + f" not observable. No heuristic vsock-proxy port " + f"exposed on the visible SGs. " + f"{HOST_TRUST_MODEL_BOILERPLATE}" + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Confidential-workload host {instance.id} does not " + f"expose any heuristic vsock-proxy TCP ports to the " + f"internet. {HOST_TRUST_MODEL_BOILERPLATE}" + ) + findings.append(report) + return findings diff --git a/prowler/providers/aws/services/ec2/ec2_service.py b/prowler/providers/aws/services/ec2/ec2_service.py index bb5444c3c1..3c20d490f2 100644 --- a/prowler/providers/aws/services/ec2/ec2_service.py +++ b/prowler/providers/aws/services/ec2/ec2_service.py @@ -127,6 +127,13 @@ class EC2(AWSService): virtualization_type=instance.get( "VirtualizationType" ), + enclaves_enabled=instance.get( + "EnclaveOptions", {} + ).get("Enabled", False), + hibernation_enabled=instance.get( + "HibernationOptions", {} + ).get("Configured", False), + platform=instance.get("Platform"), tags=instance.get("Tags"), ) ) @@ -778,6 +785,9 @@ class Instance(BaseModel): instance_profile: Optional[dict] network_interfaces: Optional[list] virtualization_type: Optional[str] + enclaves_enabled: Optional[bool] = False + hibernation_enabled: Optional[bool] = False + platform: Optional[str] = None tags: Optional[list] = [] diff --git a/prowler/providers/aws/services/ec2/lib/enclave.py b/prowler/providers/aws/services/ec2/lib/enclave.py new file mode 100644 index 0000000000..6c394a4ede --- /dev/null +++ b/prowler/providers/aws/services/ec2/lib/enclave.py @@ -0,0 +1,95 @@ +from typing import Any, Iterable, Optional, Tuple, Union + +from prowler.providers.aws.services.ec2.lib.security_groups import _is_cidr_public + +HOST_TRUST_MODEL_BOILERPLATE = ( + "This finding concerns the workload host environment. The isolation " + "guarantees of any Nitro Enclave running on this instance are " + "independent of this finding." +) + +# Threshold at which the unrestricted-ingress check summarizes a wide +# non-allow-listed port range as ``from-to`` instead of enumerating every +# port. Keeps the FAIL message actionable when a rule like ``0-65535`` is +# hit without truncating small offenders like ``[8080]``. +UNRESTRICTED_INGRESS_SUMMARY_THRESHOLD = 10 + + +def is_enclave_parent(instance: Any) -> bool: + """Return True when this EC2 instance is a candidate parent for a Nitro Enclave. + + Instances in pending, shutting-down, or terminated states are skipped so + checks do not report on lifecycle-transient resources (per RFC edge cases). + + Args: + instance: An EC2 ``Instance`` model exposing ``enclaves_enabled`` and + ``state`` attributes. + + Returns: + bool: True when the instance has enclaves enabled and is not in a + lifecycle-transient state. + """ + return bool( + getattr(instance, "enclaves_enabled", False) + ) and instance.state not in { + "pending", + "shutting-down", + "terminated", + } + + +def rule_world_facing_port_range( + rule: dict, protocols: Iterable[str] = ("tcp",) +) -> Optional[Union[str, Tuple[int, int]]]: + """Return the port range this ingress rule exposes to the world. + + "The world" is any globally routable CIDR: exact ``0.0.0.0/0`` and + ``::/0`` plus supernets such as ``0.0.0.0/1``, ``128.0.0.0/1``, ``::/1``, + ``8000::/1`` which also reach the public Internet. Detection delegates + to ``security_groups._is_cidr_public`` so the semantics match every + other Prowler check. + + ``protocols`` lists the L4 protocols the caller wants to track (``tcp`` + by default; pass ``("tcp", "udp")`` to also flag UDP ingress). The + all-protocol ``-1`` always returns ``"all"`` because it covers every + protocol, including whatever ``protocols`` requests. + + Args: + rule: A boto3 ingress rule dict with ``IpProtocol``, ``IpRanges``, + ``Ipv6Ranges``, ``FromPort`` and ``ToPort`` keys. + protocols: L4 protocols to evaluate. Defaults to ``("tcp",)``. + + Returns: + Optional[Union[str, Tuple[int, int]]]: + - ``"all"`` when the rule opens every port (``IpProtocol="-1"`` + or a tracked protocol on ``0-65535``). + - ``(from_port, to_port)`` for a specific tracked-protocol range. + - ``None`` when the rule does not expose anything to the public + Internet, is for a protocol not in ``protocols``, or is + missing port bounds. + """ + ip_ranges = rule.get("IpRanges") or [] + ipv6_ranges = rule.get("Ipv6Ranges") or [] + world_facing = any( + isinstance(r.get("CidrIp"), str) and _is_cidr_public(r["CidrIp"]) + for r in ip_ranges + ) or any( + isinstance(r.get("CidrIpv6"), str) and _is_cidr_public(r["CidrIpv6"]) + for r in ipv6_ranges + ) + if not world_facing: + return None + + protocol = rule.get("IpProtocol") + if protocol == "-1": + return "all" + if protocol not in protocols: + return None + + from_port = rule.get("FromPort") + to_port = rule.get("ToPort") + if from_port is None or to_port is None: + return None + if from_port == 0 and to_port == 65535: + return "all" + return (from_port, to_port) diff --git a/prowler/providers/aws/services/kms/kms_key_enclave_attestation_bypassable_path/__init__.py b/prowler/providers/aws/services/kms/kms_key_enclave_attestation_bypassable_path/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/kms/kms_key_enclave_attestation_bypassable_path/kms_key_enclave_attestation_bypassable_path.metadata.json b/prowler/providers/aws/services/kms/kms_key_enclave_attestation_bypassable_path/kms_key_enclave_attestation_bypassable_path.metadata.json new file mode 100644 index 0000000000..d7846a1ead --- /dev/null +++ b/prowler/providers/aws/services/kms/kms_key_enclave_attestation_bypassable_path/kms_key_enclave_attestation_bypassable_path.metadata.json @@ -0,0 +1,45 @@ +{ + "Provider": "aws", + "CheckID": "kms_key_enclave_attestation_bypassable_path", + "CheckTitle": "KMS enclave key has no authorization path that bypasses attestation", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "kms", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "AwsKmsKey", + "ResourceGroup": "security", + "Description": "Detects **bypass paths** in enclave KMS key policies: `Allow` statements that grant sensitive KMS actions without a restrictive `kms:RecipientAttestation:*` condition and without a paired `Deny` that neutralizes the gap. Includes the common root-delegation shape (`Principal: root`, `Action: kms:*`) when it is not paired with an attestation Deny.", + "Risk": "An attacker with IAM permission on the key can use the bypass path to access material without ever presenting a valid attestation document. Attestation-based access control is only as strong as the weakest authorization path in the key policy.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/kms/latest/developerguide/key-policies.html", + "https://docs.aws.amazon.com/kms/latest/developerguide/policy-evaluation.html", + "https://docs.aws.amazon.com/kms/latest/developerguide/conditions-nitro-enclaves.html" + ], + "Remediation": { + "Code": { + "CLI": "aws kms put-key-policy --key-id --policy-name default --policy file://enclave-policy.json", + "NativeIaC": "", + "Other": "1. Enumerate every Allow statement in the key policy; identify those granting sensitive actions (`kms:Decrypt`, `kms:GenerateDataKey`, etc.) without `kms:RecipientAttestation:*` conditions.\n2. Either add attestation conditions to those Allow statements, or add a Deny statement that fires when attestation is absent, e.g.: `{\"Effect\": \"Deny\", \"Principal\": \"*\", \"Action\": [\"kms:Decrypt\", ...], \"Resource\": \"*\", \"Condition\": {\"Null\": {\"kms:RecipientAttestation:PCR0\": \"true\"}}}`.\n3. Reject the common `AdminNoDataActions` shape when its action list contains `kms:*` — split administrative actions from data-plane actions so the root delegation cannot reach `kms:Decrypt`.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Treat every authorization path as equal: attestation-based access control is only as strong as the weakest Allow in the key policy. Add explicit Deny statements that fire when attestation is absent to close root-delegation gaps.", + "Url": "https://hub.prowler.com/check/kms_key_enclave_attestation_bypassable_path" + } + }, + "Categories": [ + "encryption", + "identity-access", + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [ + "kms_key_enclave_attestation_not_enforced", + "kms_key_enclave_debug_attestation_detected" + ], + "Notes": "Not covered: KMS grants (list_grants is out of scope), account-wide IAM permissions, and Deny statements using NotPrincipal. Pair with kms_key_not_publicly_accessible for cross-account guardrails." +} diff --git a/prowler/providers/aws/services/kms/kms_key_enclave_attestation_bypassable_path/kms_key_enclave_attestation_bypassable_path.py b/prowler/providers/aws/services/kms/kms_key_enclave_attestation_bypassable_path/kms_key_enclave_attestation_bypassable_path.py new file mode 100644 index 0000000000..a2a4adb8ed --- /dev/null +++ b/prowler/providers/aws/services/kms/kms_key_enclave_attestation_bypassable_path/kms_key_enclave_attestation_bypassable_path.py @@ -0,0 +1,108 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.kms.kms_client import kms_client +from prowler.providers.aws.services.kms.lib.enclave import ( + attestation_condition_keys, + is_enclave_key, + statement_is_covered_by_deny, + statement_targets_sensitive_actions, +) + + +class kms_key_enclave_attestation_bypassable_path(Check): + """Ensure every authorization path to an enclave KMS key requires attestation. + + A key policy has a **bypass path** when at least one ``Allow`` statement + grants a sensitive action (``kms:Decrypt``, ``kms:DeriveSharedSecret``, + ``kms:GenerateDataKey``, ``kms:GenerateDataKeyPair``, + ``kms:GenerateRandom``, ``kms:*``, ``*``) without a restrictive + ``kms:RecipientAttestation:*`` condition and without a paired ``Deny`` + that fires when attestation is absent. A caller with IAM permission on + the key can use that bypass path to access material without ever + presenting a valid attestation document — including the common + ``AdminNoDataActions`` shape when it uses ``kms:*`` on the root + principal. + + - PASS: every sensitive Allow enforces attestation, or unconditioned + Allows are neutralized by a Deny statement that requires attestation. + - FAIL: at least one authorization path bypasses attestation and no + Deny neutralizes it. + + Not covered (documented limitations): existing KMS grants + (``kms:list_grants`` is not captured by the service layer); IAM policies + global to the account (evaluating every principal is out of scope). If + the account-level IAM surface is a concern, complement this check with + ``kms_key_not_publicly_accessible`` and ``kms_key_enclave_attestation_not_enforced``. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the bypassable-path check. + + Iterates enabled customer-managed KMS keys flagged as enclave keys + and, for each policy, looks for the first sensitive Allow statement + that (a) does not carry an attestation condition and (b) is not + neutralized by a matching Deny. Emits FAIL when found, PASS + otherwise. + + Returns: + list[Check_Report_AWS]: one report per enclave KMS key. + """ + findings = [] + for key in kms_client.keys: + if ( + key.manager != "CUSTOMER" + or key.state != "Enabled" + or not is_enclave_key(key) + ): + continue + if key.policy is None: + if getattr(key, "policy_fetch_error", None): + report = Check_Report_AWS(metadata=self.metadata(), resource=key) + report.status = "MANUAL" + report.status_extended = ( + f"KMS enclave key {key.id} policy could not be " + f"fetched ({key.policy_fetch_error}); bypass paths " + f"cannot be evaluated." + ) + findings.append(report) + continue + + report = Check_Report_AWS(metadata=self.metadata(), resource=key) + statements = key.policy.get("Statement") or [] + if isinstance(statements, dict): + statements = [statements] + + bypass = None + for stmt in statements: + if not isinstance(stmt, dict): + continue + if stmt.get("Effect") != "Allow": + continue + if not statement_targets_sensitive_actions(stmt): + continue + if attestation_condition_keys(stmt): + continue + if statement_is_covered_by_deny(stmt, key.policy): + continue + bypass = stmt + break + + if bypass: + sid = bypass.get("Sid") or "(no Sid)" + report.status = "FAIL" + report.status_extended = ( + f"KMS enclave key {key.id} exposes a bypass path in " + f"statement '{sid}': sensitive actions are allowed " + f"without kms:RecipientAttestation:* and no Deny " + f"neutralizes the gap. Any IAM principal permitted on " + f"the key can access material without presenting " + f"attestation." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"KMS enclave key {key.id} has no bypass paths: every " + f"sensitive Allow enforces attestation or is " + f"neutralized by an explicit Deny." + ) + findings.append(report) + return findings diff --git a/prowler/providers/aws/services/kms/kms_key_enclave_attestation_no_deployment_binding/__init__.py b/prowler/providers/aws/services/kms/kms_key_enclave_attestation_no_deployment_binding/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/kms/kms_key_enclave_attestation_no_deployment_binding/kms_key_enclave_attestation_no_deployment_binding.metadata.json b/prowler/providers/aws/services/kms/kms_key_enclave_attestation_no_deployment_binding/kms_key_enclave_attestation_no_deployment_binding.metadata.json new file mode 100644 index 0000000000..7eb896ab1a --- /dev/null +++ b/prowler/providers/aws/services/kms/kms_key_enclave_attestation_no_deployment_binding/kms_key_enclave_attestation_no_deployment_binding.metadata.json @@ -0,0 +1,43 @@ +{ + "Provider": "aws", + "CheckID": "kms_key_enclave_attestation_no_deployment_binding", + "CheckTitle": "KMS enclave key attestation binds a specific deployment context", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "kms", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "informational", + "ResourceType": "AwsKmsKey", + "ResourceGroup": "security", + "Description": "Sensitive Allow statements on enclave KMS keys are checked for deployment-context binding: PCR3 (parent IAM role, AWS-recommended), PCR4 (parent instance ID), PCR8 (EIF signing cert), or an account-level condition (aws:PrincipalAccount / SourceAccount / OrgID / ResourceAccount / OrgPaths) paired with a RecipientAttestation binding. PCR0/PCR1/PCR2 travel with the EIF and do not bind deployment.", + "Risk": "A key policy bound only to image PCRs (PCR0/PCR1/PCR2) accepts the same EIF running anywhere, including an attacker-controlled account or instance. Deployment-context conditions bind attestation to a specific execution context so a leaked or replicated image cannot silently reuse the key. Materiality depends on application-layer controls.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/enclaves/latest/user/set-up-attestation.html", + "https://docs.aws.amazon.com/kms/latest/developerguide/conditions-nitro-enclaves.html" + ], + "Remediation": { + "Code": { + "CLI": "aws kms put-key-policy --key-id --policy-name default --policy file://enclave-policy-with-deployment-binding.json", + "NativeIaC": "", + "Other": "1. Pair every image binding (kms:RecipientAttestation:PCR0/PCR1/PCR2) with at least one deployment-context binding: kms:RecipientAttestation:PCR3 (parent IAM role, AWS-recommended), PCR4 (parent instance ID), or PCR8 (EIF signing certificate). AWS recommends PCR3 + PCR8 together for portability.\n2. Alternatively add an account/org condition (aws:PrincipalAccount, aws:SourceAccount, aws:PrincipalOrgID, aws:ResourceAccount, aws:PrincipalOrgPaths) so only calls from the audited account or organization can present the attestation.\n3. Ensure operators are restrictive (StringEquals, ArnEquals) and values do not contain wildcards.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Pair every image binding (`kms:RecipientAttestation:PCR0`, `PCR1`, `PCR2`) with **PCR3** (parent IAM role), **PCR4** (parent instance ID), **PCR8** (EIF signing cert), or an account/org condition so the key policy binds to a specific **deployment context** rather than just an image identity. AWS recommends **PCR3 + PCR8** together.", + "Url": "https://hub.prowler.com/check/kms_key_enclave_attestation_no_deployment_binding" + } + }, + "Categories": [ + "encryption", + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [ + "kms_key_enclave_attestation_not_enforced", + "kms_key_enclave_attestation_pcr_mismatch" + ], + "Notes": "Statements with no sensitive Allow-with-attestation are covered by kms_key_enclave_attestation_not_enforced and are not flagged here (honest MANUAL when a key has none). ForAllValues:* attestation values are only counted when paired with a Null:false guard, matching the semantics used by attestation_condition_keys." +} diff --git a/prowler/providers/aws/services/kms/kms_key_enclave_attestation_no_deployment_binding/kms_key_enclave_attestation_no_deployment_binding.py b/prowler/providers/aws/services/kms/kms_key_enclave_attestation_no_deployment_binding/kms_key_enclave_attestation_no_deployment_binding.py new file mode 100644 index 0000000000..a07d0563a5 --- /dev/null +++ b/prowler/providers/aws/services/kms/kms_key_enclave_attestation_no_deployment_binding/kms_key_enclave_attestation_no_deployment_binding.py @@ -0,0 +1,126 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.kms.kms_client import kms_client +from prowler.providers.aws.services.kms.lib.enclave import ( + attestation_condition_keys, + is_enclave_key, + statement_binds_deployment, + statement_targets_sensitive_actions, +) + + +class kms_key_enclave_attestation_no_deployment_binding(Check): + """Ensure enclave KMS attestation binds a specific deployment context. + + Deployment context per the AWS Nitro Enclaves docs. A sensitive Allow + statement satisfies deployment binding when its Condition includes at + least one of: + + - ``kms:RecipientAttestation:PCR3`` (IAM role of the parent instance) — + AWS-recommended for portability, + - ``kms:RecipientAttestation:PCR4`` (instance ID of the parent instance), + - ``kms:RecipientAttestation:PCR8`` (EIF signing certificate) — + AWS-recommended paired with PCR3, or + - An account/org condition (``aws:PrincipalAccount``, + ``aws:SourceAccount``, ``aws:PrincipalOrgID``, ``aws:ResourceAccount``, + ``aws:PrincipalOrgPaths``) with a restrictive equality operator, + **paired with** at least one restrictive RecipientAttestation binding. + + PCR0/PCR1/PCR2 all measure the enclave image (full EIF, kernel + boot + ramfs, and user application respectively). They travel with the EIF, so + a policy bound only to those PCRs still accepts the same image running + in any account, on any instance. They do not bind a deployment context. + + Severity is INFORMATIONAL: this is a hardening recommendation, not a + critical misconfiguration. Prowler models "informational findings" as + ``status=FAIL`` with ``severity=informational``. + + - PASS: every sensitive Allow with attestation also binds deployment + context. + - FAIL (informational): at least one sensitive Allow with attestation + lacks a deployment binding (PCR3/PCR4/PCR8/account condition). + - MANUAL: the key has no sensitive Allow with attestation at all + (handled by ``kms_key_enclave_attestation_not_enforced``; emitted with + an honest message rather than a vacuous PASS). + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the KMS enclave deployment-binding check. + + For each enclave-scoped customer-managed KMS key, collects every + sensitive ``Allow`` statement that carries a restrictive + ``kms:RecipientAttestation:*`` condition and verifies each one binds + deployment context (PCR3/PCR4/PCR8 or account-level condition + alongside the attestation binding). Emits MANUAL for keys without any + sensitive Allow-with-attestation (covered by ``attestation_not_enforced``) + and MANUAL for keys whose policy could not be fetched. + + Returns: + list[Check_Report_AWS]: One report per selected enclave KMS key. + """ + findings = [] + for key in kms_client.keys: + if ( + key.manager != "CUSTOMER" + or key.state != "Enabled" + or not is_enclave_key(key) + ): + continue + if key.policy is None: + if getattr(key, "policy_fetch_error", None): + report = Check_Report_AWS(metadata=self.metadata(), resource=key) + report.status = "MANUAL" + report.status_extended = ( + f"KMS enclave key {key.id} policy could not be " + f"fetched ({key.policy_fetch_error}); deployment " + f"binding cannot be verified." + ) + findings.append(report) + continue + + report = Check_Report_AWS(metadata=self.metadata(), resource=key) + statements = key.policy.get("Statement") or [] + if isinstance(statements, dict): + statements = [statements] + + attestation_stmts = [ + s + for s in statements + if isinstance(s, dict) + and s.get("Effect") == "Allow" + and statement_targets_sensitive_actions(s) + and attestation_condition_keys(s) + ] + + if not attestation_stmts: + report.status = "MANUAL" + report.status_extended = ( + f"KMS enclave key {key.id} has no sensitive Allow " + f"statements with attestation conditions to evaluate; " + f"deployment-binding is not applicable. See " + f"kms_key_enclave_attestation_not_enforced." + ) + else: + missing = [ + s.get("Sid", "") + for s in attestation_stmts + if not statement_binds_deployment(s) + ] + if not missing: + report.status = "PASS" + report.status_extended = ( + f"KMS enclave key {key.id} binds attestation to a " + f"specific deployment context (PCR3, PCR4, PCR8, or " + f"account condition) on every sensitive statement." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"KMS enclave key {key.id} enforces attestation but " + f"statement(s) {missing} lack deployment-context " + f"binding (PCR3 role, PCR4 instance ID, PCR8 signing " + f"cert, or account-level condition). PCR0/PCR1/PCR2 " + f"identify the enclave image but travel with the EIF " + f"and do not bind where it runs." + ) + findings.append(report) + return findings diff --git a/prowler/providers/aws/services/kms/kms_key_enclave_attestation_not_enforced/__init__.py b/prowler/providers/aws/services/kms/kms_key_enclave_attestation_not_enforced/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/kms/kms_key_enclave_attestation_not_enforced/kms_key_enclave_attestation_not_enforced.metadata.json b/prowler/providers/aws/services/kms/kms_key_enclave_attestation_not_enforced/kms_key_enclave_attestation_not_enforced.metadata.json new file mode 100644 index 0000000000..9f5e630416 --- /dev/null +++ b/prowler/providers/aws/services/kms/kms_key_enclave_attestation_not_enforced/kms_key_enclave_attestation_not_enforced.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "aws", + "CheckID": "kms_key_enclave_attestation_not_enforced", + "CheckTitle": "KMS enclave key requires kms:RecipientAttestation conditions on sensitive actions", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "kms", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "AwsKmsKey", + "ResourceGroup": "security", + "Description": "**Customer-managed KMS keys** used with Nitro Enclaves (identified by `prowler:enclave-key=true` tag, `enclave` in description/tags/aliases, or a policy referencing `kms:RecipientAttestation:*`). Every `Allow` on sensitive actions (`kms:Decrypt`, `DeriveSharedSecret`, `GenerateDataKey*`, `GenerateRandom`, `kms:*`, `*`) must require a `kms:RecipientAttestation:*` condition.", + "Risk": "Without an attestation condition, any principal with decrypt permission (including a compromised parent instance) can use the key. **Attestation** is the cryptographic mechanism that binds usage to the measured enclave image; its absence removes the trust boundary the enclave was designed to enforce.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/kms/latest/developerguide/policy-conditions.html#conditions-nitro-enclaves", + "https://docs.aws.amazon.com/enclaves/latest/user/kms.html" + ], + "Remediation": { + "Code": { + "CLI": "aws kms put-key-policy --key-id --policy-name default --policy file://enclave-policy.json", + "NativeIaC": "```yaml\nResources:\n example_resource:\n Type: AWS::KMS::Key\n Properties:\n KeyPolicy:\n Statement:\n - Effect: Allow\n Principal:\n AWS: arn:aws:iam::123456789012:role/enclave-parent\n Action: kms:Decrypt\n Resource: \"*\"\n Condition:\n StringEqualsIgnoreCase:\n kms:RecipientAttestation:PCR0: # critical: bind to enclave measurement\n```", + "Other": "1. Enumerate the customer-managed keys used by enclave workloads.\n2. For every Allow statement covering a sensitive KMS action, add a Condition block that references one or more `kms:RecipientAttestation:*` condition keys tied to the enclave measurement.\n3. Reject or scope any statement that cannot be conditioned on attestation.", + "Terraform": "```hcl\ndata \"aws_iam_policy_document\" \"example_resource\" {\n statement {\n actions = [\"kms:Decrypt\"]\n resources = [\"*\"]\n condition {\n test = \"StringEqualsIgnoreCase\"\n variable = \"kms:RecipientAttestation:PCR0\" # critical: bind to enclave measurement\n values = [var.expected_pcr0]\n }\n }\n}\n```" + }, + "Recommendation": { + "Text": "Bind every sensitive grant on enclave-scoped KMS keys to the enclave's measured identity via `kms:RecipientAttestation:*` conditions. This preserves the **trust boundary** the enclave exists to enforce and prevents any non-enclave principal from using the key.", + "Url": "https://hub.prowler.com/check/kms_key_enclave_attestation_not_enforced" + } + }, + "Categories": [ + "encryption", + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/aws/services/kms/kms_key_enclave_attestation_not_enforced/kms_key_enclave_attestation_not_enforced.py b/prowler/providers/aws/services/kms/kms_key_enclave_attestation_not_enforced/kms_key_enclave_attestation_not_enforced.py new file mode 100644 index 0000000000..cc474b1b33 --- /dev/null +++ b/prowler/providers/aws/services/kms/kms_key_enclave_attestation_not_enforced/kms_key_enclave_attestation_not_enforced.py @@ -0,0 +1,90 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.kms.kms_client import kms_client +from prowler.providers.aws.services.kms.lib.enclave import ( + attestation_condition_keys, + is_enclave_key, + statement_targets_sensitive_actions, +) + + +class kms_key_enclave_attestation_not_enforced(Check): + """Ensure enclave-scoped KMS keys require attestation on sensitive actions. + + KMS keys that back Nitro Enclave workloads are identified by the explicit + ``prowler:enclave-key=true`` tag, or by the substring ``enclave`` in the + key description or any tag key/value. Every ``Allow`` statement in the + key policy that grants a sensitive action (``kms:Decrypt``, + ``kms:DeriveSharedSecret``, ``kms:GenerateDataKey``, + ``kms:GenerateDataKeyPair``, ``kms:GenerateRandom``, ``kms:*``, ``*``) + must carry at least one ``kms:RecipientAttestation:*`` condition. + + - PASS: Every sensitive Allow statement carries an attestation condition. + - FAIL: At least one sensitive Allow statement is unconditioned. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the KMS enclave-attestation-condition check. + + Iterates over enabled customer-managed KMS keys tagged/described as + enclave keys and walks their policy statements looking for sensitive + ``Allow`` statements that lack a ``kms:RecipientAttestation:*`` + condition key. + + Returns: + list[Check_Report_AWS]: One report per selected enclave KMS key. + """ + findings = [] + for key in kms_client.keys: + if ( + key.manager != "CUSTOMER" + or key.state != "Enabled" + or not is_enclave_key(key) + ): + continue + if key.policy is None: + if getattr(key, "policy_fetch_error", None): + report = Check_Report_AWS(metadata=self.metadata(), resource=key) + report.status = "MANUAL" + report.status_extended = ( + f"KMS enclave key {key.id} policy could not be " + f"fetched ({key.policy_fetch_error}); attestation " + f"enforcement cannot be verified." + ) + findings.append(report) + continue + + report = Check_Report_AWS(metadata=self.metadata(), resource=key) + report.status = "PASS" + report.status_extended = ( + f"KMS enclave key {key.id} enforces attestation on every sensitive " + f"Allow statement." + ) + + statements = key.policy.get("Statement") or [] + if isinstance(statements, dict): + statements = [statements] + + for statement in statements: + if not isinstance(statement, dict): + continue + if statement.get("Effect") != "Allow": + continue + if not statement_targets_sensitive_actions(statement): + continue + if not attestation_condition_keys(statement): + actions = statement.get("Action", []) + if isinstance(actions, str): + actions = [actions] + action_names = [a for a in actions if isinstance(a, str)] + actions_str = ( + ", ".join(sorted(action_names)) if action_names else "" + ) + report.status = "FAIL" + report.status_extended = ( + f"KMS enclave key {key.id} allows sensitive action(s) " + f"{actions_str} without any kms:RecipientAttestation:* " + f"condition." + ) + break + findings.append(report) + return findings diff --git a/prowler/providers/aws/services/kms/kms_key_enclave_attestation_pcr_mismatch/__init__.py b/prowler/providers/aws/services/kms/kms_key_enclave_attestation_pcr_mismatch/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/kms/kms_key_enclave_attestation_pcr_mismatch/kms_key_enclave_attestation_pcr_mismatch.metadata.json b/prowler/providers/aws/services/kms/kms_key_enclave_attestation_pcr_mismatch/kms_key_enclave_attestation_pcr_mismatch.metadata.json new file mode 100644 index 0000000000..862480d3de --- /dev/null +++ b/prowler/providers/aws/services/kms/kms_key_enclave_attestation_pcr_mismatch/kms_key_enclave_attestation_pcr_mismatch.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "aws", + "CheckID": "kms_key_enclave_attestation_pcr_mismatch", + "CheckTitle": "KMS enclave key attestation PCRs match customer-supplied golden values", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "kms", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "AwsKmsKey", + "ResourceGroup": "security", + "Description": "Compares the `kms:RecipientAttestation:PCR` (and equivalent `ImageSha384`) values referenced by an enclave key policy against a customer-provided list of trusted PCR hashes configured under `enclave_golden_pcr_values` in `audit_config`. Detects **image provenance drift**: policies whose attestation conditions still reference PCRs that no longer correspond to a known-good build.", + "Risk": "A KMS enclave key policy that still binds attestation but whose PCR values point at an untrusted or unknown enclave image undermines the **integrity** the attestation was supposed to enforce. A poisoned CI/CD pipeline or malicious policy update can preserve KMS access under an image the operator has never audited.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/enclaves/latest/user/set-up-attestation.html", + "https://docs.aws.amazon.com/kms/latest/developerguide/conditions-nitro-enclaves.html" + ], + "Remediation": { + "Code": { + "CLI": "aws kms put-key-policy --key-id --policy-name default --policy file://enclave-policy.json", + "NativeIaC": "", + "Other": "1. Configure the Prowler `audit_config` with the trusted PCR values produced by your enclave CI/CD pipeline under `enclave_golden_pcr_values` (per PCR bucket), e.g. `enclave_golden_pcr_values: {PCR0: [], PCR8: []}`.\n2. When rotating enclave images, publish the new PCRs to the golden list _before_ the KMS policy is updated so any drift surfaces immediately.\n3. Rotate KMS policies whose PCRs are not in the golden list, or update the golden list to reflect the newly audited image.", + "Terraform": "```hcl\ncondition {\n test = \"StringEqualsIgnoreCase\"\n variable = \"kms:RecipientAttestation:PCR0\"\n values = [var.golden_pcr0]\n}\n```" + }, + "Recommendation": { + "Text": "Treat KMS enclave policies as **provenance contracts**: pin them to PCR hashes produced by an auditable build pipeline and configure Prowler's golden list so drift is caught before it becomes an incident.", + "Url": "https://hub.prowler.com/check/kms_key_enclave_attestation_pcr_mismatch" + } + }, + "Categories": [ + "encryption", + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Requires `enclave_golden_pcr_values` in `audit_config`. Without it, the check reports MANUAL for every enclave key rather than silently PASSing." +} diff --git a/prowler/providers/aws/services/kms/kms_key_enclave_attestation_pcr_mismatch/kms_key_enclave_attestation_pcr_mismatch.py b/prowler/providers/aws/services/kms/kms_key_enclave_attestation_pcr_mismatch/kms_key_enclave_attestation_pcr_mismatch.py new file mode 100644 index 0000000000..4522f4c2bd --- /dev/null +++ b/prowler/providers/aws/services/kms/kms_key_enclave_attestation_pcr_mismatch/kms_key_enclave_attestation_pcr_mismatch.py @@ -0,0 +1,145 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.kms.kms_client import kms_client +from prowler.providers.aws.services.kms.lib.enclave import ( + GOLDEN_PCR_CONFIG_KEY, + _pcr_to_bytes, + attestation_values_by_pcr, + is_enclave_key, + normalize_golden_pcr_config, + statement_targets_sensitive_actions, +) + + +class kms_key_enclave_attestation_pcr_mismatch(Check): + """Ensure enclave KMS attestation PCRs match customer-supplied golden values. + + Compares the ``kms:RecipientAttestation:PCR`` (and equivalent + ``ImageSha384``) values referenced by every restrictive statement in the + key policy against a customer-provided list of trusted PCR hashes from + their audited enclave build pipeline. Detects supply-chain drift where a + policy still references attestation but the attested measurement no longer + corresponds to a known-good build. + + - MANUAL: no ``enclave_golden_pcr_values`` audit_config block, the block + contains no usable PCR buckets, or the policy references at least one + PCR ID for which no golden list is configured. The check cannot make a + safe assertion; the operator must extend the golden list or review the + uncovered PCRs by hand. + - PASS: every PCR referenced by the policy is covered by the golden config + *and* every referenced value is in its golden list. + - FAIL: at least one referenced PCR value is not in its configured golden + list. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the golden-PCR provenance check. + + Iterates enabled customer-managed KMS keys flagged as enclave keys and, + for every sensitive ``Allow`` statement, aggregates the restrictive + attestation values by PCR ID. When ``enclave_golden_pcr_values`` is + configured, compares each PCR bucket that has a golden list against it + (PCR buckets without a configured list are skipped for that key). + + Returns: + list[Check_Report_AWS]: One report per selected enclave KMS key. + """ + findings = [] + golden = normalize_golden_pcr_config( + kms_client.audit_config.get(GOLDEN_PCR_CONFIG_KEY) + ) + + for key in kms_client.keys: + if ( + key.manager != "CUSTOMER" + or key.state != "Enabled" + or not is_enclave_key(key) + ): + continue + if key.policy is None: + if getattr(key, "policy_fetch_error", None): + report = Check_Report_AWS(metadata=self.metadata(), resource=key) + report.status = "MANUAL" + report.status_extended = ( + f"KMS enclave key {key.id} policy could not be " + f"fetched ({key.policy_fetch_error}); PCR provenance " + f"cannot be verified against the golden list." + ) + findings.append(report) + continue + + report = Check_Report_AWS(metadata=self.metadata(), resource=key) + + if not golden: + report.status = "MANUAL" + report.status_extended = ( + f"KMS enclave key {key.id} provenance cannot be verified: " + f"no 'enclave_golden_pcr_values' configured. Set a golden " + f"PCR list in audit_config and re-run this check." + ) + findings.append(report) + continue + + observed: dict = {} + statements = key.policy.get("Statement") or [] + if isinstance(statements, dict): + statements = [statements] + for statement in statements: + if not isinstance(statement, dict): + continue + if statement.get("Effect") != "Allow": + continue + if not statement_targets_sensitive_actions(statement): + continue + for pcr_id, values in attestation_values_by_pcr(statement).items(): + observed.setdefault(pcr_id, set()).update(values) + + uncovered = sorted(observed.keys() - golden.keys()) + mismatches: list = [] + for pcr_id, allowed in golden.items(): + seen = observed.get(pcr_id) + if not seen: + continue + # Compare on canonical bytes (48 raw bytes from hex or + # base64) so hex vs base64 mismatches between the golden + # config and the policy do not produce false positives. + allowed_bytes = { + b for a in allowed if (b := _pcr_to_bytes(a)) is not None + } + bad = sorted( + v + for v in seen + if (vb := _pcr_to_bytes(v)) is None or vb not in allowed_bytes + ) + if bad: + mismatches.append((pcr_id, bad)) + + if not observed: + report.status = "MANUAL" + report.status_extended = ( + f"KMS enclave key {key.id} has no restrictive attestation " + f"bindings on sensitive statements; provenance cannot be " + f"verified against the golden list." + ) + elif mismatches: + report.status = "FAIL" + details = "; ".join(f"{pcr}={bad}" for pcr, bad in sorted(mismatches)) + report.status_extended = ( + f"KMS enclave key {key.id} references PCR values outside " + f"the configured golden list: {details}." + ) + elif uncovered: + report.status = "MANUAL" + report.status_extended = ( + f"KMS enclave key {key.id} references PCR IDs {uncovered} " + f"for which no golden list is configured; provenance " + f"cannot be verified. Extend 'enclave_golden_pcr_values' " + f"to cover them or review by hand." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"KMS enclave key {key.id} attestation PCR values all " + f"match the configured golden list." + ) + findings.append(report) + return findings diff --git a/prowler/providers/aws/services/kms/kms_key_enclave_attestation_unknown_image/__init__.py b/prowler/providers/aws/services/kms/kms_key_enclave_attestation_unknown_image/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/kms/kms_key_enclave_attestation_unknown_image/kms_key_enclave_attestation_unknown_image.metadata.json b/prowler/providers/aws/services/kms/kms_key_enclave_attestation_unknown_image/kms_key_enclave_attestation_unknown_image.metadata.json new file mode 100644 index 0000000000..a7a89e9905 --- /dev/null +++ b/prowler/providers/aws/services/kms/kms_key_enclave_attestation_unknown_image/kms_key_enclave_attestation_unknown_image.metadata.json @@ -0,0 +1,44 @@ +{ + "Provider": "aws", + "CheckID": "kms_key_enclave_attestation_unknown_image", + "CheckTitle": "No enclave with an unknown image identity has called this KMS key", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "kms", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "AwsKmsKey", + "ResourceGroup": "security", + "Description": "Scans CloudTrail attestation activity and flags PCR values missing from `enclave_golden_pcr_values` (only for buckets with a configured golden list). Complements `kms_key_enclave_attestation_pcr_mismatch` by catching real enclave calls with an unrecognized image. MEDIUM by default, overridable to HIGH via `enclave_unknown_image_severity`.", + "Risk": "An attestation event whose PCRs cannot be traced back to a known-good enclave build indicates either a stale golden list, a policy broad enough to accept unaudited images, or a compromise scenario in which an unknown image is being executed with legitimate KMS access. Materiality depends on the operator's threat model.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/kms/latest/developerguide/ct-nitro-enclave.html", + "https://docs.aws.amazon.com/enclaves/latest/user/set-up-attestation.html" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Configure the Prowler `audit_config` with your golden PCR list, e.g. `enclave_golden_pcr_values: {PCR0: [], PCR8: []}`. Optionally set `enclave_unknown_image_severity: high` to escalate findings.\n2. Compare the flagged PCR values against your enclave CI/CD build catalogue. If they map to a legitimate build that is missing from `enclave_golden_pcr_values`, extend the list.\n3. If the PCR values do NOT map to any legitimate build, treat as a suspected incident: identify the enclave via CloudTrail's `attestationDocumentModuleId`, terminate it, and audit the launch pipeline.\n4. Consider tightening the KMS key policy so only golden PCRs are accepted (paired with `kms_key_enclave_attestation_pcr_mismatch`).", + "Terraform": "" + }, + "Recommendation": { + "Text": "Treat the `enclave_golden_pcr_values` list as a **live registry of trusted enclave images**. Any runtime attestation from a PCR outside that list is either a *documentation gap* (extend the list) or a *suspected incident* (investigate).", + "Url": "https://hub.prowler.com/check/kms_key_enclave_attestation_unknown_image" + } + }, + "Categories": [ + "encryption", + "logging", + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [ + "kms_key_enclave_attestation_pcr_mismatch", + "kms_key_enclave_debug_attestation_detected" + ], + "Notes": "Requires `enclave_golden_pcr_values` in `audit_config`. Debug-mode events (zeroed PCR0/1/2) are discarded to avoid double reporting with `kms_key_enclave_debug_attestation_detected`. **PCR buckets without a golden list are not evaluated**: to catch unknown values in a PCR bucket, add a golden list for that bucket. Only standard PCR fields (ImageDigest/PCR0, PCR1-4, PCR8) exposed by CloudTrail are inspected; custom PCRs are out of scope. Severity is overridable per-finding via `enclave_unknown_image_severity`." +} diff --git a/prowler/providers/aws/services/kms/kms_key_enclave_attestation_unknown_image/kms_key_enclave_attestation_unknown_image.py b/prowler/providers/aws/services/kms/kms_key_enclave_attestation_unknown_image/kms_key_enclave_attestation_unknown_image.py new file mode 100644 index 0000000000..5b969bacd4 --- /dev/null +++ b/prowler/providers/aws/services/kms/kms_key_enclave_attestation_unknown_image/kms_key_enclave_attestation_unknown_image.py @@ -0,0 +1,272 @@ +from prowler.lib.check.models import Check, Check_Report_AWS, Severity +from prowler.providers.aws.services.cloudtrail.cloudtrail_client import ( + cloudtrail_client, +) +from prowler.providers.aws.services.kms.kms_client import kms_client +from prowler.providers.aws.services.kms.lib.enclave import ( + DEFAULT_ENCLAVE_UNKNOWN_IMAGE_LOOKBACK_HOURS, + DEFAULT_ENCLAVE_UNKNOWN_IMAGE_MAX_EVENTS, + DEFAULT_ENCLAVE_UNKNOWN_IMAGE_SEVERITY, + ENCLAVE_UNKNOWN_IMAGE_ALLOWED_SEVERITIES, + ENCLAVE_UNKNOWN_IMAGE_CONFIG_LOOKBACK_KEY, + ENCLAVE_UNKNOWN_IMAGE_CONFIG_MAX_EVENTS_KEY, + ENCLAVE_UNKNOWN_IMAGE_CONFIG_SEVERITY_KEY, + ENCLAVE_UNKNOWN_IMAGE_CONFIG_TARGET_KEYS_KEY, + GOLDEN_PCR_CONFIG_KEY, + SENSITIVE_ENCLAVE_KMS_EVENTS, + key_id_from_arn, + normalize_golden_pcr_config, + parse_enclave_kms_event, + resolve_kms_key_resource, + synthetic_account_kms_resource, + unknown_pcrs, +) + + +class kms_key_enclave_attestation_unknown_image(Check): + """Detect KMS attestation events from unrecognized enclave images. + + Complementary to ``kms_key_enclave_attestation_pcr_mismatch``: + + - ``kms_key_enclave_attestation_pcr_mismatch`` audits the KMS **key + policy** (config-time). It detects when a policy authorises PCR + values not present in the operator's golden list. + - This check audits **runtime CloudTrail activity**. It detects when + any enclave actually calls KMS with PCR values not present in the + golden list — even if the key policy would happen to allow them. + Useful when the golden list has been updated but a policy is still + permissive, or when the policy is broad enough to accept images the + operator never audited. + + Verdicts per KMS key ARN observed in CloudTrail events (or per target + key in ``enclave_unknown_image_target_key_ids`` if configured): + + - FAIL: at least one non-debug attestation event references a PCR + value that is NOT in the configured golden list. + - PASS: events observed against the key, every referenced PCR present + in the golden list, and the lookup covered the full window. + - MANUAL: no golden PCR values configured; no non-debug attestation + events found in the window; or the event cap was reached without a + confirmed unknown event (coverage-limited fail-closed). + + Debug-mode events (all-zero PCRs) are silently discarded — they are + scoped to ``kms_key_enclave_debug_attestation_detected`` (Check 10-A) + to avoid double reporting. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the unknown-image attestation check. + + Iterates every configured KMS trail (deduping on multi-region), + walks CloudTrail attestation events, groups by KMS key ARN, and + emits one report per observed (or targeted) key. Non-debug events + with PCRs that fall outside the configured golden list are flagged + FAIL; the finding severity may be overridden to ``"high"`` via + ``enclave_unknown_image_severity`` in ``audit_config`` (applied + through ``check_metadata.Severity`` so it propagates to output). + + Returns: + list[Check_Report_AWS]: one report per KMS key evaluated. + """ + findings = [] + cfg = kms_client.audit_config or {} + + golden = normalize_golden_pcr_config(cfg.get(GOLDEN_PCR_CONFIG_KEY)) + lookback_hours = cfg.get( + ENCLAVE_UNKNOWN_IMAGE_CONFIG_LOOKBACK_KEY, + DEFAULT_ENCLAVE_UNKNOWN_IMAGE_LOOKBACK_HOURS, + ) + max_events = cfg.get( + ENCLAVE_UNKNOWN_IMAGE_CONFIG_MAX_EVENTS_KEY, + DEFAULT_ENCLAVE_UNKNOWN_IMAGE_MAX_EVENTS, + ) + target_key_ids = set( + cfg.get(ENCLAVE_UNKNOWN_IMAGE_CONFIG_TARGET_KEYS_KEY, []) or [] + ) + severity_override = str( + cfg.get( + ENCLAVE_UNKNOWN_IMAGE_CONFIG_SEVERITY_KEY, + DEFAULT_ENCLAVE_UNKNOWN_IMAGE_SEVERITY, + ) + ).lower() + if severity_override not in ENCLAVE_UNKNOWN_IMAGE_ALLOWED_SEVERITIES: + severity_override = DEFAULT_ENCLAVE_UNKNOWN_IMAGE_SEVERITY + + lookback_minutes = max(1, int(lookback_hours) * 60) + max_events = max(1, int(max_events)) + + if not golden: + report = Check_Report_AWS( + metadata=self.metadata(), + resource=synthetic_account_kms_resource( + kms_client.audited_account, kms_client.region + ), + ) + report.status = "MANUAL" + report.status_extended = ( + "Cannot verify unknown-image attestation activity: no " + f"'{GOLDEN_PCR_CONFIG_KEY}' configured. Set a golden PCR " + "list in audit_config and re-run this check." + ) + findings.append(report) + return findings + + trails = ( + list(cloudtrail_client.trails.values()) if cloudtrail_client.trails else [] + ) + if not trails: + report = Check_Report_AWS( + metadata=self.metadata(), + resource=synthetic_account_kms_resource( + kms_client.audited_account, kms_client.region + ), + ) + report.status = "MANUAL" + report.status_extended = ( + "No CloudTrail trails are configured in the account; " + "unknown-image attestation activity cannot be observed." + ) + findings.append(report) + return findings + + # LookupEvents is a per-region API even for multi-region trails, so + # iterate over every audited region. + regions_to_scan = sorted(cloudtrail_client.regional_clients.keys()) + + events_by_key: dict = {} + any_coverage_gap = False + coverage_errors: list = [] + total_processed = 0 + + for region in regions_to_scan: + if total_processed >= max_events: + any_coverage_gap = True + break + for event_name in SENSITIVE_ENCLAVE_KMS_EVENTS: + if total_processed >= max_events: + any_coverage_gap = True + break + page_events, truncated, error = cloudtrail_client._lookup_events_page( + region=region, + event_name=event_name, + minutes=lookback_minutes, + ) + if error is not None: + any_coverage_gap = True + coverage_errors.append(f"{region}:{event_name} ({error})") + continue + page_events = page_events or [] + # Honour ``max_events`` at page granularity: with a cap of N + # and a 50-event page, process only the first ``N-total`` and + # flag coverage-incomplete for the rest. + remaining = max_events - total_processed + for raw in page_events[:remaining]: + parsed = parse_enclave_kms_event(raw) + if parsed is None: + continue + if parsed["is_debug"]: + # Debug events belong to + # ``kms_key_enclave_debug_attestation_detected``. + continue + key_arn = parsed["key_arn"] + if key_arn is None: + continue + if ( + target_key_ids + and key_id_from_arn(key_arn) not in target_key_ids + ): + continue + unknown = unknown_pcrs(parsed["observed_pcrs"], golden) + events_by_key.setdefault(key_arn, []).append( + {**parsed, "unknown": unknown} + ) + total_processed += min(len(page_events), remaining) + if len(page_events) > remaining: + any_coverage_gap = True + if truncated: + any_coverage_gap = True + + keys_to_report = set(events_by_key.keys()) + if target_key_ids: + for key in kms_client.keys: + if key_id_from_arn(key.arn) in target_key_ids: + keys_to_report.add(key.arn) + + if not keys_to_report: + report = Check_Report_AWS( + metadata=self.metadata(), + resource=synthetic_account_kms_resource( + kms_client.audited_account, kms_client.region + ), + ) + report.status = "MANUAL" + base = ( + f"No non-debug attestation events found in the last " + f"{lookback_hours}h; unknown-image status cannot be " + f"determined." + ) + if coverage_errors: + report.status_extended = ( + f"{base} Additionally, CloudTrail lookup failed for " + f"{len(coverage_errors)} region/event pair(s): " + f"{', '.join(coverage_errors[:5])}" + f"{'...' if len(coverage_errors) > 5 else ''}. " + f"Coverage is incomplete." + ) + else: + report.status_extended = base + findings.append(report) + return findings + + for key_arn in sorted(keys_to_report): + report = Check_Report_AWS( + metadata=self.metadata(), + resource=resolve_kms_key_resource(kms_client, key_arn), + ) + # Override severity per-finding via check_metadata (Prowler-standard + # pattern used by rds_instance_certificate_expiration and others). + # Setting report.severity as a raw attr has no effect on output. + report.check_metadata.Severity = Severity[severity_override] + events = events_by_key.get(key_arn, []) + unknown_events = [e for e in events if e["unknown"]] + + if unknown_events: + sample = sorted(unknown_events, key=lambda e: str(e["event_time"]))[0] + mismatches = "; ".join( + f"{pcr}={val}" for pcr, val in sorted(sample["unknown"].items()) + ) + report.status = "FAIL" + report.status_extended = ( + f"KMS key {key_arn} received a " + f"{sample['event_name']} call at {sample['event_time']} " + f"whose attestation PCR values are NOT in the " + f"configured golden list ({mismatches}). The enclave " + f"image identity cannot be verified against any known " + f"registry." + ) + elif not events: + report.status = "MANUAL" + report.status_extended = ( + f"KMS key {key_arn} has no non-debug attestation " + f"events in the last {lookback_hours}h; " + f"unknown-image status cannot be verified." + ) + elif any_coverage_gap: + report.status = "MANUAL" + report.status_extended = ( + f"KMS key {key_arn} observed {len(events)} attestation " + f"events with known PCRs, but the CloudTrail lookup " + f"reached the event cap ({max_events}) or page " + f"truncation. An unknown-image event may exist beyond " + f"the cap." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"KMS key {key_arn} observed {len(events)} attestation " + f"events in the last {lookback_hours}h; every " + f"referenced PCR is present in the configured golden " + f"list." + ) + findings.append(report) + return findings diff --git a/prowler/providers/aws/services/kms/kms_key_enclave_debug_attestation_detected/__init__.py b/prowler/providers/aws/services/kms/kms_key_enclave_debug_attestation_detected/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/kms/kms_key_enclave_debug_attestation_detected/kms_key_enclave_debug_attestation_detected.metadata.json b/prowler/providers/aws/services/kms/kms_key_enclave_debug_attestation_detected/kms_key_enclave_debug_attestation_detected.metadata.json new file mode 100644 index 0000000000..df8b9c6731 --- /dev/null +++ b/prowler/providers/aws/services/kms/kms_key_enclave_debug_attestation_detected/kms_key_enclave_debug_attestation_detected.metadata.json @@ -0,0 +1,44 @@ +{ + "Provider": "aws", + "CheckID": "kms_key_enclave_debug_attestation_detected", + "CheckTitle": "No Nitro Enclave debug-mode attestation observed against this KMS key", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "kms", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "AwsKmsKey", + "ResourceGroup": "security", + "Description": "Detects **Nitro Enclaves** launched with `--debug-mode` via CloudTrail KMS-from-enclave events. Debug enclaves produce attestations with zeroed image/kernel/application PCRs (PCR0/1/2); the check flags every KMS key that received such a call. Configurable via `enclave_debug_lookback_window_hours` (default 2160h / 90d) and `enclave_debug_max_events` (5000).", + "Risk": "Debug mode zeros the image, kernel and application PCRs (PCR0/1/2) in the attestation document, so PCR-bound key policies release material to enclaves whose measurement cannot be trusted. A debug enclave calling this KMS key is functionally equivalent to no enclave at all.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/enclaves/latest/user/cmd-nitro-run-enclave.html", + "https://docs.aws.amazon.com/kms/latest/developerguide/ct-nitro-enclave.html" + ], + "Remediation": { + "Code": { + "CLI": "nitro-cli run-enclave --cpu-count --memory --eif-path ", + "NativeIaC": "", + "Other": "1. Identify the enclave that produced the zeroed-PCR attestation from the CloudTrail event's `attestationDocumentModuleId` (embeds the parent instance-id).\n2. On the flagged host, terminate the debug enclave and relaunch without `--debug-mode`.\n3. Audit the enclave orchestration pipeline for hardcoded `--debug-mode` that leaked into production.\n4. Tighten the KMS key policy to bind non-zero PCR values so future debug enclaves fail closed at the key-policy layer.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Never run production Nitro Enclaves with `--debug-mode`. Enforce non-debug launch flags in the enclave orchestration pipeline and pair with strict PCR bindings on KMS policies so debug enclaves are rejected at the key-policy layer.", + "Url": "https://hub.prowler.com/check/kms_key_enclave_debug_attestation_detected" + } + }, + "Categories": [ + "encryption", + "logging", + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [ + "kms_key_enclave_attestation_not_enforced", + "kms_key_enclave_attestation_no_deployment_binding" + ], + "Notes": "CloudTrail-based detection: enclaves that never call KMS in the window are unobservable and reported MANUAL. Truncated pages / max_events cap also emit MANUAL rather than PASS to avoid a false PASS. Only standard PCR fields (ImageDigest/PCR0, PCR1-4, PCR8) exposed by CloudTrail are inspected; custom PCRs are out of scope." +} diff --git a/prowler/providers/aws/services/kms/kms_key_enclave_debug_attestation_detected/kms_key_enclave_debug_attestation_detected.py b/prowler/providers/aws/services/kms/kms_key_enclave_debug_attestation_detected/kms_key_enclave_debug_attestation_detected.py new file mode 100644 index 0000000000..60102eaa90 --- /dev/null +++ b/prowler/providers/aws/services/kms/kms_key_enclave_debug_attestation_detected/kms_key_enclave_debug_attestation_detected.py @@ -0,0 +1,252 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.cloudtrail.cloudtrail_client import ( + cloudtrail_client, +) +from prowler.providers.aws.services.kms.kms_client import kms_client +from prowler.providers.aws.services.kms.lib.enclave import ( + DEFAULT_ENCLAVE_DEBUG_LOOKBACK_HOURS, + DEFAULT_ENCLAVE_DEBUG_MAX_EVENTS, + ENCLAVE_DEBUG_CONFIG_LOOKBACK_KEY, + ENCLAVE_DEBUG_CONFIG_MAX_EVENTS_KEY, + ENCLAVE_DEBUG_CONFIG_TARGET_KEYS_KEY, + SENSITIVE_ENCLAVE_KMS_EVENTS, + key_id_from_arn, + parse_enclave_kms_event, + resolve_kms_key_resource, + synthetic_account_kms_resource, +) + + +class kms_key_enclave_debug_attestation_detected(Check): + """Detect Nitro Enclave debug-mode attestation on KMS calls (CloudTrail). + + Enclaves launched with ``--debug-mode`` produce attestation documents + whose image/kernel/application PCRs (``PCR0``, ``PCR1``, ``PCR2``) are + zeroed. When such an enclave calls a sensitive KMS operation + (``Decrypt``, ``GenerateDataKey``, ``GenerateDataKeyPair``, + ``GenerateRandom``) the recipient attestation is logged in CloudTrail + with those zeroed PCRs. This Tier 1, read-only check scans CloudTrail + events and attributes findings to the KMS keys that were targeted. + + Verdicts per KMS key ARN observed in CloudTrail events (or per target + key in ``enclave_debug_target_key_ids`` if configured): + + - FAIL: at least one CloudTrail event against the key has zeroed PCR0/1/2. + - PASS: events observed against the key, none debug, and the lookup + covered the full window (no page truncation). + - MANUAL: no events attributable to the key in the window, or the + event cap was reached without a confirmed debug event + (coverage-limited — fail-closed). + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the CloudTrail-based debug-attestation check. + + Iterates every configured KMS trail (or the multi-region trail if + available), issues paginated ``lookup_events`` calls for each + sensitive enclave KMS event name, groups the parsed events by key + ARN, and emits one report per observed (or targeted) KMS key. + + Returns: + list[Check_Report_AWS]: one report per KMS key evaluated. + """ + findings = [] + + lookback_hours = kms_client.audit_config.get( + ENCLAVE_DEBUG_CONFIG_LOOKBACK_KEY, + DEFAULT_ENCLAVE_DEBUG_LOOKBACK_HOURS, + ) + max_events = kms_client.audit_config.get( + ENCLAVE_DEBUG_CONFIG_MAX_EVENTS_KEY, + DEFAULT_ENCLAVE_DEBUG_MAX_EVENTS, + ) + target_key_ids = set( + kms_client.audit_config.get(ENCLAVE_DEBUG_CONFIG_TARGET_KEYS_KEY, []) or [] + ) + # Fall back to defaults if the operator writes a non-numeric value in + # ``audit_config`` (e.g. a stray string). Do not abort the check. + # Normalize ``lookback_hours`` in place so status messages report the + # value actually used for the CloudTrail lookup, not the invalid input. + try: + lookback_hours = max(1, int(lookback_hours)) + except (TypeError, ValueError): + lookback_hours = DEFAULT_ENCLAVE_DEBUG_LOOKBACK_HOURS + lookback_minutes = lookback_hours * 60 + try: + max_events = max(1, int(max_events)) + except (TypeError, ValueError): + max_events = DEFAULT_ENCLAVE_DEBUG_MAX_EVENTS + + trails = ( + list(cloudtrail_client.trails.values()) if cloudtrail_client.trails else [] + ) + if not trails: + return self._emit_no_trail_manual(target_key_ids) + + # LookupEvents is a per-region API even for multi-region trails, so + # iterate over every audited region. A multi-region trail in us-east-1 + # cannot expose eu-west-1 KMS events via a us-east-1 lookup. + regions_to_scan = sorted(cloudtrail_client.regional_clients.keys()) + + events_by_key: dict = {} + any_coverage_gap = False + coverage_errors: list = [] + total_events_processed = 0 + + for region in regions_to_scan: + for event_name in SENSITIVE_ENCLAVE_KMS_EVENTS: + if total_events_processed >= max_events: + any_coverage_gap = True + break + page_events, truncated, error = cloudtrail_client._lookup_events_page( + region=region, + event_name=event_name, + minutes=lookback_minutes, + ) + if error is not None: + any_coverage_gap = True + coverage_errors.append(f"{region}:{event_name} ({error})") + continue + for raw in page_events or []: + parsed = parse_enclave_kms_event(raw) + if parsed is None: + continue + key_arn = parsed["key_arn"] + if key_arn is None: + continue + if ( + target_key_ids + and key_id_from_arn(key_arn) not in target_key_ids + ): + continue + events_by_key.setdefault(key_arn, []).append(parsed) + total_events_processed += len(page_events or []) + if truncated: + any_coverage_gap = True + if total_events_processed >= max_events: + break + + keys_to_report = set(events_by_key.keys()) + if target_key_ids: + for key in kms_client.keys: + if key_id_from_arn(key.arn) in target_key_ids: + keys_to_report.add(key.arn) + + if not keys_to_report: + report = Check_Report_AWS( + metadata=self.metadata(), + resource=synthetic_account_kms_resource( + kms_client.audited_account, kms_client.region + ), + ) + report.status = "MANUAL" + base = ( + f"No KMS-from-enclave attestation events found in the last " + f"{lookback_hours}h. Debug-mode status cannot be determined " + f"from available data; enclaves that never call KMS in the " + f"window are not observable via this check." + ) + if coverage_errors: + report.status_extended = ( + f"{base} Additionally, CloudTrail lookup failed for " + f"{len(coverage_errors)} region/event pair(s): " + f"{', '.join(coverage_errors[:5])}" + f"{'...' if len(coverage_errors) > 5 else ''}. " + f"Coverage is incomplete." + ) + else: + report.status_extended = base + findings.append(report) + return findings + + for key_arn in sorted(keys_to_report): + report = Check_Report_AWS( + metadata=self.metadata(), + resource=resolve_kms_key_resource(kms_client, key_arn), + ) + events = events_by_key.get(key_arn, []) + debug_events = [e for e in events if e["is_debug"]] + + if debug_events: + sample = sorted(debug_events, key=lambda e: str(e["event_time"]))[0] + report.status = "FAIL" + report.status_extended = ( + f"KMS key {key_arn} received a " + f"{sample['event_name']} call at {sample['event_time']} " + f"with an attestation document whose PCR0/1/2 are zeroed, " + f"indicating a Nitro Enclave running in --debug-mode." + ) + elif not events: + report.status = "MANUAL" + report.status_extended = ( + f"KMS key {key_arn} has no KMS-from-enclave attestation " + f"events in the last {lookback_hours}h; debug-mode " + f"status cannot be verified for this key." + ) + elif any_coverage_gap: + report.status = "MANUAL" + report.status_extended = ( + f"KMS key {key_arn} observed {len(events)} enclave " + f"attestation events with legitimate PCRs, but the " + f"CloudTrail lookup reached the event cap " + f"({max_events}) or page truncation. A debug event may " + f"exist beyond the cap; narrow " + f"'enclave_debug_lookback_window_hours' or raise " + f"'enclave_debug_max_events' for confirmatory coverage." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"KMS key {key_arn} observed {len(events)} enclave " + f"attestation events in the last {lookback_hours}h and " + f"none carry zeroed PCR0/1/2." + ) + findings.append(report) + return findings + + def _emit_no_trail_manual(self, target_key_ids: set[str]) -> list[Check_Report_AWS]: + """Emit MANUAL findings when no CloudTrail trail is configured. + + Without a trail the check has no data source, so it fails closed + with MANUAL against every candidate key (or a synthetic + account-scoped resource when the account has no KMS keys either). + + Args: + target_key_ids: Optional filter of KMS key-ids to report on. + When empty every customer-managed key is a candidate. + + Returns: + list[Check_Report_AWS]: One MANUAL report per candidate key, + or a single account-scoped MANUAL when no keys exist. + """ + findings = [] + candidate_keys = [ + k + for k in kms_client.keys + if not target_key_ids or key_id_from_arn(k.arn) in target_key_ids + ] + if not candidate_keys: + report = Check_Report_AWS( + metadata=self.metadata(), + resource=synthetic_account_kms_resource( + kms_client.audited_account, kms_client.region + ), + ) + report.status = "MANUAL" + report.status_extended = ( + "No CloudTrail trails are configured in the account; " + "debug-attestation activity cannot be observed." + ) + findings.append(report) + return findings + for key in candidate_keys: + report = Check_Report_AWS(metadata=self.metadata(), resource=key) + report.status = "MANUAL" + report.status_extended = ( + f"KMS key {key.arn} debug-attestation status cannot be " + f"verified: no CloudTrail trails are configured in the " + f"account. Enable CloudTrail to observe KMS-from-enclave " + f"activity." + ) + findings.append(report) + return findings diff --git a/prowler/providers/aws/services/kms/kms_service.py b/prowler/providers/aws/services/kms/kms_service.py index 4269e5ccf8..b1c3ba388b 100644 --- a/prowler/providers/aws/services/kms/kms_service.py +++ b/prowler/providers/aws/services/kms/kms_service.py @@ -19,6 +19,7 @@ class KMS(AWSService): self._get_key_rotation_status() self._get_key_policy() self._list_resource_tags() + self.__threading_call__(self._list_aliases) def _list_keys(self, regional_client): logger.info("KMS - Listing Keys...") @@ -58,6 +59,7 @@ class KMS(AWSService): key.manager = response["KeyMetadata"]["KeyManager"] key.spec = response["KeyMetadata"]["CustomerMasterKeySpec"] key.multi_region = response["KeyMetadata"]["MultiRegion"] + key.description = response["KeyMetadata"].get("Description", "") except Exception as error: logger.error( f"{regional_client.region} -- {error.__class__.__name__}:{error.__traceback__.tb_lineno} -- {error}" @@ -106,6 +108,7 @@ class KMS(AWSService): )["Policy"] ) except Exception as error: + key.policy_fetch_error = error.__class__.__name__ logger.error( f"{regional_client.region} -- {error.__class__.__name__}:{error.__traceback__.tb_lineno} -- {error}" ) @@ -136,6 +139,26 @@ class KMS(AWSService): f"{regional_client.region} -- {error.__class__.__name__}:{error.__traceback__.tb_lineno} -- {error}" ) + def _list_aliases(self, regional_client): + logger.info("KMS - Listing Aliases...") + try: + aliases_by_key_id = {} + paginator = regional_client.get_paginator("list_aliases") + for page in paginator.paginate(): + for alias in page.get("Aliases", []): + target_key_id = alias.get("TargetKeyId") + if target_key_id: + aliases_by_key_id.setdefault(target_key_id, []).append( + alias["AliasName"] + ) + for key in self.keys: + if key.region == regional_client.region and key.id in aliases_by_key_id: + key.aliases = aliases_by_key_id[key.id] + except Exception as error: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}:{error.__traceback__.tb_lineno} -- {error}" + ) + class Key(BaseModel): id: str @@ -145,7 +168,14 @@ class Key(BaseModel): manager: Optional[str] rotation_enabled: Optional[bool] policy: Optional[dict] + # Populated by _get_key_policy on API failure. Distinguishes "policy not + # applicable" (None + no error) from "policy could not be fetched" (None + + # error class name). Checks that make security assertions from the policy + # should emit MANUAL when this is set, not silently skip the key. + policy_fetch_error: Optional[str] = None spec: Optional[str] region: str multi_region: Optional[bool] + description: Optional[str] = "" + aliases: Optional[list] = [] tags: Optional[list] = [] diff --git a/prowler/providers/aws/services/kms/lib/__init__.py b/prowler/providers/aws/services/kms/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/kms/lib/enclave.py b/prowler/providers/aws/services/kms/lib/enclave.py new file mode 100644 index 0000000000..347b203a17 --- /dev/null +++ b/prowler/providers/aws/services/kms/lib/enclave.py @@ -0,0 +1,783 @@ +import base64 +import binascii +import json +import re +from typing import Any + +from py_iam_expand.actions import InvalidActionHandling, expand_actions + +from prowler.lib.logger import logger + +# CloudTrail event names that carry a Recipient attestation document when +# invoked by a Nitro Enclave. Kept in sync with ``SENSITIVE_ENCLAVE_ACTIONS`` +# so the policy and runtime checks evaluate the same surface. +SENSITIVE_ENCLAVE_KMS_EVENTS = ( + "Decrypt", + "DeriveSharedSecret", + "GenerateDataKey", + "GenerateDataKeyPair", + "GenerateRandom", +) +DEFAULT_ENCLAVE_DEBUG_LOOKBACK_HOURS = ( + 2160 # 90 days, matches CloudTrail management-event retention +) +DEFAULT_ENCLAVE_DEBUG_MAX_EVENTS = 5000 +ENCLAVE_DEBUG_CONFIG_LOOKBACK_KEY = "enclave_debug_lookback_window_hours" +ENCLAVE_DEBUG_CONFIG_MAX_EVENTS_KEY = "enclave_debug_max_events" +ENCLAVE_DEBUG_CONFIG_TARGET_KEYS_KEY = "enclave_debug_target_key_ids" + +DEFAULT_ENCLAVE_UNKNOWN_IMAGE_LOOKBACK_HOURS = ( + 2160 # 90 days, matches CloudTrail management-event retention +) +DEFAULT_ENCLAVE_UNKNOWN_IMAGE_MAX_EVENTS = 5000 +DEFAULT_ENCLAVE_UNKNOWN_IMAGE_SEVERITY = "medium" +ENCLAVE_UNKNOWN_IMAGE_CONFIG_LOOKBACK_KEY = ( + "enclave_unknown_image_lookback_window_hours" +) +ENCLAVE_UNKNOWN_IMAGE_CONFIG_MAX_EVENTS_KEY = "enclave_unknown_image_max_events" +ENCLAVE_UNKNOWN_IMAGE_CONFIG_TARGET_KEYS_KEY = "enclave_unknown_image_target_key_ids" +ENCLAVE_UNKNOWN_IMAGE_CONFIG_SEVERITY_KEY = "enclave_unknown_image_severity" +ENCLAVE_UNKNOWN_IMAGE_ALLOWED_SEVERITIES = ("medium", "high") + +# Fields that debug mode zeros. Verified against AWS's official +# aws-nitro-enclaves-cli README: "All the platform configuration registers +# (PCRs) except for PCR3, PCR4 and PCR8 will have all their values set to 0". +# PCR0/1/2 are enclave measurements (image digest, kernel, application) — +# zeroed in debug mode. PCR3/4 encode host role and instance ID — always +# populated. PCR8 is the signing certificate — zeroed for unsigned EIFs +# regardless of debug mode. +_DEBUG_ZEROED_PCR_FIELDS = ( + "attestationDocumentEnclaveImageDigest", + "attestationDocumentEnclavePCR1", + "attestationDocumentEnclavePCR2", +) + +# PCR values are SHA-384 hashes (48 bytes). They travel across the stack in two +# formats: +# - **Hex** (96 chars): produced by ``nitro-cli describe-eif`` and typically +# what users put in golden config and KMS policy conditions. +# - **Base64** (64 chars): what CloudTrail records under +# ``additionalEventData.recipient`` because JSON cannot carry raw bytes. +# All comparisons canonicalize to 48 raw bytes and are re-rendered as lower +# hex for storage/display. +_PCR_BYTE_LENGTH = 48 +_PCR_HEX_RE = re.compile(r"^[0-9a-fA-F]{96}$") +_PCR_BASE64_RE = re.compile(r"^[A-Za-z0-9+/]{64}$") +_ALL_ZERO_PCR_BYTES = b"\x00" * _PCR_BYTE_LENGTH + + +def _pcr_to_bytes(value): + """Return the 48-byte PCR value from hex (96 chars) or base64 (64 chars). + + Returns ``None`` when the input is not a string or cannot be decoded as + either format. Accepts both because CloudTrail records base64 while + ``nitro-cli`` and KMS policies use hex. + """ + if not isinstance(value, str): + return None + if _PCR_HEX_RE.match(value): + try: + return bytes.fromhex(value) + except ValueError: + return None + if _PCR_BASE64_RE.match(value): + try: + decoded = base64.b64decode(value, validate=True) + except (binascii.Error, ValueError): + return None + if len(decoded) == _PCR_BYTE_LENGTH: + return decoded + return None + + +def _pcr_bytes_to_hex(pcr_bytes): + """Canonical string form for storage / display.""" + return pcr_bytes.hex().lower() + + +def extract_pcrs_from_recipient(recipient: dict) -> dict: + """Return ``{PCR_id: hex_value_lower}`` from a CloudTrail recipient block. + + ``ImageSha384`` (equivalent to PCR0 per the RFC) is collapsed under + ``PCR0``. Values are canonicalized to **lowercase hex** regardless of + the wire format (CloudTrail emits base64, some sources use hex). Non- + string, non-decodable, or absent PCR fields are skipped so partial / + truncated recipients yield partial maps rather than raising. + """ + if not isinstance(recipient, dict): + return {} + per_pcr: dict = {} + field_to_pcr = { + "attestationDocumentEnclaveImageDigest": "PCR0", + "attestationDocumentEnclavePCR1": "PCR1", + "attestationDocumentEnclavePCR2": "PCR2", + "attestationDocumentEnclavePCR3": "PCR3", + "attestationDocumentEnclavePCR4": "PCR4", + "attestationDocumentEnclavePCR8": "PCR8", + } + for field, pcr_id in field_to_pcr.items(): + raw = recipient.get(field) + pcr_bytes = _pcr_to_bytes(raw) + if pcr_bytes is not None: + per_pcr[pcr_id] = _pcr_bytes_to_hex(pcr_bytes) + return per_pcr + + +def is_debug_attestation(recipient: dict) -> bool: + """Return True when a CloudTrail attestation recipient looks debug-mode. + + Reality vs docs: the RFC v2.6 and the AWS user guide describe debug mode + as "all PCRs zeroed", but the aws-nitro-enclaves-cli README (and empirical + playground behavior) confirm that only the **enclave-measurement** PCRs + are zeroed — PCR3/PCR4 encode host role/instance and are always populated, + PCR8 depends on EIF signing. + + A recipient is classified as debug-mode when every field in + ``_DEBUG_ZEROED_PCR_FIELDS`` (PCR0 = ImageDigest, PCR1, PCR2) is present + AND decodes to 48 all-zero bytes. Values may arrive in either hex + (nitro-cli/audit_config format) or base64 (CloudTrail format); both are + normalized transparently. Partial recipients (missing any of the three + diagnostic fields) or non-decodable values are conservative-False. + """ + if not isinstance(recipient, dict): + return False + for key in _DEBUG_ZEROED_PCR_FIELDS: + raw = recipient.get(key) + pcr_bytes = _pcr_to_bytes(raw) + if pcr_bytes is None or pcr_bytes != _ALL_ZERO_PCR_BYTES: + return False + return True + + +def _extract_key_arn(raw, event): + """Return the KMS key ARN referenced by a CloudTrail event, or ``None``. + + The ARN can appear in the top-level ``Resources`` block (as returned by + ``lookup_events``) or inside the ``resources`` field of the parsed + payload. Both are tolerated to guard against schema drift. + """ + for source in (raw.get("Resources") or [], event.get("resources") or []): + for res in source: + if not isinstance(res, dict): + continue + rtype = res.get("ResourceType") or res.get("type") or "" + if isinstance(rtype, str) and "KMS::Key" in rtype: + arn = res.get("ResourceName") or res.get("ARN") + if isinstance(arn, str) and arn.startswith("arn:"): + return arn + return None + + +def parse_enclave_kms_event(raw: dict) -> dict | None: + """Return a parsed enclave-KMS event or ``None`` when non-relevant. + + ``raw`` is a single CloudTrail Event as returned by ``lookup_events``: a + dict with a ``CloudTrailEvent`` JSON-string payload. Returns ``None`` for + events without the enclave recipient block, unrecognized module IDs, or + malformed JSON. When relevant, returns a dict with ``instance_id``, + ``event_time``, ``event_name``, ``is_debug`` and ``key_arn`` (the latter + may be ``None`` if the event's Resources block does not name a key). + """ + payload = raw.get("CloudTrailEvent") + if not isinstance(payload, str): + return None + try: + event = json.loads(payload) + except (ValueError, TypeError): + return None + additional = event.get("additionalEventData") + recipient = additional.get("recipient") if isinstance(additional, dict) else None + if not isinstance(recipient, dict): + return None + module_id = recipient.get("attestationDocumentModuleId") + if not isinstance(module_id, str) or "-enc" not in module_id: + return None + return { + "instance_id": module_id.split("-enc", 1)[0], + "event_time": raw.get("EventTime") or event.get("eventTime"), + "event_name": event.get("eventName"), + "is_debug": is_debug_attestation(recipient), + "key_arn": _extract_key_arn(raw, event), + "observed_pcrs": extract_pcrs_from_recipient(recipient), + } + + +def unknown_pcrs(observed: dict, golden: dict) -> dict: + """Return observed PCRs not present in the golden list for their bucket. + + Semantics: only PCR IDs that have a golden list configured are evaluated. + Observed PCR buckets without a golden baseline are NOT flagged — without + a baseline we cannot make an "unknown" assertion. Operators who want to + catch unknown values in a specific PCR bucket must add a golden list for + that bucket in ``audit_config``. This matches the semantics documented + in the ``kms_key_enclave_attestation_unknown_image`` metadata Notes. + + Values are compared as raw bytes: the observed value comes from CloudTrail + (typically base64) and the golden values come from ``audit_config`` (users + typically paste hex from ``nitro-cli describe-eif``). Both are decoded to + 48-byte SHA384 buffers before comparison so hex/base64 mismatches don't + cause false positives. + """ + if not isinstance(observed, dict) or not isinstance(golden, dict): + return {} + unknown = {} + for pcr_id, value in observed.items(): + allowed = golden.get(pcr_id) + if not allowed: + continue + obs_bytes = _pcr_to_bytes(value) + if obs_bytes is None: + continue + allowed_bytes = {b for a in allowed if (b := _pcr_to_bytes(a)) is not None} + if obs_bytes not in allowed_bytes: + unknown[pcr_id] = value + return unknown + + +def key_id_from_arn(arn: str) -> str: + """Return the KMS key-id suffix of a full ARN, or the input verbatim. + + Accepts both aliases (``arn:aws:kms:*:*:key/``) and non-KMS or + malformed strings; in the latter case returns the input unchanged so + callers can compare against opaque identifiers without special-casing. + """ + if not isinstance(arn, str) or "/" not in arn: + return arn or "" + return arn.rsplit("/", 1)[-1] + + +def resolve_kms_key_resource(kms_client_ref, arn): + """Return the Key model matching ``arn`` or a lightweight stub. + + When a CloudTrail event references a key that is not in ``kms_client`` + (different region, KMS-key-not-in-cache, etc.), we still need a resource + object with ``arn``, ``id`` and ``region`` for ``Check_Report_AWS`` to + populate its resource fields. The stub carries the ARN as identity so + the report stays actionable. + """ + for key in kms_client_ref.keys: + if key.arn == arn: + return key + return _StubKmsResource(arn=arn, region=getattr(kms_client_ref, "region", "global")) + + +def synthetic_account_kms_resource(account_id, region): + """Return a placeholder resource for account-scoped findings. + + Used when the check has nothing key-specific to report (e.g., no + CloudTrail trails at all) but must still emit a finding. + """ + return _StubKmsResource( + arn=f"arn:aws:kms:{region or 'global'}:{account_id or 'unknown'}:enclave-debug-attestation", + region=region or "global", + ) + + +class _StubKmsResource: + """Minimal resource shim for ``Check_Report_AWS`` when the Key is out of cache. + + ``Check_Report`` calls ``.dict()`` on the resource to serialize into the + finding's ``resource`` field. Without this, Prowler logs a "could not be + converted to dict" ERROR and drops the resource metadata (leaving + ``resource_id/arn/region`` populated via getattr but ``resource`` empty). + """ + + def __init__(self, arn: str, region: str): + self.arn = arn + self.id = key_id_from_arn(arn) or arn + self.region = region + self.tags = [] + + def dict(self): + return { + "arn": self.arn, + "id": self.id, + "region": self.region, + "tags": self.tags, + } + + +SENSITIVE_ENCLAVE_ACTIONS = { + "kms:Decrypt", + "kms:DeriveSharedSecret", + "kms:GenerateDataKey", + "kms:GenerateDataKeyPair", + "kms:GenerateRandom", +} + +ATTESTATION_CONDITION_PREFIX = "kms:RecipientAttestation:" +_ATTESTATION_PREFIX_LOWER = ATTESTATION_CONDITION_PREFIX.lower() + + +def _is_attestation_key(cond_key) -> bool: + """AWS condition keys are case-insensitive, so match the prefix that way.""" + return isinstance(cond_key, str) and cond_key.lower().startswith( + _ATTESTATION_PREFIX_LOWER + ) + + +def _expanded_actions(patterns) -> set: + """Expand AWS action patterns (with wildcards) to canonical actions.""" + expanded = set() + for pattern in patterns: + try: + expanded.update(expand_actions(pattern, InvalidActionHandling.REMOVE)) + except Exception as error: + # Do not crash the check on unrecognized patterns or library + # errors, but leave an audit trail so silent failures can be + # debugged in production. + logger.error( + f"expand_actions failed on pattern {pattern!r}: " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: " + f"{error}" + ) + return expanded + + +def is_enclave_key(key: Any) -> bool: + """Return True when the KMS key looks like a Nitro Enclave workload key. + + Any signal suffices: tag ``prowler:enclave-key=true``, alias or + description/tag containing ``enclave`` (case-insensitive), or a policy that + already references any ``kms:RecipientAttestation:*`` condition key. + """ + for t in key.tags or []: + if ( + t.get("TagKey") == "prowler:enclave-key" + and str(t.get("TagValue", "")).lower() == "true" + ): + return True + + for alias in getattr(key, "aliases", []) or []: + if isinstance(alias, str) and "enclave" in alias.removeprefix("alias/").lower(): + return True + + description = getattr(key, "description", "") or "" + if "enclave" in description.lower(): + return True + for t in key.tags or []: + if "enclave" in str(t.get("TagKey", "")).lower(): + return True + if "enclave" in str(t.get("TagValue", "")).lower(): + return True + + if key.policy: + statements = key.policy.get("Statement") or [] + if isinstance(statements, dict): + statements = [statements] + for statement in statements: + if not isinstance(statement, dict): + continue + condition = statement.get("Condition") or {} + for kv in condition.values(): + if isinstance(kv, dict) and any(_is_attestation_key(k) for k in kv): + return True + + return False + + +def statement_actions(statement) -> set: + """Return the statement's Action field as a set (handles str or list).""" + action = statement.get("Action", []) + if isinstance(action, str): + return {action} + if isinstance(action, list): + return {a for a in action if isinstance(a, str)} + return set() + + +def statement_targets_sensitive_actions(statement) -> bool: + """True when the statement grants any sensitive-enclave action. + + Uses py_iam_expand to canonicalize case and expand wildcards + (``kms:GenerateDataKey*``, ``kms:*``, ``*``). ``NotAction`` in an Allow + grants everything except the excluded set, so we return True unless the + exclusion covers every sensitive action. + """ + if "NotAction" in statement: + raw = statement["NotAction"] + if isinstance(raw, str): + raw_patterns = {raw} + elif isinstance(raw, list): + raw_patterns = {p for p in raw if isinstance(p, str)} + else: + raw_patterns = set() + excluded = _expanded_actions(raw_patterns) + return not SENSITIVE_ENCLAVE_ACTIONS.issubset(excluded) + + patterns = statement_actions(statement) + if not patterns: + return False + return bool(SENSITIVE_ENCLAVE_ACTIONS & _expanded_actions(patterns)) + + +def _sensitive_actions_granted(statement) -> set: + """Return the sensitive-enclave actions this Allow statement effectively grants.""" + if "NotAction" in statement: + raw = statement["NotAction"] + if isinstance(raw, str): + raw_patterns = {raw} + elif isinstance(raw, list): + raw_patterns = {p for p in raw if isinstance(p, str)} + else: + raw_patterns = set() + excluded = _expanded_actions(raw_patterns) + return SENSITIVE_ENCLAVE_ACTIONS - excluded + return SENSITIVE_ENCLAVE_ACTIONS & _expanded_actions(statement_actions(statement)) + + +def _deny_covers_missing_attestation(deny_stmt, sensitive_actions) -> bool: + """Return True when a Deny fires whenever attestation context is absent. + + Recognized patterns: + + - ``Null: {"kms:RecipientAttestation:PCR*": "true"}`` — Deny when the + attestation context key is absent from the request. + - ``StringNotEqualsIfExists`` / ``StringNotEqualsIgnoreCaseIfExists`` + over ``kms:RecipientAttestation:*`` — the ``IfExists`` variant treats + absent keys as matching the operator, so combined with Deny it also + denies calls without attestation. + + The Deny also has to cover every sensitive action the Allow granted; a + Deny that only names ``kms:Decrypt`` does not neutralize an Allow that + granted ``kms:GenerateDataKey`` too. + """ + if not isinstance(deny_stmt, dict) or deny_stmt.get("Effect") != "Deny": + return False + principal = deny_stmt.get("Principal") + # Accept every AWS-idiomatic way of writing "everyone": + # - ``"*"`` + # - ``{"AWS": "*"}`` + # - ``{"AWS": ["*"]}`` (list form is a legitimate policy variant) + # NotPrincipal is intentionally NOT evaluated here. + is_everyone = principal == "*" or ( + isinstance(principal, dict) + and ( + principal.get("AWS") == "*" + or (isinstance(principal.get("AWS"), list) and "*" in principal["AWS"]) + ) + ) + if not is_everyone: + return False + # A Deny with NotAction denies everything except the listed set. It + # covers the sensitive Allow when the NotAction list does NOT include + # any of the sensitive actions the Allow granted. + if "NotAction" in deny_stmt: + raw = deny_stmt["NotAction"] + if isinstance(raw, str): + raw_patterns = {raw} + elif isinstance(raw, list): + raw_patterns = {p for p in raw if isinstance(p, str)} + else: + raw_patterns = set() + excluded = _expanded_actions(raw_patterns) + if not sensitive_actions.isdisjoint(excluded): + # At least one sensitive action is in the NotAction exclusion set → + # this Deny does NOT deny that action, so it cannot cover the + # bypass on that action. + return False + else: + deny_actions = _expanded_actions(statement_actions(deny_stmt)) + if not sensitive_actions.issubset(deny_actions): + return False + condition = deny_stmt.get("Condition") or {} + if not isinstance(condition, dict): + return False + null_block = condition.get("Null") or {} + if isinstance(null_block, dict): + for k, v in null_block.items(): + if not isinstance(k, str) or not _is_attestation_key(k): + continue + if isinstance(v, str) and v.lower() == "true": + return True + if isinstance(v, list) and any( + isinstance(x, str) and x.lower() == "true" for x in v + ): + return True + for op in ("StringNotEqualsIfExists", "StringNotEqualsIgnoreCaseIfExists"): + block = condition.get(op) or {} + if isinstance(block, dict) and any( + isinstance(k, str) and _is_attestation_key(k) for k in block + ): + return True + return False + + +def statement_is_covered_by_deny(allow_stmt, policy) -> bool: + """Return True when an unconditioned Allow is neutralized by a Deny. + + Iterates the policy's Deny statements looking for one that fires when + the caller does not present a valid ``kms:RecipientAttestation:*`` + context key AND that covers every sensitive action the Allow granted. + """ + sensitive = _sensitive_actions_granted(allow_stmt) + if not sensitive: + return True + statements = policy.get("Statement") or [] + if isinstance(statements, dict): + statements = [statements] + for stmt in statements: + if _deny_covers_missing_attestation(stmt, sensitive): + return True + return False + + +_RESTRICTIVE_ATTESTATION_OPERATORS = { + "StringEquals", + "StringEqualsIgnoreCase", + "StringLike", + "ForAllValues:StringEquals", + "ForAllValues:StringEqualsIgnoreCase", + "ForAllValues:StringLike", + "ForAnyValue:StringEquals", + "ForAnyValue:StringEqualsIgnoreCase", + "ForAnyValue:StringLike", +} + + +def _values_are_restrictive(cond_value) -> bool: + """A value (or value list) restricts access iff no entry contains a + StringLike wildcard character (``*`` or ``?``). PCR and ImageSha + attestation values are fixed hex hashes; any wildcard — full (``*``) or + partial (``abc*``, ``abc??``) — makes the binding non-restrictive. + """ + if isinstance(cond_value, str): + return "*" not in cond_value and "?" not in cond_value + if isinstance(cond_value, list): + strings = [v for v in cond_value if isinstance(v, str)] + return bool(strings) and all("*" not in v and "?" not in v for v in strings) + return False + + +def _null_guarded_keys(condition) -> set: + """Return the (lowercased) condition keys guarded by ``Null: "false"``. + + A ``Null:false`` guard forces the request context key to be present, which + blocks the vacuous-true evaluation of ``ForAllValues:*`` when the caller + omits the key entirely. + """ + guarded = set() + null_block = condition.get("Null") or {} + if not isinstance(null_block, dict): + return guarded + for key, value in null_block.items(): + if not isinstance(key, str): + continue + if isinstance(value, str) and value.lower() == "false": + guarded.add(key.lower()) + elif isinstance(value, list) and any( + isinstance(v, str) and v.lower() == "false" for v in value + ): + guarded.add(key.lower()) + return guarded + + +def attestation_condition_keys(statement) -> set: + """Return the ``kms:RecipientAttestation:*`` keys bound by a restrictive condition. + + A binding counts only when the operator is in the restrictive whitelist + (``StringEquals``, ``StringEqualsIgnoreCase``, ``StringLike`` and their + ``ForAllValues:``/``ForAnyValue:`` variants) *and* the value is not the + wildcard ``*``. Non-restrictive operators (``Null``, ``StringNotEquals``, + ``StringNotLike``, ``*IfExists``) are ignored. + + ``ForAllValues:*`` variants evaluate to true when the request context key + is absent, which lets a caller bypass attestation entirely. They only + count as restrictive when the same statement pairs them with a + ``Null:"false"`` guard on the same key. + """ + keys = set() + condition = statement.get("Condition", {}) or {} + null_guarded = _null_guarded_keys(condition) + for operator, kv in condition.items(): + if not isinstance(kv, dict): + continue + if operator not in _RESTRICTIVE_ATTESTATION_OPERATORS: + continue + is_for_all_values = operator.startswith("ForAllValues:") + for cond_key, cond_value in kv.items(): + if not _is_attestation_key(cond_key): + continue + if not _values_are_restrictive(cond_value): + continue + if is_for_all_values and cond_key.lower() not in null_guarded: + continue + keys.add(cond_key) + return keys + + +def _normalize_pcr_suffix(cond_key) -> str: + """Return the upper-cased suffix, mapping ``ImageSha384`` to ``PCR0``. + + ``ImageSha384`` is equivalent to ``PCR0`` per the RFC. Casings collapse via + upper-case to match AWS's case-insensitive condition-key semantics. + """ + suffix = cond_key[len(ATTESTATION_CONDITION_PREFIX) :] + if suffix.lower() == "imagesha384": + return "PCR0" + return suffix.upper() + + +def collapse_pcr0_and_imagesha384(condition_keys) -> set: + """Return the distinct attestation binding suffixes. + + Delegates each key to ``_normalize_pcr_suffix`` so ``ImageSha384`` and + every casing of ``PCR0`` collapse to a single ``PCR0`` binding. + """ + return {_normalize_pcr_suffix(key) for key in condition_keys} + + +# Deployment-context binding per AWS Nitro Enclaves docs +# (https://docs.aws.amazon.com/enclaves/latest/user/set-up-attestation.html#where): +# * PCR3 = IAM role of the parent instance (AWS-recommended binding) +# * PCR4 = Instance ID of the parent instance +# * PCR8 = EIF signing certificate +# AWS explicitly recommends "PCR3 and PCR8 together for the best flexibility". +# +# PCR1 (kernel + boot ramfs) and PCR2 (application) are intentionally NOT +# accepted: they are image-identity refinements that travel with the EIF, +# just like PCR0. A key bound only to PCR0+PCR1+PCR2 still accepts the same +# EIF running anywhere. This diverges from RFC v2.7 §6 Check 8's summary +# table (which lists PCR1/PCR2) because the check's semantic contract is +# "no_deployment_binding" — accepting software-identity PCRs would mislabel +# image-identity bindings as deployment bindings. +_DEPLOYMENT_PCR_SUFFIXES = {"PCR3", "PCR4", "PCR8"} +_DEPLOYMENT_ACCOUNT_CONDITION_KEYS = { + "aws:principalaccount", + "aws:sourceaccount", + "aws:principalorgid", + "aws:principalorgpaths", + "aws:resourceaccount", +} +# Strictly restrictive equality operators only. Deliberately excludes: +# * StringEqualsIfExists / StringEqualsIgnoreCaseIfExists — vacuous-true +# when the request-context key is absent (same trap as ForAllValues without +# a Null:false guard); would allow bypass by omitting the key. +# * ArnLike — accepts wildcards ``*``/``?`` in the value; does not bind to a +# specific ARN by definition. +# * ForAllValues:* — vacuous-true when the key is absent. +# * ForAnyValue:* — matches if ANY value in a multi-valued context matches, +# not restrictive for multi-valued keys. +_DEPLOYMENT_ACCOUNT_OPERATORS = { + "StringEquals", + "StringEqualsIgnoreCase", + "ArnEquals", +} + + +def statement_binds_deployment(statement) -> bool: + """Return True when a sensitive Allow's Condition binds deployment context. + + Deployment context per AWS Nitro Enclaves docs = any of: + - PCR3 (IAM role of the parent instance) as a restrictive attestation + binding — AWS-recommended for portability, OR + - PCR4 (instance ID of the parent instance) as a restrictive + attestation binding, OR + - PCR8 (EIF signing certificate) as a restrictive attestation + binding — AWS-recommended paired with PCR3, OR + - An account-level condition (``aws:PrincipalAccount``, + ``aws:SourceAccount``, ``aws:PrincipalOrgID``, ``aws:ResourceAccount``, + ``aws:PrincipalOrgPaths``) with a **strictly restrictive** equality + operator (``StringEquals``, ``StringEqualsIgnoreCase``, ``ArnEquals``) + AND a non-wildcard value, paired with at least one restrictive + RecipientAttestation binding. + + Non-restrictive operator families are intentionally rejected: ``*IfExists`` + (vacuous-true when the request-context key is absent), ``ArnLike`` (allows + wildcards), ``ForAllValues:*`` (vacuous-true when the key is absent), and + ``ForAnyValue:*`` (matches partial multi-value contexts). Using any of + these does NOT satisfy deployment binding for this check. + + PCR0/PCR1/PCR2 identify the enclave image (whole EIF / kernel / app) but + all travel with the EIF and do not tighten where the image is allowed to + run. A policy bound only to those PCRs returns False here and the check + emits FAIL (severity: informational). + """ + condition = statement.get("Condition") or {} + if not isinstance(condition, dict): + return False + attestation_keys = attestation_condition_keys(statement) + if not attestation_keys: + return False + bindings = collapse_pcr0_and_imagesha384(attestation_keys) + if bindings & _DEPLOYMENT_PCR_SUFFIXES: + return True + for op, block in condition.items(): + if op not in _DEPLOYMENT_ACCOUNT_OPERATORS or not isinstance(block, dict): + continue + for cond_key, cond_value in block.items(): + if not isinstance(cond_key, str): + continue + if cond_key.lower() not in _DEPLOYMENT_ACCOUNT_CONDITION_KEYS: + continue + if _values_are_restrictive(cond_value): + return True + return False + + +GOLDEN_PCR_CONFIG_KEY = "enclave_golden_pcr_values" + + +def normalize_golden_pcr_config(raw) -> dict: + """Normalize the ``audit_config`` golden-PCR block to ``{PCR_id: {values}}``. + + Accepts the raw ``{PCR0: [hash, ...], ...}`` shape from ``audit_config``. + Non-string PCR IDs and non-list value collections are dropped. Values are + lower-cased so they compare deterministically against the values returned + by ``attestation_values_by_pcr``. PCR buckets with no usable values are + omitted so callers can treat their presence as "configured". + """ + if not isinstance(raw, dict): + return {} + normalized: dict = {} + for pcr_id, values in raw.items(): + if not isinstance(pcr_id, str): + continue + if isinstance(values, str): + values = [values] + if not isinstance(values, list): + continue + cleaned = {v.lower() for v in values if isinstance(v, str) and v} + if not cleaned: + continue + normalized[pcr_id.upper()] = cleaned + return normalized + + +def attestation_values_by_pcr(statement) -> dict: + """Return a ``{PCR_id: {values}}`` map of restrictive attestation bindings. + + Only condition keys returned by ``attestation_condition_keys`` (i.e., those + already filtered for restrictive operators, non-wildcard values, and + ``ForAllValues:`` + ``Null:false`` pairing) contribute. Values are + lower-cased so hex hashes compare deterministically against the configured + golden list. ``ImageSha384`` values collapse under the ``PCR0`` bucket to + match ``collapse_pcr0_and_imagesha384``'s semantics. + """ + per_pcr: dict = {} + restrictive_keys = attestation_condition_keys(statement) + if not restrictive_keys: + return per_pcr + restrictive_lower = {k.lower() for k in restrictive_keys} + condition = statement.get("Condition", {}) or {} + for operator, kv in condition.items(): + if not isinstance(kv, dict): + continue + if operator not in _RESTRICTIVE_ATTESTATION_OPERATORS: + continue + for cond_key, cond_value in kv.items(): + if not isinstance(cond_key, str): + continue + if cond_key.lower() not in restrictive_lower: + continue + pcr_id = _normalize_pcr_suffix(cond_key) + if isinstance(cond_value, str): + values = [cond_value] + elif isinstance(cond_value, list): + values = [v for v in cond_value if isinstance(v, str)] + else: + continue + bucket = per_pcr.setdefault(pcr_id, set()) + for v in values: + bucket.add(v.lower()) + return per_pcr diff --git a/prowler/providers/aws/services/vpc/vpc_service.py b/prowler/providers/aws/services/vpc/vpc_service.py index 75c2f93d21..ef3076a4cf 100644 --- a/prowler/providers/aws/services/vpc/vpc_service.py +++ b/prowler/providers/aws/services/vpc/vpc_service.py @@ -339,6 +339,7 @@ class VPC(AWSService): regional_client.region ] public = False + public_ipv6 = False nat_gateway = False route_tables_for_subnet = ( regional_client_for_subnet.describe_route_tables( @@ -366,14 +367,30 @@ class VPC(AWSService): "RouteTables" ): for route in route_table.get("Routes"): - if ( + # ``igw-*`` is a full internet gateway; the + # egress-only variant is ``eigw-*`` and must + # NOT match (outbound-only, does not make the + # subnet reachable from the Internet). + is_igw = ( "GatewayId" in route - and "igw" in route["GatewayId"] + and isinstance(route["GatewayId"], str) + and route["GatewayId"].startswith("igw-") + ) + if ( + is_igw and route.get("DestinationCidrBlock", "") == "0.0.0.0/0" ): # If the route table has a default route to an internet gateway, the subnet is public public = True + if ( + is_igw + and route.get("DestinationIpv6CidrBlock", "") + == "::/0" + ): + # ::/0 → IGW makes the subnet reachable + # from the public IPv6 Internet. + public_ipv6 = True if "NatGatewayId" in route: nat_gateway = True subnet_name = "" @@ -391,6 +408,7 @@ class VPC(AWSService): region=regional_client.region, availability_zone=subnet["AvailabilityZone"], public=public, + public_ipv6=public_ipv6, nat_gateway=nat_gateway, tags=subnet.get("Tags"), mapPublicIpOnLaunch=subnet["MapPublicIpOnLaunch"], @@ -449,6 +467,7 @@ class VpcSubnet(BaseModel): cidr_block: Optional[str] availability_zone: str public: bool + public_ipv6: bool = False in_use: bool = False nat_gateway: bool region: str diff --git a/tests/providers/aws/services/ec2/ec2_confidential_workload_host_imdsv2_not_enforced/ec2_confidential_workload_host_imdsv2_not_enforced_test.py b/tests/providers/aws/services/ec2/ec2_confidential_workload_host_imdsv2_not_enforced/ec2_confidential_workload_host_imdsv2_not_enforced_test.py new file mode 100644 index 0000000000..7e8d35376c --- /dev/null +++ b/tests/providers/aws/services/ec2/ec2_confidential_workload_host_imdsv2_not_enforced/ec2_confidential_workload_host_imdsv2_not_enforced_test.py @@ -0,0 +1,160 @@ +from unittest import mock + +from boto3 import resource +from moto import mock_aws + +from tests.providers.aws.utils import AWS_REGION_US_EAST_1, set_mocked_aws_provider + +EXAMPLE_AMI_ID = "ami-12c6146b" + +CHECK_MODULE = ( + "prowler.providers.aws.services.ec2.ec2_confidential_workload_host_imdsv2_not_enforced" + ".ec2_confidential_workload_host_imdsv2_not_enforced" +) + + +class Test_ec2_confidential_workload_host_imdsv2_not_enforced: + @mock_aws + def test_no_instances(self): + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=EC2(aws_provider)), + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_imdsv2_not_enforced.ec2_confidential_workload_host_imdsv2_not_enforced import ( + ec2_confidential_workload_host_imdsv2_not_enforced, + ) + + assert ec2_confidential_workload_host_imdsv2_not_enforced().execute() == [] + + @mock_aws + def test_non_enclave_instance_skipped(self): + ec2 = resource("ec2", region_name=AWS_REGION_US_EAST_1) + ec2.create_instances( + ImageId=EXAMPLE_AMI_ID, + MinCount=1, + MaxCount=1, + MetadataOptions={"HttpTokens": "optional", "HttpEndpoint": "enabled"}, + ) + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=EC2(aws_provider)) as client, + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_imdsv2_not_enforced.ec2_confidential_workload_host_imdsv2_not_enforced import ( + ec2_confidential_workload_host_imdsv2_not_enforced, + ) + + client.instances[0].enclaves_enabled = False + assert ec2_confidential_workload_host_imdsv2_not_enforced().execute() == [] + + @mock_aws + def test_enclave_parent_imdsv2_enforced_pass(self): + ec2 = resource("ec2", region_name=AWS_REGION_US_EAST_1) + instance = ec2.create_instances( + ImageId=EXAMPLE_AMI_ID, + MinCount=1, + MaxCount=1, + MetadataOptions={"HttpTokens": "required", "HttpEndpoint": "enabled"}, + )[0] + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=EC2(aws_provider)) as client, + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_imdsv2_not_enforced.ec2_confidential_workload_host_imdsv2_not_enforced import ( + ec2_confidential_workload_host_imdsv2_not_enforced, + ) + + client.instances[0].enclaves_enabled = True + client.instances[0].http_tokens = "required" + + result = ec2_confidential_workload_host_imdsv2_not_enforced().execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == instance.id + assert "enforces IMDSv2" in result[0].status_extended + + @mock_aws + def test_enclave_parent_imdsv1_fail(self): + ec2 = resource("ec2", region_name=AWS_REGION_US_EAST_1) + instance = ec2.create_instances( + ImageId=EXAMPLE_AMI_ID, + MinCount=1, + MaxCount=1, + MetadataOptions={"HttpTokens": "optional", "HttpEndpoint": "enabled"}, + )[0] + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=EC2(aws_provider)) as client, + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_imdsv2_not_enforced.ec2_confidential_workload_host_imdsv2_not_enforced import ( + ec2_confidential_workload_host_imdsv2_not_enforced, + ) + + client.instances[0].enclaves_enabled = True + client.instances[0].http_tokens = "optional" + + result = ec2_confidential_workload_host_imdsv2_not_enforced().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == instance.id + assert "does not enforce IMDSv2" in result[0].status_extended + + @mock_aws + def test_terminated_instance_skipped(self): + ec2 = resource("ec2", region_name=AWS_REGION_US_EAST_1) + ec2.create_instances( + ImageId=EXAMPLE_AMI_ID, + MinCount=1, + MaxCount=1, + MetadataOptions={"HttpTokens": "optional", "HttpEndpoint": "enabled"}, + ) + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=EC2(aws_provider)) as client, + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_imdsv2_not_enforced.ec2_confidential_workload_host_imdsv2_not_enforced import ( + ec2_confidential_workload_host_imdsv2_not_enforced, + ) + + client.instances[0].enclaves_enabled = True + client.instances[0].state = "terminated" + + assert ec2_confidential_workload_host_imdsv2_not_enforced().execute() == [] diff --git a/tests/providers/aws/services/ec2/ec2_confidential_workload_host_not_running/ec2_confidential_workload_host_not_running_test.py b/tests/providers/aws/services/ec2/ec2_confidential_workload_host_not_running/ec2_confidential_workload_host_not_running_test.py new file mode 100644 index 0000000000..072d6ae24d --- /dev/null +++ b/tests/providers/aws/services/ec2/ec2_confidential_workload_host_not_running/ec2_confidential_workload_host_not_running_test.py @@ -0,0 +1,229 @@ +from unittest import mock + +from boto3 import resource +from moto import mock_aws + +from tests.providers.aws.utils import AWS_REGION_US_EAST_1, set_mocked_aws_provider + +EXAMPLE_AMI_ID = "ami-12c6146b" + +CHECK_MODULE = ( + "prowler.providers.aws.services.ec2.ec2_confidential_workload_host_not_running" + ".ec2_confidential_workload_host_not_running" +) + + +class Test_ec2_confidential_workload_host_not_running: + @mock_aws + def test_no_instances(self): + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=EC2(aws_provider)), + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_not_running.ec2_confidential_workload_host_not_running import ( + ec2_confidential_workload_host_not_running, + ) + + assert ec2_confidential_workload_host_not_running().execute() == [] + + @mock_aws + def test_running_enclave_parent_pass(self): + ec2 = resource("ec2", region_name=AWS_REGION_US_EAST_1) + ec2.create_instances(ImageId=EXAMPLE_AMI_ID, MinCount=1, MaxCount=1) + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=EC2(aws_provider)) as client, + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_not_running.ec2_confidential_workload_host_not_running import ( + ec2_confidential_workload_host_not_running, + ) + + client.instances[0].enclaves_enabled = True + client.instances[0].state = "running" + + result = ec2_confidential_workload_host_not_running().execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + @mock_aws + def test_stopped_enclave_parent_fail(self): + ec2 = resource("ec2", region_name=AWS_REGION_US_EAST_1) + ec2.create_instances(ImageId=EXAMPLE_AMI_ID, MinCount=1, MaxCount=1) + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=EC2(aws_provider)) as client, + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_not_running.ec2_confidential_workload_host_not_running import ( + ec2_confidential_workload_host_not_running, + ) + + client.instances[0].enclaves_enabled = True + client.instances[0].state = "stopped" + + result = ec2_confidential_workload_host_not_running().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "stopped" in result[0].status_extended + + @mock_aws + def test_terminated_enclave_parent_fail(self): + ec2 = resource("ec2", region_name=AWS_REGION_US_EAST_1) + ec2.create_instances(ImageId=EXAMPLE_AMI_ID, MinCount=1, MaxCount=1) + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=EC2(aws_provider)) as client, + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_not_running.ec2_confidential_workload_host_not_running import ( + ec2_confidential_workload_host_not_running, + ) + + client.instances[0].enclaves_enabled = True + client.instances[0].state = "terminated" + + result = ec2_confidential_workload_host_not_running().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "terminated" in result[0].status_extended + + @mock_aws + def test_non_enclave_instance_skipped(self): + ec2 = resource("ec2", region_name=AWS_REGION_US_EAST_1) + ec2.create_instances(ImageId=EXAMPLE_AMI_ID, MinCount=1, MaxCount=1) + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=EC2(aws_provider)) as client, + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_not_running.ec2_confidential_workload_host_not_running import ( + ec2_confidential_workload_host_not_running, + ) + + client.instances[0].enclaves_enabled = False + client.instances[0].state = "stopped" + + assert ec2_confidential_workload_host_not_running().execute() == [] + + @mock_aws + def test_stopping_state_pass_transient(self): + # RFC v2.7: 'stopping' is a transient state, reported as PASS with note. + ec2 = resource("ec2", region_name=AWS_REGION_US_EAST_1) + ec2.create_instances(ImageId=EXAMPLE_AMI_ID, MinCount=1, MaxCount=1) + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=EC2(aws_provider)) as ec2c, + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_not_running.ec2_confidential_workload_host_not_running import ( + ec2_confidential_workload_host_not_running, + ) + + ec2c.instances[0].enclaves_enabled = True + ec2c.instances[0].state = "stopping" + + result = ec2_confidential_workload_host_not_running().execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "transient" in result[0].status_extended + assert "stopping" in result[0].status_extended + + @mock_aws + def test_pending_state_pass_transient(self): + # RFC v2.7: 'pending' is a transient state, reported as PASS with note. + ec2 = resource("ec2", region_name=AWS_REGION_US_EAST_1) + ec2.create_instances(ImageId=EXAMPLE_AMI_ID, MinCount=1, MaxCount=1) + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=EC2(aws_provider)) as ec2c, + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_not_running.ec2_confidential_workload_host_not_running import ( + ec2_confidential_workload_host_not_running, + ) + + ec2c.instances[0].enclaves_enabled = True + ec2c.instances[0].state = "pending" + + result = ec2_confidential_workload_host_not_running().execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "transient" in result[0].status_extended + + @mock_aws + def test_shutting_down_state_flagged(self): + ec2 = resource("ec2", region_name=AWS_REGION_US_EAST_1) + ec2.create_instances(ImageId=EXAMPLE_AMI_ID, MinCount=1, MaxCount=1) + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=EC2(aws_provider)) as ec2c, + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_not_running.ec2_confidential_workload_host_not_running import ( + ec2_confidential_workload_host_not_running, + ) + + ec2c.instances[0].enclaves_enabled = True + ec2c.instances[0].state = "shutting-down" + + result = ec2_confidential_workload_host_not_running().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "shutting-down" in result[0].status_extended diff --git a/tests/providers/aws/services/ec2/ec2_confidential_workload_host_public_ip/ec2_confidential_workload_host_public_ip_test.py b/tests/providers/aws/services/ec2/ec2_confidential_workload_host_public_ip/ec2_confidential_workload_host_public_ip_test.py new file mode 100644 index 0000000000..60f37a29dd --- /dev/null +++ b/tests/providers/aws/services/ec2/ec2_confidential_workload_host_public_ip/ec2_confidential_workload_host_public_ip_test.py @@ -0,0 +1,393 @@ +from ipaddress import IPv4Address, IPv6Address +from unittest import mock + +from boto3 import client, resource +from moto import mock_aws + +from tests.providers.aws.utils import AWS_REGION_US_EAST_1, set_mocked_aws_provider + +EXAMPLE_AMI_ID = "ami-12c6146b" + +CHECK_MODULE = ( + "prowler.providers.aws.services.ec2.ec2_confidential_workload_host_public_ip" + ".ec2_confidential_workload_host_public_ip" +) + + +def _create_enclave_instance(subnet_id=None, associate_public_ip=False): + ec2 = resource("ec2", region_name=AWS_REGION_US_EAST_1) + kwargs = dict(ImageId=EXAMPLE_AMI_ID, MinCount=1, MaxCount=1) + if subnet_id: + kwargs["NetworkInterfaces"] = [ + { + "SubnetId": subnet_id, + "DeviceIndex": 0, + "AssociatePublicIpAddress": associate_public_ip, + } + ] + return ec2.create_instances(**kwargs)[0] + + +class Test_ec2_confidential_workload_host_public_ip: + @mock_aws + def test_no_instances(self): + from prowler.providers.aws.services.ec2.ec2_service import EC2 + from prowler.providers.aws.services.vpc.vpc_service import VPC + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=EC2(aws_provider)), + mock.patch(f"{CHECK_MODULE}.vpc_client", new=VPC(aws_provider)), + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_public_ip.ec2_confidential_workload_host_public_ip import ( + ec2_confidential_workload_host_public_ip, + ) + + assert ec2_confidential_workload_host_public_ip().execute() == [] + + @mock_aws + def test_enclave_in_private_subnet_no_public_ip_pass(self): + instance = _create_enclave_instance() + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + from prowler.providers.aws.services.vpc.vpc_service import VPC + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=EC2(aws_provider)) as ec2c, + mock.patch(f"{CHECK_MODULE}.vpc_client", new=VPC(aws_provider)) as vpcc, + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_public_ip.ec2_confidential_workload_host_public_ip import ( + ec2_confidential_workload_host_public_ip, + ) + + ec2c.instances[0].enclaves_enabled = True + ec2c.instances[0].public_ip = None + subnet = vpcc.vpc_subnets.get(ec2c.instances[0].subnet_id) + if subnet is not None: + subnet.public = False + + result = ec2_confidential_workload_host_public_ip().execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == instance.id + assert "not in a public subnet" in result[0].status_extended + + @mock_aws + def test_enclave_with_public_ip_fail(self): + instance = _create_enclave_instance() + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + from prowler.providers.aws.services.vpc.vpc_service import VPC + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=EC2(aws_provider)) as ec2c, + mock.patch(f"{CHECK_MODULE}.vpc_client", new=VPC(aws_provider)) as vpcc, + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_public_ip.ec2_confidential_workload_host_public_ip import ( + ec2_confidential_workload_host_public_ip, + ) + + ec2c.instances[0].enclaves_enabled = True + ec2c.instances[0].public_ip = "203.0.113.10" + subnet = vpcc.vpc_subnets.get(ec2c.instances[0].subnet_id) + if subnet is not None: + subnet.public = False + + result = ec2_confidential_workload_host_public_ip().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == instance.id + assert "203.0.113.10" in result[0].status_extended + + @mock_aws + def test_enclave_in_public_subnet_fail(self): + ec2c = client("ec2", region_name=AWS_REGION_US_EAST_1) + vpc_id = ec2c.create_vpc(CidrBlock="10.0.0.0/16")["Vpc"]["VpcId"] + subnet_id = ec2c.create_subnet(VpcId=vpc_id, CidrBlock="10.0.1.0/24")["Subnet"][ + "SubnetId" + ] + igw_id = ec2c.create_internet_gateway()["InternetGateway"]["InternetGatewayId"] + ec2c.attach_internet_gateway(InternetGatewayId=igw_id, VpcId=vpc_id) + rt_id = ec2c.create_route_table(VpcId=vpc_id)["RouteTable"]["RouteTableId"] + ec2c.create_route( + RouteTableId=rt_id, + DestinationCidrBlock="0.0.0.0/0", + GatewayId=igw_id, + ) + ec2c.associate_route_table(RouteTableId=rt_id, SubnetId=subnet_id) + + instance = _create_enclave_instance(subnet_id=subnet_id) + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + from prowler.providers.aws.services.vpc.vpc_service import VPC + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=EC2(aws_provider)) as ec2mc, + mock.patch(f"{CHECK_MODULE}.vpc_client", new=VPC(aws_provider)), + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_public_ip.ec2_confidential_workload_host_public_ip import ( + ec2_confidential_workload_host_public_ip, + ) + + ec2mc.instances[0].enclaves_enabled = True + ec2mc.instances[0].public_ip = None + + result = ec2_confidential_workload_host_public_ip().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == instance.id + assert "internet gateway" in result[0].status_extended + + @mock_aws + def test_enclave_with_global_ipv6_on_eni_fail(self): + instance = _create_enclave_instance() + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + from prowler.providers.aws.services.vpc.vpc_service import VPC + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=EC2(aws_provider)) as ec2c, + mock.patch(f"{CHECK_MODULE}.vpc_client", new=VPC(aws_provider)) as vpcc, + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_public_ip.ec2_confidential_workload_host_public_ip import ( + ec2_confidential_workload_host_public_ip, + ) + + ec2c.instances[0].enclaves_enabled = True + ec2c.instances[0].public_ip = None + subnet = vpcc.vpc_subnets.get(ec2c.instances[0].subnet_id) + if subnet is not None: + subnet.public = False + subnet.public_ipv6 = False + + eni_id = ec2c.instances[0].network_interfaces[0] + ec2c.network_interfaces[eni_id].public_ip_addresses = [ + IPv6Address("2001:db8::1") + ] + + result = ec2_confidential_workload_host_public_ip().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == instance.id + assert "2001:db8::1" in result[0].status_extended + assert "global IPv6" in result[0].status_extended + + @mock_aws + def test_enclave_in_ipv6_only_public_subnet_fail(self): + ec2c = client("ec2", region_name=AWS_REGION_US_EAST_1) + vpc_id = ec2c.create_vpc(CidrBlock="10.0.0.0/16")["Vpc"]["VpcId"] + subnet_id = ec2c.create_subnet(VpcId=vpc_id, CidrBlock="10.0.1.0/24")["Subnet"][ + "SubnetId" + ] + igw_id = ec2c.create_internet_gateway()["InternetGateway"]["InternetGatewayId"] + ec2c.attach_internet_gateway(InternetGatewayId=igw_id, VpcId=vpc_id) + rt_id = ec2c.create_route_table(VpcId=vpc_id)["RouteTable"]["RouteTableId"] + # IPv6-only default route to IGW; no 0.0.0.0/0 → IGW here. + ec2c.create_route( + RouteTableId=rt_id, + DestinationIpv6CidrBlock="::/0", + GatewayId=igw_id, + ) + ec2c.associate_route_table(RouteTableId=rt_id, SubnetId=subnet_id) + + instance = _create_enclave_instance(subnet_id=subnet_id) + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + from prowler.providers.aws.services.vpc.vpc_service import VPC + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=EC2(aws_provider)) as ec2mc, + mock.patch(f"{CHECK_MODULE}.vpc_client", new=VPC(aws_provider)), + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_public_ip.ec2_confidential_workload_host_public_ip import ( + ec2_confidential_workload_host_public_ip, + ) + + ec2mc.instances[0].enclaves_enabled = True + ec2mc.instances[0].public_ip = None + + result = ec2_confidential_workload_host_public_ip().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == instance.id + assert "::/0" in result[0].status_extended + + @mock_aws + def test_enclave_with_both_public_ipv4_and_global_ipv6_fail(self): + instance = _create_enclave_instance() + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + from prowler.providers.aws.services.vpc.vpc_service import VPC + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=EC2(aws_provider)) as ec2c, + mock.patch(f"{CHECK_MODULE}.vpc_client", new=VPC(aws_provider)) as vpcc, + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_public_ip.ec2_confidential_workload_host_public_ip import ( + ec2_confidential_workload_host_public_ip, + ) + + ec2c.instances[0].enclaves_enabled = True + ec2c.instances[0].public_ip = "203.0.113.10" + subnet = vpcc.vpc_subnets.get(ec2c.instances[0].subnet_id) + if subnet is not None: + subnet.public = False + subnet.public_ipv6 = False + + eni_id = ec2c.instances[0].network_interfaces[0] + ec2c.network_interfaces[eni_id].public_ip_addresses = [ + IPv4Address("203.0.113.10"), + IPv6Address("2001:db8::1"), + ] + + result = ec2_confidential_workload_host_public_ip().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == instance.id + assert "203.0.113.10" in result[0].status_extended + assert "2001:db8::1" in result[0].status_extended + + @mock_aws + def test_enclave_with_ipv4_public_ip_but_no_global_ipv6_pass_still_fail(self): + # Regression: only IPv4 in public_ip_addresses on the ENI must not + # be mistaken for an IPv6 signal. FAIL comes solely from + # instance.public_ip; the ENI IPv6 reason must NOT appear. + instance = _create_enclave_instance() + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + from prowler.providers.aws.services.vpc.vpc_service import VPC + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=EC2(aws_provider)) as ec2c, + mock.patch(f"{CHECK_MODULE}.vpc_client", new=VPC(aws_provider)) as vpcc, + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_public_ip.ec2_confidential_workload_host_public_ip import ( + ec2_confidential_workload_host_public_ip, + ) + + ec2c.instances[0].enclaves_enabled = True + ec2c.instances[0].public_ip = "203.0.113.10" + subnet = vpcc.vpc_subnets.get(ec2c.instances[0].subnet_id) + if subnet is not None: + subnet.public = False + subnet.public_ipv6 = False + + eni_id = ec2c.instances[0].network_interfaces[0] + ec2c.network_interfaces[eni_id].public_ip_addresses = [ + IPv4Address("203.0.113.10") + ] + + result = ec2_confidential_workload_host_public_ip().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == instance.id + assert "203.0.113.10" in result[0].status_extended + assert "global IPv6" not in result[0].status_extended + + @mock_aws + def test_non_enclave_instance_skipped(self): + _create_enclave_instance() + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + from prowler.providers.aws.services.vpc.vpc_service import VPC + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=EC2(aws_provider)) as ec2c, + mock.patch(f"{CHECK_MODULE}.vpc_client", new=VPC(aws_provider)), + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_public_ip.ec2_confidential_workload_host_public_ip import ( + ec2_confidential_workload_host_public_ip, + ) + + ec2c.instances[0].enclaves_enabled = False + ec2c.instances[0].public_ip = "203.0.113.10" + + assert ec2_confidential_workload_host_public_ip().execute() == [] + + @mock_aws + def test_missing_subnet_reports_manual_not_pass(self): + # A subnet ID that isn't resolvable in vpc_client means we cannot + # verify subnet exposure — MANUAL, not PASS. + _create_enclave_instance() + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + from prowler.providers.aws.services.vpc.vpc_service import VPC + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + ec2_svc = EC2(aws_provider) + vpc_svc = VPC(aws_provider) + # Force subnet resolution to fail. + vpc_svc.vpc_subnets = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=ec2_svc) as ec2c, + mock.patch(f"{CHECK_MODULE}.vpc_client", new=vpc_svc), + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_public_ip.ec2_confidential_workload_host_public_ip import ( + ec2_confidential_workload_host_public_ip, + ) + + ec2c.instances[0].enclaves_enabled = True + ec2c.instances[0].public_ip = None + result = ec2_confidential_workload_host_public_ip().execute() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "cannot be fully verified" in result[0].status_extended + assert "subnet" in result[0].status_extended diff --git a/tests/providers/aws/services/ec2/ec2_confidential_workload_host_unrestricted_ingress/ec2_confidential_workload_host_unrestricted_ingress_test.py b/tests/providers/aws/services/ec2/ec2_confidential_workload_host_unrestricted_ingress/ec2_confidential_workload_host_unrestricted_ingress_test.py new file mode 100644 index 0000000000..333df2e345 --- /dev/null +++ b/tests/providers/aws/services/ec2/ec2_confidential_workload_host_unrestricted_ingress/ec2_confidential_workload_host_unrestricted_ingress_test.py @@ -0,0 +1,476 @@ +from unittest import mock + +from boto3 import client, resource +from moto import mock_aws + +from tests.providers.aws.utils import AWS_REGION_US_EAST_1, set_mocked_aws_provider + +EXAMPLE_AMI_ID = "ami-12c6146b" + +CHECK_MODULE = ( + "prowler.providers.aws.services.ec2.ec2_confidential_workload_host_unrestricted_ingress" + ".ec2_confidential_workload_host_unrestricted_ingress" +) + + +def _create_enclave_with_sg(sg_ingress): + ec2c = client("ec2", region_name=AWS_REGION_US_EAST_1) + vpc_id = ec2c.create_vpc(CidrBlock="10.0.0.0/16")["Vpc"]["VpcId"] + sg_id = ec2c.create_security_group( + GroupName="enclave-sg", + Description="enclave sg", + VpcId=vpc_id, + )["GroupId"] + if sg_ingress: + ec2c.authorize_security_group_ingress(GroupId=sg_id, IpPermissions=sg_ingress) + ec2c.create_subnet(VpcId=vpc_id, CidrBlock="10.0.1.0/24") + ec2r = resource("ec2", region_name=AWS_REGION_US_EAST_1) + instance = ec2r.create_instances( + ImageId=EXAMPLE_AMI_ID, + MinCount=1, + MaxCount=1, + SecurityGroupIds=[sg_id], + )[0] + return instance, sg_id + + +class Test_ec2_confidential_workload_host_unrestricted_ingress: + @mock_aws + def test_no_instances(self): + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=EC2(aws_provider)), + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_unrestricted_ingress.ec2_confidential_workload_host_unrestricted_ingress import ( + ec2_confidential_workload_host_unrestricted_ingress, + ) + + assert ec2_confidential_workload_host_unrestricted_ingress().execute() == [] + + @mock_aws + def test_only_allow_listed_ports_pass(self): + instance, _ = _create_enclave_with_sg( + [ + { + "IpProtocol": "tcp", + "FromPort": 443, + "ToPort": 443, + "IpRanges": [{"CidrIp": "0.0.0.0/0"}], + } + ] + ) + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=EC2(aws_provider)) as ec2c, + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_unrestricted_ingress.ec2_confidential_workload_host_unrestricted_ingress import ( + ec2_confidential_workload_host_unrestricted_ingress, + ) + + ec2c.instances[0].enclaves_enabled = True + result = ec2_confidential_workload_host_unrestricted_ingress().execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == instance.id + + @mock_aws + def test_non_allow_listed_port_open_fail(self): + instance, _ = _create_enclave_with_sg( + [ + { + "IpProtocol": "tcp", + "FromPort": 8080, + "ToPort": 8080, + "IpRanges": [{"CidrIp": "0.0.0.0/0"}], + } + ] + ) + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=EC2(aws_provider)) as ec2c, + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_unrestricted_ingress.ec2_confidential_workload_host_unrestricted_ingress import ( + ec2_confidential_workload_host_unrestricted_ingress, + ) + + ec2c.instances[0].enclaves_enabled = True + result = ec2_confidential_workload_host_unrestricted_ingress().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == instance.id + assert "8080" in result[0].status_extended + + @mock_aws + def test_configurable_allow_list_overrides_default(self): + instance, _ = _create_enclave_with_sg( + [ + { + "IpProtocol": "tcp", + "FromPort": 8080, + "ToPort": 8080, + "IpRanges": [{"CidrIp": "0.0.0.0/0"}], + } + ] + ) + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + aws_provider._audit_config = {"enclave_sg_allow_ports": [8080]} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=EC2(aws_provider)) as ec2c, + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_unrestricted_ingress.ec2_confidential_workload_host_unrestricted_ingress import ( + ec2_confidential_workload_host_unrestricted_ingress, + ) + + ec2c.instances[0].enclaves_enabled = True + result = ec2_confidential_workload_host_unrestricted_ingress().execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == instance.id + + @mock_aws + def test_non_enclave_instance_skipped(self): + _create_enclave_with_sg( + [ + { + "IpProtocol": "tcp", + "FromPort": 8080, + "ToPort": 8080, + "IpRanges": [{"CidrIp": "0.0.0.0/0"}], + } + ] + ) + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=EC2(aws_provider)) as ec2c, + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_unrestricted_ingress.ec2_confidential_workload_host_unrestricted_ingress import ( + ec2_confidential_workload_host_unrestricted_ingress, + ) + + ec2c.instances[0].enclaves_enabled = False + assert ec2_confidential_workload_host_unrestricted_ingress().execute() == [] + + @mock_aws + def test_wide_range_summarized_fail(self): + # Rule opens TCP 1000-65535 to 0.0.0.0/0 (~64k non-allow-listed + # ports). Should be reported as the "1000-65535" summary, never + # as thousands of individual ports. + instance, _ = _create_enclave_with_sg( + [ + { + "IpProtocol": "tcp", + "FromPort": 1000, + "ToPort": 65535, + "IpRanges": [{"CidrIp": "0.0.0.0/0"}], + } + ] + ) + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=EC2(aws_provider)) as ec2c, + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_unrestricted_ingress.ec2_confidential_workload_host_unrestricted_ingress import ( + ec2_confidential_workload_host_unrestricted_ingress, + ) + + ec2c.instances[0].enclaves_enabled = True + result = ec2_confidential_workload_host_unrestricted_ingress().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == instance.id + assert "1000-65535" in result[0].status_extended + # Sanity: individual ports must not leak into the message. + assert "1001, 1002" not in result[0].status_extended + + @mock_aws + def test_medium_range_ports_listed_individually_fail(self): + # 6 ports (8080-8085), below the 10-port summary threshold — must + # list each port individually so operators see the exact set. + instance, _ = _create_enclave_with_sg( + [ + { + "IpProtocol": "tcp", + "FromPort": 8080, + "ToPort": 8085, + "IpRanges": [{"CidrIp": "0.0.0.0/0"}], + } + ] + ) + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=EC2(aws_provider)) as ec2c, + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_unrestricted_ingress.ec2_confidential_workload_host_unrestricted_ingress import ( + ec2_confidential_workload_host_unrestricted_ingress, + ) + + ec2c.instances[0].enclaves_enabled = True + result = ec2_confidential_workload_host_unrestricted_ingress().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == instance.id + for port in range(8080, 8086): + assert str(port) in result[0].status_extended + + @mock_aws + def test_all_protocol_rule_reported_as_all_fail(self): + # IpProtocol=-1 opens every protocol/port. Fast-path must catch it + # as "all" without expanding to 65k ports. + instance, _ = _create_enclave_with_sg( + [ + { + "IpProtocol": "-1", + "IpRanges": [{"CidrIp": "0.0.0.0/0"}], + } + ] + ) + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=EC2(aws_provider)) as ec2c, + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_unrestricted_ingress.ec2_confidential_workload_host_unrestricted_ingress import ( + ec2_confidential_workload_host_unrestricted_ingress, + ) + + ec2c.instances[0].enclaves_enabled = True + result = ec2_confidential_workload_host_unrestricted_ingress().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == instance.id + assert "all" in result[0].status_extended + + @mock_aws + def test_ipv6_world_cidr_flagged_fail(self): + # Only ::/0 (IPv6) is world-facing; no IpRanges. Must still FAIL. + instance, _ = _create_enclave_with_sg( + [ + { + "IpProtocol": "tcp", + "FromPort": 8080, + "ToPort": 8080, + "Ipv6Ranges": [{"CidrIpv6": "::/0"}], + } + ] + ) + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=EC2(aws_provider)) as ec2c, + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_unrestricted_ingress.ec2_confidential_workload_host_unrestricted_ingress import ( + ec2_confidential_workload_host_unrestricted_ingress, + ) + + ec2c.instances[0].enclaves_enabled = True + result = ec2_confidential_workload_host_unrestricted_ingress().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == instance.id + assert "8080" in result[0].status_extended + + @mock_aws + def test_udp_world_facing_non_allow_listed_port_fail(self): + # UDP was silently ignored before; the check now must flag it. + instance, _ = _create_enclave_with_sg( + [ + { + "IpProtocol": "udp", + "FromPort": 12345, + "ToPort": 12345, + "IpRanges": [{"CidrIp": "0.0.0.0/0"}], + } + ] + ) + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=EC2(aws_provider)) as ec2c, + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_unrestricted_ingress.ec2_confidential_workload_host_unrestricted_ingress import ( + ec2_confidential_workload_host_unrestricted_ingress, + ) + + ec2c.instances[0].enclaves_enabled = True + result = ec2_confidential_workload_host_unrestricted_ingress().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == instance.id + assert "12345" in result[0].status_extended + + @mock_aws + def test_udp_world_facing_ipv6_non_allow_listed_port_fail(self): + # UDP over ::/0 is equally reachable — must FAIL. + instance, _ = _create_enclave_with_sg( + [ + { + "IpProtocol": "udp", + "FromPort": 5353, + "ToPort": 5353, + "Ipv6Ranges": [{"CidrIpv6": "::/0"}], + } + ] + ) + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=EC2(aws_provider)) as ec2c, + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_unrestricted_ingress.ec2_confidential_workload_host_unrestricted_ingress import ( + ec2_confidential_workload_host_unrestricted_ingress, + ) + + ec2c.instances[0].enclaves_enabled = True + result = ec2_confidential_workload_host_unrestricted_ingress().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == instance.id + assert "5353" in result[0].status_extended + + @mock_aws + def test_icmp_world_facing_not_flagged_pass(self): + # ICMP is not TCP/UDP → not tracked. Rule allows ICMP world-wide; + # the check should still PASS because we only track L4 traffic. + _create_enclave_with_sg( + [ + { + "IpProtocol": "icmp", + "FromPort": -1, + "ToPort": -1, + "IpRanges": [{"CidrIp": "0.0.0.0/0"}], + } + ] + ) + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=EC2(aws_provider)) as ec2c, + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_unrestricted_ingress.ec2_confidential_workload_host_unrestricted_ingress import ( + ec2_confidential_workload_host_unrestricted_ingress, + ) + + ec2c.instances[0].enclaves_enabled = True + result = ec2_confidential_workload_host_unrestricted_ingress().execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + @mock_aws + def test_missing_security_group_reports_manual_not_pass(self): + # If an SG referenced by the instance is not present in ec2_client + # (e.g., collection failure), the check must emit MANUAL rather than + # silently PASSing on incomplete SG visibility. + _create_enclave_with_sg([]) + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + ec2_svc = EC2(aws_provider) + ec2_svc.security_groups = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=ec2_svc) as ec2c, + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_unrestricted_ingress.ec2_confidential_workload_host_unrestricted_ingress import ( + ec2_confidential_workload_host_unrestricted_ingress, + ) + + ec2c.instances[0].enclaves_enabled = True + result = ec2_confidential_workload_host_unrestricted_ingress().execute() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "cannot be fully verified" in result[0].status_extended diff --git a/tests/providers/aws/services/ec2/ec2_confidential_workload_host_vsock_proxy_exposed/ec2_confidential_workload_host_vsock_proxy_exposed_test.py b/tests/providers/aws/services/ec2/ec2_confidential_workload_host_vsock_proxy_exposed/ec2_confidential_workload_host_vsock_proxy_exposed_test.py new file mode 100644 index 0000000000..0175ad9b27 --- /dev/null +++ b/tests/providers/aws/services/ec2/ec2_confidential_workload_host_vsock_proxy_exposed/ec2_confidential_workload_host_vsock_proxy_exposed_test.py @@ -0,0 +1,258 @@ +from unittest import mock + +from boto3 import client, resource +from moto import mock_aws + +from tests.providers.aws.utils import AWS_REGION_US_EAST_1, set_mocked_aws_provider + +EXAMPLE_AMI_ID = "ami-12c6146b" + +CHECK_MODULE = ( + "prowler.providers.aws.services.ec2.ec2_confidential_workload_host_vsock_proxy_exposed" + ".ec2_confidential_workload_host_vsock_proxy_exposed" +) + + +def _create_enclave_with_sg(sg_ingress): + ec2c = client("ec2", region_name=AWS_REGION_US_EAST_1) + vpc_id = ec2c.create_vpc(CidrBlock="10.0.0.0/16")["Vpc"]["VpcId"] + sg_id = ec2c.create_security_group( + GroupName="enclave-sg", + Description="enclave sg", + VpcId=vpc_id, + )["GroupId"] + if sg_ingress: + ec2c.authorize_security_group_ingress(GroupId=sg_id, IpPermissions=sg_ingress) + ec2c.create_subnet(VpcId=vpc_id, CidrBlock="10.0.1.0/24") + ec2r = resource("ec2", region_name=AWS_REGION_US_EAST_1) + instance = ec2r.create_instances( + ImageId=EXAMPLE_AMI_ID, + MinCount=1, + MaxCount=1, + SecurityGroupIds=[sg_id], + )[0] + return instance, sg_id + + +class Test_ec2_confidential_workload_host_vsock_proxy_exposed: + @mock_aws + def test_no_instances(self): + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=EC2(aws_provider)), + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_vsock_proxy_exposed.ec2_confidential_workload_host_vsock_proxy_exposed import ( + ec2_confidential_workload_host_vsock_proxy_exposed, + ) + + assert ec2_confidential_workload_host_vsock_proxy_exposed().execute() == [] + + @mock_aws + def test_no_vsock_ports_exposed_pass(self): + instance, _ = _create_enclave_with_sg( + [ + { + "IpProtocol": "tcp", + "FromPort": 443, + "ToPort": 443, + "IpRanges": [{"CidrIp": "0.0.0.0/0"}], + } + ] + ) + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=EC2(aws_provider)) as ec2c, + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_vsock_proxy_exposed.ec2_confidential_workload_host_vsock_proxy_exposed import ( + ec2_confidential_workload_host_vsock_proxy_exposed, + ) + + ec2c.instances[0].enclaves_enabled = True + result = ec2_confidential_workload_host_vsock_proxy_exposed().execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == instance.id + + @mock_aws + def test_vsock_port_5000_exposed_fail(self): + instance, _ = _create_enclave_with_sg( + [ + { + "IpProtocol": "tcp", + "FromPort": 5000, + "ToPort": 5000, + "IpRanges": [{"CidrIp": "0.0.0.0/0"}], + } + ] + ) + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=EC2(aws_provider)) as ec2c, + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_vsock_proxy_exposed.ec2_confidential_workload_host_vsock_proxy_exposed import ( + ec2_confidential_workload_host_vsock_proxy_exposed, + ) + + ec2c.instances[0].enclaves_enabled = True + result = ec2_confidential_workload_host_vsock_proxy_exposed().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == instance.id + assert "5000" in result[0].status_extended + assert "heuristic" in result[0].status_extended.lower() + + @mock_aws + def test_custom_vsock_ports_via_audit_config(self): + instance, _ = _create_enclave_with_sg( + [ + { + "IpProtocol": "tcp", + "FromPort": 12345, + "ToPort": 12345, + "IpRanges": [{"CidrIp": "0.0.0.0/0"}], + } + ] + ) + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + aws_provider._audit_config = {"enclave_vsock_ports": [12345]} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=EC2(aws_provider)) as ec2c, + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_vsock_proxy_exposed.ec2_confidential_workload_host_vsock_proxy_exposed import ( + ec2_confidential_workload_host_vsock_proxy_exposed, + ) + + ec2c.instances[0].enclaves_enabled = True + result = ec2_confidential_workload_host_vsock_proxy_exposed().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == instance.id + assert "12345" in result[0].status_extended + + @mock_aws + def test_non_enclave_instance_skipped(self): + _create_enclave_with_sg( + [ + { + "IpProtocol": "tcp", + "FromPort": 5000, + "ToPort": 5000, + "IpRanges": [{"CidrIp": "0.0.0.0/0"}], + } + ] + ) + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=EC2(aws_provider)) as ec2c, + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_vsock_proxy_exposed.ec2_confidential_workload_host_vsock_proxy_exposed import ( + ec2_confidential_workload_host_vsock_proxy_exposed, + ) + + ec2c.instances[0].enclaves_enabled = False + assert ec2_confidential_workload_host_vsock_proxy_exposed().execute() == [] + + @mock_aws + def test_ipv6_world_cidr_flags_vsock_port_fail(self): + # Vsock proxy port 5000 exposed via ::/0 (IPv6) only. Must FAIL. + instance, _ = _create_enclave_with_sg( + [ + { + "IpProtocol": "tcp", + "FromPort": 5000, + "ToPort": 5000, + "Ipv6Ranges": [{"CidrIpv6": "::/0"}], + } + ] + ) + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=EC2(aws_provider)) as ec2c, + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_vsock_proxy_exposed.ec2_confidential_workload_host_vsock_proxy_exposed import ( + ec2_confidential_workload_host_vsock_proxy_exposed, + ) + + ec2c.instances[0].enclaves_enabled = True + result = ec2_confidential_workload_host_vsock_proxy_exposed().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == instance.id + assert "5000" in result[0].status_extended + + @mock_aws + def test_missing_security_group_reports_manual_not_pass(self): + # If an SG referenced by the instance is not present in ec2_client + # (e.g., collection failure), the check must emit MANUAL rather than + # silently PASSing on incomplete SG visibility. + _create_enclave_with_sg([]) + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + ec2_svc = EC2(aws_provider) + ec2_svc.security_groups = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.ec2_client", new=ec2_svc) as ec2c, + ): + from prowler.providers.aws.services.ec2.ec2_confidential_workload_host_vsock_proxy_exposed.ec2_confidential_workload_host_vsock_proxy_exposed import ( + ec2_confidential_workload_host_vsock_proxy_exposed, + ) + + ec2c.instances[0].enclaves_enabled = True + result = ec2_confidential_workload_host_vsock_proxy_exposed().execute() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "cannot be fully verified" in result[0].status_extended diff --git a/tests/providers/aws/services/ec2/ec2_service_test.py b/tests/providers/aws/services/ec2/ec2_service_test.py index 5cb5a15950..eacd921c38 100644 --- a/tests/providers/aws/services/ec2/ec2_service_test.py +++ b/tests/providers/aws/services/ec2/ec2_service_test.py @@ -382,6 +382,60 @@ class Test_EC2_Service: assert ec2.instances[0].network_interfaces is not None assert ec2.instances[0].virtualization_type == "hvm" + # Test EC2 Describe Instances maps EnclaveOptions, HibernationOptions, Platform + @mock_aws + def test_describe_instances_maps_enclave_hibernation_platform(self): + # moto does not persist EnclaveOptions/HibernationOptions/Platform on + # describe_instances, so we mock the paginator's response and verify + # that _describe_instances extracts these fields into the model. + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1] + ) + ec2 = EC2(aws_provider) + + fake_page = { + "Reservations": [ + { + "Instances": [ + { + "InstanceId": "i-0123456789abcdef0", + "State": {"Name": "running"}, + "InstanceType": "m5.xlarge", + "ImageId": EXAMPLE_AMI_ID, + "LaunchTime": MOCK_DATETIME, + "PrivateDnsName": "ip-10-0-0-1.ec2.internal", + "PrivateIpAddress": "10.0.0.1", + "SubnetId": "subnet-1234", + "Monitoring": {"State": "disabled"}, + "MetadataOptions": { + "HttpTokens": "required", + "HttpEndpoint": "enabled", + }, + "EnclaveOptions": {"Enabled": True}, + "HibernationOptions": {"Configured": True}, + "Platform": "windows", + } + ] + } + ] + } + + fake_paginator = mock.MagicMock() + fake_paginator.paginate.return_value = iter([fake_page]) + + regional_client = ec2.regional_clients[AWS_REGION_US_EAST_1] + with mock.patch.object( + regional_client, "get_paginator", return_value=fake_paginator + ): + ec2.instances = [] + ec2._describe_instances(regional_client) + + matched = [i for i in ec2.instances if i.id == "i-0123456789abcdef0"] + assert len(matched) == 1 + assert matched[0].enclaves_enabled is True + assert matched[0].hibernation_enabled is True + assert matched[0].platform == "windows" + # Test EC2 Describe Security Groups @mock_aws def test_describe_security_groups(self): diff --git a/tests/providers/aws/services/kms/kms_key_enclave_attestation_bypassable_path/kms_key_enclave_attestation_bypassable_path_test.py b/tests/providers/aws/services/kms/kms_key_enclave_attestation_bypassable_path/kms_key_enclave_attestation_bypassable_path_test.py new file mode 100644 index 0000000000..28b458e150 --- /dev/null +++ b/tests/providers/aws/services/kms/kms_key_enclave_attestation_bypassable_path/kms_key_enclave_attestation_bypassable_path_test.py @@ -0,0 +1,521 @@ +import json +from unittest import mock + +from boto3 import client +from moto import mock_aws + +from tests.providers.aws.utils import AWS_REGION_US_EAST_1, set_mocked_aws_provider + +CHECK_MODULE = ( + "prowler.providers.aws.services.kms.kms_key_enclave_attestation_bypassable_path" + ".kms_key_enclave_attestation_bypassable_path" +) + +PCR0 = "a" * 96 +ROOT = "arn:aws:iam::123456789012:root" + + +def _policy(*statements): + return {"Version": "2012-10-17", "Statement": list(statements)} + + +def _allow_sensitive_with_attestation(sid="Enc"): + return { + "Sid": sid, + "Effect": "Allow", + "Principal": {"AWS": ROOT}, + "Action": ["kms:Decrypt", "kms:GenerateDataKey"], + "Resource": "*", + "Condition": { + "StringEqualsIgnoreCase": {"kms:RecipientAttestation:PCR0": PCR0} + }, + } + + +def _allow_sensitive_no_condition(sid="NoAtt", action="kms:Decrypt"): + return { + "Sid": sid, + "Effect": "Allow", + "Principal": {"AWS": ROOT}, + "Action": action, + "Resource": "*", + } + + +def _root_admin_wildcard(): + return { + "Sid": "RootAdminAllData", + "Effect": "Allow", + "Principal": {"AWS": ROOT}, + "Action": "kms:*", + "Resource": "*", + } + + +def _admin_no_data(): + return { + "Sid": "AdminNoDataActions", + "Effect": "Allow", + "Principal": {"AWS": ROOT}, + "Action": ["kms:Describe*", "kms:List*", "kms:Get*"], + "Resource": "*", + } + + +def _deny_when_attestation_absent(actions=None): + return { + "Sid": "DenyIfNoAtt", + "Effect": "Deny", + "Principal": "*", + "Action": actions + or [ + "kms:Decrypt", + "kms:GenerateDataKey", + "kms:GenerateDataKeyPair", + "kms:GenerateRandom", + ], + "Resource": "*", + "Condition": {"Null": {"kms:RecipientAttestation:PCR0": "true"}}, + } + + +def _deny_no_attest_condition(): + return { + "Sid": "DenyDecrypt", + "Effect": "Deny", + "Principal": "*", + "Action": "kms:Decrypt", + "Resource": "*", + } + + +def _create_enclave_key(kms, policy): + return kms.create_key( + MultiRegion=False, + Policy=json.dumps(policy), + Tags=[{"TagKey": "prowler:enclave-key", "TagValue": "true"}], + )["KeyMetadata"] + + +def _run(): + from prowler.providers.aws.services.kms.kms_service import KMS + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.kms_client", new=KMS(aws_provider)), + ): + from prowler.providers.aws.services.kms.kms_key_enclave_attestation_bypassable_path.kms_key_enclave_attestation_bypassable_path import ( + kms_key_enclave_attestation_bypassable_path, + ) + + return kms_key_enclave_attestation_bypassable_path().execute() + + +class Test_kms_key_enclave_attestation_bypassable_path: + @mock_aws + def test_no_keys_returns_empty(self): + assert _run() == [] + + @mock_aws + def test_non_enclave_key_skipped(self): + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + kms.create_key(MultiRegion=False) + assert _run() == [] + + @mock_aws + def test_empty_statement_list_passes(self): + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + _create_enclave_key(kms, _policy()) + result = _run() + assert len(result) == 1 + assert result[0].status == "PASS" + + @mock_aws + def test_all_sensitive_allows_have_attestation_pass(self): + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + _create_enclave_key( + kms, _policy(_admin_no_data(), _allow_sensitive_with_attestation()) + ) + result = _run() + assert len(result) == 1 + assert result[0].status == "PASS" + + @mock_aws + def test_sensitive_allow_without_attestation_fail(self): + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + _create_enclave_key(kms, _policy(_allow_sensitive_no_condition())) + result = _run() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "NoAtt" in result[0].status_extended + assert "bypass path" in result[0].status_extended + + @mock_aws + def test_root_delegation_kms_wildcard_fail(self): + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + _create_enclave_key(kms, _policy(_root_admin_wildcard())) + result = _run() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "RootAdminAllData" in result[0].status_extended + + @mock_aws + def test_root_delegation_kms_wildcard_with_attestation_pass(self): + # kms:* with attestation condition → no bypass + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + stmt = _root_admin_wildcard() + stmt["Condition"] = { + "StringEqualsIgnoreCase": {"kms:RecipientAttestation:PCR0": PCR0} + } + _create_enclave_key(kms, _policy(stmt)) + result = _run() + assert len(result) == 1 + assert result[0].status == "PASS" + + @mock_aws + def test_two_allows_one_with_attestation_one_without_fail(self): + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + _create_enclave_key( + kms, + _policy( + _allow_sensitive_with_attestation(sid="Enc"), + _allow_sensitive_no_condition(sid="Backdoor"), + ), + ) + result = _run() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "Backdoor" in result[0].status_extended + + @mock_aws + def test_allow_without_attestation_but_deny_null_covers_pass(self): + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + _create_enclave_key( + kms, + _policy( + _allow_sensitive_no_condition(), + _deny_when_attestation_absent(), + ), + ) + result = _run() + assert len(result) == 1 + assert result[0].status == "PASS" + + @mock_aws + def test_allow_decrypt_deny_decrypt_missing_attestation_pass(self): + # The Allow grants only kms:Decrypt and the Deny neutralises exactly + # that action when attestation is absent, so the sensitive-action + # coverage set matches and the finding is PASS. + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + _create_enclave_key( + kms, + _policy( + _allow_sensitive_no_condition(), # grants Decrypt only + _deny_when_attestation_absent(actions=["kms:Decrypt"]), + ), + ) + result = _run() + assert len(result) == 1 + assert result[0].status == "PASS" + + @mock_aws + def test_deny_missing_attestation_condition_fail(self): + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + _create_enclave_key( + kms, + _policy( + _allow_sensitive_no_condition(), + _deny_no_attest_condition(), + ), + ) + result = _run() + assert len(result) == 1 + assert result[0].status == "FAIL" + + @mock_aws + def test_deny_string_not_equals_if_exists_covers_pass(self): + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + deny_stmt = { + "Sid": "DenyOnMismatch", + "Effect": "Deny", + "Principal": "*", + "Action": [ + "kms:Decrypt", + "kms:GenerateDataKey", + "kms:GenerateDataKeyPair", + "kms:GenerateRandom", + ], + "Resource": "*", + "Condition": { + "StringNotEqualsIfExists": {"kms:RecipientAttestation:PCR0": PCR0} + }, + } + _create_enclave_key(kms, _policy(_allow_sensitive_no_condition(), deny_stmt)) + result = _run() + assert len(result) == 1 + assert result[0].status == "PASS" + + @mock_aws + def test_not_action_excluding_only_non_sensitive_fail(self): + # NotAction excludes only kms:ListKeys → Allow grants everything else, + # including sensitive actions, without any attestation condition. + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + stmt = { + "Sid": "AllExceptList", + "Effect": "Allow", + "Principal": {"AWS": ROOT}, + "NotAction": ["kms:ListKeys"], + "Resource": "*", + } + _create_enclave_key(kms, _policy(stmt)) + result = _run() + assert len(result) == 1 + assert result[0].status == "FAIL" + + @mock_aws + def test_only_non_sensitive_actions_pass(self): + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + _create_enclave_key(kms, _policy(_admin_no_data())) + result = _run() + assert len(result) == 1 + assert result[0].status == "PASS" + + @mock_aws + def test_deny_sensitive_without_conditions_passes(self): + # A statement with Effect=Deny on sensitive actions is not itself a + # bypass path (Deny is restrictive by nature). No Allow → PASS. + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + _create_enclave_key(kms, _policy(_deny_no_attest_condition())) + result = _run() + assert len(result) == 1 + assert result[0].status == "PASS" + + @mock_aws + def test_case_insensitive_action_kms_decrypt_lowercased_fail(self): + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + stmt = _allow_sensitive_no_condition(action="kms:decrypt") + _create_enclave_key(kms, _policy(stmt)) + result = _run() + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_statement_as_dict_not_list_parsed(self): + # Policy with a single statement expressed as a dict (not a list). + # Mocked directly because moto is strict about Statement shape. + key = mock.MagicMock() + key.manager = "CUSTOMER" + key.state = "Enabled" + key.policy = { + "Version": "2012-10-17", + "Statement": _allow_sensitive_no_condition(), + } + key.tags = [{"TagKey": "prowler:enclave-key", "TagValue": "true"}] + key.id = "single-stmt-dict-key" + key.arn = ( + f"arn:aws:kms:{AWS_REGION_US_EAST_1}:123456789012:key/single-stmt-dict-key" + ) + key.region = AWS_REGION_US_EAST_1 + client_mock = mock.MagicMock() + client_mock.keys = [key] + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider([AWS_REGION_US_EAST_1]), + ), + mock.patch(f"{CHECK_MODULE}.kms_client", new=client_mock), + ): + from prowler.providers.aws.services.kms.kms_key_enclave_attestation_bypassable_path.kms_key_enclave_attestation_bypassable_path import ( + kms_key_enclave_attestation_bypassable_path, + ) + + result = kms_key_enclave_attestation_bypassable_path().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_malformed_statement_null_does_not_crash(self): + # Injecting non-dict statements should be silently skipped. + key = mock.MagicMock() + key.manager = "CUSTOMER" + key.state = "Enabled" + key.policy = { + "Version": "2012-10-17", + "Statement": [None, "not a dict", _allow_sensitive_with_attestation()], + } + key.tags = [{"TagKey": "prowler:enclave-key", "TagValue": "true"}] + key.id = "malformed-key" + key.arn = f"arn:aws:kms:{AWS_REGION_US_EAST_1}:123456789012:key/malformed-key" + key.region = AWS_REGION_US_EAST_1 + client_mock = mock.MagicMock() + client_mock.keys = [key] + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider([AWS_REGION_US_EAST_1]), + ), + mock.patch(f"{CHECK_MODULE}.kms_client", new=client_mock), + ): + from prowler.providers.aws.services.kms.kms_key_enclave_attestation_bypassable_path.kms_key_enclave_attestation_bypassable_path import ( + kms_key_enclave_attestation_bypassable_path, + ) + + result = kms_key_enclave_attestation_bypassable_path().execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + @mock_aws + def test_deny_with_not_action_excluding_only_non_sensitive_covers_pass(self): + # Deny with NotAction:["kms:ListKeys"] denies every action except + # ListKeys → covers Decrypt/GenerateDataKey/etc. Paired with + # Null:true on RecipientAttestation, this DOES neutralize the Allow. + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + deny = { + "Sid": "DenyAllExceptListNoAtt", + "Effect": "Deny", + "Principal": "*", + "NotAction": ["kms:ListKeys"], + "Resource": "*", + "Condition": {"Null": {"kms:RecipientAttestation:PCR0": "true"}}, + } + _create_enclave_key(kms, _policy(_allow_sensitive_no_condition(), deny)) + result = _run() + assert len(result) == 1 + assert result[0].status == "PASS" + + @mock_aws + def test_deny_with_not_action_that_excludes_sensitive_action_fail(self): + # Deny with NotAction:["kms:Decrypt"] → does NOT deny Decrypt → + # cannot cover an Allow that granted Decrypt. + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + deny = { + "Sid": "DenyEverythingExceptDecrypt", + "Effect": "Deny", + "Principal": "*", + "NotAction": ["kms:Decrypt"], + "Resource": "*", + "Condition": {"Null": {"kms:RecipientAttestation:PCR0": "true"}}, + } + _create_enclave_key( + kms, _policy(_allow_sensitive_no_condition(action="kms:Decrypt"), deny) + ) + result = _run() + assert len(result) == 1 + assert result[0].status == "FAIL" + + @mock_aws + def test_disabled_key_skipped(self): + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + key = _create_enclave_key(kms, _policy(_allow_sensitive_no_condition())) + kms.disable_key(KeyId=key["KeyId"]) + assert _run() == [] + + @mock_aws + def test_deny_with_string_not_equals_ignore_case_if_exists_covers(self): + # Documented restrictive Deny operator variant that should neutralize + # a sensitive unconditioned Allow. + policy = { + "Version": "2012-10-17", + "Id": "enclave-key", + "Statement": [ + { + "Sid": "RootAllData", + "Effect": "Allow", + "Principal": {"AWS": "arn:aws:iam::123456789012:root"}, + "Action": "kms:Decrypt", + "Resource": "*", + }, + { + "Sid": "DenyWithoutAttestation", + "Effect": "Deny", + "Principal": "*", + "Action": "kms:Decrypt", + "Resource": "*", + "Condition": { + "StringNotEqualsIgnoreCaseIfExists": { + "kms:RecipientAttestation:PCR0": "a" * 96 + } + }, + }, + ], + } + kms_native = client("kms", region_name=AWS_REGION_US_EAST_1) + key = kms_native.create_key( + MultiRegion=False, + Policy=json.dumps(policy), + Tags=[{"TagKey": "prowler:enclave-key", "TagValue": "true"}], + )["KeyMetadata"] + + from prowler.providers.aws.services.kms.kms_service import KMS + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.kms_client", new=KMS(aws_provider)), + ): + from prowler.providers.aws.services.kms.kms_key_enclave_attestation_bypassable_path.kms_key_enclave_attestation_bypassable_path import ( + kms_key_enclave_attestation_bypassable_path, + ) + + result = kms_key_enclave_attestation_bypassable_path().execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == key["KeyId"] + + @mock_aws + def test_deny_with_not_principal_does_not_cover(self): + # Documented limitation: Deny statements using NotPrincipal are not + # recognized as neutralizing the sensitive Allow. Verify fail-closed. + policy = { + "Version": "2012-10-17", + "Id": "enclave-key", + "Statement": [ + { + "Sid": "RootAllData", + "Effect": "Allow", + "Principal": {"AWS": "arn:aws:iam::123456789012:root"}, + "Action": "kms:Decrypt", + "Resource": "*", + }, + { + "Sid": "DenyNotPrincipal", + "Effect": "Deny", + "NotPrincipal": {"AWS": "arn:aws:iam::123456789012:role/enclave"}, + "Action": "kms:Decrypt", + "Resource": "*", + "Condition": {"Null": {"kms:RecipientAttestation:PCR0": "true"}}, + }, + ], + } + kms_native = client("kms", region_name=AWS_REGION_US_EAST_1) + key = kms_native.create_key( + MultiRegion=False, + Policy=json.dumps(policy), + Tags=[{"TagKey": "prowler:enclave-key", "TagValue": "true"}], + )["KeyMetadata"] + + from prowler.providers.aws.services.kms.kms_service import KMS + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.kms_client", new=KMS(aws_provider)), + ): + from prowler.providers.aws.services.kms.kms_key_enclave_attestation_bypassable_path.kms_key_enclave_attestation_bypassable_path import ( + kms_key_enclave_attestation_bypassable_path, + ) + + result = kms_key_enclave_attestation_bypassable_path().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == key["KeyId"] diff --git a/tests/providers/aws/services/kms/kms_key_enclave_attestation_no_deployment_binding/kms_key_enclave_attestation_no_deployment_binding_test.py b/tests/providers/aws/services/kms/kms_key_enclave_attestation_no_deployment_binding/kms_key_enclave_attestation_no_deployment_binding_test.py new file mode 100644 index 0000000000..94a5fdea02 --- /dev/null +++ b/tests/providers/aws/services/kms/kms_key_enclave_attestation_no_deployment_binding/kms_key_enclave_attestation_no_deployment_binding_test.py @@ -0,0 +1,505 @@ +import json +from unittest import mock + +from boto3 import client +from moto import mock_aws + +from tests.providers.aws.utils import AWS_REGION_US_EAST_1, set_mocked_aws_provider + +CHECK_MODULE = ( + "prowler.providers.aws.services.kms.kms_key_enclave_attestation_no_deployment_binding" + ".kms_key_enclave_attestation_no_deployment_binding" +) + +PCR0_HASH = "a" * 96 +PCR4_HASH = "b" * 96 +PCR8_HASH = "c" * 96 +PCR1_HASH = "d" * 96 + + +def _policy(*statements): + return {"Version": "2012-10-17", "Id": "enclave-key", "Statement": list(statements)} + + +def _sensitive_allow(condition): + return { + "Sid": "EnclaveData", + "Effect": "Allow", + "Principal": {"AWS": "arn:aws:iam::123456789012:role/enclave-parent"}, + "Action": ["kms:Decrypt", "kms:GenerateDataKey"], + "Resource": "*", + "Condition": condition, + } + + +def _create_enclave_tagged_key(kms, policy): + return kms.create_key( + MultiRegion=False, + Policy=json.dumps(policy), + Tags=[{"TagKey": "prowler:enclave-key", "TagValue": "true"}], + )["KeyMetadata"] + + +def _run(policy): + from prowler.providers.aws.services.kms.kms_service import KMS + + kms_native = client("kms", region_name=AWS_REGION_US_EAST_1) + key = _create_enclave_tagged_key(kms_native, policy) + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + kms_service = KMS(aws_provider) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.kms_client", new=kms_service), + ): + from prowler.providers.aws.services.kms.kms_key_enclave_attestation_no_deployment_binding.kms_key_enclave_attestation_no_deployment_binding import ( + kms_key_enclave_attestation_no_deployment_binding, + ) + + return kms_key_enclave_attestation_no_deployment_binding().execute(), key + + +class Test_kms_key_enclave_attestation_no_deployment_binding: + @mock_aws + def test_no_keys_returns_empty(self): + from prowler.providers.aws.services.kms.kms_service import KMS + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.kms_client", new=KMS(aws_provider)), + ): + from prowler.providers.aws.services.kms.kms_key_enclave_attestation_no_deployment_binding.kms_key_enclave_attestation_no_deployment_binding import ( + kms_key_enclave_attestation_no_deployment_binding, + ) + + assert kms_key_enclave_attestation_no_deployment_binding().execute() == [] + + @mock_aws + def test_non_enclave_key_skipped(self): + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + kms.create_key(MultiRegion=False) + + from prowler.providers.aws.services.kms.kms_service import KMS + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.kms_client", new=KMS(aws_provider)), + ): + from prowler.providers.aws.services.kms.kms_key_enclave_attestation_no_deployment_binding.kms_key_enclave_attestation_no_deployment_binding import ( + kms_key_enclave_attestation_no_deployment_binding, + ) + + assert kms_key_enclave_attestation_no_deployment_binding().execute() == [] + + @mock_aws + def test_key_without_attestation_reports_honest_manual(self): + # No sensitive Allow with attestation → cannot evaluate deployment + # binding here (covered by attestation_not_enforced). Emit MANUAL + # with an honest message, NOT a vacuous PASS. + policy = _policy( + { + "Sid": "RootAdminAllData", + "Effect": "Allow", + "Principal": {"AWS": "arn:aws:iam::123456789012:root"}, + "Action": "kms:*", + "Resource": "*", + } + ) + result, key = _run(policy) + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "not applicable" in result[0].status_extended + assert "attestation_not_enforced" in result[0].status_extended + + @mock_aws + def test_pcr0_only_reports_fail(self): + policy = _policy( + _sensitive_allow( + {"StringEqualsIgnoreCase": {"kms:RecipientAttestation:PCR0": PCR0_HASH}} + ) + ) + result, _ = _run(policy) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "PCR0/PCR1/PCR2" in result[0].status_extended + + @mock_aws + def test_pcr0_and_pcr4_pass(self): + policy = _policy( + _sensitive_allow( + { + "StringEqualsIgnoreCase": { + "kms:RecipientAttestation:PCR0": PCR0_HASH, + "kms:RecipientAttestation:PCR4": PCR4_HASH, + } + } + ) + ) + result, _ = _run(policy) + assert len(result) == 1 + assert result[0].status == "PASS" + assert "deployment context" in result[0].status_extended + + @mock_aws + def test_pcr0_and_pcr8_pass(self): + policy = _policy( + _sensitive_allow( + { + "StringEqualsIgnoreCase": { + "kms:RecipientAttestation:PCR0": PCR0_HASH, + "kms:RecipientAttestation:PCR8": PCR8_HASH, + } + } + ) + ) + result, _ = _run(policy) + assert len(result) == 1 + assert result[0].status == "PASS" + + @mock_aws + def test_pcr0_and_pcr1_fail(self): + # PCR1 (kernel) is image identity, not deployment context. + # Per AWS docs, only PCR3/PCR4/PCR8 bind deployment. + policy = _policy( + _sensitive_allow( + { + "StringEqualsIgnoreCase": { + "kms:RecipientAttestation:PCR0": PCR0_HASH, + "kms:RecipientAttestation:PCR1": PCR1_HASH, + } + } + ) + ) + result, _ = _run(policy) + assert len(result) == 1 + assert result[0].status == "FAIL" + + @mock_aws + def test_pcr0_and_pcr2_fail(self): + # PCR2 (application) is image identity, not deployment context. + policy = _policy( + _sensitive_allow( + { + "StringEqualsIgnoreCase": { + "kms:RecipientAttestation:PCR0": PCR0_HASH, + "kms:RecipientAttestation:PCR2": "e" * 96, + } + } + ) + ) + result, _ = _run(policy) + assert len(result) == 1 + assert result[0].status == "FAIL" + + @mock_aws + def test_pcr0_and_pcr3_pass(self): + # PCR3 = parent IAM role, AWS-recommended deployment binding. + policy = _policy( + _sensitive_allow( + { + "StringEqualsIgnoreCase": { + "kms:RecipientAttestation:PCR0": PCR0_HASH, + "kms:RecipientAttestation:PCR3": "f" * 96, + } + } + ) + ) + result, _ = _run(policy) + assert len(result) == 1 + assert result[0].status == "PASS" + + @mock_aws + def test_account_condition_alongside_attestation_pass(self): + policy = _policy( + _sensitive_allow( + { + "StringEquals": { + "kms:RecipientAttestation:PCR0": PCR0_HASH, + "aws:PrincipalAccount": "123456789012", + } + } + ) + ) + result, _ = _run(policy) + assert len(result) == 1 + assert result[0].status == "PASS" + + @mock_aws + def test_account_condition_without_attestation_manual(self): + # aws:PrincipalAccount alone without any RecipientAttestation binding + # is NOT deployment binding for this check (attestation must exist). + policy = _policy( + _sensitive_allow({"StringEquals": {"aws:PrincipalAccount": "123456789012"}}) + ) + result, _ = _run(policy) + assert len(result) == 1 + # No attestation at all → MANUAL (covered by attestation_not_enforced). + assert result[0].status == "MANUAL" + + @mock_aws + def test_wildcard_account_value_not_restrictive_fail(self): + # aws:PrincipalAccount with a wildcard value is not restrictive. + policy = _policy( + _sensitive_allow( + { + "StringEquals": { + "kms:RecipientAttestation:PCR0": PCR0_HASH, + "aws:PrincipalAccount": "12345*", + } + } + ) + ) + result, _ = _run(policy) + assert len(result) == 1 + assert result[0].status == "FAIL" + + @mock_aws + def test_imagesha384_alone_fail(self): + # ImageSha384 collapses to PCR0; without PCR4/PCR8/account → INFO. + policy = _policy( + _sensitive_allow( + { + "StringEqualsIgnoreCase": { + "kms:RecipientAttestation:ImageSha384": PCR0_HASH + } + } + ) + ) + result, _ = _run(policy) + assert len(result) == 1 + assert result[0].status == "FAIL" + + @mock_aws + def test_per_statement_evaluation_fails_when_any_lacks_binding(self): + # Two attestation statements: one with PCR4, one with only PCR0. + # RFC-compliant is per-statement evaluation, so the check reports + # INFO because a caller could hit the PCR0-only statement. + policy = _policy( + _sensitive_allow( + { + "StringEqualsIgnoreCase": { + "kms:RecipientAttestation:PCR0": PCR0_HASH, + "kms:RecipientAttestation:PCR4": PCR4_HASH, + } + } + ), + { + "Sid": "PCR0Only", + "Effect": "Allow", + "Principal": {"AWS": "arn:aws:iam::123456789012:role/other"}, + "Action": ["kms:Decrypt"], + "Resource": "*", + "Condition": { + "StringEqualsIgnoreCase": { + "kms:RecipientAttestation:PCR0": PCR0_HASH, + } + }, + }, + ) + result, _ = _run(policy) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "PCR0Only" in result[0].status_extended + + @mock_aws + def test_forallvalues_pcr4_without_null_guard_manual(self): + # ForAllValues:StringEquals is vacuous-true when the key is absent + # unless Null:false locks presence. Without Null:false → not counted. + policy = _policy( + _sensitive_allow( + { + "ForAllValues:StringEqualsIgnoreCase": { + "kms:RecipientAttestation:PCR0": PCR0_HASH, + "kms:RecipientAttestation:PCR4": PCR4_HASH, + } + } + ) + ) + result, _ = _run(policy) + assert len(result) == 1 + assert result[0].status == "MANUAL" + + @mock_aws + def test_case_insensitive_attestation_keys_recognized(self): + # kms:recipientattestation:pcr4 (lowercase) still recognized. + policy = _policy( + _sensitive_allow( + { + "StringEqualsIgnoreCase": { + "kms:RecipientAttestation:PCR0": PCR0_HASH, + "kms:recipientattestation:pcr4": PCR4_HASH, + } + } + ) + ) + result, _ = _run(policy) + assert len(result) == 1 + assert result[0].status == "PASS" + + @mock_aws + def test_org_id_condition_pass(self): + policy = _policy( + _sensitive_allow( + { + "StringEquals": { + "kms:RecipientAttestation:PCR0": PCR0_HASH, + "aws:PrincipalOrgID": "o-abc123def456", + } + } + ) + ) + result, _ = _run(policy) + assert len(result) == 1 + assert result[0].status == "PASS" + + @mock_aws + def test_policy_fetch_error_reports_manual(self): + # Simulate a GetKeyPolicy failure and confirm the check emits MANUAL + # rather than silently skipping the key (fail-closed on missing + # visibility). + policy = _policy( + _sensitive_allow( + {"StringEqualsIgnoreCase": {"kms:RecipientAttestation:PCR0": PCR0_HASH}} + ) + ) + from prowler.providers.aws.services.kms.kms_service import KMS + + kms_native = client("kms", region_name=AWS_REGION_US_EAST_1) + key = _create_enclave_tagged_key(kms_native, policy) + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + kms_service = KMS(aws_provider) + for k in kms_service.keys: + if k.id == key["KeyId"]: + k.policy = None + k.policy_fetch_error = "AccessDeniedException" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.kms_client", new=kms_service), + ): + from prowler.providers.aws.services.kms.kms_key_enclave_attestation_no_deployment_binding.kms_key_enclave_attestation_no_deployment_binding import ( + kms_key_enclave_attestation_no_deployment_binding, + ) + + result = kms_key_enclave_attestation_no_deployment_binding().execute() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "policy could not be fetched" in result[0].status_extended + assert "AccessDeniedException" in result[0].status_extended + + @mock_aws + def test_string_equals_if_exists_account_condition_does_not_bind(self): + # StringEqualsIfExists is vacuous-true when the request-context key is + # absent — a caller can bypass by not sending aws:PrincipalAccount. + # Should NOT satisfy deployment binding. + policy = _policy( + _sensitive_allow( + { + "StringEqualsIgnoreCase": { + "kms:RecipientAttestation:PCR0": PCR0_HASH + }, + "StringEqualsIfExists": {"aws:PrincipalAccount": "123456789012"}, + } + ) + ) + result, _ = _run(policy) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "lack deployment-context binding" in result[0].status_extended + + @mock_aws + def test_arn_like_account_condition_does_not_bind(self): + # ArnLike allows wildcards — does not bind to a specific ARN. + policy = _policy( + _sensitive_allow( + { + "StringEqualsIgnoreCase": { + "kms:RecipientAttestation:PCR0": PCR0_HASH + }, + "ArnLike": {"aws:PrincipalArn": "arn:aws:iam::*:role/*"}, + } + ) + ) + result, _ = _run(policy) + assert len(result) == 1 + assert result[0].status == "FAIL" + + @mock_aws + def test_for_any_value_string_equals_account_condition_does_not_bind(self): + # ForAnyValue:StringEquals matches if ANY value in a multi-valued + # context matches — not strictly restrictive. + policy = _policy( + _sensitive_allow( + { + "StringEqualsIgnoreCase": { + "kms:RecipientAttestation:PCR0": PCR0_HASH + }, + "ForAnyValue:StringEquals": { + "aws:PrincipalAccount": "123456789012" + }, + } + ) + ) + result, _ = _run(policy) + assert len(result) == 1 + assert result[0].status == "FAIL" + + @mock_aws + def test_for_all_values_string_equals_account_condition_does_not_bind(self): + # ForAllValues:* is vacuous-true when the key is absent. + policy = _policy( + _sensitive_allow( + { + "StringEqualsIgnoreCase": { + "kms:RecipientAttestation:PCR0": PCR0_HASH + }, + "ForAllValues:StringEquals": { + "aws:PrincipalAccount": "123456789012" + }, + } + ) + ) + result, _ = _run(policy) + assert len(result) == 1 + assert result[0].status == "FAIL" + + @mock_aws + def test_arn_equals_on_non_account_key_does_not_bind_fail(self): + # Only ``aws:PrincipalAccount``, ``aws:SourceAccount``, + # ``aws:PrincipalOrgID``, ``aws:ResourceAccount``, and + # ``aws:PrincipalOrgPaths`` count as account/org bindings — + # ``aws:SourceArn`` and ``aws:PrincipalArn`` do not, regardless of + # operator. Documents the intentional scope of the account allow-list. + policy = _policy( + _sensitive_allow( + { + "StringEqualsIgnoreCase": { + "kms:RecipientAttestation:PCR0": PCR0_HASH + }, + "ArnEquals": { + "aws:SourceArn": ( + "arn:aws:iam::123456789012:role/enclave-parent" + ) + }, + } + ) + ) + result, _ = _run(policy) + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/aws/services/kms/kms_key_enclave_attestation_not_enforced/kms_key_enclave_attestation_not_enforced_test.py b/tests/providers/aws/services/kms/kms_key_enclave_attestation_not_enforced/kms_key_enclave_attestation_not_enforced_test.py new file mode 100644 index 0000000000..0549a4db5f --- /dev/null +++ b/tests/providers/aws/services/kms/kms_key_enclave_attestation_not_enforced/kms_key_enclave_attestation_not_enforced_test.py @@ -0,0 +1,884 @@ +import json +from unittest import mock + +from boto3 import client +from moto import mock_aws + +from tests.providers.aws.utils import AWS_REGION_US_EAST_1, set_mocked_aws_provider + +CHECK_MODULE = ( + "prowler.providers.aws.services.kms.kms_key_enclave_attestation_not_enforced" + ".kms_key_enclave_attestation_not_enforced" +) + + +def _policy_with_action(action, condition=None): + stmt = { + "Effect": "Allow", + "Principal": {"AWS": "arn:aws:iam::123456789012:role/enclave-parent"}, + "Action": action, + "Resource": "*", + } + if condition is not None: + stmt["Condition"] = condition + return { + "Version": "2012-10-17", + "Id": "enclave-key", + "Statement": [stmt], + } + + +def _create_enclave_tagged_key(kms, policy): + return kms.create_key( + MultiRegion=False, + Policy=json.dumps(policy), + Tags=[{"TagKey": "prowler:enclave-key", "TagValue": "true"}], + )["KeyMetadata"] + + +class Test_kms_key_enclave_attestation_not_enforced: + @mock_aws + def test_no_keys(self): + from prowler.providers.aws.services.kms.kms_service import KMS + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.kms_client", new=KMS(aws_provider)), + ): + from prowler.providers.aws.services.kms.kms_key_enclave_attestation_not_enforced.kms_key_enclave_attestation_not_enforced import ( + kms_key_enclave_attestation_not_enforced, + ) + + assert kms_key_enclave_attestation_not_enforced().execute() == [] + + @mock_aws + def test_non_enclave_key_skipped(self): + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + kms.create_key(MultiRegion=False) + + from prowler.providers.aws.services.kms.kms_service import KMS + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.kms_client", new=KMS(aws_provider)), + ): + from prowler.providers.aws.services.kms.kms_key_enclave_attestation_not_enforced.kms_key_enclave_attestation_not_enforced import ( + kms_key_enclave_attestation_not_enforced, + ) + + assert kms_key_enclave_attestation_not_enforced().execute() == [] + + @mock_aws + def test_enclave_key_with_attestation_pass(self): + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + key = _create_enclave_tagged_key( + kms, + _policy_with_action( + "kms:Decrypt", + condition={ + "StringEqualsIgnoreCase": { + "kms:RecipientAttestation:PCR0": "abcd" * 24 + } + }, + ), + ) + + from prowler.providers.aws.services.kms.kms_service import KMS + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.kms_client", new=KMS(aws_provider)), + ): + from prowler.providers.aws.services.kms.kms_key_enclave_attestation_not_enforced.kms_key_enclave_attestation_not_enforced import ( + kms_key_enclave_attestation_not_enforced, + ) + + result = kms_key_enclave_attestation_not_enforced().execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == key["KeyId"] + + @mock_aws + def test_enclave_key_without_attestation_fail(self): + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + key = _create_enclave_tagged_key(kms, _policy_with_action("kms:Decrypt")) + + from prowler.providers.aws.services.kms.kms_service import KMS + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.kms_client", new=KMS(aws_provider)), + ): + from prowler.providers.aws.services.kms.kms_key_enclave_attestation_not_enforced.kms_key_enclave_attestation_not_enforced import ( + kms_key_enclave_attestation_not_enforced, + ) + + result = kms_key_enclave_attestation_not_enforced().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == key["KeyId"] + assert "RecipientAttestation" in result[0].status_extended + + @mock_aws + def test_enclave_key_wildcard_without_attestation_fail(self): + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + key = _create_enclave_tagged_key(kms, _policy_with_action("kms:*")) + + from prowler.providers.aws.services.kms.kms_service import KMS + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.kms_client", new=KMS(aws_provider)), + ): + from prowler.providers.aws.services.kms.kms_key_enclave_attestation_not_enforced.kms_key_enclave_attestation_not_enforced import ( + kms_key_enclave_attestation_not_enforced, + ) + + result = kms_key_enclave_attestation_not_enforced().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == key["KeyId"] + + @mock_aws + def test_enclave_key_via_description_fallback(self): + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + key = kms.create_key( + MultiRegion=False, + Description="production enclave key for the vault workload", + Policy=json.dumps(_policy_with_action("kms:Decrypt")), + )["KeyMetadata"] + + from prowler.providers.aws.services.kms.kms_service import KMS + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.kms_client", new=KMS(aws_provider)), + ): + from prowler.providers.aws.services.kms.kms_key_enclave_attestation_not_enforced.kms_key_enclave_attestation_not_enforced import ( + kms_key_enclave_attestation_not_enforced, + ) + + result = kms_key_enclave_attestation_not_enforced().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == key["KeyId"] + + @mock_aws + def test_enclave_key_via_alias_signal_fail(self): + # No tag, no description hit, no attestation in policy: alias alone + # must qualify the key so Check 8 catches the missing condition. + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + key = kms.create_key( + MultiRegion=False, + Policy=json.dumps(_policy_with_action("kms:Decrypt")), + )["KeyMetadata"] + kms.create_alias( + AliasName="alias/enclave-signing-key", TargetKeyId=key["KeyId"] + ) + + from prowler.providers.aws.services.kms.kms_service import KMS + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.kms_client", new=KMS(aws_provider)), + ): + from prowler.providers.aws.services.kms.kms_key_enclave_attestation_not_enforced.kms_key_enclave_attestation_not_enforced import ( + kms_key_enclave_attestation_not_enforced, + ) + + result = kms_key_enclave_attestation_not_enforced().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == key["KeyId"] + + @mock_aws + def test_enclave_key_via_policy_attestation_signal_fail(self): + # No tag, no alias, no description hit. One statement has attestation + # (triggers Signal 4 for is_enclave_key), another sensitive Allow + # statement lacks any condition and must FAIL Check 8. + policy = { + "Version": "2012-10-17", + "Id": "mixed-attestation", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam::123456789012:role/enclave-parent" + }, + "Action": "kms:Decrypt", + "Resource": "*", + "Condition": { + "StringEqualsIgnoreCase": { + "kms:RecipientAttestation:PCR0": "a" * 96 + } + }, + }, + { + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam::123456789012:role/enclave-parent" + }, + "Action": "kms:GenerateDataKey", + "Resource": "*", + }, + ], + } + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + key = kms.create_key(MultiRegion=False, Policy=json.dumps(policy))[ + "KeyMetadata" + ] + + from prowler.providers.aws.services.kms.kms_service import KMS + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.kms_client", new=KMS(aws_provider)), + ): + from prowler.providers.aws.services.kms.kms_key_enclave_attestation_not_enforced.kms_key_enclave_attestation_not_enforced import ( + kms_key_enclave_attestation_not_enforced, + ) + + result = kms_key_enclave_attestation_not_enforced().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == key["KeyId"] + assert "kms:GenerateDataKey" in result[0].status_extended + + @mock_aws + def test_action_wildcard_pattern_without_attestation_fail(self): + # kms:GenerateDataKey* expands to include kms:GenerateDataKey + # and kms:GenerateDataKeyPair — both sensitive. No attestation → FAIL. + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + key = _create_enclave_tagged_key( + kms, _policy_with_action("kms:GenerateDataKey*") + ) + + from prowler.providers.aws.services.kms.kms_service import KMS + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.kms_client", new=KMS(aws_provider)), + ): + from prowler.providers.aws.services.kms.kms_key_enclave_attestation_not_enforced.kms_key_enclave_attestation_not_enforced import ( + kms_key_enclave_attestation_not_enforced, + ) + + result = kms_key_enclave_attestation_not_enforced().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == key["KeyId"] + + @mock_aws + def test_action_case_insensitive_without_attestation_fail(self): + # IAM actions are case-insensitive: "KMS:decrypt" == "kms:Decrypt". + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + key = _create_enclave_tagged_key(kms, _policy_with_action("KMS:decrypt")) + + from prowler.providers.aws.services.kms.kms_service import KMS + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.kms_client", new=KMS(aws_provider)), + ): + from prowler.providers.aws.services.kms.kms_key_enclave_attestation_not_enforced.kms_key_enclave_attestation_not_enforced import ( + kms_key_enclave_attestation_not_enforced, + ) + + result = kms_key_enclave_attestation_not_enforced().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == key["KeyId"] + + @mock_aws + def test_notaction_grants_sensitive_without_attestation_fail(self): + # NotAction: kms:ListKeys grants everything except ListKeys — includes + # every sensitive action. No attestation → FAIL. + stmt = { + "Effect": "Allow", + "Principal": {"AWS": "arn:aws:iam::123456789012:role/enclave-parent"}, + "NotAction": "kms:ListKeys", + "Resource": "*", + } + policy = {"Version": "2012-10-17", "Id": "notaction", "Statement": [stmt]} + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + key = kms.create_key( + MultiRegion=False, + Policy=json.dumps(policy), + Tags=[{"TagKey": "prowler:enclave-key", "TagValue": "true"}], + )["KeyMetadata"] + + from prowler.providers.aws.services.kms.kms_service import KMS + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.kms_client", new=KMS(aws_provider)), + ): + from prowler.providers.aws.services.kms.kms_key_enclave_attestation_not_enforced.kms_key_enclave_attestation_not_enforced import ( + kms_key_enclave_attestation_not_enforced, + ) + + result = kms_key_enclave_attestation_not_enforced().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == key["KeyId"] + + @mock_aws + def test_stringlike_wildcard_value_treated_as_no_attestation_fail(self): + # StringLike with value "*" permits any PCR value — not restrictive. + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + key = _create_enclave_tagged_key( + kms, + _policy_with_action( + "kms:Decrypt", + condition={"StringLike": {"kms:RecipientAttestation:PCR0": "*"}}, + ), + ) + + from prowler.providers.aws.services.kms.kms_service import KMS + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.kms_client", new=KMS(aws_provider)), + ): + from prowler.providers.aws.services.kms.kms_key_enclave_attestation_not_enforced.kms_key_enclave_attestation_not_enforced import ( + kms_key_enclave_attestation_not_enforced, + ) + + result = kms_key_enclave_attestation_not_enforced().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == key["KeyId"] + + @mock_aws + def test_null_operator_treated_as_no_attestation_fail(self): + # Null: {"...": "true"} means "the key must NOT be present" — inverted. + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + key = _create_enclave_tagged_key( + kms, + _policy_with_action( + "kms:Decrypt", + condition={"Null": {"kms:RecipientAttestation:PCR0": "true"}}, + ), + ) + + from prowler.providers.aws.services.kms.kms_service import KMS + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.kms_client", new=KMS(aws_provider)), + ): + from prowler.providers.aws.services.kms.kms_key_enclave_attestation_not_enforced.kms_key_enclave_attestation_not_enforced import ( + kms_key_enclave_attestation_not_enforced, + ) + + result = kms_key_enclave_attestation_not_enforced().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == key["KeyId"] + + @mock_aws + def test_stringnotequals_treated_as_no_attestation_fail(self): + # StringNotEquals inverts the check — permits any value except the + # listed one. Not restrictive for our purposes. + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + key = _create_enclave_tagged_key( + kms, + _policy_with_action( + "kms:Decrypt", + condition={ + "StringNotEquals": {"kms:RecipientAttestation:PCR0": "some-value"} + }, + ), + ) + + from prowler.providers.aws.services.kms.kms_service import KMS + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.kms_client", new=KMS(aws_provider)), + ): + from prowler.providers.aws.services.kms.kms_key_enclave_attestation_not_enforced.kms_key_enclave_attestation_not_enforced import ( + kms_key_enclave_attestation_not_enforced, + ) + + result = kms_key_enclave_attestation_not_enforced().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == key["KeyId"] + + @mock_aws + def test_stringequalsifexists_treated_as_no_attestation_fail(self): + # *IfExists passes when the key is not present in the request — + # allows access without attestation, so not restrictive. + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + key = _create_enclave_tagged_key( + kms, + _policy_with_action( + "kms:Decrypt", + condition={ + "StringEqualsIfExists": {"kms:RecipientAttestation:PCR0": "a" * 96} + }, + ), + ) + + from prowler.providers.aws.services.kms.kms_service import KMS + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.kms_client", new=KMS(aws_provider)), + ): + from prowler.providers.aws.services.kms.kms_key_enclave_attestation_not_enforced.kms_key_enclave_attestation_not_enforced import ( + kms_key_enclave_attestation_not_enforced, + ) + + result = kms_key_enclave_attestation_not_enforced().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == key["KeyId"] + + @mock_aws + def test_malformed_policy_statement_null_does_not_crash(self): + # A policy with Statement=null (JSON null, Python None) must not + # crash the check. AWS itself would reject this at submit time, but + # a corrupted document should still be handled gracefully. + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + key = _create_enclave_tagged_key(kms, _policy_with_action("kms:Decrypt")) + + from prowler.providers.aws.services.kms.kms_service import KMS + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + kms_svc = KMS(aws_provider) + for k in kms_svc.keys: + if k.id == key["KeyId"]: + k.policy = {"Statement": None} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.kms_client", new=kms_svc), + ): + from prowler.providers.aws.services.kms.kms_key_enclave_attestation_not_enforced.kms_key_enclave_attestation_not_enforced import ( + kms_key_enclave_attestation_not_enforced, + ) + + # Must not raise. The key is in scope via the explicit tag + # (Signal 1); the malformed statement list is treated as "no + # offending statements" → default PASS. + result = kms_key_enclave_attestation_not_enforced().execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == key["KeyId"] + + @mock_aws + def test_signal_4_guard_against_null_statement(self): + # Explicitly exercise is_enclave_key's Signal 4 guard: the key must + # have NO hints from Signals 1/2/3 (no enclave tag, no alias, no + # "enclave" in description/tags) so the function reaches Signal 4 + # with a policy whose Statement is null. Without the guard, the + # iteration crashes; with the guard, it returns False and the check + # skips the key. + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + key = kms.create_key(MultiRegion=False)["KeyMetadata"] + + from prowler.providers.aws.services.kms.kms_service import KMS + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + kms_svc = KMS(aws_provider) + for k in kms_svc.keys: + if k.id == key["KeyId"]: + k.tags = [] + k.aliases = [] + k.description = "" + k.policy = {"Statement": None} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.kms_client", new=kms_svc), + ): + from prowler.providers.aws.services.kms.kms_key_enclave_attestation_not_enforced.kms_key_enclave_attestation_not_enforced import ( + kms_key_enclave_attestation_not_enforced, + ) + + # Must not raise. Key is not an enclave key → skipped → no findings. + result = kms_key_enclave_attestation_not_enforced().execute() + assert result == [] + + @mock_aws + def test_partial_wildcard_value_treated_as_no_attestation_fail(self): + # StringLike with a partial wildcard (`"abc*"`) permits any PCR that + # starts with "abc". PCR/ImageSha values are fixed hex hashes; wildcard + # patterns of any shape must not count as a restrictive binding. + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + key = _create_enclave_tagged_key( + kms, + _policy_with_action( + "kms:Decrypt", + condition={"StringLike": {"kms:RecipientAttestation:PCR0": "abc*"}}, + ), + ) + + from prowler.providers.aws.services.kms.kms_service import KMS + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.kms_client", new=KMS(aws_provider)), + ): + from prowler.providers.aws.services.kms.kms_key_enclave_attestation_not_enforced.kms_key_enclave_attestation_not_enforced import ( + kms_key_enclave_attestation_not_enforced, + ) + + result = kms_key_enclave_attestation_not_enforced().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == key["KeyId"] + + @mock_aws + def test_mixed_case_condition_key_recognized_as_attestation_pass(self): + # AWS condition keys are case-insensitive. A policy with + # `KMS:recipientAttestation:PCR0` (mixed case) must still be + # recognized as an attestation binding → Check 8 PASS. + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + key = _create_enclave_tagged_key( + kms, + _policy_with_action( + "kms:Decrypt", + condition={ + "StringEqualsIgnoreCase": { + "KMS:recipientAttestation:PCR0": "a" * 96 + } + }, + ), + ) + + from prowler.providers.aws.services.kms.kms_service import KMS + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.kms_client", new=KMS(aws_provider)), + ): + from prowler.providers.aws.services.kms.kms_key_enclave_attestation_not_enforced.kms_key_enclave_attestation_not_enforced import ( + kms_key_enclave_attestation_not_enforced, + ) + + result = kms_key_enclave_attestation_not_enforced().execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == key["KeyId"] + + @mock_aws + def test_value_list_with_wildcard_entry_treated_as_no_attestation_fail(self): + # Any wildcard in a value list disqualifies the whole condition. + # `[valid_hash, "abc?"]` cannot be trusted as restrictive because + # the wildcard entry alone permits any 4-char value starting with abc. + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + key = _create_enclave_tagged_key( + kms, + _policy_with_action( + "kms:Decrypt", + condition={ + "StringEquals": { + "kms:RecipientAttestation:PCR0": ["a" * 96, "abc?"] + } + }, + ), + ) + + from prowler.providers.aws.services.kms.kms_service import KMS + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.kms_client", new=KMS(aws_provider)), + ): + from prowler.providers.aws.services.kms.kms_key_enclave_attestation_not_enforced.kms_key_enclave_attestation_not_enforced import ( + kms_key_enclave_attestation_not_enforced, + ) + + result = kms_key_enclave_attestation_not_enforced().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == key["KeyId"] + + @mock_aws + def test_for_all_values_without_null_guard_treated_as_no_attestation_fail(self): + # ForAllValues:StringEquals evaluates to true when the request + # context key is absent. Without a Null:"false" guard, a caller + # can obtain the sensitive action without providing any attestation. + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + key = _create_enclave_tagged_key( + kms, + _policy_with_action( + "kms:Decrypt", + condition={ + "ForAllValues:StringEquals": { + "kms:RecipientAttestation:PCR0": "a" * 96 + } + }, + ), + ) + + from prowler.providers.aws.services.kms.kms_service import KMS + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.kms_client", new=KMS(aws_provider)), + ): + from prowler.providers.aws.services.kms.kms_key_enclave_attestation_not_enforced.kms_key_enclave_attestation_not_enforced import ( + kms_key_enclave_attestation_not_enforced, + ) + + result = kms_key_enclave_attestation_not_enforced().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == key["KeyId"] + + @mock_aws + def test_for_all_values_with_null_guard_treated_as_attestation_pass(self): + # Pairing ForAllValues:StringEquals with Null:"false" on the same key + # forces the caller to send the attestation, restoring restrictive + # semantics and satisfying the check. + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + key = _create_enclave_tagged_key( + kms, + _policy_with_action( + "kms:Decrypt", + condition={ + "ForAllValues:StringEquals": { + "kms:RecipientAttestation:PCR0": "a" * 96 + }, + "Null": {"kms:RecipientAttestation:PCR0": "false"}, + }, + ), + ) + + from prowler.providers.aws.services.kms.kms_service import KMS + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.kms_client", new=KMS(aws_provider)), + ): + from prowler.providers.aws.services.kms.kms_key_enclave_attestation_not_enforced.kms_key_enclave_attestation_not_enforced import ( + kms_key_enclave_attestation_not_enforced, + ) + + result = kms_key_enclave_attestation_not_enforced().execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == key["KeyId"] + + @mock_aws + def test_for_all_values_null_guard_case_insensitive_key_match_pass(self): + # The Null guard key uses a different casing than the ForAllValues + # binding. AWS treats condition keys case-insensitively, so the guard + # must still cover the binding. + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + key = _create_enclave_tagged_key( + kms, + _policy_with_action( + "kms:Decrypt", + condition={ + "ForAllValues:StringEquals": { + "kms:RecipientAttestation:PCR0": "a" * 96 + }, + "Null": {"KMS:recipientattestation:pcr0": "false"}, + }, + ), + ) + + from prowler.providers.aws.services.kms.kms_service import KMS + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.kms_client", new=KMS(aws_provider)), + ): + from prowler.providers.aws.services.kms.kms_key_enclave_attestation_not_enforced.kms_key_enclave_attestation_not_enforced import ( + kms_key_enclave_attestation_not_enforced, + ) + + result = kms_key_enclave_attestation_not_enforced().execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == key["KeyId"] + + @mock_aws + def test_for_all_values_null_guard_for_different_key_still_fail(self): + # A Null:false guard on a different attestation key does not rescue + # the ForAllValues binding — the caller can still omit PCR0 and pass. + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + key = _create_enclave_tagged_key( + kms, + _policy_with_action( + "kms:Decrypt", + condition={ + "ForAllValues:StringEquals": { + "kms:RecipientAttestation:PCR0": "a" * 96 + }, + "Null": {"kms:RecipientAttestation:PCR1": "false"}, + }, + ), + ) + + from prowler.providers.aws.services.kms.kms_service import KMS + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.kms_client", new=KMS(aws_provider)), + ): + from prowler.providers.aws.services.kms.kms_key_enclave_attestation_not_enforced.kms_key_enclave_attestation_not_enforced import ( + kms_key_enclave_attestation_not_enforced, + ) + + result = kms_key_enclave_attestation_not_enforced().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == key["KeyId"] + + @mock_aws + def test_policy_fetch_error_reports_manual(self): + # Simulate a GetKeyPolicy failure and confirm the check emits MANUAL + # (fail-closed on missing visibility) instead of silently skipping. + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + key = _create_enclave_tagged_key( + kms, + _policy_with_action("kms:Decrypt"), + ) + + from prowler.providers.aws.services.kms.kms_service import KMS + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + kms_service = KMS(aws_provider) + for k in kms_service.keys: + if k.id == key["KeyId"]: + k.policy = None + k.policy_fetch_error = "AccessDeniedException" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.kms_client", new=kms_service), + ): + from prowler.providers.aws.services.kms.kms_key_enclave_attestation_not_enforced.kms_key_enclave_attestation_not_enforced import ( + kms_key_enclave_attestation_not_enforced, + ) + + result = kms_key_enclave_attestation_not_enforced().execute() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "policy could not be fetched" in result[0].status_extended + assert "AccessDeniedException" in result[0].status_extended diff --git a/tests/providers/aws/services/kms/kms_key_enclave_attestation_pcr_mismatch/kms_key_enclave_attestation_pcr_mismatch_test.py b/tests/providers/aws/services/kms/kms_key_enclave_attestation_pcr_mismatch/kms_key_enclave_attestation_pcr_mismatch_test.py new file mode 100644 index 0000000000..5c6ae07513 --- /dev/null +++ b/tests/providers/aws/services/kms/kms_key_enclave_attestation_pcr_mismatch/kms_key_enclave_attestation_pcr_mismatch_test.py @@ -0,0 +1,493 @@ +import json +from unittest import mock + +from boto3 import client +from moto import mock_aws + +from tests.providers.aws.utils import AWS_REGION_US_EAST_1, set_mocked_aws_provider + +CHECK_MODULE = ( + "prowler.providers.aws.services.kms.kms_key_enclave_attestation_pcr_mismatch" + ".kms_key_enclave_attestation_pcr_mismatch" +) + +PCR0_GOLD = "a" * 96 +PCR0_BAD = "b" * 96 +PCR1_GOLD = "c" * 96 +PCR8_GOLD = "d" * 96 + + +def _enclave_policy(condition, action="kms:Decrypt"): + return { + "Version": "2012-10-17", + "Id": "enclave-key", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": "arn:aws:iam::123456789012:role/enclave-parent"}, + "Action": action, + "Resource": "*", + "Condition": condition, + } + ], + } + + +def _create_enclave_key(kms, condition, tags=None): + if tags is None: + tags = [{"TagKey": "prowler:enclave-key", "TagValue": "true"}] + return kms.create_key( + MultiRegion=False, + Policy=json.dumps(_enclave_policy(condition)), + Tags=tags, + )["KeyMetadata"] + + +def _run_check(golden_config): + from prowler.providers.aws.services.kms.kms_service import KMS + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + if golden_config is not None: + aws_provider._audit_config = {"enclave_golden_pcr_values": golden_config} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.kms_client", new=KMS(aws_provider)), + ): + from prowler.providers.aws.services.kms.kms_key_enclave_attestation_pcr_mismatch.kms_key_enclave_attestation_pcr_mismatch import ( + kms_key_enclave_attestation_pcr_mismatch, + ) + + return kms_key_enclave_attestation_pcr_mismatch().execute() + + +class Test_kms_key_enclave_attestation_pcr_mismatch: + @mock_aws + def test_no_keys(self): + assert _run_check({"PCR0": [PCR0_GOLD]}) == [] + + @mock_aws + def test_non_enclave_key_skipped(self): + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + kms.create_key(MultiRegion=False) # no enclave tag, no attestation + + assert _run_check({"PCR0": [PCR0_GOLD]}) == [] + + @mock_aws + def test_no_config_reports_manual(self): + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + _create_enclave_key( + kms, + {"StringEqualsIgnoreCase": {"kms:RecipientAttestation:PCR0": PCR0_GOLD}}, + ) + + result = _run_check(None) + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "no 'enclave_golden_pcr_values'" in result[0].status_extended + + @mock_aws + def test_empty_config_reports_manual(self): + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + _create_enclave_key( + kms, + {"StringEqualsIgnoreCase": {"kms:RecipientAttestation:PCR0": PCR0_GOLD}}, + ) + + result = _run_check({}) + assert len(result) == 1 + assert result[0].status == "MANUAL" + + @mock_aws + def test_pcr0_in_golden_list_pass(self): + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + _create_enclave_key( + kms, + {"StringEqualsIgnoreCase": {"kms:RecipientAttestation:PCR0": PCR0_GOLD}}, + ) + + result = _run_check({"PCR0": [PCR0_GOLD]}) + assert len(result) == 1 + assert result[0].status == "PASS" + + @mock_aws + def test_pcr0_not_in_golden_list_fail(self): + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + _create_enclave_key( + kms, + {"StringEqualsIgnoreCase": {"kms:RecipientAttestation:PCR0": PCR0_BAD}}, + ) + + result = _run_check({"PCR0": [PCR0_GOLD]}) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "PCR0" in result[0].status_extended + assert PCR0_BAD in result[0].status_extended + + @mock_aws + def test_imagesha384_collapses_to_pcr0(self): + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + _create_enclave_key( + kms, + { + "StringEqualsIgnoreCase": { + "kms:RecipientAttestation:ImageSha384": PCR0_GOLD + } + }, + ) + + result = _run_check({"PCR0": [PCR0_GOLD]}) + assert len(result) == 1 + assert result[0].status == "PASS" + + @mock_aws + def test_case_insensitive_condition_key_match(self): + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + _create_enclave_key( + kms, + {"StringEqualsIgnoreCase": {"kms:recipientattestation:pcr0": PCR0_GOLD}}, + ) + + result = _run_check({"PCR0": [PCR0_GOLD]}) + assert len(result) == 1 + assert result[0].status == "PASS" + + @mock_aws + def test_partial_config_uncovered_pcr_reports_manual(self): + # Policy binds PCR0 (good) and PCR1 (unknown value). Only PCR0 is + # configured in the golden list. The PCR1 binding is unverified → + # fail-closed to MANUAL rather than silent PASS. + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + _create_enclave_key( + kms, + { + "StringEqualsIgnoreCase": { + "kms:RecipientAttestation:PCR0": PCR0_GOLD, + "kms:RecipientAttestation:PCR1": "eeee" * 24, + } + }, + ) + + result = _run_check({"PCR0": [PCR0_GOLD]}) + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "PCR1" in result[0].status_extended + assert "no golden list is configured" in result[0].status_extended + + @mock_aws + def test_all_pcrs_covered_and_matching_pass(self): + # Policy binds PCR0 + PCR1; both are configured and both values are in + # their respective golden lists → PASS. + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + _create_enclave_key( + kms, + { + "StringEqualsIgnoreCase": { + "kms:RecipientAttestation:PCR0": PCR0_GOLD, + "kms:RecipientAttestation:PCR1": PCR1_GOLD, + } + }, + ) + + result = _run_check({"PCR0": [PCR0_GOLD], "PCR1": [PCR1_GOLD]}) + assert len(result) == 1 + assert result[0].status == "PASS" + + @mock_aws + def test_configured_pcr_never_referenced_reports_manual(self): + # Policy only binds PCR0; golden list only configures PCR8. PCR0 is + # uncovered → MANUAL with the uncovered-PCRs message. + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + _create_enclave_key( + kms, + {"StringEqualsIgnoreCase": {"kms:RecipientAttestation:PCR0": PCR0_GOLD}}, + ) + + result = _run_check({"PCR8": [PCR8_GOLD]}) + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "PCR0" in result[0].status_extended + assert "no golden list is configured" in result[0].status_extended + + @mock_aws + def test_empty_value_list_for_pcr_treated_as_unconfigured(self): + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + _create_enclave_key( + kms, + {"StringEqualsIgnoreCase": {"kms:RecipientAttestation:PCR0": PCR0_BAD}}, + ) + + # Only PCR1 has a non-empty list, so PCR0 is not verified and PCR1 is + # not referenced → MANUAL. + result = _run_check({"PCR0": [], "PCR1": [PCR1_GOLD]}) + assert len(result) == 1 + assert result[0].status == "MANUAL" + + @mock_aws + def test_value_list_contains_wildcard_skipped(self): + # attestation_condition_keys already filters wildcards out. A policy + # with only a wildcard binding cannot be verified against a golden + # list, so nothing is checked → MANUAL. + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + _create_enclave_key( + kms, + {"StringLike": {"kms:RecipientAttestation:PCR0": PCR0_GOLD + "*"}}, + ) + + result = _run_check({"PCR0": [PCR0_GOLD]}) + assert len(result) == 1 + assert result[0].status == "MANUAL" + + @mock_aws + def test_pcr_value_list_with_one_bad_entry_fails(self): + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + _create_enclave_key( + kms, + { + "StringEqualsIgnoreCase": { + "kms:RecipientAttestation:PCR0": [PCR0_GOLD, PCR0_BAD] + } + }, + ) + + result = _run_check({"PCR0": [PCR0_GOLD]}) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert PCR0_BAD in result[0].status_extended + assert PCR0_GOLD not in result[0].status_extended + + @mock_aws + def test_value_compare_case_insensitive(self): + # Golden values are lowercased at load; policy values are lowercased + # at read. Mixed-case hex must still match. + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + _create_enclave_key( + kms, + { + "StringEqualsIgnoreCase": { + "kms:RecipientAttestation:PCR0": PCR0_GOLD.upper() + } + }, + ) + + result = _run_check({"PCR0": [PCR0_GOLD]}) + assert len(result) == 1 + assert result[0].status == "PASS" + + @mock_aws + def test_multiple_keys_mixed_verdicts(self): + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + _create_enclave_key( + kms, + {"StringEqualsIgnoreCase": {"kms:RecipientAttestation:PCR0": PCR0_GOLD}}, + ) + _create_enclave_key( + kms, + {"StringEqualsIgnoreCase": {"kms:RecipientAttestation:PCR0": PCR0_BAD}}, + ) + _create_enclave_key( + kms, + {"StringEqualsIgnoreCase": {"kms:RecipientAttestation:PCR8": PCR8_GOLD}}, + ) + + result = _run_check({"PCR0": [PCR0_GOLD]}) + statuses = sorted(r.status for r in result) + assert statuses == ["FAIL", "MANUAL", "PASS"] + + @mock_aws + def test_non_sensitive_action_ignored(self): + # Attestation binding on kms:ListKeys (non-sensitive) does not trigger + # golden verification: the policy has no sensitive statement to check + # → MANUAL (nothing verified). + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + kms.create_key( + MultiRegion=False, + Policy=json.dumps( + _enclave_policy( + { + "StringEqualsIgnoreCase": { + "kms:RecipientAttestation:PCR0": PCR0_BAD + } + }, + action="kms:ListKeys", + ) + ), + Tags=[{"TagKey": "prowler:enclave-key", "TagValue": "true"}], + ) + + result = _run_check({"PCR0": [PCR0_GOLD]}) + assert len(result) == 1 + assert result[0].status == "MANUAL" + + @mock_aws + def test_disabled_key_skipped(self): + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + key = _create_enclave_key( + kms, + {"StringEqualsIgnoreCase": {"kms:RecipientAttestation:PCR0": PCR0_BAD}}, + ) + kms.disable_key(KeyId=key["KeyId"]) + + assert _run_check({"PCR0": [PCR0_GOLD]}) == [] + + @mock_aws + def test_for_all_values_without_null_guard_ignored(self): + # ForAllValues:* without Null:false guard was already filtered by + # attestation_condition_keys as non-restrictive. That statement's PCR + # bindings must NOT be verified against golden values → MANUAL. + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + _create_enclave_key( + kms, + { + "ForAllValues:StringEqualsIgnoreCase": { + "kms:RecipientAttestation:PCR0": PCR0_BAD + } + }, + ) + + result = _run_check({"PCR0": [PCR0_GOLD]}) + assert len(result) == 1 + assert result[0].status == "MANUAL" + + @mock_aws + def test_for_all_values_with_null_guard_verified(self): + # ForAllValues + Null:false is restrictive → PCR bindings ARE verified. + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + _create_enclave_key( + kms, + { + "ForAllValues:StringEqualsIgnoreCase": { + "kms:RecipientAttestation:PCR0": PCR0_BAD + }, + "Null": {"kms:RecipientAttestation:PCR0": "false"}, + }, + ) + + result = _run_check({"PCR0": [PCR0_GOLD]}) + assert len(result) == 1 + assert result[0].status == "FAIL" + + @mock_aws + def test_multi_statement_aggregation(self): + # Two Allow statements on the same key, each binding PCR0 to a + # different value. Both values should aggregate into observed. If the + # golden list covers only one of them, the other value must surface as + # a mismatch → FAIL. + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + policy = { + "Version": "2012-10-17", + "Id": "enclave-key", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam::123456789012:role/enclave-parent" + }, + "Action": "kms:Decrypt", + "Resource": "*", + "Condition": { + "StringEqualsIgnoreCase": { + "kms:RecipientAttestation:PCR0": PCR0_GOLD + } + }, + }, + { + "Effect": "Allow", + "Principal": {"AWS": "arn:aws:iam::123456789012:role/other-parent"}, + "Action": "kms:GenerateDataKey", + "Resource": "*", + "Condition": { + "StringEqualsIgnoreCase": { + "kms:RecipientAttestation:PCR0": PCR0_BAD + } + }, + }, + ], + } + kms.create_key( + MultiRegion=False, + Policy=json.dumps(policy), + Tags=[{"TagKey": "prowler:enclave-key", "TagValue": "true"}], + ) + + result = _run_check({"PCR0": [PCR0_GOLD]}) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert PCR0_BAD in result[0].status_extended + + @mock_aws + def test_not_action_covers_sensitive_actions(self): + # NotAction that does NOT exclude every sensitive action grants those + # sensitive actions. The binding should be verified against golden + # values just like an explicit Action list. + kms = client("kms", region_name=AWS_REGION_US_EAST_1) + policy = { + "Version": "2012-10-17", + "Id": "enclave-key", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam::123456789012:role/enclave-parent" + }, + "NotAction": "kms:ListKeys", + "Resource": "*", + "Condition": { + "StringEqualsIgnoreCase": { + "kms:RecipientAttestation:PCR0": PCR0_BAD + } + }, + } + ], + } + kms.create_key( + MultiRegion=False, + Policy=json.dumps(policy), + Tags=[{"TagKey": "prowler:enclave-key", "TagValue": "true"}], + ) + + result = _run_check({"PCR0": [PCR0_GOLD]}) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert PCR0_BAD in result[0].status_extended + + @mock_aws + def test_policy_fetch_error_reports_manual(self): + # GetKeyPolicy failure → MANUAL, not silent skip (fail-closed). + kms_native = client("kms", region_name=AWS_REGION_US_EAST_1) + key = _create_enclave_key( + kms_native, + {"StringEqualsIgnoreCase": {"kms:RecipientAttestation:PCR0": PCR0_GOLD}}, + ) + + from prowler.providers.aws.services.kms.kms_service import KMS + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + kms_service = KMS(aws_provider) + # Configure golden PCRs directly on the service (aws_provider.audit_config + # is a read-only property). + kms_service.audit_config = {"enclave_golden_pcr_values": {"PCR0": [PCR0_GOLD]}} + for k in kms_service.keys: + if k.id == key["KeyId"]: + k.policy = None + k.policy_fetch_error = "AccessDeniedException" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.kms_client", new=kms_service), + ): + from prowler.providers.aws.services.kms.kms_key_enclave_attestation_pcr_mismatch.kms_key_enclave_attestation_pcr_mismatch import ( + kms_key_enclave_attestation_pcr_mismatch, + ) + + result = kms_key_enclave_attestation_pcr_mismatch().execute() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "policy could not be fetched" in result[0].status_extended + assert "AccessDeniedException" in result[0].status_extended diff --git a/tests/providers/aws/services/kms/kms_key_enclave_attestation_unknown_image/kms_key_enclave_attestation_unknown_image_test.py b/tests/providers/aws/services/kms/kms_key_enclave_attestation_unknown_image/kms_key_enclave_attestation_unknown_image_test.py new file mode 100644 index 0000000000..e70277180d --- /dev/null +++ b/tests/providers/aws/services/kms/kms_key_enclave_attestation_unknown_image/kms_key_enclave_attestation_unknown_image_test.py @@ -0,0 +1,445 @@ +import json +from datetime import datetime, timezone +from unittest import mock + +from prowler.providers.aws.services.kms.lib.enclave import SENSITIVE_ENCLAVE_KMS_EVENTS +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +CHECK_MODULE = ( + "prowler.providers.aws.services.kms.kms_key_enclave_attestation_unknown_image" + ".kms_key_enclave_attestation_unknown_image" +) + +GOLDEN_PCR0 = "a" * 96 +GOLDEN_PCR1 = "b" * 96 +UNKNOWN_PCR0 = "c" * 96 +ZERO_PCR = "0" * 96 +KEY_ARN_A = f"arn:aws:kms:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:key/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" +KEY_ARN_B = f"arn:aws:kms:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:key/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" +INSTANCE_ID = "i-0123456789abcdef0" + + +def _mock_trail(is_multiregion=True): + trail = mock.MagicMock() + trail.is_multiregion = is_multiregion + trail.region = AWS_REGION_US_EAST_1 + return trail + + +def _mock_key(arn): + k = mock.MagicMock() + k.arn = arn + k.id = arn.rsplit("/", 1)[-1] + k.region = AWS_REGION_US_EAST_1 + k.tags = [] + return k + + +def _event( + key_arn, + event_name="Decrypt", + pcr0=GOLDEN_PCR0, + pcr1=GOLDEN_PCR1, + debug=False, + event_time=None, + instance_id=INSTANCE_ID, +): + if debug: + # Real debug enclave produces all-zeros across every PCR field. + recipient = { + "attestationDocumentModuleId": f"{instance_id}-enc9876abcd543210ef12", + "attestationDocumentEnclaveImageDigest": ZERO_PCR, + "attestationDocumentEnclavePCR1": ZERO_PCR, + "attestationDocumentEnclavePCR2": ZERO_PCR, + "attestationDocumentEnclavePCR3": ZERO_PCR, + "attestationDocumentEnclavePCR4": ZERO_PCR, + "attestationDocumentEnclavePCR8": ZERO_PCR, + } + else: + recipient = { + "attestationDocumentModuleId": f"{instance_id}-enc9876abcd543210ef12", + "attestationDocumentEnclaveImageDigest": pcr0, + "attestationDocumentEnclavePCR1": pcr1, + } + payload = { + "eventName": event_name, + "eventSource": "kms.amazonaws.com", + "additionalEventData": {"recipient": recipient}, + "resources": [ + {"accountId": AWS_ACCOUNT_NUMBER, "type": "AWS::KMS::Key", "ARN": key_arn} + ], + } + return { + "EventTime": event_time or datetime(2026, 7, 15, 12, 0, tzinfo=timezone.utc), + "CloudTrailEvent": json.dumps(payload), + "Resources": [{"ResourceType": "AWS::KMS::Key", "ResourceName": key_arn}], + } + + +def _run( + events_by_event_name=None, + trails=None, + truncated=False, + audit_config=None, + keys=None, + regions=None, + lookup_error=None, +): + kms_client = mock.MagicMock() + kms_client.keys = keys or [] + kms_client.audit_config = audit_config or {} + kms_client.audited_account = AWS_ACCOUNT_NUMBER + kms_client.region = AWS_REGION_US_EAST_1 + + cloudtrail_client = mock.MagicMock() + if trails is None: + cloudtrail_client.trails = {"t": _mock_trail(is_multiregion=True)} + else: + cloudtrail_client.trails = trails + cloudtrail_client.regional_clients = { + r: mock.MagicMock() for r in (regions or [AWS_REGION_US_EAST_1]) + } + + def _lookup(region, event_name, minutes): + if lookup_error is not None: + return [], False, lookup_error + return (events_by_event_name or {}).get(event_name, []), truncated, None + + cloudtrail_client._lookup_events_page = _lookup + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider(), + ), + mock.patch(f"{CHECK_MODULE}.kms_client", new=kms_client), + mock.patch(f"{CHECK_MODULE}.cloudtrail_client", new=cloudtrail_client), + ): + from prowler.providers.aws.services.kms.kms_key_enclave_attestation_unknown_image.kms_key_enclave_attestation_unknown_image import ( + kms_key_enclave_attestation_unknown_image, + ) + + return kms_key_enclave_attestation_unknown_image().execute() + + +class Test_kms_key_enclave_attestation_unknown_image: + def test_no_golden_config_reports_manual(self): + result = _run(events_by_event_name={"Decrypt": [_event(KEY_ARN_A)]}) + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "enclave_golden_pcr_values" in result[0].status_extended + + def test_no_trails_reports_manual(self): + result = _run( + trails={}, + audit_config={"enclave_golden_pcr_values": {"PCR0": [GOLDEN_PCR0]}}, + ) + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "No CloudTrail trails" in result[0].status_extended + + def test_no_events_reports_manual(self): + result = _run( + events_by_event_name={}, + audit_config={"enclave_golden_pcr_values": {"PCR0": [GOLDEN_PCR0]}}, + ) + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "No non-debug attestation events" in result[0].status_extended + + def test_events_all_known_pcrs_pass(self): + result = _run( + events_by_event_name={"Decrypt": [_event(KEY_ARN_A)]}, + audit_config={ + "enclave_golden_pcr_values": { + "PCR0": [GOLDEN_PCR0], + "PCR1": [GOLDEN_PCR1], + } + }, + ) + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_unknown_pcr_reports_fail(self): + result = _run( + events_by_event_name={"Decrypt": [_event(KEY_ARN_A, pcr0=UNKNOWN_PCR0)]}, + audit_config={"enclave_golden_pcr_values": {"PCR0": [GOLDEN_PCR0]}}, + ) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "PCR0" in result[0].status_extended + assert UNKNOWN_PCR0 in result[0].status_extended + assert result[0].check_metadata.Severity.value == "medium" # default + + def test_severity_override_high(self): + result = _run( + events_by_event_name={"Decrypt": [_event(KEY_ARN_A, pcr0=UNKNOWN_PCR0)]}, + audit_config={ + "enclave_golden_pcr_values": {"PCR0": [GOLDEN_PCR0]}, + "enclave_unknown_image_severity": "high", + }, + ) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity.value == "high" + + def test_severity_override_invalid_falls_back_to_medium(self): + result = _run( + events_by_event_name={"Decrypt": [_event(KEY_ARN_A, pcr0=UNKNOWN_PCR0)]}, + audit_config={ + "enclave_golden_pcr_values": {"PCR0": [GOLDEN_PCR0]}, + "enclave_unknown_image_severity": "critical", + }, + ) + assert len(result) == 1 + assert result[0].check_metadata.Severity.value == "medium" + + def test_debug_events_discarded_from_scope(self): + # Debug events (zeroed PCR0/1/2) belong to kms_key_enclave_debug_attestation_detected; not reported here. + result = _run( + events_by_event_name={"Decrypt": [_event(KEY_ARN_A, debug=True)]}, + audit_config={"enclave_golden_pcr_values": {"PCR0": [GOLDEN_PCR0]}}, + ) + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "No non-debug attestation events" in result[0].status_extended + + def test_mixed_debug_and_unknown_reports_fail_only_on_unknown(self): + result = _run( + events_by_event_name={ + "Decrypt": [ + _event(KEY_ARN_A, debug=True), + _event(KEY_ARN_A, pcr0=UNKNOWN_PCR0), + ] + }, + audit_config={"enclave_golden_pcr_values": {"PCR0": [GOLDEN_PCR0]}}, + ) + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_pcr_bucket_without_golden_is_ignored(self): + # Only PCR0 has a golden list; PCR1 unknown value should NOT fail. + result = _run( + events_by_event_name={ + "Decrypt": [_event(KEY_ARN_A, pcr0=GOLDEN_PCR0, pcr1="ffff" * 24)] + }, + audit_config={"enclave_golden_pcr_values": {"PCR0": [GOLDEN_PCR0]}}, + ) + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_case_insensitive_pcr_value_match(self): + # Golden values are lowercased at load; observed values also + # lowercased. Mixed-case hex should still match. + result = _run( + events_by_event_name={ + "Decrypt": [_event(KEY_ARN_A, pcr0=GOLDEN_PCR0.upper())] + }, + audit_config={"enclave_golden_pcr_values": {"PCR0": [GOLDEN_PCR0]}}, + ) + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_target_key_filter_narrows_reports(self): + events = { + "Decrypt": [ + _event(KEY_ARN_A, pcr0=UNKNOWN_PCR0), + _event(KEY_ARN_B, pcr0=UNKNOWN_PCR0), + ] + } + result = _run( + events_by_event_name=events, + audit_config={ + "enclave_golden_pcr_values": {"PCR0": [GOLDEN_PCR0]}, + "enclave_unknown_image_target_key_ids": [KEY_ARN_A.rsplit("/", 1)[-1]], + }, + ) + arns = {r.resource_arn for r in result} + assert arns == {KEY_ARN_A} + + def test_multiple_keys_mixed_verdicts(self): + events = { + "Decrypt": [ + _event(KEY_ARN_A, pcr0=UNKNOWN_PCR0), + _event(KEY_ARN_B, pcr0=GOLDEN_PCR0), + ] + } + result = _run( + events_by_event_name=events, + audit_config={"enclave_golden_pcr_values": {"PCR0": [GOLDEN_PCR0]}}, + ) + by_arn = {r.resource_arn: r.status for r in result} + assert by_arn[KEY_ARN_A] == "FAIL" + assert by_arn[KEY_ARN_B] == "PASS" + + def test_truncated_page_no_unknown_reports_manual_coverage_limited(self): + result = _run( + events_by_event_name={"Decrypt": [_event(KEY_ARN_A)]}, + audit_config={"enclave_golden_pcr_values": {"PCR0": [GOLDEN_PCR0]}}, + truncated=True, + ) + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + "event cap" in result[0].status_extended + or "truncation" in result[0].status_extended + ) + + def test_truncated_page_with_unknown_still_fails(self): + result = _run( + events_by_event_name={"Decrypt": [_event(KEY_ARN_A, pcr0=UNKNOWN_PCR0)]}, + audit_config={"enclave_golden_pcr_values": {"PCR0": [GOLDEN_PCR0]}}, + truncated=True, + ) + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_malformed_event_dropped(self): + result = _run( + events_by_event_name={"Decrypt": [{"CloudTrailEvent": "not-json"}]}, + audit_config={"enclave_golden_pcr_values": {"PCR0": [GOLDEN_PCR0]}}, + ) + assert len(result) == 1 + assert result[0].status == "MANUAL" + + def test_imagesha384_collapses_to_pcr0(self): + # Only ImageSha384 field present should collapse to PCR0 bucket. + recipient = { + "attestationDocumentModuleId": f"{INSTANCE_ID}-enc9876abcd543210ef12", + "attestationDocumentEnclaveImageDigest": UNKNOWN_PCR0, + } + payload = { + "eventName": "Decrypt", + "additionalEventData": {"recipient": recipient}, + "resources": [ + { + "accountId": AWS_ACCOUNT_NUMBER, + "type": "AWS::KMS::Key", + "ARN": KEY_ARN_A, + } + ], + } + raw = { + "EventTime": datetime(2026, 7, 15, tzinfo=timezone.utc), + "CloudTrailEvent": json.dumps(payload), + } + result = _run( + events_by_event_name={"Decrypt": [raw]}, + audit_config={"enclave_golden_pcr_values": {"PCR0": [GOLDEN_PCR0]}}, + ) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "PCR0" in result[0].status_extended + + def test_multi_region_iterates_every_region(self): + # LookupEvents is per-region even for multi-region trails. + calls_per_region = {} + + def _lookup(region, event_name, minutes): + calls_per_region.setdefault(region, 0) + calls_per_region[region] += 1 + return [], False, None + + cloudtrail_client = mock.MagicMock() + cloudtrail_client.trails = {"t": _mock_trail(is_multiregion=True)} + cloudtrail_client.regional_clients = { + "us-east-1": mock.MagicMock(), + "eu-west-1": mock.MagicMock(), + "ap-south-1": mock.MagicMock(), + } + cloudtrail_client._lookup_events_page = _lookup + + kms_client = mock.MagicMock() + kms_client.keys = [] + kms_client.audit_config = {"enclave_golden_pcr_values": {"PCR0": [GOLDEN_PCR0]}} + kms_client.audited_account = AWS_ACCOUNT_NUMBER + kms_client.region = AWS_REGION_US_EAST_1 + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider(), + ), + mock.patch(f"{CHECK_MODULE}.kms_client", new=kms_client), + mock.patch(f"{CHECK_MODULE}.cloudtrail_client", new=cloudtrail_client), + ): + from prowler.providers.aws.services.kms.kms_key_enclave_attestation_unknown_image.kms_key_enclave_attestation_unknown_image import ( + kms_key_enclave_attestation_unknown_image, + ) + + kms_key_enclave_attestation_unknown_image().execute() + + assert set(calls_per_region.keys()) == {"us-east-1", "eu-west-1", "ap-south-1"} + assert all( + v == len(SENSITIVE_ENCLAVE_KMS_EVENTS) for v in calls_per_region.values() + ) + + def test_lookup_error_reported_as_incomplete_coverage(self): + result = _run( + events_by_event_name={}, + audit_config={"enclave_golden_pcr_values": {"PCR0": [GOLDEN_PCR0]}}, + lookup_error="AccessDeniedException", + ) + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "CloudTrail lookup failed" in result[0].status_extended + assert "AccessDeniedException" in result[0].status_extended + + def test_recipient_missing_or_non_dict_dropped(self): + malformed = [] + for bad_recipient in (None, "not-a-dict", ["list"], 42): + payload = { + "eventName": "Decrypt", + "additionalEventData": {"recipient": bad_recipient}, + "resources": [ + { + "accountId": AWS_ACCOUNT_NUMBER, + "type": "AWS::KMS::Key", + "ARN": KEY_ARN_A, + } + ], + } + malformed.append( + { + "EventTime": datetime(2026, 7, 15, tzinfo=timezone.utc), + "CloudTrailEvent": json.dumps(payload), + } + ) + result = _run( + events_by_event_name={"Decrypt": malformed}, + audit_config={"enclave_golden_pcr_values": {"PCR0": [GOLDEN_PCR0]}}, + ) + assert len(result) == 1 + assert result[0].status == "MANUAL" + + def test_missing_module_id_dropped(self): + payload = { + "eventName": "Decrypt", + "additionalEventData": { + "recipient": { + "attestationDocumentEnclaveImageDigest": GOLDEN_PCR0, + # attestationDocumentModuleId missing + } + }, + "resources": [ + { + "accountId": AWS_ACCOUNT_NUMBER, + "type": "AWS::KMS::Key", + "ARN": KEY_ARN_A, + } + ], + } + raw = { + "EventTime": datetime(2026, 7, 15, tzinfo=timezone.utc), + "CloudTrailEvent": json.dumps(payload), + } + result = _run( + events_by_event_name={"Decrypt": [raw]}, + audit_config={"enclave_golden_pcr_values": {"PCR0": [GOLDEN_PCR0]}}, + ) + assert len(result) == 1 + assert result[0].status == "MANUAL" diff --git a/tests/providers/aws/services/kms/kms_key_enclave_debug_attestation_detected/kms_key_enclave_debug_attestation_detected_test.py b/tests/providers/aws/services/kms/kms_key_enclave_debug_attestation_detected/kms_key_enclave_debug_attestation_detected_test.py new file mode 100644 index 0000000000..d6407b4963 --- /dev/null +++ b/tests/providers/aws/services/kms/kms_key_enclave_debug_attestation_detected/kms_key_enclave_debug_attestation_detected_test.py @@ -0,0 +1,578 @@ +import json +from datetime import datetime, timezone +from unittest import mock + +from prowler.providers.aws.services.kms.lib.enclave import SENSITIVE_ENCLAVE_KMS_EVENTS +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +CHECK_MODULE = ( + "prowler.providers.aws.services.kms.kms_key_enclave_debug_attestation_detected" + ".kms_key_enclave_debug_attestation_detected" +) + +REAL_PCR = "a" * 96 +ZERO_PCR = "0" * 96 +KEY_ARN_A = f"arn:aws:kms:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:key/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" +KEY_ARN_B = f"arn:aws:kms:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:key/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" +INSTANCE_ID = "i-0123456789abcdef0" + + +def _mock_trail(is_multiregion=True): + trail = mock.MagicMock() + trail.is_multiregion = is_multiregion + trail.region = AWS_REGION_US_EAST_1 + return trail + + +def _mock_key(arn): + k = mock.MagicMock() + k.arn = arn + k.id = arn.rsplit("/", 1)[-1] + k.region = AWS_REGION_US_EAST_1 + k.tags = [] + return k + + +def _event(key_arn, event_name, debug=False, event_time=None, instance_id=INSTANCE_ID): + value = ZERO_PCR if debug else REAL_PCR + recipient = { + "attestationDocumentModuleId": f"{instance_id}-enc9876abcd543210ef12", + "attestationDocumentEnclaveImageDigest": value, + "attestationDocumentEnclavePCR1": value, + "attestationDocumentEnclavePCR2": value, + "attestationDocumentEnclavePCR3": value, + "attestationDocumentEnclavePCR4": value, + "attestationDocumentEnclavePCR8": value, + } + payload = { + "eventName": event_name, + "eventSource": "kms.amazonaws.com", + "additionalEventData": {"recipient": recipient}, + "resources": [ + {"accountId": AWS_ACCOUNT_NUMBER, "type": "AWS::KMS::Key", "ARN": key_arn} + ], + } + return { + "EventTime": event_time or datetime(2026, 7, 14, 12, 0, tzinfo=timezone.utc), + "CloudTrailEvent": json.dumps(payload), + "Resources": [{"ResourceType": "AWS::KMS::Key", "ResourceName": key_arn}], + } + + +def _run( + events_by_event_name=None, + trails=None, + truncated=False, + audit_config=None, + keys=None, + regions=None, + lookup_error=None, +): + kms_client = mock.MagicMock() + kms_client.keys = keys or [] + kms_client.audit_config = audit_config or {} + kms_client.audited_account = AWS_ACCOUNT_NUMBER + kms_client.region = AWS_REGION_US_EAST_1 + + cloudtrail_client = mock.MagicMock() + if trails is None: + cloudtrail_client.trails = {"t": _mock_trail(is_multiregion=True)} + else: + cloudtrail_client.trails = trails + # Regions the check will iterate; default to a single region so each event + # is delivered exactly once (multi-region setup adds a separate test). + cloudtrail_client.regional_clients = { + r: mock.MagicMock() for r in (regions or [AWS_REGION_US_EAST_1]) + } + + def _lookup(region, event_name, minutes): + if lookup_error is not None: + return [], False, lookup_error + return (events_by_event_name or {}).get(event_name, []), truncated, None + + cloudtrail_client._lookup_events_page = _lookup + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider(), + ), + mock.patch(f"{CHECK_MODULE}.kms_client", new=kms_client), + mock.patch(f"{CHECK_MODULE}.cloudtrail_client", new=cloudtrail_client), + ): + from prowler.providers.aws.services.kms.kms_key_enclave_debug_attestation_detected.kms_key_enclave_debug_attestation_detected import ( + kms_key_enclave_debug_attestation_detected, + ) + + return kms_key_enclave_debug_attestation_detected().execute() + + +class Test_kms_key_enclave_debug_attestation_detected: + def test_no_trails_no_keys_returns_single_manual(self): + result = _run(trails={}) + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "No CloudTrail trails" in result[0].status_extended + + def test_no_trails_with_target_key_reports_manual_per_key(self): + result = _run( + trails={}, + keys=[_mock_key(KEY_ARN_A)], + audit_config={ + "enclave_debug_target_key_ids": [KEY_ARN_A.rsplit("/", 1)[-1]] + }, + ) + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert KEY_ARN_A in result[0].status_extended + + def test_no_events_reports_manual_account_scope(self): + result = _run(events_by_event_name={}) + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "No KMS-from-enclave attestation events" in result[0].status_extended + + def test_debug_event_against_key_reports_fail(self): + result = _run( + events_by_event_name={ + "Decrypt": [_event(KEY_ARN_A, "Decrypt", debug=True)] + }, + ) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert KEY_ARN_A in result[0].status_extended + assert "PCR0/1/2 are zeroed" in result[0].status_extended + + def test_legit_event_only_reports_pass(self): + result = _run( + events_by_event_name={ + "GenerateDataKey": [_event(KEY_ARN_A, "GenerateDataKey", debug=False)] + }, + ) + assert len(result) == 1 + assert result[0].status == "PASS" + assert "none carry zeroed PCR0/1/2" in result[0].status_extended + + def test_mixed_events_any_debug_reports_fail(self): + result = _run( + events_by_event_name={ + "Decrypt": [ + _event(KEY_ARN_A, "Decrypt", debug=False), + _event(KEY_ARN_A, "Decrypt", debug=True), + ] + }, + ) + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_truncated_page_no_debug_reports_manual_coverage_limited(self): + result = _run( + events_by_event_name={ + "Decrypt": [_event(KEY_ARN_A, "Decrypt", debug=False)] + }, + truncated=True, + ) + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "event cap" in result[0].status_extended + + def test_truncated_page_with_debug_still_fails(self): + result = _run( + events_by_event_name={ + "Decrypt": [_event(KEY_ARN_A, "Decrypt", debug=True)] + }, + truncated=True, + ) + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_multiple_keys_per_key_verdicts(self): + events = { + "Decrypt": [ + _event(KEY_ARN_A, "Decrypt", debug=True), + _event(KEY_ARN_B, "Decrypt", debug=False), + ] + } + result = _run(events_by_event_name=events) + by_arn = {r.resource_arn: r.status for r in result} + assert by_arn[KEY_ARN_A] == "FAIL" + assert by_arn[KEY_ARN_B] == "PASS" + + def test_target_key_filter_narrows_reports(self): + events = { + "Decrypt": [ + _event(KEY_ARN_A, "Decrypt", debug=True), + _event(KEY_ARN_B, "Decrypt", debug=True), + ] + } + result = _run( + events_by_event_name=events, + audit_config={ + "enclave_debug_target_key_ids": [KEY_ARN_A.rsplit("/", 1)[-1]] + }, + ) + arns = {r.resource_arn for r in result} + assert arns == {KEY_ARN_A} + + def test_target_key_configured_no_events_but_key_known_reports_per_key_manual(self): + result = _run( + events_by_event_name={}, + audit_config={ + "enclave_debug_target_key_ids": [KEY_ARN_A.rsplit("/", 1)[-1]] + }, + keys=[_mock_key(KEY_ARN_A)], + ) + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert result[0].resource_arn == KEY_ARN_A + + def test_custom_lookback_window_flows_through(self): + captured = {} + + def _lookup(region, event_name, minutes): + captured["minutes"] = minutes + return [], False, None + + kms_client = mock.MagicMock() + kms_client.keys = [] + kms_client.audit_config = {"enclave_debug_lookback_window_hours": 6} + kms_client.audited_account = AWS_ACCOUNT_NUMBER + kms_client.region = AWS_REGION_US_EAST_1 + cloudtrail_client = mock.MagicMock() + cloudtrail_client.trails = {"t": _mock_trail(is_multiregion=True)} + cloudtrail_client.regional_clients = {AWS_REGION_US_EAST_1: mock.MagicMock()} + cloudtrail_client._lookup_events_page = _lookup + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider(), + ), + mock.patch(f"{CHECK_MODULE}.kms_client", new=kms_client), + mock.patch(f"{CHECK_MODULE}.cloudtrail_client", new=cloudtrail_client), + ): + from prowler.providers.aws.services.kms.kms_key_enclave_debug_attestation_detected.kms_key_enclave_debug_attestation_detected import ( + kms_key_enclave_debug_attestation_detected, + ) + + kms_key_enclave_debug_attestation_detected().execute() + + assert captured["minutes"] == 6 * 60 + + def test_event_without_key_arn_is_dropped(self): + raw = { + "EventTime": datetime(2026, 7, 14, tzinfo=timezone.utc), + "CloudTrailEvent": json.dumps( + { + "eventName": "Decrypt", + "additionalEventData": { + "recipient": { + "attestationDocumentModuleId": f"{INSTANCE_ID}-enc9876abcd543210ef12", + "attestationDocumentEnclaveImageDigest": ZERO_PCR, + } + }, + } + ), + } + result = _run(events_by_event_name={"Decrypt": [raw]}) + assert len(result) == 1 + assert result[0].status == "MANUAL" + + def test_malformed_event_dropped(self): + result = _run( + events_by_event_name={"Decrypt": [{"CloudTrailEvent": "not-json"}]} + ) + assert len(result) == 1 + assert result[0].status == "MANUAL" + + def test_partial_zero_pcrs_not_flagged_as_debug(self): + recipient = { + "attestationDocumentModuleId": f"{INSTANCE_ID}-enc9876abcd543210ef12", + "attestationDocumentEnclaveImageDigest": ZERO_PCR, + "attestationDocumentEnclavePCR1": REAL_PCR, + } + payload = { + "eventName": "Decrypt", + "additionalEventData": {"recipient": recipient}, + "resources": [ + { + "accountId": AWS_ACCOUNT_NUMBER, + "type": "AWS::KMS::Key", + "ARN": KEY_ARN_A, + } + ], + } + raw = { + "EventTime": datetime(2026, 7, 14, tzinfo=timezone.utc), + "CloudTrailEvent": json.dumps(payload), + } + result = _run(events_by_event_name={"Decrypt": [raw]}) + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_single_zero_pcr_field_alone_not_debug(self): + # Per RFC v2.6 debug enclaves have ALL PCRs zero; a single zero PCR + # field with the rest absent is treated as truncated/malformed, not + # as debug-mode evidence. + recipient = { + "attestationDocumentModuleId": f"{INSTANCE_ID}-enc9876abcd543210ef12", + "attestationDocumentEnclavePCR1": ZERO_PCR, + } + payload = { + "eventName": "Decrypt", + "additionalEventData": {"recipient": recipient}, + "resources": [ + { + "accountId": AWS_ACCOUNT_NUMBER, + "type": "AWS::KMS::Key", + "ARN": KEY_ARN_A, + } + ], + } + raw = { + "EventTime": datetime(2026, 7, 14, tzinfo=timezone.utc), + "CloudTrailEvent": json.dumps(payload), + } + result = _run(events_by_event_name={"Decrypt": [raw]}) + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_event_for_non_kms_resource_ignored(self): + payload = { + "eventName": "Decrypt", + "additionalEventData": { + "recipient": { + "attestationDocumentModuleId": f"{INSTANCE_ID}-enc9876abcd543210ef12", + "attestationDocumentEnclaveImageDigest": ZERO_PCR, + } + }, + "resources": [{"type": "AWS::S3::Bucket", "ARN": "arn:aws:s3:::my-bucket"}], + } + raw = { + "EventTime": datetime(2026, 7, 14, tzinfo=timezone.utc), + "CloudTrailEvent": json.dumps(payload), + } + result = _run(events_by_event_name={"Decrypt": [raw]}) + assert len(result) == 1 + assert result[0].status == "MANUAL" + + def test_multiple_events_against_same_key_aggregate(self): + events = { + "Decrypt": [ + _event(KEY_ARN_A, "Decrypt", debug=False), + _event(KEY_ARN_A, "Decrypt", debug=False), + _event(KEY_ARN_A, "Decrypt", debug=False), + ] + } + result = _run(events_by_event_name=events) + assert len(result) == 1 + assert result[0].status == "PASS" + assert "3 enclave attestation events" in result[0].status_extended + + def test_multi_region_iterates_every_region(self): + # LookupEvents is per-region even for multi-region trails; the check + # must iterate every regional client, not just the trail's home region. + calls_per_region = {} + + def _lookup(region, event_name, minutes): + calls_per_region.setdefault(region, 0) + calls_per_region[region] += 1 + return [], False, None + + cloudtrail_client = mock.MagicMock() + cloudtrail_client.trails = {"t": _mock_trail(is_multiregion=True)} + cloudtrail_client.regional_clients = { + "us-east-1": mock.MagicMock(), + "eu-west-1": mock.MagicMock(), + "ap-south-1": mock.MagicMock(), + } + cloudtrail_client._lookup_events_page = _lookup + + kms_client = mock.MagicMock() + kms_client.keys = [] + kms_client.audit_config = {} + kms_client.audited_account = AWS_ACCOUNT_NUMBER + kms_client.region = AWS_REGION_US_EAST_1 + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider(), + ), + mock.patch(f"{CHECK_MODULE}.kms_client", new=kms_client), + mock.patch(f"{CHECK_MODULE}.cloudtrail_client", new=cloudtrail_client), + ): + from prowler.providers.aws.services.kms.kms_key_enclave_debug_attestation_detected.kms_key_enclave_debug_attestation_detected import ( + kms_key_enclave_debug_attestation_detected, + ) + + kms_key_enclave_debug_attestation_detected().execute() + + # Every region hit once per sensitive event name (5 event names: + # Decrypt, DeriveSharedSecret, GenerateDataKey, GenerateDataKeyPair, + # GenerateRandom). + assert set(calls_per_region.keys()) == {"us-east-1", "eu-west-1", "ap-south-1"} + assert all( + v == len(SENSITIVE_ENCLAVE_KMS_EVENTS) for v in calls_per_region.values() + ) + + def test_lookup_error_reported_as_incomplete_coverage(self): + result = _run( + events_by_event_name={}, + lookup_error="AccessDeniedException", + ) + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "CloudTrail lookup failed" in result[0].status_extended + assert "AccessDeniedException" in result[0].status_extended + assert "Coverage is incomplete" in result[0].status_extended + + def test_recipient_missing_or_non_dict_dropped(self): + # additionalEventData without a recipient block, or recipient as a + # string / list, should be treated as non-relevant (parsed=None), not + # raise. Verify by running one event with each malformed shape. + malformed = [] + for bad_recipient in (None, "not-a-dict", ["list"], 42): + payload = { + "eventName": "Decrypt", + "additionalEventData": {"recipient": bad_recipient}, + "resources": [ + { + "accountId": AWS_ACCOUNT_NUMBER, + "type": "AWS::KMS::Key", + "ARN": KEY_ARN_A, + } + ], + } + malformed.append( + { + "EventTime": datetime(2026, 7, 14, tzinfo=timezone.utc), + "CloudTrailEvent": json.dumps(payload), + } + ) + result = _run(events_by_event_name={"Decrypt": malformed}) + # All events dropped → no per-key finding, single synthetic MANUAL. + assert len(result) == 1 + assert result[0].status == "MANUAL" + + def test_missing_module_id_dropped(self): + # A recipient without attestationDocumentModuleId (or without "-enc") + # should be treated as non-enclave and dropped by parse_enclave_kms_event. + payload = { + "eventName": "Decrypt", + "additionalEventData": { + "recipient": { + "attestationDocumentEnclaveImageDigest": ZERO_PCR, + "attestationDocumentEnclavePCR1": ZERO_PCR, + "attestationDocumentEnclavePCR2": ZERO_PCR, + "attestationDocumentEnclavePCR3": ZERO_PCR, + "attestationDocumentEnclavePCR4": ZERO_PCR, + "attestationDocumentEnclavePCR8": ZERO_PCR, + # attestationDocumentModuleId intentionally missing + } + }, + "resources": [ + { + "accountId": AWS_ACCOUNT_NUMBER, + "type": "AWS::KMS::Key", + "ARN": KEY_ARN_A, + } + ], + } + raw = { + "EventTime": datetime(2026, 7, 14, tzinfo=timezone.utc), + "CloudTrailEvent": json.dumps(payload), + } + result = _run(events_by_event_name={"Decrypt": [raw]}) + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "No KMS-from-enclave" in result[0].status_extended + + +# Base64 encoding of a SHA384 hash (48 bytes = 64 base64 chars). CloudTrail +# records PCR values in this format, hex is only used by nitro-cli / audit_config. +ZERO_PCR_BASE64 = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +REAL_PCR_BASE64 = "COlmS551s/ZEgeUQiSsr24Q7IqoXj7rGPsUqOgSwOGls4xRcsJbdf30qxcdSL0OP" + + +class Test_debug_attestation_base64_encoding: + """Reality: CloudTrail returns PCR values in base64, not hex. + These tests protect against regressing to the hex-only bug caught in playground. + """ + + def test_base64_all_zero_recipient_detected_as_debug(self): + # A real debug enclave in CloudTrail shows all 6 fields as + # "AAAA..." (64 chars base64 of zero bytes). + from prowler.providers.aws.services.kms.lib.enclave import ( + is_debug_attestation, + ) + + recipient = { + "attestationDocumentModuleId": f"{INSTANCE_ID}-enc9876abcd543210ef12", + "attestationDocumentEnclaveImageDigest": ZERO_PCR_BASE64, + "attestationDocumentEnclavePCR1": ZERO_PCR_BASE64, + "attestationDocumentEnclavePCR2": ZERO_PCR_BASE64, + "attestationDocumentEnclavePCR3": ZERO_PCR_BASE64, + "attestationDocumentEnclavePCR4": ZERO_PCR_BASE64, + "attestationDocumentEnclavePCR8": ZERO_PCR_BASE64, + } + assert is_debug_attestation(recipient) is True + + def test_base64_real_recipient_not_debug(self): + from prowler.providers.aws.services.kms.lib.enclave import ( + is_debug_attestation, + ) + + recipient = { + "attestationDocumentModuleId": f"{INSTANCE_ID}-enc9876abcd543210ef12", + "attestationDocumentEnclaveImageDigest": REAL_PCR_BASE64, + "attestationDocumentEnclavePCR1": REAL_PCR_BASE64, + "attestationDocumentEnclavePCR2": REAL_PCR_BASE64, + "attestationDocumentEnclavePCR3": REAL_PCR_BASE64, + "attestationDocumentEnclavePCR4": REAL_PCR_BASE64, + "attestationDocumentEnclavePCR8": REAL_PCR_BASE64, + } + assert is_debug_attestation(recipient) is False + + def test_base64_extract_pcrs_yields_canonical_hex(self): + # extract_pcrs_from_recipient should decode base64 and re-render as hex + # so downstream comparisons work regardless of input encoding. + from prowler.providers.aws.services.kms.lib.enclave import ( + extract_pcrs_from_recipient, + ) + + recipient = { + "attestationDocumentEnclaveImageDigest": REAL_PCR_BASE64, + } + result = extract_pcrs_from_recipient(recipient) + # Should be lowercase hex, 96 chars. + assert "PCR0" in result + assert len(result["PCR0"]) == 96 + assert result["PCR0"] == result["PCR0"].lower() + # And it should equal the hex form of the same bytes + import base64 + + assert result["PCR0"] == base64.b64decode(REAL_PCR_BASE64).hex() + + def test_unknown_pcrs_cross_encoding_hex_golden_vs_base64_observed(self): + # The playground scenario: user configures golden in hex (from + # nitro-cli describe-eif), CloudTrail returns base64. They must match. + import base64 + + from prowler.providers.aws.services.kms.lib.enclave import ( + unknown_pcrs, + ) + + real_hex = base64.b64decode(REAL_PCR_BASE64).hex() + observed = {"PCR0": real_hex} # already canonicalized to hex + golden = {"PCR0": {real_hex}} # user provided hex + assert unknown_pcrs(observed, golden) == {} + + # And if observed doesn't match golden → flagged. + other_hex = "b" * 96 + observed = {"PCR0": other_hex} + assert unknown_pcrs(observed, golden) == {"PCR0": other_hex} diff --git a/tests/providers/aws/services/kms/kms_service_test.py b/tests/providers/aws/services/kms/kms_service_test.py index 082ccf129f..f7f3f2b683 100644 --- a/tests/providers/aws/services/kms/kms_service_test.py +++ b/tests/providers/aws/services/kms/kms_service_test.py @@ -154,3 +154,60 @@ class Test_KMS_Service: assert kms.keys[0].policy == json.loads(default_policy) assert kms.keys[1].arn == key2["Arn"] assert kms.keys[1].policy == json.loads(public_policy) + + # Test KMS List Aliases + @mock_aws + def test_list_aliases(self): + kms_client = client("kms", region_name=AWS_REGION_US_EAST_1) + key_with_alias = kms_client.create_key()["KeyMetadata"] + key_without_alias = kms_client.create_key()["KeyMetadata"] + kms_client.create_alias( + AliasName="alias/enclave-signing-key", + TargetKeyId=key_with_alias["KeyId"], + ) + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + kms = KMS(aws_provider) + by_id = {k.id: k for k in kms.keys} + assert by_id[key_with_alias["KeyId"]].aliases == ["alias/enclave-signing-key"] + assert by_id[key_without_alias["KeyId"]].aliases == [] + + # Test KMS Describe Key maps Description + @mock_aws + def test_describe_key_maps_description(self): + kms_client = client("kms", region_name=AWS_REGION_US_EAST_1) + key_with_desc = kms_client.create_key( + MultiRegion=False, + Description="production enclave key for the vault workload", + )["KeyMetadata"] + key_without_desc = kms_client.create_key(MultiRegion=False)["KeyMetadata"] + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + kms = KMS(aws_provider) + by_id = {k.id: k for k in kms.keys} + assert ( + by_id[key_with_desc["KeyId"]].description + == "production enclave key for the vault workload" + ) + assert by_id[key_without_desc["KeyId"]].description == "" + + # Test KMS Get Key Policy failure surfaces policy_fetch_error + @mock_aws + def test_get_key_policy_failure_records_error(self): + kms_client = client("kms", region_name=AWS_REGION_US_EAST_1) + key = kms_client.create_key(MultiRegion=False)["KeyMetadata"] + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + kms = KMS(aws_provider) + + # Monkey-patch the regional client so GetKeyPolicy raises, reset the + # (possibly-populated) policy field, and re-invoke _get_key_policy. + def _boom(**_): + raise RuntimeError("simulated GetKeyPolicy failure") + + kms.regional_clients[AWS_REGION_US_EAST_1].get_key_policy = _boom + for k in kms.keys: + k.policy = None + k.policy_fetch_error = None + kms._get_key_policy() + + target = next(k for k in kms.keys if k.id == key["KeyId"]) + assert target.policy is None + assert target.policy_fetch_error == "RuntimeError" diff --git a/tests/providers/aws/services/vpc/vpc_service_test.py b/tests/providers/aws/services/vpc/vpc_service_test.py index 88a49b354f..948d8b308b 100644 --- a/tests/providers/aws/services/vpc/vpc_service_test.py +++ b/tests/providers/aws/services/vpc/vpc_service_test.py @@ -528,10 +528,54 @@ class Test_VPC_Service: assert vpc.subnets[0].cidr_block == "10.0.0.0/16" assert vpc.subnets[0].availability_zone == f"{AWS_REGION_US_EAST_1}a" assert vpc.subnets[0].public + assert vpc.subnets[0].public_ipv6 is False assert vpc.subnets[0].nat_gateway is False assert vpc.subnets[0].region == AWS_REGION_US_EAST_1 assert vpc.subnets[0].tags == [] + @mock_aws + def test_describe_vpc_subnets_public_ipv6_route(self): + # ::/0 → IGW must set public_ipv6=True even when there is no + # IPv4 default route on the same table. + ec2_client = client("ec2", region_name=AWS_REGION_US_EAST_1) + vpc_id = ec2_client.create_vpc(CidrBlock="10.0.0.0/16")["Vpc"]["VpcId"] + subnet_id = ec2_client.create_subnet( + VpcId=vpc_id, + CidrBlock="10.0.0.0/16", + AvailabilityZone=f"{AWS_REGION_US_EAST_1}a", + )["Subnet"]["SubnetId"] + igw_id = ec2_client.create_internet_gateway()["InternetGateway"][ + "InternetGatewayId" + ] + ec2_client.attach_internet_gateway(InternetGatewayId=igw_id, VpcId=vpc_id) + route_table_id = ec2_client.create_route_table(VpcId=vpc_id)["RouteTable"][ + "RouteTableId" + ] + ec2_client.associate_route_table( + RouteTableId=route_table_id, SubnetId=subnet_id + ) + ec2_client.create_route( + RouteTableId=route_table_id, + DestinationIpv6CidrBlock="::/0", + GatewayId=igw_id, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + from prowler.providers.aws.services.vpc.vpc_service import VPC + + vpc = VPC(aws_provider) + target_subnet = None + for v in vpc.vpcs.values(): + if v.cidr_block == "10.0.0.0/16": + target_subnet = v.subnets[0] + break + assert target_subnet is not None + assert target_subnet.id == subnet_id + assert target_subnet.public is False + assert target_subnet.public_ipv6 is True + @mock_aws def test_vpc_subnet_with_open_nacl(self): # Generate VPC Client