diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b75263d..2f104afe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +## v0.83 [2023-11-30] + +_Enhancements_ + +- Added the following controls to the `All Controls` benchmark: ([#733](https://github.com/turbot/steampipe-mod-aws-compliance/pull/733)) + - `api_gateway_rest_api_public_endpoint_with_authorizer` + - `dlm_ebs_snapshot_lifecycle_policy_enabled` + - `docdb_cluster_instance_encryption_at_rest_enabled` + - `ebs_volume_snapshot_exists` + - `elasticache_cluster_no_public_subnet` + - `iam_role_no_administrator_access_policy_attached` + - `iam_user_access_key_unused_45` + - `iam_user_console_access_unused_45` + - `neptune_db_cluster_no_public_subnet` + ## v0.82 [2023-11-03] _Breaking changes_ diff --git a/all_controls/all_controls.sp b/all_controls/all_controls.sp index 33c9257d..e0ee9ca7 100644 --- a/all_controls/all_controls.sp +++ b/all_controls/all_controls.sp @@ -24,6 +24,7 @@ benchmark "all_controls" { benchmark.all_controls_config, benchmark.all_controls_dax, benchmark.all_controls_directoryservice, + benchmark.all_controls_dlm, benchmark.all_controls_dms, benchmark.all_controls_docdb, benchmark.all_controls_drs, diff --git a/all_controls/apigateway.sp b/all_controls/apigateway.sp index 0a0584f5..008a46ea 100644 --- a/all_controls/apigateway.sp +++ b/all_controls/apigateway.sp @@ -8,6 +8,7 @@ benchmark "all_controls_apigateway" { title = "API Gateway" description = "This section contains recommendations for configuring API Gateway resources." children = [ + control.api_gateway_rest_api_public_endpoint_with_authorizer, control.api_gatewayv2_route_authorization_type_configured, control.api_gatewayv2_route_authorizer_configured, control.apigateway_rest_api_authorizers_configured, diff --git a/all_controls/dlm.sp b/all_controls/dlm.sp new file mode 100644 index 00000000..4733bb07 --- /dev/null +++ b/all_controls/dlm.sp @@ -0,0 +1,17 @@ +locals { + all_controls_dlm_common_tags = merge(local.all_controls_common_tags, { + service = "AWS/DLM" + }) +} + +benchmark "all_controls_dlm" { + title = "DLM" + description = "This section contains recommendations for configuring DLM resources." + children = [ + control.dlm_ebs_snapshot_lifecycle_policy_enabled + ] + + tags = merge(local.all_controls_dlm_common_tags, { + type = "Benchmark" + }) +} diff --git a/all_controls/docdb.sp b/all_controls/docdb.sp index df3df3d6..aa3b6e02 100644 --- a/all_controls/docdb.sp +++ b/all_controls/docdb.sp @@ -10,6 +10,7 @@ benchmark "all_controls_docdb" { children = [ control.docdb_cluster_backup_retention_period_7_days, control.docdb_cluster_encryption_at_rest_enabled, + control.docdb_cluster_instance_encryption_at_rest_enabled, control.docdb_cluster_instance_logging_enabled ] diff --git a/all_controls/ebs.sp b/all_controls/ebs.sp index 5addf1df..6d6c9d17 100644 --- a/all_controls/ebs.sp +++ b/all_controls/ebs.sp @@ -15,6 +15,7 @@ benchmark "all_controls_ebs" { control.ebs_volume_encryption_at_rest_enabled, control.ebs_volume_in_backup_plan, control.ebs_volume_protected_by_backup_plan, + control.ebs_volume_snapshot_exists, control.ebs_volume_unused ] diff --git a/all_controls/elasticache.sp b/all_controls/elasticache.sp index 8407bd00..6f3c066d 100644 --- a/all_controls/elasticache.sp +++ b/all_controls/elasticache.sp @@ -10,6 +10,7 @@ benchmark "all_controls_elasticache" { children = [ control.elasticache_cluster_auto_minor_version_upgrade_enabled, control.elasticache_cluster_no_default_subnet_group, + control.elasticache_cluster_no_public_subnet, control.elasticache_redis_cluster_automatic_backup_retention_15_days, control.elasticache_replication_group_auto_failover_enabled, control.elasticache_replication_group_encryption_at_rest_enabled, diff --git a/all_controls/iam.sp b/all_controls/iam.sp index cde53227..2479809d 100644 --- a/all_controls/iam.sp +++ b/all_controls/iam.sp @@ -37,6 +37,7 @@ benchmark "all_controls_iam" { control.iam_policy_no_star_star, control.iam_policy_unused, control.iam_role_cross_account_read_only_access_policy, + control.iam_role_no_administrator_access_policy_attached, control.iam_role_unused_60, control.iam_root_last_used, control.iam_root_user_hardware_mfa_enabled, @@ -47,8 +48,10 @@ benchmark "all_controls_iam" { control.iam_server_certificate_not_expired, control.iam_support_role, control.iam_user_access_key_age_90, + control.iam_user_access_key_unused_45, control.iam_user_access_keys_and_password_at_setup, control.iam_user_console_access_mfa_enabled, + control.iam_user_console_access_unused_45, control.iam_user_group_role_cloudshell_fullaccess_restricted, control.iam_user_hardware_mfa_enabled, control.iam_user_in_group, diff --git a/all_controls/neptune.sp b/all_controls/neptune.sp index 3a3c1189..07d6aff9 100644 --- a/all_controls/neptune.sp +++ b/all_controls/neptune.sp @@ -14,6 +14,7 @@ benchmark "all_controls_neptune" { control.neptune_db_cluster_deletion_protection_enabled, control.neptune_db_cluster_encryption_at_rest_enabled, control.neptune_db_cluster_iam_authentication_enabled, + control.neptune_db_cluster_no_public_subnet, control.neptune_db_cluster_snapshot_encryption_at_rest_enabled, control.neptune_db_cluster_snapshot_prohibit_public_access ] diff --git a/conformance_pack/apigateway.sp b/conformance_pack/apigateway.sp index 1cedeff7..bff5dd0f 100644 --- a/conformance_pack/apigateway.sp +++ b/conformance_pack/apigateway.sp @@ -138,6 +138,14 @@ control "api_gatewayv2_route_authorizer_configured" { tags = local.conformance_pack_apigateway_common_tags } +control "api_gateway_rest_api_public_endpoint_with_authorizer" { + title = "API Gateway REST API public endpoints should be configured with authorizer" + description = "Ensure API Gateway REST API public endpoint is configured with authorizer. This rule is non-compliant if API Gateway REST API public endpoint has no authorizer configured." + query = query.api_gateway_rest_api_public_endpoint_with_authorizer + + tags = local.conformance_pack_apigateway_common_tags +} + query "apigateway_stage_cache_encryption_at_rest_enabled" { sql = <<-EOQ select @@ -354,3 +362,25 @@ query "gatewayv2_stage_access_logging_enabled" { aws_api_gatewayv2_stage; EOQ } + +query "api_gateway_rest_api_public_endpoint_with_authorizer" { + sql = <<-EOQ + select + 'arn:' || p.partition || ':apigateway:' || p.region || '::/apis/' || p.api_id as resource, + case + when not (endpoint_configuration_types ? 'PRIVATE') and (a.provider_arns is not null and jsonb_array_length(a.provider_arns) > 0 ) then 'ok' + when not (endpoint_configuration_types ? 'PRIVATE') and ( a.provider_arns is null or jsonb_array_length(a.provider_arns) = 0 ) then 'alarm' + else 'ok' + end as status, + case + when not (endpoint_configuration_types ? 'PRIVATE') and (a.provider_arns is not null and jsonb_array_length(a.provider_arns) > 0 ) then p.name || ' has public endpoint with authorizer.' + when not (endpoint_configuration_types ? 'PRIVATE') and ( a.provider_arns is null or jsonb_array_length(a.provider_arns) = 0 ) then p.name || ' has public endpoint without authorizer.' + else p.name || ' has private endpoint.' + end as reason + ${replace(local.tag_dimensions_qualifier_sql, "__QUALIFIER__", "p.")} + ${replace(local.common_dimensions_qualifier_sql, "__QUALIFIER__", "p.")} + from + aws_api_gateway_rest_api as p + left join aws_api_gateway_authorizer as a on p.api_id = a.rest_api_id; + EOQ +} \ No newline at end of file diff --git a/conformance_pack/dlm.sp b/conformance_pack/dlm.sp new file mode 100644 index 00000000..6082e81a --- /dev/null +++ b/conformance_pack/dlm.sp @@ -0,0 +1,55 @@ +locals { + conformance_pack_dlm_common_tags = merge(local.aws_compliance_common_tags, { + service = "AWS/DLM" + }) +} + +control "dlm_ebs_snapshot_lifecycle_policy_enabled" { + title = "DLM EBS snapshot lifecycle policy should be enabled" + description = "Ensure DLM EBS snapshot lifecycle policy is enabled in all the regions with EBS snapshots." + query = query.dlm_ebs_snapshot_lifecycle_policy_enabled + + tags = local.conformance_pack_dlm_common_tags +} + +query "dlm_ebs_snapshot_lifecycle_policy_enabled" { + sql = <<-EOQ + with region_with_ebs_snapshots as( + select + distinct region, + partition, + account_id, + _ctx + from + aws_ebs_snapshot + ), dlm_ebs_lifecycle_policy as ( + select + region, + account_id, + count(*) + from + aws_dlm_lifecycle_policy + where + policy_details ->> 'PolicyType' like 'EBS_SNAPSHOT%' + group by + region, + account_id + ) + select + 'arn:' || r.partition || '::' || r.region || ':' || r.account_id as resource, + case + when p.region is not null then 'ok' + else 'alarm' + end as status, + case + when p.region is not null then 'EBS snapshot DLM policy exist in region ' || r.region || '.' + else 'EBS snapshots DLM policy does not exist in region ' || r.region || '.' + end as reason + ${replace(local.common_dimensions_qualifier_sql, "__QUALIFIER__", "r.")} + from + region_with_ebs_snapshots as r + left join dlm_ebs_lifecycle_policy as p on p.region = r.region and r.account_id = p.account_id; + EOQ +} + + diff --git a/conformance_pack/docdb.sp b/conformance_pack/docdb.sp index 50bc3c55..0ab94675 100644 --- a/conformance_pack/docdb.sp +++ b/conformance_pack/docdb.sp @@ -28,6 +28,14 @@ control "docdb_cluster_instance_logging_enabled" { tags = local.conformance_pack_docdb_common_tags } +control "docdb_cluster_instance_encryption_at_rest_enabled" { + title = "DocumentDB instance should be encrypted at rest" + description = "This control checks whether an DocumentDB instance is encrypted at rest. The control fails if an DocumentDB instance isn't encrypted at rest." + query = query.docdb_cluster_instance_encryption_at_rest_enabled + + tags = local.conformance_pack_docdb_common_tags +} + query "docdb_cluster_instance_logging_enabled" { sql = <<-EOQ select @@ -83,3 +91,22 @@ query "docdb_cluster_backup_retention_period_7_days" { aws_docdb_cluster; EOQ } + +query "docdb_cluster_instance_encryption_at_rest_enabled" { + sql = <<-EOQ + select + db_instance_arn as resource, + case + when storage_encrypted then 'ok' + else 'alarm' + end as status, + case + when storage_encrypted then title || ' encrypted at rest.' + else title || ' not encrypted at rest.' + end as reason + ${local.tag_dimensions_sql} + ${local.common_dimensions_sql} + from + aws_docdb_cluster_instance; + EOQ +} diff --git a/conformance_pack/ebs.sp b/conformance_pack/ebs.sp index 170ee5c9..45e8d855 100644 --- a/conformance_pack/ebs.sp +++ b/conformance_pack/ebs.sp @@ -149,6 +149,14 @@ control "ebs_snapshot_encryption_enabled" { tags = local.conformance_pack_ebs_common_tags } +control "ebs_volume_snapshot_exists" { + title = "EBS volume snapshots should exist" + description = "Ensure that EBS volume snapshots exist. This rule is non-compliant if the EBS volume does not have any snapshot." + query = query.ebs_volume_snapshot_exists + + tags = local.conformance_pack_ebs_common_tags +} + query "ebs_snapshot_not_publicly_restorable" { sql = <<-EOQ select @@ -344,3 +352,32 @@ query "ebs_snapshot_encryption_enabled" { aws_ebs_snapshot; EOQ } + +query "ebs_volume_snapshot_exists" { + sql = <<-EOQ + with volume_with_snapshots as ( + select + volume_id, + count(*) as snap_count + from + aws_ebs_snapshot + group by + volume_id + ) + select + v.arn as resource, + case + when s.volume_id is not null then 'ok' + else 'alarm' + end as status, + case + when s.volume_id is not null then v.title || ' has ' || s.snap_count || ' snapshot(s).' + else v.title || ' does not have snapshot.' + end as reason + ${local.tag_dimensions_sql} + ${local.common_dimensions_sql} + from + aws_ebs_volume as v + left join volume_with_snapshots as s on s.volume_id = v.volume_id; + EOQ +} \ No newline at end of file diff --git a/conformance_pack/elasticache.sp b/conformance_pack/elasticache.sp index ea0539b4..e285d865 100644 --- a/conformance_pack/elasticache.sp +++ b/conformance_pack/elasticache.sp @@ -77,6 +77,14 @@ control "elasticache_redis_cluster_automatic_backup_retention_15_days" { }) } +control "elasticache_cluster_no_public_subnet" { + title = "ElastiCache clusters should not use public_subnet" + description = "This control checks if ElastiCache clusters are configured with public subnet as there is a risk of exposing sensitive data." + query = query.elasticache_cluster_no_public_subnet + + tags = local.conformance_pack_elasticache_common_tags +} + query "elasticache_redis_cluster_automatic_backup_retention_15_days" { sql = <<-EOQ select @@ -215,3 +223,85 @@ query "elasticache_cluster_auto_minor_version_upgrade_enabled" { aws_elasticache_cluster; EOQ } + +query "elasticache_cluster_no_public_subnet" { + sql = <<-EOQ + with subnets_with_explicit_route as ( + select + distinct ( a ->> 'SubnetId') as all_sub + from + aws_vpc_route_table as t, + jsonb_array_elements(associations) as a + where + a ->> 'SubnetId' is not null + ), public_subnets_with_explicit_route as ( + select + distinct a ->> 'SubnetId' as SubnetId + from + aws_vpc_route_table as t, + jsonb_array_elements(associations) as a, + jsonb_array_elements(routes) as r + where + r ->> 'DestinationCidrBlock' = '0.0.0.0/0' + and + ( + r ->> 'GatewayId' like 'igw-%' + or r ->> 'NatGatewayId' like 'nat-%' + ) + and a ->> 'SubnetId' is not null + ), public_subnets_with_implicit_route as ( + select + distinct route_table_id, + vpc_id, + region + from + aws_vpc_route_table as t, + jsonb_array_elements(associations) as a, + jsonb_array_elements(routes) as r + where + a ->> 'Main' = 'true' + and r ->> 'DestinationCidrBlock' = '0.0.0.0/0' + and ( + r ->> 'GatewayId' like 'igw-%' + or r ->> 'NatGatewayId' like 'nat-%' + ) + ), subnet_accessibility as ( + select + subnet_id, + vpc_id, + case + when s.subnet_id in (select all_sub from subnets_with_explicit_route where all_sub not in (select SubnetId from public_subnets_with_explicit_route )) then 'private' + when p.SubnetId is not null or s.vpc_id in ( select vpc_id from public_subnets_with_implicit_route) then 'public' + else 'private' + end as access + from + aws_vpc_subnet as s + left join public_subnets_with_explicit_route as p on p.SubnetId = s.subnet_id + ), cluster_public_subnet as ( + select + distinct arn, + cache_subnet_group_name + from + aws_elasticache_subnet_group, + jsonb_array_elements(subnets) as s + left join subnet_accessibility as a on a.subnet_id = s ->> 'SubnetIdentifier' + where + a.access = 'public' + ) + select + c.arn as resource, + case + when s.cache_subnet_group_name is not null then 'alarm' + else 'ok' + end as status, + case + when s.cache_subnet_group_name is not null then c.title || ' has public subnet.' + else c.title || ' has private subnet.' + end as reason + ${local.tag_dimensions_sql} + ${local.common_dimensions_sql} + from + aws_elasticache_cluster as c + left join cluster_public_subnet as s on s.cache_subnet_group_name = c.cache_subnet_group_name; + EOQ +} \ No newline at end of file diff --git a/conformance_pack/iam.sp b/conformance_pack/iam.sp index b68a578a..446d176b 100644 --- a/conformance_pack/iam.sp +++ b/conformance_pack/iam.sp @@ -671,6 +671,30 @@ control "iam_inline_policy_no_administrative_privileges" { tags = local.conformance_pack_iam_common_tags } +control "iam_user_console_access_unused_45" { + title = "Ensure IAM users with console access unused for 45 days or greater are disabled" + description = "AWS IAM users can access AWS resources using console access. It is recommended that console access that have been unused in 45 or greater days be deactivated or removed." + query = query.iam_user_console_access_unused_45 + + tags = local.conformance_pack_iam_common_tags +} + +control "iam_user_access_key_unused_45" { + title = "Ensure IAM users with access keys unused for 45 days or greater are disabled" + description = "AWS IAM users can access AWS resources using access keys. It is recommended that access keys that have been unused in 45 or greater days be deactivated or removed." + query = query.iam_user_access_key_unused_45 + + tags = local.conformance_pack_iam_common_tags +} + +control "iam_role_no_administrator_access_policy_attached" { + title = "Ensure IAM role not attached with Administratoraccess policy" + description = "AWS IAM role should not be attached Administratoraccess policy." + query = query.iam_role_no_administrator_access_policy_attached + + tags = local.conformance_pack_iam_common_tags +} + query "iam_account_password_policy_strong_min_reuse_24" { sql = <<-EOQ select @@ -2237,4 +2261,89 @@ query "iam_inline_policy_no_administrative_privileges" { full_administrative_privilege_policies as p left join bad_policies as bad on p.arn = bad.arn; EOQ +} + +query "iam_user_console_access_unused_45" { + sql = <<-EOQ + select + user_arn as resource, + case + when not password_enabled then 'ok' + when password_enabled and password_last_used is null then 'alarm' + when password_enabled and password_last_used < (current_date - interval '45' day) then 'alarm' + else 'ok' + end status, + user_name || + case + when not password_enabled then ' password not enabled.' + when password_enabled and password_last_used is null then ' password created ' || to_char(password_last_changed, 'DD-Mon-YYYY') || ' never used.' + else ' password used ' || to_char(password_last_used, 'DD-Mon-YYYY') || '.' + end as reason + ${local.common_dimensions_global_sql} + from + aws_iam_credential_report; + EOQ +} + +query "iam_user_access_key_unused_45" { + sql = <<-EOQ + select + user_arn as resource, + case + when not access_key_1_active then 'ok' + when access_key_1_active and access_key_1_last_used_date is null then 'alarm' + when access_key_1_active and access_key_1_last_used_date < (current_date - interval '45' day) then 'alarm' + when not access_key_2_active then 'ok' + when access_key_2_active and access_key_2_last_used_date is null then 'alarm' + when access_key_2_active and access_key_2_last_used_date < (current_date - interval '45' day) then 'alarm' + else 'ok' + end as status, + user_name || + case + when not access_key_1_active then ' key 1 not enabled,' + when access_key_1_active and access_key_1_last_used_date is null then ' key 1 created ' || to_char(access_key_1_last_rotated, 'DD-Mon-YYYY') || ' never used,' + else ' key 1 used ' || to_char(access_key_1_last_used_date, 'DD-Mon-YYYY') || ',' + end || + case + when not access_key_2_active then ' key 2 not enabled.' + when access_key_2_active and access_key_2_last_used_date is null then ' key 2 created ' || to_char(access_key_2_last_rotated, 'DD-Mon-YYYY') || ' never used.' + else ' key 2 used ' || to_char(access_key_2_last_used_date, 'DD-Mon-YYYY') || '.' + end as reason + ${local.common_dimensions_global_sql} + from + aws_iam_credential_report; + EOQ +} + +query "iam_role_no_administrator_access_policy_attached" { + sql = <<-EOQ + with admin_roles as ( + select + arn, + name, + attachments + from + aws_iam_role, + jsonb_array_elements_text(attached_policy_arns) as attachments + where + split_part(attachments, '/', 2) = 'AdministratorAccess' + ) + select + r.arn as resource, + case + when ar.arn is not null then 'alarm' + else 'ok' + end as status, + case + when ar.arn is not null then r.name || ' have AdministratorAccess policy attached.' + else r.name || ' does not have AdministratorAccess policy attached.' + end as reason + ${replace(local.tag_dimensions_qualifier_sql, "__QUALIFIER__", "r.")} + ${replace(local.common_dimensions_qualifier_sql, "__QUALIFIER__", "r.")} + from + aws_iam_role as r + left join admin_roles ar on r.arn = ar.arn + order by + r.name; + EOQ } \ No newline at end of file diff --git a/conformance_pack/neptune.sp b/conformance_pack/neptune.sp index a98aea32..91f38cf3 100644 --- a/conformance_pack/neptune.sp +++ b/conformance_pack/neptune.sp @@ -68,6 +68,14 @@ control "neptune_db_cluster_copy_tags_to_snapshot_enabled" { tags = local.conformance_pack_neptune_common_tags } +control "neptune_db_cluster_no_public_subnet" { + title = "Neptune DB clusters should not use public_subnet" + description = "This control checks if Neptune DB clusters are configured with public subnet as there is a risk of exposing sensitive data." + query = query.neptune_db_cluster_no_public_subnet + + tags = local.conformance_pack_elasticache_common_tags +} + query "neptune_db_cluster_audit_logging_enabled" { sql = <<-EOQ select @@ -219,3 +227,85 @@ query "neptune_db_cluster_snapshot_prohibit_public_access" { jsonb_array_elements(db_cluster_snapshot_attributes) as cluster_snapshot; EOQ } + +query "neptune_db_cluster_no_public_subnet" { + sql = <<-EOQ + with subnets_with_explicit_route as ( + select + distinct ( a ->> 'SubnetId') as all_sub + from + aws_vpc_route_table as t, + jsonb_array_elements(associations) as a + where + a ->> 'SubnetId' is not null + ), public_subnets_with_explicit_route as ( + select + distinct a ->> 'SubnetId' as SubnetId + from + aws_vpc_route_table as t, + jsonb_array_elements(associations) as a, + jsonb_array_elements(routes) as r + where + r ->> 'DestinationCidrBlock' = '0.0.0.0/0' + and + ( + r ->> 'GatewayId' like 'igw-%' + or r ->> 'NatGatewayId' like 'nat-%' + ) + and a ->> 'SubnetId' is not null + ), public_subnets_with_implicit_route as ( + select + distinct route_table_id, + vpc_id, + region + from + aws_vpc_route_table as t, + jsonb_array_elements(associations) as a, + jsonb_array_elements(routes) as r + where + a ->> 'Main' = 'true' + and r ->> 'DestinationCidrBlock' = '0.0.0.0/0' + and ( + r ->> 'GatewayId' like 'igw-%' + or r ->> 'NatGatewayId' like 'nat-%' + ) + ), subnet_accessibility as ( + select + subnet_id, + vpc_id, + case + when s.subnet_id in (select all_sub from subnets_with_explicit_route where all_sub not in (select SubnetId from public_subnets_with_explicit_route )) then 'private' + when p.SubnetId is not null or s.vpc_id in ( select vpc_id from public_subnets_with_implicit_route) then 'public' + else 'private' + end as access + from + aws_vpc_subnet as s + left join public_subnets_with_explicit_route as p on p.SubnetId = s.subnet_id + ), cluster_public_subnet as ( + select + distinct arn, + name as subnet_group_name + from + aws_rds_db_subnet_group, + jsonb_array_elements(subnets) as s + left join subnet_accessibility as a on a.subnet_id = s ->> 'SubnetIdentifier' + where + a.access = 'public' + ) + select + c.arn as resource, + case + when s.subnet_group_name is not null then 'alarm' + else 'ok' + end as status, + case + when s.subnet_group_name is not null then c.title || ' has public subnet.' + else c.title || ' has private subnet.' + end as reason + ${local.tag_dimensions_sql} + ${local.common_dimensions_sql} + from + aws_neptune_db_cluster as c + left join cluster_public_subnet as s on s.subnet_group_name = c.db_subnet_group; + EOQ +} \ No newline at end of file