├── .gitattributes ├── .github ├── PULL_REQUEST_TEMPLATE.md ├── workflows │ ├── sync-labels.yml │ ├── add-issue-to-project.yml │ └── stale.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ ├── bug_report.md │ └── config.yml ├── docs ├── aws_thrifty_dashboard.png ├── aws_thrifty_ebs_dashboard.png ├── aws_thrifty_mod_terminal.png └── index.md ├── controls ├── docs │ ├── cloudtrail.md │ ├── s3.md │ ├── network.md │ ├── eks.md │ ├── route53.md │ ├── apigateway.md │ ├── cloudfront.md │ ├── emr.md │ ├── lambda.md │ ├── secretsmanager.md │ ├── cloudwatch.md │ ├── dynamodb.md │ ├── elasticache.md │ ├── cost-explorer.md │ ├── ecr.md │ ├── ecs.md │ ├── redshift.md │ ├── ebs.md │ ├── ec2.md │ └── rds.md ├── s3.pp ├── cloudfront.pp ├── apigateway.pp ├── secretsmanager.pp ├── ecr.pp ├── network.pp ├── cloudwatch.pp ├── elasticache.pp ├── route53.pp ├── eks.pp ├── emr.pp ├── dynamodb.pp ├── cloudtrail.pp ├── cost_explorer.pp ├── lambda.pp ├── ecs.pp ├── redshift.pp ├── ebs.pp ├── rds.pp └── ec2.pp ├── .gitignore ├── mod.pp ├── powerpipe.ppvars.example ├── variables.pp ├── README.md ├── CHANGELOG.md └── LICENSE /.gitattributes: -------------------------------------------------------------------------------- 1 | **/*.sp linguist-language=HCL 2 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Checklist 2 | - [ ] Issue(s) linked 3 | -------------------------------------------------------------------------------- /docs/aws_thrifty_dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/turbot/steampipe-mod-aws-thrifty/HEAD/docs/aws_thrifty_dashboard.png -------------------------------------------------------------------------------- /docs/aws_thrifty_ebs_dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/turbot/steampipe-mod-aws-thrifty/HEAD/docs/aws_thrifty_ebs_dashboard.png -------------------------------------------------------------------------------- /docs/aws_thrifty_mod_terminal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/turbot/steampipe-mod-aws-thrifty/HEAD/docs/aws_thrifty_mod_terminal.png -------------------------------------------------------------------------------- /controls/docs/cloudtrail.md: -------------------------------------------------------------------------------- 1 | ## Thrifty CloudTrail Benchmark 2 | 3 | Thrifty developers know that multiple active CloudTrail Trails can add significant costs. Be thrifty and eliminate redundant trails. 4 | -------------------------------------------------------------------------------- /controls/docs/s3.md: -------------------------------------------------------------------------------- 1 | ## Thrifty S3 Benchmark 2 | 3 | Thrifty developers ensure their S3 buckets have managed lifecycle policies. This S3 benchmark currently checks that your buckets have a lifecycle policy attached to them. 4 | -------------------------------------------------------------------------------- /controls/docs/network.md: -------------------------------------------------------------------------------- 1 | ## Thrifty Network Benchmark 2 | 3 | Thrifty developers ensure that they delete unused network resources. This benchmark focuses on finding resources that are not attached (and therefore not in use). 4 | -------------------------------------------------------------------------------- /.github/workflows/sync-labels.yml: -------------------------------------------------------------------------------- 1 | name: Sync Labels 2 | on: 3 | schedule: 4 | - cron: "30 22 * * 1" 5 | workflow_dispatch: 6 | 7 | jobs: 8 | sync_labels_workflow: 9 | uses: turbot/steampipe-workflows/.github/workflows/sync-labels.yml@main -------------------------------------------------------------------------------- /controls/docs/eks.md: -------------------------------------------------------------------------------- 1 | ## Thrifty EKS Benchmark 2 | 3 | Thrifty developers ensure that their EKS node groups configured with linux EC2 instances uses graviton as AWS graviton processors are custom-built by AWS to deliver the best price performance for cloud workloads. -------------------------------------------------------------------------------- /controls/docs/route53.md: -------------------------------------------------------------------------------- 1 | ## Thrifty Route 53 Benchmark 2 | 3 | Thrifty developers keep a careful eye on the actual usage of Route 53 service. This Route 53 benchmark currently checks unused hosted zones, unnecessary health checks and unhealthy resolver endpoints. 4 | -------------------------------------------------------------------------------- /controls/docs/apigateway.md: -------------------------------------------------------------------------------- 1 | # Thrifty API Gateway Benchmark 2 | 3 | Thrifty developers optimize their API Gateway configurations for both cost and performance. This benchmark identifies API Gateway stages that could benefit from caching to improve performance and reduce backend load. 4 | -------------------------------------------------------------------------------- /controls/docs/cloudfront.md: -------------------------------------------------------------------------------- 1 | ## Thrifty CloudFront Benchmark 2 | 3 | Thrifty developers keep a careful eye on the price class of CloudFront distribution resources to reduce your delivery prices by excluding Amazon CloudFront’s more expensive edge locations from your Amazon CloudFront distribution. 4 | -------------------------------------------------------------------------------- /controls/docs/emr.md: -------------------------------------------------------------------------------- 1 | ## Thrifty EMR Benchmark 2 | 3 | Thrifty developers ensure that their EMR clusters use the latest generation of Amazon Elastic MapReduce instances instead of the previous generation of instances for better hardware performance, and also ensure that the clusters are not idle for more than 30 minutes. 4 | -------------------------------------------------------------------------------- /.github/workflows/add-issue-to-project.yml: -------------------------------------------------------------------------------- 1 | name: Assign Issue to Project 2 | 3 | on: 4 | issues: 5 | types: [opened] 6 | 7 | jobs: 8 | add-to-project: 9 | uses: turbot/steampipe-workflows/.github/workflows/assign-issue-to-project.yml@main 10 | with: 11 | issue_number: ${{ github.event.issue.number }} 12 | repository: ${{ github.repository }} 13 | secrets: inherit 14 | -------------------------------------------------------------------------------- /controls/docs/lambda.md: -------------------------------------------------------------------------------- 1 | ## Thrifty Lambda Benchmark 2 | 3 | Timeout is the amount of time that Lambda allows a function to run before stopping it. Excessive timeouts result in retries and additional execution time for the function, incurring request charges and billed duration. 4 | 5 | Lambda charges based on number of requests for your function. Function errors may result in retries that incur extra charges. 6 | -------------------------------------------------------------------------------- /controls/docs/secretsmanager.md: -------------------------------------------------------------------------------- 1 | ## Thrifty Secrets Manager Benchmark 2 | 3 | Thrifty developers ensure their secrets manager's secret is in use. This Secrets Manager Benchmark currently checks if your secrets manager secret is in use. 4 | 5 | ## Variables 6 | 7 | | Variable | Description | Default | 8 | | - | - | - | 9 | | secretsmanager_secret_last_used | The default number of days secrets manager secrets to be considered in-use. | 90 days | -------------------------------------------------------------------------------- /controls/docs/cloudwatch.md: -------------------------------------------------------------------------------- 1 | ## Thrifty CloudWatch Benchmark 2 | 3 | Thrifty developers actively manage the retention of their Cloudtrail logs. This benchmark focuses on finding log groups without retention and inactive log-streams. 4 | 5 | ## Variables 6 | 7 | | Variable | Description | Default | 8 | | - | - | - | 9 | | cloudwatch_log_stream_age_max_days | The maximum number of days log streams are allowed without any log event written to them. | 90 days | 10 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Stale Issues and PRs 2 | on: 3 | schedule: 4 | - cron: "30 23 * * *" 5 | workflow_dispatch: 6 | inputs: 7 | dryRun: 8 | description: Set to true for a dry run 9 | required: false 10 | default: "false" 11 | type: string 12 | 13 | jobs: 14 | stale_workflow: 15 | uses: turbot/steampipe-workflows/.github/workflows/stale.yml@main 16 | with: 17 | dryRun: ${{ github.event.inputs.dryRun }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | # Steampipe variable files 18 | *.spvars 19 | *.auto.spvars 20 | 21 | # Powerpipe variable files 22 | *.ppvars 23 | *.auto.ppvars -------------------------------------------------------------------------------- /controls/docs/dynamodb.md: -------------------------------------------------------------------------------- 1 | ## Thrifty DynamoDB Benchmark 2 | 3 | Thrifty developers optimize their DynamoDB tables for cost efficiency. This benchmark focuses on identifying tables with stale data and those that could benefit from auto-scaling to reduce costs. 4 | 5 | ## Variables 6 | 7 | | Variable | Description | Default | 8 | | - | - | - | 9 | | dynamodb_table_stale_data_max_days | The maximum number of days table data can be unchanged before it is considered stale. | 90 days | 10 | 11 | -------------------------------------------------------------------------------- /controls/docs/elasticache.md: -------------------------------------------------------------------------------- 1 | ## Thrifty ElastiCache Benchmark 2 | 3 | Thrifty developers check their long-running ElastiCache clusters are associated with reserved nodes. 4 | 5 | ## Variables 6 | 7 | | Variable | Description | Default | 8 | | - | - | - | 9 | | elasticache_running_cluster_age_max_days | The maximum number of days clusters are allowed to run. | 90 days | 10 | | elasticache_running_cluster_age_warning_days | The number of days clusters can be running before sending a warning. | 30 days | 11 | -------------------------------------------------------------------------------- /controls/docs/cost-explorer.md: -------------------------------------------------------------------------------- 1 | ## Thrifty Cost Explorer Benchmark 2 | 3 | Thrifty developers actively monitor their cloud usage and cost data. This benchmark highlights service charges that have changed drastically over the last two full months of cost data. 4 | 5 | ## Variables 6 | 7 | | Variable | Description | Default | 8 | | - | - | - | 9 | | cost_explorer_service_cost_max_cost_units | The maximum difference in cost units allowed for service costs between the current and previous month. | 10 cost units | 10 | -------------------------------------------------------------------------------- /controls/docs/ecr.md: -------------------------------------------------------------------------------- 1 | # ECR Checks 2 | 3 | Elastic Container Registry (ECR) is a fully managed container registry service. While it's relatively inexpensive, unused repositories and images still incur storage costs and can create clutter. 4 | 5 | ## Variables 6 | 7 | This control uses the following variables: 8 | 9 | | Variable | Description | Default | 10 | |----------|-------------|---------| 11 | | `ecr_repository_image_age_max_days` | The number of days an ECR repository can go without image pulls before being considered unused. | 90 | 12 | 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Powerpipe version (`powerpipe -v`)** 14 | Example: v0.3.0 15 | 16 | **Steampipe version (`steampipe -v`)** 17 | Example: v0.3.0 18 | 19 | **Plugin version (`steampipe plugin list`)** 20 | Example: v0.5.0 21 | 22 | **To reproduce** 23 | Steps to reproduce the behavior (please include relevant code and/or commands). 24 | 25 | **Expected behavior** 26 | A clear and concise description of what you expected to happen. 27 | 28 | **Additional context** 29 | Add any other context about the problem here. 30 | -------------------------------------------------------------------------------- /controls/docs/ecs.md: -------------------------------------------------------------------------------- 1 | ## Thrifty ECS Benchmark 2 | 3 | Thrifty developers eliminate their underutilized ECS clusters and ECS services without an autoscaling policy. This benchmark focuses on finding ECS clusters that have low utilization and an ECS service without an autoscaling policy. 4 | 5 | ## Variables 6 | 7 | | Variable | Description | Default | 8 | | - | - | - | 9 | | ecs_cluster_avg_cpu_utilization_high | The average CPU utilization required for clusters to be considered frequently used. This value should be higher than ecs_cluster_avg_cpu_utilization_low. | 35% | 10 | | ecs_cluster_avg_cpu_utilization_low | The average CPU utilization required for clusters to be considered infrequently used. This value should be lower than ecs_cluster_avg_cpu_utilization_high. | 20% | 11 | -------------------------------------------------------------------------------- /mod.pp: -------------------------------------------------------------------------------- 1 | mod "aws_thrifty" { 2 | # Hub metadata 3 | title = "AWS Thrifty " 4 | description = "Are you a Thrifty AWS developer? This mod checks your AWS account(s) for unused and under utilized resources using Powerpipe and Steampipe." 5 | color = "#FF9900" 6 | documentation = file("./docs/index.md") 7 | icon = "/images/mods/turbot/aws-thrifty.svg" 8 | categories = ["aws", "cost", "thrifty", "public cloud"] 9 | 10 | opengraph { 11 | title = "Powerpipe Mod for AWS Thrifty" 12 | description = "Are you a Thrifty AWS developer? This mod checks your AWS account(s) for unused and under utilized resources using Powerpipe and Steampipe." 13 | image = "/images/mods/turbot/aws-thrifty-social-graphic.png" 14 | } 15 | 16 | require { 17 | plugin "aws" { 18 | min_version = "0.112.0" 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Questions 4 | url: https://turbot.com/community/join 5 | about: GitHub issues in this repository are only intended for bug reports and feature requests. Other issues will be closed. Please ask and answer questions through the Steampipe Slack community. 6 | - name: Powerpipe CLI Bug Reports and Feature Requests 7 | url: https://github.com/turbot/powerpipe/issues/new/choose 8 | about: Powerpipe CLI has its own codebase. Bug reports and feature requests for those pieces of functionality should be directed to that repository. 9 | - name: Steampipe CLI Bug Reports and Feature Requests 10 | url: https://github.com/turbot/steampipe/issues/new/choose 11 | about: Steampipe CLI has its own codebase. Bug reports and feature requests for those pieces of functionality should be directed to that repository. -------------------------------------------------------------------------------- /controls/docs/redshift.md: -------------------------------------------------------------------------------- 1 | ## Thrifty Redshift Benchmark 2 | 3 | Thrifty developers check whether their long-running Redshift clusters are associated with reserved nodes. 4 | 5 | ## Variables 6 | 7 | | Variable | Description | Default | 8 | | - | - | - | 9 | | redshift_cluster_avg_cpu_utilization_high | The average CPU utilization required for clusters to be considered frequently used. This value should be higher than `redshift_cluster_avg_cpu_utilization_low`. | 35% | 10 | | redshift_cluster_avg_cpu_utilization_low | The average CPU utilization required for clusters to be considered infrequently used. This value should be lower than `redshift_cluster_avg_cpu_utilization_high`. | 20% | 11 | | redshift_running_cluster_age_max_days | The maximum number of days clusters are allowed to run. | 90 days | 12 | | redshift_running_cluster_age_warning_days | The number of days clusters can be running before sending a warning. | 30 days | 13 | -------------------------------------------------------------------------------- /controls/docs/ebs.md: -------------------------------------------------------------------------------- 1 | ## Thrifty EBS Benchmark 2 | 3 | Thrifty developers keep a careful eye for unused and under-utilized EBS volumes. Elastic block store is a key component of hidden cost on AWS, and this benchmark looks for EBS volumes that are unused, under-utilized, out-dates and oversized. 4 | 5 | ## Variables 6 | 7 | | Variable | Description | Default | 8 | | - | - | - | 9 | | ebs_snapshot_age_max_days | The maximum number of days snapshots can be retained. | 90 days | 10 | | ebs_volume_avg_read_write_ops_high | The number of average read/write ops required for volumes to be considered frequently used. This value should be higher than `ebs_volume_avg_read_write_ops_low`. | 500 ops/min | 11 | | ebs_volume_avg_read_write_ops_low | The number of average read/write ops required for volumes to be considered infrequently used. This value should be lower than `ebs_volume_avg_read_write_ops_high`. | 100 ops/min | 12 | | ebs_volume_max_iops | The maximum IOPS allowed for volumes. | 32,000 IOPS | 13 | | ebs_volume_max_size_gb | The maximum size (GB) allowed for volumes. | 100 GB | 14 | -------------------------------------------------------------------------------- /controls/s3.pp: -------------------------------------------------------------------------------- 1 | locals { 2 | s3_common_tags = merge(local.aws_thrifty_common_tags, { 3 | service = "AWS/S3" 4 | }) 5 | } 6 | 7 | benchmark "s3" { 8 | title = "S3 Checks" 9 | description = "Thrifty developers ensure their S3 buckets have a managed lifecycle." 10 | documentation = file("./controls/docs/s3.md") 11 | children = [ 12 | control.buckets_with_no_lifecycle 13 | ] 14 | 15 | tags = merge(local.s3_common_tags, { 16 | type = "Benchmark" 17 | }) 18 | } 19 | 20 | control "buckets_with_no_lifecycle" { 21 | title = "Buckets should have lifecycle policies" 22 | description = "S3 Buckets should have a lifecycle policy associated for data retention." 23 | severity = "low" 24 | tags = merge(local.s3_common_tags, { 25 | class = "managed" 26 | }) 27 | 28 | sql = <<-EOQ 29 | select 30 | arn as resource, 31 | case 32 | when lifecycle_rules is null then 'alarm' 33 | else 'ok' 34 | end as status, 35 | case 36 | when lifecycle_rules is null then name || ' does not have lifecycle policy.' 37 | else name || ' has a lifecycle policy.' 38 | end as reason 39 | ${local.tag_dimensions_sql} 40 | ${local.common_dimensions_sql} 41 | from 42 | aws_s3_bucket; 43 | EOQ 44 | } 45 | -------------------------------------------------------------------------------- /controls/docs/ec2.md: -------------------------------------------------------------------------------- 1 | ## Thrifty EC2 Benchmark 2 | 3 | Thrifty developers eliminate their unused and underutilized EC2 instances. This benchmark focuses on finding resources that have not been restarted recently, have low utilization, using very large instance sizes, and reserved instances scheduled to expire within the next 30 days or have expired in the preceding 30 days. 4 | 5 | ## Variables 6 | 7 | | Variable | Description | Default | 8 | | - | - | - | 9 | | ec2_instance_allowed_types | A list of allowed instance types. PostgreSQL wildcards are supported. | ["%.nano", "%.micro", "%.small", "%.medium", "%.large", "%.xlarge", "%._xlarge"] | 10 | | ec2_instance_avg_cpu_utilization_high | The average CPU utilization required for instances to be considered frequently used. This value should be higher than `ec2_instance_avg_cpu_utilization_low`. | 35% | 11 | | ec2_instance_avg_cpu_utilization_low | The average CPU utilization required for instances to be considered infrequently used. This value should be lower than `ec2_instance_avg_cpu_utilization_high`. | 20% | 12 | | ec2_reserved_instance_expiration_warning_days | The number of days reserved instances can be running before sending a warning. | 30 days | 13 | | ec2_running_instance_age_max_days | The maximum number of days instances are allowed to run. | 90 days | 14 | -------------------------------------------------------------------------------- /controls/docs/rds.md: -------------------------------------------------------------------------------- 1 | ## Thrifty RDS Benchmark 2 | 3 | Thrifty developers eliminate their unused and under-utilized RDS instances. This benchmark focuses on testing your RDS DB instances to ensure they are in-use, correctly-sized and using the latest cost-effective instance types. 4 | 5 | ## Variables 6 | 7 | | Variable | Description | Default | 8 | | - | - | - | 9 | | rds_db_instance_avg_connections | The minimum number of average connections per day required for DB instances to be considered in-use. | 2 connections/day | 10 | | rds_db_instance_avg_cpu_utilization_high | The average CPU utilization required for DB instances to be considered frequently used. This value should be higher than rds_db_instance_avg_cpu_utilization_low. | 50% | 11 | | rds_db_instance_avg_cpu_utilization_low | The average CPU utilization required for DB instances to be considered infrequently used. This value should be lower than rds_db_instance_avg_cpu_utilization_high. | 25% | 12 | | rds_running_db_instance_age_max_days | The maximum number of days DB instances are allowed to run. | 90 days | 13 | | rds_running_db_instance_age_warning_days | The number of days DB instances can be running before sending a warning. | 30 days | 14 | | rds_snapshot_unused_max_days | The maximum number of days an RDS snapshot can be retained after its source DB instance is deleted. | 30 days | 15 | -------------------------------------------------------------------------------- /controls/cloudfront.pp: -------------------------------------------------------------------------------- 1 | locals { 2 | cloudfront_common_tags = merge(local.aws_thrifty_common_tags, { 3 | service = "AWS/CloudFront" 4 | }) 5 | } 6 | 7 | benchmark "cloudfront" { 8 | title = "CloudFront Checks" 9 | description = "Thrifty developers checks price class of cloudfront distribution for cost optimization." 10 | documentation = file("./controls/docs/cloudfront.md") 11 | 12 | children = [ 13 | control.cloudfront_distribution_pricing_class 14 | ] 15 | 16 | tags = merge(local.cloudfront_common_tags, { 17 | type = "Benchmark" 18 | }) 19 | } 20 | 21 | control "cloudfront_distribution_pricing_class" { 22 | title = "CloudFront distribution pricing class should be reviewed" 23 | description = "CloudFront distribution pricing class should be reviewed. Price Classes let you reduce your delivery prices by excluding Amazon CloudFront’s more expensive edge locations from your Amazon CloudFront distribution." 24 | severity = "low" 25 | 26 | tags = merge(local.cloudfront_common_tags, { 27 | class = "managed" 28 | }) 29 | 30 | sql = <<-EOQ 31 | select 32 | id as resource, 33 | case 34 | when price_class = 'PriceClass_All' then 'info' 35 | else 'ok' 36 | end as status, 37 | title || ' has ' || price_class || '.' 38 | as reason 39 | ${local.tag_dimensions_sql} 40 | ${local.common_dimensions_sql} 41 | from 42 | aws_cloudfront_distribution; 43 | EOQ 44 | 45 | } 46 | -------------------------------------------------------------------------------- /controls/apigateway.pp: -------------------------------------------------------------------------------- 1 | locals { 2 | apigateway_common_tags = { 3 | service = "AWS/API Gateway" 4 | } 5 | } 6 | 7 | benchmark "apigateway" { 8 | title = "API Gateway Checks" 9 | description = "Best practices for AWS API Gateway resources." 10 | documentation = file("./controls/docs/apigateway.md") 11 | 12 | children = [ 13 | control.apigateway_stage_with_caching_disabled 14 | ] 15 | 16 | tags = merge(local.apigateway_common_tags, { 17 | type = "Benchmark" 18 | }) 19 | } 20 | 21 | control "apigateway_stage_with_caching_disabled" { 22 | title = "API Gateway Stage with Caching Disabled" 23 | description = "API Gateway stages should have caching enabled to improve performance and reduce backend load. Stages without caching may experience higher latency and increased costs." 24 | severity = "low" 25 | 26 | tags = merge(local.apigateway_common_tags, { 27 | class = "managed" 28 | }) 29 | 30 | sql = <<-EOQ 31 | select 32 | rest_api_id as resource, 33 | case 34 | when not cache_cluster_enabled or cache_cluster_enabled is null then 'alarm' 35 | else 'ok' 36 | end as status, 37 | case 38 | when not cache_cluster_enabled or cache_cluster_enabled is null 39 | then name || ' stage in API Gateway ' || rest_api_id || ' has caching disabled which may impact performance.' 40 | else name || ' stage in API Gateway ' || rest_api_id || ' has caching enabled.' 41 | end as reason 42 | ${local.tag_dimensions_sql} 43 | ${local.common_dimensions_sql} 44 | from 45 | aws_api_gateway_stage; 46 | EOQ 47 | } -------------------------------------------------------------------------------- /powerpipe.ppvars.example: -------------------------------------------------------------------------------- 1 | # Dimensions 2 | common_dimensions = [ "account_id", "region" ] 3 | tag_dimensions = [] 4 | 5 | # CloudWatch 6 | cloudwatch_log_stream_age_max_days = 90 7 | 8 | # Cost Explorer 9 | cost_explorer_service_cost_max_cost_units = 10 10 | 11 | # DynamoDB 12 | dynamodb_table_stale_data_max_days = 90 13 | 14 | # EBS 15 | ebs_snapshot_age_max_days = 90 16 | ebs_volume_avg_read_write_ops_high = 500 17 | ebs_volume_avg_read_write_ops_low = 100 18 | ebs_volume_max_iops = 32000 19 | ebs_volume_max_size_gb = 100 20 | 21 | # EC2 22 | ec2_instance_allowed_types = ["%.nano", "%.micro", "%.small", "%.medium", "%.large", "%.xlarge", "%._xlarge"] 23 | ec2_instance_avg_cpu_utilization_high = 35 24 | ec2_instance_avg_cpu_utilization_low = 20 25 | ec2_reserved_instance_expiration_warning_days = 30 26 | ec2_running_instance_age_max_days = 90 27 | 28 | # ECR 29 | ecr_repository_image_age_max_days = 90 30 | 31 | # ECS 32 | ecs_cluster_avg_cpu_utilization_high = 35 33 | ecs_cluster_avg_cpu_utilization_low = 20 34 | 35 | # Elasticache 36 | elasticache_running_cluster_age_max_days = 90 37 | elasticache_running_cluster_age_warning_days = 30 38 | 39 | # RDS 40 | rds_db_instance_avg_connections = 2 41 | rds_db_instance_avg_cpu_utilization_high = 50 42 | rds_db_instance_avg_cpu_utilization_low = 25 43 | rds_running_db_instance_age_max_days = 90 44 | rds_running_db_instance_age_warning_days = 30 45 | 46 | # Redshift 47 | redshift_cluster_avg_cpu_utilization_high = 35 48 | redshift_cluster_avg_cpu_utilization_low = 20 49 | redshift_running_cluster_age_max_days = 90 50 | redshift_running_cluster_age_warning_days = 30 51 | -------------------------------------------------------------------------------- /controls/secretsmanager.pp: -------------------------------------------------------------------------------- 1 | variable "secretsmanager_secret_last_used" { 2 | type = number 3 | description = "The default number of days secrets manager secrets to be considered in-use." 4 | default = 90 5 | } 6 | 7 | locals { 8 | secretsmanager_common_tags = merge(local.aws_thrifty_common_tags, { 9 | service = "AWS/Secrets Manager" 10 | }) 11 | } 12 | 13 | benchmark "secretsmanager" { 14 | title = "Secrets Manager Checks" 15 | description = "Thrifty developers ensure their secrets manager secret is in use." 16 | documentation = file("./controls/docs/secretsmanager.md") 17 | 18 | children = [ 19 | control.secretsmanager_secret_unused 20 | ] 21 | 22 | tags = merge(local.secretsmanager_common_tags, { 23 | type = "Benchmark" 24 | }) 25 | } 26 | 27 | control "secretsmanager_secret_unused" { 28 | title = "Unused Secrets Manager secrets should be deleted" 29 | description = "AWS Secrets Manager secrets should be accessed within a specified number of days. The default value is 90 days." 30 | severity = "low" 31 | 32 | tags = merge(local.secretsmanager_common_tags, { 33 | class = "unused" 34 | }) 35 | 36 | param "secretsmanager_secret_last_used" { 37 | description = "The specified number of days since secrets manager secret last used." 38 | default = var.secretsmanager_secret_last_used 39 | } 40 | 41 | sql = <<-EOQ 42 | select 43 | arn as resource, 44 | case 45 | when date_part('day', now()-last_accessed_date) < $1 then 'ok' 46 | else 'alarm' 47 | end as status, 48 | case 49 | when last_accessed_date is null then title || ' is never used.' 50 | else title || ' is last used ' || age(current_date, last_accessed_date) || ' ago.' 51 | end as reason 52 | ${local.tag_dimensions_sql} 53 | ${local.common_dimensions_sql} 54 | from 55 | aws_secretsmanager_secret; 56 | EOQ 57 | 58 | } 59 | -------------------------------------------------------------------------------- /variables.pp: -------------------------------------------------------------------------------- 1 | // Benchmarks and controls for specific services should override the "service" tag 2 | locals { 3 | aws_thrifty_common_tags = { 4 | category = "Cost" 5 | plugin = "aws" 6 | service = "AWS" 7 | } 8 | } 9 | 10 | variable "common_dimensions" { 11 | type = list(string) 12 | description = "A list of common dimensions to add to each control." 13 | # Define which common dimensions should be added to each control. 14 | # - account_id 15 | # - connection_name (_ctx ->> 'connection_name') 16 | # - region 17 | default = ["account_id", "region"] 18 | } 19 | 20 | variable "tag_dimensions" { 21 | type = list(string) 22 | description = "A list of tags to add as dimensions to each control." 23 | # A list of tag names to include as dimensions for resources that support 24 | # tags (e.g. "Owner", "Environment"). Default to empty since tag names are 25 | # a personal choice - for commonly used tag names see 26 | # https://docs.aws.amazon.com/general/latest/gr/aws_tagging.html#tag-categories 27 | default = [] 28 | } 29 | 30 | locals { 31 | 32 | # Local internal variable to build the SQL select clause for common 33 | # dimensions using a table name qualifier if required. Do not edit directly. 34 | common_dimensions_qualifier_sql = <<-EOQ 35 | %{~if contains(var.common_dimensions, "connection_name")}, __QUALIFIER___ctx ->> 'connection_name' as connection_name%{endif~} 36 | %{~if contains(var.common_dimensions, "region")}, __QUALIFIER__region%{endif~} 37 | %{~if contains(var.common_dimensions, "account_id")}, __QUALIFIER__account_id%{endif~} 38 | EOQ 39 | 40 | # Local internal variable to build the SQL select clause for tag 41 | # dimensions. Do not edit directly. 42 | tag_dimensions_qualifier_sql = <<-EOQ 43 | %{~for dim in var.tag_dimensions}, __QUALIFIER__tags ->> '${dim}' as "${replace(dim, "\"", "\"\"")}"%{endfor~} 44 | EOQ 45 | 46 | } 47 | 48 | locals { 49 | 50 | # Local internal variable with the full SQL select clause for common 51 | # dimensions. Do not edit directly. 52 | common_dimensions_sql = replace(local.common_dimensions_qualifier_sql, "__QUALIFIER__", "") 53 | tag_dimensions_sql = replace(local.tag_dimensions_qualifier_sql, "__QUALIFIER__", "") 54 | 55 | } -------------------------------------------------------------------------------- /controls/ecr.pp: -------------------------------------------------------------------------------- 1 | variable "ecr_repository_image_age_max_days" { 2 | type = number 3 | description = "The number of days an ECR repository can go without image pulls before being considered unused." 4 | default = 90 5 | } 6 | 7 | locals { 8 | ecr_common_tags = merge(local.aws_thrifty_common_tags, { 9 | service = "AWS/ECR" 10 | }) 11 | } 12 | 13 | benchmark "ecr" { 14 | title = "ECR Checks" 15 | description = "Thrifty developers eliminate unused ECR repository images." 16 | documentation = file("./controls/docs/ecr.md") 17 | children = [ 18 | control.ecr_repository_unused_images 19 | ] 20 | 21 | tags = merge(local.ecr_common_tags, { 22 | type = "Benchmark" 23 | }) 24 | } 25 | 26 | control "ecr_repository_unused_images" { 27 | title = "ECR repositories with unused images should be reviewed" 28 | description = "ECR repositories with images that haven't been pulled in a long time may be unused and should be reviewed for cleanup." 29 | severity = "low" 30 | 31 | tags = merge(local.ecr_common_tags, { 32 | class = "unused" 33 | }) 34 | 35 | sql = <<-EOQ 36 | with latest_pulls as ( 37 | select 38 | repository_name, 39 | max(last_recorded_pull_time) as last_pull 40 | from 41 | aws_ecr_image 42 | group by 43 | repository_name 44 | ) 45 | select 46 | r.arn as resource, 47 | case 48 | when i.last_pull is null then 'alarm' 49 | when i.last_pull < (current_timestamp - interval '${var.ecr_repository_image_age_max_days} days') then 'alarm' 50 | else 'ok' 51 | end as status, 52 | case 53 | when i.last_pull is null then r.title || ' has no images that have ever been pulled.' 54 | when i.last_pull < (current_timestamp - interval '${var.ecr_repository_image_age_max_days} days') then r.title || ' has not had any images pulled in the last ${var.ecr_repository_image_age_max_days} days. Last pull was on ' || to_char(i.last_pull, 'YYYY-MM-DD') 55 | else r.title || ' has had images pulled within the last ${var.ecr_repository_image_age_max_days} days. Last pull was on ' || to_char(i.last_pull, 'YYYY-MM-DD') 56 | end as reason, 57 | r.region, 58 | r.account_id 59 | from 60 | aws_ecr_repository r 61 | left join latest_pulls i on r.repository_name = i.repository_name; 62 | EOQ 63 | } -------------------------------------------------------------------------------- /controls/network.pp: -------------------------------------------------------------------------------- 1 | locals { 2 | vpc_common_tags = merge(local.aws_thrifty_common_tags, { 3 | service = "AWS/VPC" 4 | }) 5 | } 6 | 7 | benchmark "network" { 8 | title = "Networking Checks" 9 | description = "Thrifty developers ensure delete unused network resources." 10 | documentation = file("./controls/docs/network.md") 11 | 12 | children = [ 13 | control.unattached_eips, 14 | control.vpc_nat_gateway_unused 15 | ] 16 | 17 | tags = merge(local.vpc_common_tags, { 18 | type = "Benchmark" 19 | }) 20 | } 21 | 22 | control "unattached_eips" { 23 | title = "Unattached elastic IP addresses (EIPs) should be released" 24 | description = "Unattached Elastic IPs are charged by AWS, they should be released." 25 | severity = "low" 26 | 27 | tags = merge(local.vpc_common_tags, { 28 | class = "unused" 29 | }) 30 | 31 | sql = <<-EOQ 32 | select 33 | 'arn:' || partition || ':ec2:' || region || ':' || account_id || ':eip/' || allocation_id as resource, 34 | case 35 | when association_id is null then 'alarm' 36 | else 'ok' 37 | end as status, 38 | case 39 | when association_id is null then public_ip || ' has no association.' 40 | else public_ip || ' associated with ' || private_ip_address || '.' 41 | end as reason 42 | ${local.common_dimensions_sql} 43 | from 44 | aws_vpc_eip; 45 | EOQ 46 | 47 | } 48 | 49 | control "vpc_nat_gateway_unused" { 50 | title = "Unused NAT gateways should be deleted" 51 | description = "NAT gateway are charged on an hourly basis once they are provisioned and available, so unused gateways should be deleted." 52 | severity = "low" 53 | tags = merge(local.vpc_common_tags, { 54 | class = "unused" 55 | }) 56 | 57 | sql = <<-EOQ 58 | select 59 | nat.arn as resource, 60 | case 61 | when nat.state <> 'available' then 'alarm' 62 | when sum(average) = 0 then 'alarm' 63 | else 'ok' 64 | end as status, 65 | case 66 | when nat.state <> 'available' then nat.title || ' in ' || nat.state || ' state.' 67 | when sum(average) = 0 then nat.title || ' not in-use.' 68 | else nat.title || ' in-use.' 69 | end as reason 70 | ${local.tag_dimensions_sql} 71 | ${replace(local.common_dimensions_qualifier_sql, "__QUALIFIER__", "nat.")} 72 | from 73 | aws_vpc_nat_gateway as nat 74 | left join aws_vpc_nat_gateway_metric_bytes_out_to_destination as dest on nat.nat_gateway_id = dest.nat_gateway_id 75 | group by 76 | nat.title, 77 | nat.arn, 78 | nat.state, 79 | nat.region, 80 | nat.account_id; 81 | EOQ 82 | } 83 | -------------------------------------------------------------------------------- /controls/cloudwatch.pp: -------------------------------------------------------------------------------- 1 | variable "cloudwatch_log_stream_age_max_days" { 2 | type = number 3 | description = "The maximum number of days log streams are allowed without any log event written to them." 4 | default = 90 5 | } 6 | 7 | locals { 8 | cloudwatch_common_tags = merge(local.aws_thrifty_common_tags, { 9 | service = "AWS/CloudWatch" 10 | }) 11 | } 12 | 13 | benchmark "cloudwatch" { 14 | title = "CloudWatch Checks" 15 | description = "Thrifty developers actively manage the retention of their Cloudtrail logs." 16 | documentation = file("./controls/docs/cloudwatch.md") 17 | children = [ 18 | control.cw_log_group_retention, 19 | control.cw_log_stream_unused 20 | ] 21 | 22 | tags = merge(local.cloudwatch_common_tags, { 23 | type = "Benchmark" 24 | }) 25 | } 26 | 27 | control "cw_log_group_retention" { 28 | title = "CloudWatch Log Groups retention should be enabled" 29 | description = "All log groups should have a defined retention configuration." 30 | severity = "low" 31 | 32 | tags = merge(local.cloudwatch_common_tags, { 33 | class = "managed" 34 | }) 35 | sql = <<-EOQ 36 | select 37 | arn as resource, 38 | case 39 | when retention_in_days is null then 'alarm' 40 | else 'ok' 41 | end as status, 42 | case 43 | when retention_in_days is null then name || ' does not have data retention enabled.' 44 | else name || ' is set to ' || retention_in_days || ' day retention.' 45 | end as reason 46 | ${local.tag_dimensions_sql} 47 | ${local.common_dimensions_sql} 48 | from 49 | aws_cloudwatch_log_group; 50 | EOQ 51 | } 52 | 53 | control "cw_log_stream_unused" { 54 | title = "Unused log streams should be removed if not required" 55 | description = "Unnecessary log streams should be deleted for storage cost savings." 56 | severity = "low" 57 | 58 | param "cloudwatch_log_stream_age_max_days" { 59 | description = "The maximum number of days log streams are allowed without any log event written to them." 60 | default = var.cloudwatch_log_stream_age_max_days 61 | } 62 | 63 | tags = merge(local.cloudwatch_common_tags, { 64 | class = "unused" 65 | }) 66 | 67 | sql = <<-EOQ 68 | select 69 | arn as resource, 70 | case 71 | when last_ingestion_time is null then 'error' 72 | when date_part('day', now() - last_ingestion_time) > $1 then 'alarm' 73 | else 'ok' 74 | end as status, 75 | case 76 | when last_ingestion_time is null then name || ' is not reporting a last ingestion time.' 77 | else name || ' last log ingestion was ' || date_part('day', now() - last_ingestion_time) || ' days ago.' 78 | end as reason 79 | ${local.common_dimensions_sql} 80 | from 81 | aws_cloudwatch_log_stream; 82 | EOQ 83 | } 84 | -------------------------------------------------------------------------------- /controls/elasticache.pp: -------------------------------------------------------------------------------- 1 | variable "elasticache_running_cluster_age_max_days" { 2 | type = number 3 | description = "The maximum number of days clusters are allowed to run." 4 | default = 90 5 | } 6 | 7 | variable "elasticache_running_cluster_age_warning_days" { 8 | type = number 9 | description = "The number of days clusters can be running before sending a warning." 10 | default = 30 11 | } 12 | 13 | locals { 14 | elasticache_common_tags = merge(local.aws_thrifty_common_tags, { 15 | service = "AWS/ElastiCache" 16 | }) 17 | } 18 | 19 | benchmark "elasticache" { 20 | title = "ElastiCache Checks" 21 | description = "Thrifty developers check their long running ElastiCache clusters are associated with reserved nodes." 22 | documentation = file("./controls/docs/elasticache.md") 23 | children = [ 24 | control.elasticache_cluster_long_running 25 | ] 26 | 27 | tags = merge(local.elasticache_common_tags, { 28 | type = "Benchmark" 29 | }) 30 | } 31 | 32 | control "elasticache_cluster_long_running" { 33 | title = "Long running ElastiCache clusters should have reserved nodes purchased for them" 34 | description = "Long running clusters should be associated with reserved nodes, which provide a significant discount." 35 | severity = "low" 36 | 37 | param "elasticache_running_cluster_age_max_days" { 38 | description = "The maximum number of days clusters are allowed to run." 39 | default = var.elasticache_running_cluster_age_max_days 40 | } 41 | 42 | param "elasticache_running_cluster_age_warning_days" { 43 | description = "The number of days clusters can be running before sending a warning." 44 | default = var.elasticache_running_cluster_age_warning_days 45 | } 46 | 47 | tags = merge(local.redshift_common_tags, { 48 | class = "managed" 49 | }) 50 | sql = <<-EOQ 51 | with filter_clusters as ( 52 | select 53 | distinct c.replication_group_id as name, 54 | c.cache_cluster_create_time, 55 | c._ctx, 56 | c.region, 57 | c.account_id, 58 | 'redis' as engine, 59 | c.partition 60 | from 61 | aws_elasticache_replication_group as rg 62 | left join aws_elasticache_cluster as c on rg.replication_group_id = c.replication_group_id 63 | union 64 | select 65 | cache_cluster_id as name, 66 | cache_cluster_create_time, 67 | _ctx, 68 | region, 69 | account_id, 70 | engine, 71 | partition 72 | from 73 | aws_elasticache_cluster 74 | where 75 | engine = 'memcached' 76 | ) 77 | select 78 | 'arn:' || partition || ':elasticache:' || region || ':' || account_id || ':cluster:' || name as resource, 79 | case 80 | when date_part('day', now() - cache_cluster_create_time) > $1 then 'alarm' 81 | when date_part('day', now() - cache_cluster_create_time) > $2 then 'info' 82 | else 'ok' 83 | end as status, 84 | name || ' ' || engine || ' created on ' || cache_cluster_create_time || ' (' || date_part('day', now() - cache_cluster_create_time) || ' days).' 85 | as reason 86 | ${local.common_dimensions_sql} 87 | from 88 | filter_clusters; 89 | EOQ 90 | } 91 | -------------------------------------------------------------------------------- /controls/route53.pp: -------------------------------------------------------------------------------- 1 | locals { 2 | route53_common_tags = merge(local.aws_thrifty_common_tags, { 3 | service = "AWS/Route 53" 4 | }) 5 | } 6 | 7 | benchmark "route53" { 8 | title = "Route 53 Checks" 9 | description = "Thrifty developers keep a careful eye on the actual usage of Route 53 service." 10 | documentation = file("./controls/docs/route53.md") 11 | 12 | children = [ 13 | control.route53_health_check_unused, 14 | control.route53_record_higher_ttl 15 | ] 16 | 17 | tags = merge(local.route53_common_tags, { 18 | type = "Benchmark" 19 | }) 20 | } 21 | 22 | control "route53_record_higher_ttl" { 23 | title = "Route 53 records should have higher TTL configured" 24 | description = "If you configure a higher TTL for your records, the intermediate resolvers cache the records for longer time. As a result, there are fewer queries received by the name servers. This configuration reduces the charges corresponding to the DNS queries answered. A value between an hour (3600s) and a day (86,400s) is a common choice." 25 | severity = "low" 26 | 27 | tags = merge(local.route53_common_tags, { 28 | class = "Higher" 29 | }) 30 | 31 | sql = <<-EOQ 32 | select 33 | 'arn:' || r.partition || ':route53:::hostedzone/' || r.zone_id || '/recordset/' || r.name || '/' || r.type as resource, 34 | case 35 | when ttl::int < 3600 then 'alarm' 36 | else 'ok' 37 | end as status, 38 | case 39 | when ttl::int < 3600 then r.title || ' TTL value is ' || ttl || 's.' 40 | else r.title || ' TTL value is ' || ttl || 's.' 41 | end as reason 42 | ${local.tag_dimensions_sql} 43 | ${replace(local.common_dimensions_qualifier_sql, "__QUALIFIER__", "r.")} 44 | from 45 | aws_route53_zone as z, 46 | aws_route53_record as r 47 | where 48 | r.zone_id = z.id; 49 | EOQ 50 | 51 | } 52 | 53 | control "route53_health_check_unused" { 54 | title = "Unnecessary health checks should be deleted" 55 | description = "When you associate health checks with an endpoint, health check requests are sent to the endpoint's IP address. These health check requests are sent to validate that the requests are operating as intended. Health check charges are incurred based on their associated endpoints. To avoid health check charges, delete any health checks that aren't used with an RRset record and are no longer required." 56 | severity = "low" 57 | 58 | tags = merge(local.route53_common_tags, { 59 | class = "unused" 60 | }) 61 | 62 | sql = <<-EOQ 63 | with health_check as ( 64 | select 65 | r.health_check_id as health_check_id 66 | from 67 | aws_route53_zone as z, 68 | aws_route53_record as r 69 | where 70 | r.zone_id = z.id 71 | ) 72 | select 73 | 'arn:' || h.partition || ':route53:::healthcheck/' || h.id as resource, 74 | case 75 | when c.health_check_id is null then 'alarm' 76 | else 'ok' 77 | end as status, 78 | case 79 | when c.health_check_id is null then h.title || ' is unnecessary.' 80 | else h.title || ' is necessary.' 81 | end as reason 82 | ${local.tag_dimensions_sql} 83 | ${local.common_dimensions_sql} 84 | from 85 | aws_route53_health_check as h 86 | left join health_check as c on h.id = c.health_check_id; 87 | EOQ 88 | } 89 | -------------------------------------------------------------------------------- /controls/eks.pp: -------------------------------------------------------------------------------- 1 | locals { 2 | eks_common_tags = merge(local.aws_thrifty_common_tags, { 3 | service = "AWS/EKS" 4 | }) 5 | } 6 | 7 | benchmark "eks" { 8 | title = "EKS Checks" 9 | description = "Thrifty developers ensure their EKS resources are optimized." 10 | documentation = file("./controls/docs/eks.md") 11 | children = [ 12 | control.eks_node_group_with_graviton 13 | ] 14 | 15 | tags = merge(local.eks_common_tags, { 16 | type = "Benchmark" 17 | }) 18 | } 19 | 20 | control "eks_node_group_with_graviton" { 21 | title = "EKS node groups without graviton processor should be reviewed" 22 | description = "With graviton processor (arm64 - 64-bit ARM architecture), you can save money in two ways. First, your functions run more efficiently due to the Graviton architecture. Second, you pay less for the time that they run. In fact, Lambda functions powered by Graviton are designed to deliver up to 19 percent better performance at 20 percent lower cost." 23 | severity = "low" 24 | 25 | tags = merge(local.eks_common_tags, { 26 | class = "deprecated" 27 | }) 28 | 29 | sql = <<-EOQ 30 | with node_group_using_launch_template_image_id as ( 31 | select 32 | g.arn as node_group_arn, 33 | v.image_id as image_id 34 | from 35 | aws_eks_node_group as g 36 | left join aws_ec2_launch_template_version as v on v.launch_template_id = g.launch_template ->> 'Id' and v.version_number = (g.launch_template ->> 'Version')::int 37 | where 38 | g.launch_template is not null 39 | ), ami_architecture as ( 40 | select 41 | node_group_arn, 42 | architecture, 43 | case when s.platform_details = 'Linux/UNIX' then 'linux' else platform_details end as platform 44 | from 45 | node_group_using_launch_template_image_id as i 46 | left join aws_ec2_ami_shared as s on s.image_id = i.image_id 47 | where 48 | architecture is not null 49 | union 50 | select 51 | node_group_arn, 52 | architecture, 53 | case when a.platform_details = 'Linux/UNIX' then 'linux' else platform_details end as platform 54 | from 55 | node_group_using_launch_template_image_id as i 56 | left join aws_ec2_ami as a on a.image_id = i.image_id 57 | where 58 | architecture is not null 59 | ) 60 | select 61 | arn as resource, 62 | case 63 | when ami_type = 'CUSTOM%' and a.platform <> 'linux' then 'skip' 64 | when ami_type = 'CUSTOM%' and a.architecture = 'arm_64' and a.platform = 'linux' then 'ok' 65 | when ami_type = 'CUSTOM%' and a.architecture <> 'arm_64' and a.platform = 'linux' then 'alarm' 66 | when ami_type not like 'AL2_%' then 'skip' 67 | when ami_type = 'AL2_ARM_64' then 'ok' 68 | else 'alarm' 69 | end as status, 70 | case 71 | when ami_type = 'CUSTOM%' and a.platform <> 'linux' then title || ' is not using linux platform.' 72 | when ami_type = 'CUSTOM%' and a.architecture = 'x86_64' and a.platform = 'linux' then title || ' is using Graviton processor.' 73 | when ami_type = 'CUSTOM%' and a.architecture <> 'arm_64' and a.platform = 'linux' then title || ' is not using Graviton processor.' 74 | when ami_type not like 'AL2_%' then title || ' is not using linux platform.' 75 | when ami_type = 'AL2_ARM_64' then title || ' is using Graviton processor.' 76 | else title || ' is not using Graviton processor.' 77 | end as reason 78 | ${local.tag_dimensions_sql} 79 | ${local.common_dimensions_sql} 80 | from 81 | aws_eks_node_group as g 82 | left join ami_architecture as a on a.node_group_arn = g.arn; 83 | EOQ 84 | } 85 | -------------------------------------------------------------------------------- /controls/emr.pp: -------------------------------------------------------------------------------- 1 | locals { 2 | emr_common_tags = merge(local.aws_thrifty_common_tags, { 3 | service = "AWS/EMR" 4 | }) 5 | } 6 | 7 | benchmark "emr" { 8 | title = "EMR Checks" 9 | description = "Thrifty developers checks EMR clusters of previous generation instances and idle clusters." 10 | documentation = file("./controls/docs/emr.md") 11 | children = [ 12 | control.emr_cluster_instance_prev_gen, 13 | control.emr_cluster_is_idle_30_minutes 14 | ] 15 | 16 | tags = merge(local.emr_common_tags, { 17 | type = "Benchmark" 18 | }) 19 | } 20 | 21 | control "emr_cluster_instance_prev_gen" { 22 | title = "EMR clusters of previous generation instances should be reviewed" 23 | description = "EMR clusters of previous generations instance types (c1,cc2,cr1,m2,g2,i2,m1) should be replaced by latest generation instance types for better hardware performance." 24 | severity = "low" 25 | 26 | tags = merge(local.emr_common_tags, { 27 | class = "managed" 28 | }) 29 | sql = <<-EOQ 30 | select 31 | ig.id as resource, 32 | case 33 | when ig.state = 'TERMINATED' then 'skip' 34 | when ig.instance_type like 'c1.%' then 'alarm' 35 | when ig.instance_type like 'cc2.%' then 'alarm' 36 | when ig.instance_type like 'cr1.%' then 'alarm' 37 | when ig.instance_type like 'm2.%' then 'alarm' 38 | when ig.instance_type like 'g2.%' then 'alarm' 39 | when ig.instance_type like 'i2,m1.%' then 'alarm' 40 | when ig.instance_type like 'c1.%' then 'alarm' 41 | else 'info' 42 | end as status, 43 | case 44 | when ig.state = 'TERMINATED' then ig.cluster_id || ' is ' || ig.state || '.' 45 | else ig.cluster_id || ' has ' || ig.instance_type || ' instance type.' 46 | end as reason 47 | ${local.tag_dimensions_sql} 48 | ${replace(local.common_dimensions_qualifier_sql, "__QUALIFIER__", "c.")} 49 | from 50 | aws_emr_instance_group as ig, 51 | aws_emr_cluster as c 52 | where 53 | ig.cluster_id = c.id 54 | and ig.instance_group_type = 'MASTER'; 55 | EOQ 56 | } 57 | 58 | control "emr_cluster_is_idle_30_minutes" { 59 | title = "EMR clusters idle for more than 30 minutes should be reviewed" 60 | description = "EMR clusters which is live but not currently running tasks should be reviewed and checked whether the cluster has been idle for more than 30 minutes." 61 | severity = "low" 62 | 63 | tags = merge(local.emr_common_tags, { 64 | class = "unused" 65 | }) 66 | sql = <<-EOQ 67 | with cluster_metrics as ( 68 | select 69 | id, 70 | maximum, 71 | date(timestamp) as timestamp 72 | from 73 | aws_emr_cluster_metric_is_idle 74 | where 75 | timestamp >= current_timestamp - interval '40 minutes' 76 | ), 77 | emr_cluster_isidle as ( 78 | select 79 | id, 80 | count(maximum) as count, 81 | sum(maximum)/count(maximum) as avagsum 82 | from 83 | cluster_metrics 84 | group by id, timestamp 85 | ) 86 | select 87 | i.id as resource, 88 | case 89 | when u.id is null then 'error' 90 | when avagsum = 1 and count >= 7 then 'alarm' 91 | else 'ok' 92 | end as status, 93 | case 94 | when u.id is null then 'CloudWatch metrics not available for ' || i.title || '.' 95 | else i.title || ' is idle from last ' || (count*5 - 5) || ' minutes.' 96 | end as reason 97 | ${local.tag_dimensions_sql} 98 | ${replace(local.common_dimensions_qualifier_sql, "__QUALIFIER__", "i.")} 99 | from 100 | aws_emr_cluster as i 101 | left join emr_cluster_isidle as u on u.id = i.id; 102 | EOQ 103 | } 104 | -------------------------------------------------------------------------------- /controls/dynamodb.pp: -------------------------------------------------------------------------------- 1 | variable "dynamodb_table_stale_data_max_days" { 2 | type = number 3 | description = "The maximum number of days table data can be unchanged before it is considered stale." 4 | default = 90 5 | } 6 | 7 | locals { 8 | dynamodb_common_tags = merge(local.aws_thrifty_common_tags, { 9 | service = "AWS/DynamoDB" 10 | }) 11 | } 12 | 13 | benchmark "dynamodb" { 14 | title = "DynamoDB Checks" 15 | description = "Thrifty developers delete DynamoDB tables with stale data." 16 | documentation = file("./controls/docs/dynamodb.md") 17 | 18 | children = [ 19 | control.stale_dynamodb_table_data, 20 | control.dynamodb_table_without_autoscaling 21 | ] 22 | 23 | tags = merge(local.dynamodb_common_tags, { 24 | type = "Benchmark" 25 | }) 26 | } 27 | 28 | control "stale_dynamodb_table_data" { 29 | title = "Tables with stale data should be reviewed" 30 | description = "If the data has not changed recently and has become stale, the table should be reviewed." 31 | severity = "low" 32 | 33 | param "dynamodb_table_stale_data_max_days" { 34 | description = "The maximum number of days table data can be unchanged before it is considered stale." 35 | default = var.dynamodb_table_stale_data_max_days 36 | } 37 | 38 | tags = merge(local.dynamodb_common_tags, { 39 | class = "unused" 40 | }) 41 | 42 | sql = <<-EOQ 43 | select 44 | 'arn:' || partition || ':dynamodb:' || region || ':' || account_id || ':table/' || name as resource, 45 | case 46 | when latest_stream_label is null then 'info' 47 | when date_part('day', now() - (latest_stream_label::timestamptz)) > $1 then 'alarm' 48 | else 'ok' 49 | end as status, 50 | case 51 | when latest_stream_label is null then name || ' is not configured for change data capture.' 52 | else name || ' was changed ' || date_part('day', now() - (latest_stream_label::timestamptz)) || ' days ago.' 53 | end as reason 54 | ${local.tag_dimensions_sql} 55 | ${local.common_dimensions_sql} 56 | from 57 | aws_dynamodb_table; 58 | EOQ 59 | } 60 | 61 | control "dynamodb_table_without_autoscaling" { 62 | title = "DynamoDB tables without auto-scaling should be reviewed" 63 | description = "DynamoDB tables with provisioned capacity mode should use auto-scaling to optimize costs. Auto-scaling automatically adjusts read and write capacity based on actual usage patterns, helping to avoid over-provisioning and reduce costs." 64 | severity = "low" 65 | 66 | tags = merge(local.dynamodb_common_tags, { 67 | class = "cost" 68 | }) 69 | 70 | sql = <<-EOQ 71 | with dynamodb_autoscaling as ( 72 | select 73 | split_part(resource_id, '/', 2) as table_name, 74 | count(*) as scaling_configs 75 | from 76 | aws_appautoscaling_target 77 | where 78 | service_namespace = 'dynamodb' 79 | group by 80 | split_part(resource_id, '/', 2) 81 | ) 82 | select 83 | 'arn:' || t.partition || ':dynamodb:' || t.region || ':' || t.account_id || ':table/' || t.name as resource, 84 | case 85 | when t.billing_mode = 'PAY_PER_REQUEST' then 'ok' 86 | when coalesce(a.scaling_configs, 0) > 0 then 'ok' 87 | else 'alarm' 88 | end as status, 89 | case 90 | when t.billing_mode = 'PAY_PER_REQUEST' then t.name || ' uses on-demand capacity mode.' 91 | when coalesce(a.scaling_configs, 0) > 0 then t.name || ' has auto-scaling configured.' 92 | else t.name || ' uses provisioned capacity without auto-scaling.' 93 | end as reason 94 | ${local.tag_dimensions_sql} 95 | ${local.common_dimensions_sql} 96 | from 97 | aws_dynamodb_table as t 98 | left join dynamodb_autoscaling as a on t.name = a.table_name; 99 | EOQ 100 | } 101 | -------------------------------------------------------------------------------- /controls/cloudtrail.pp: -------------------------------------------------------------------------------- 1 | locals { 2 | cloudtrail_common_tags = merge(local.aws_thrifty_common_tags, { 3 | service = "AWS/CloudTrail" 4 | }) 5 | } 6 | 7 | benchmark "cloudtrail" { 8 | title = "CloudTrail Checks" 9 | description = "Thrifty developers know that multiple active CloudTrail Trails can add significant costs. Be thrifty and eliminate the extra trails. One trail to rule them all." 10 | documentation = file("./controls/docs/cloudtrail.md") 11 | 12 | children = [ 13 | control.multiple_global_trails, 14 | control.multiple_regional_trails 15 | ] 16 | 17 | tags = merge(local.cloudtrail_common_tags, { 18 | type = "Benchmark" 19 | }) 20 | } 21 | 22 | control "multiple_global_trails" { 23 | title = "Are there redundant global CloudTrail trails?" 24 | description = "Your first cloudtrail in each account is free, additional trails are expensive." 25 | severity = "low" 26 | 27 | tags = merge(local.cloudtrail_common_tags, { 28 | class = "managed" 29 | }) 30 | 31 | sql = <<-EOQ 32 | with global_trails as ( 33 | select 34 | account_id, 35 | count(*) as total 36 | from 37 | aws_cloudtrail_trail 38 | where 39 | is_multi_region_trail and region = home_region 40 | group by 41 | account_id, 42 | is_multi_region_trail 43 | ) 44 | select 45 | arn as resource, 46 | case 47 | when total > 1 then 'alarm' 48 | else 'ok' 49 | end as status, 50 | case 51 | when total > 1 then name || ' is one of ' || total || ' global trails.' 52 | else name || ' is the only global trail.' 53 | end as reason 54 | ${local.tag_dimensions_sql} 55 | ${replace(local.common_dimensions_qualifier_sql, "__QUALIFIER__", "t.")} 56 | from 57 | aws_cloudtrail_trail as t, 58 | global_trails 59 | where 60 | is_multi_region_trail 61 | and region = home_region; 62 | EOQ 63 | } 64 | 65 | control "multiple_regional_trails" { 66 | title = "Are there redundant regional CloudTrail trails?" 67 | description = "Your first cloudtrail in each region is free, additional trails are expensive." 68 | severity = "low" 69 | 70 | tags = merge(local.cloudtrail_common_tags, { 71 | class = "managed" 72 | }) 73 | 74 | sql = <<-EOQ 75 | with 76 | global_trails as ( 77 | select 78 | count(*) as total 79 | from 80 | aws_cloudtrail_trail 81 | where 82 | is_multi_region_trail 83 | ), 84 | org_trails as ( 85 | select 86 | count(*) as total 87 | from 88 | aws_cloudtrail_trail 89 | where 90 | is_organization_trail 91 | ), 92 | regional_trails as ( 93 | select 94 | region, 95 | count(*) as total 96 | from 97 | aws_cloudtrail_trail 98 | where 99 | not is_multi_region_trail 100 | and not is_organization_trail 101 | group by 102 | region 103 | ) 104 | select 105 | arn as resource, 106 | case 107 | when global_trails.total > 0 then 'alarm' 108 | when org_trails.total > 0 then 'alarm' 109 | when regional_trails.total > 1 then 'alarm' 110 | else 'ok' 111 | end as status, 112 | case 113 | when global_trails.total > 0 then name || ' is redundant to a global trail.' 114 | when org_trails.total > 0 then name || ' is redundant to a organizational trail.' 115 | when regional_trails.total > 1 then name || ' is one of ' || regional_trails.total || ' trails in ' || t.region || '.' 116 | else name || ' is the only global trail.' 117 | end as reason 118 | ${local.tag_dimensions_sql} 119 | ${replace(local.common_dimensions_qualifier_sql, "__QUALIFIER__", "t.")} 120 | from 121 | aws_cloudtrail_trail t, 122 | global_trails, 123 | org_trails, 124 | regional_trails 125 | where 126 | regional_trails.region = t.region 127 | and not is_multi_region_trail 128 | and not is_organization_trail; 129 | EOQ 130 | } 131 | -------------------------------------------------------------------------------- /controls/cost_explorer.pp: -------------------------------------------------------------------------------- 1 | variable "cost_explorer_service_cost_max_cost_units" { 2 | type = number 3 | description = "The maximum difference in cost units allowed for service costs between the current and previous month." 4 | default = 10 5 | } 6 | 7 | locals { 8 | cost_explorer_common_tags = merge(local.aws_thrifty_common_tags, { 9 | service = "AWS/CostExplorer" 10 | }) 11 | } 12 | 13 | benchmark "cost_explorer" { 14 | title = "Cost Explorer Checks" 15 | description = "Thrifty developers actively monitor their cloud usage and cost data." 16 | documentation = file("./controls/docs/cost-explorer.md") 17 | children = [ 18 | control.full_month_cost_changes 19 | ] 20 | 21 | tags = merge(local.cost_explorer_common_tags, { 22 | type = "Benchmark" 23 | }) 24 | } 25 | 26 | control "full_month_cost_changes" { 27 | title = "What services have changed in cost over last two months?" 28 | description = "Compares the cost of services between the last two full months of AWS usage." 29 | severity = "low" 30 | 31 | param "cost_explorer_service_cost_max_cost_units" { 32 | description = "The maximum difference in cost units allowed for service costs between the current and previous month." 33 | default = var.cost_explorer_service_cost_max_cost_units 34 | } 35 | 36 | tags = merge(local.cost_explorer_common_tags, { 37 | class = "managed" 38 | }) 39 | sql = <<-EOQ 40 | with 41 | base_month as ( 42 | select 43 | dimension_1 as service_name, 44 | replace(lower(trim(dimension_1)), ' ', '-') as service, 45 | partition, 46 | account_id, 47 | _ctx, 48 | net_unblended_cost_unit as unit, 49 | sum(net_unblended_cost_amount) as cost, 50 | region 51 | from 52 | aws_cost_usage 53 | where 54 | granularity = 'MONTHLY' 55 | and dimension_type_1 = 'SERVICE' 56 | and dimension_type_2 = 'RECORD_TYPE' 57 | and dimension_2 not in ('Credit') 58 | and period_start >= date_trunc('month', current_date - interval '2' month) 59 | and period_start < date_trunc('month', current_date - interval '1' month) 60 | group by 61 | 1,2,3,4,5,unit,region 62 | ), 63 | prev_month as ( 64 | select 65 | dimension_1 as service_name, 66 | replace(lower(trim(dimension_1)), ' ', '-') as service, 67 | partition, 68 | account_id, 69 | _ctx, 70 | net_unblended_cost_unit as unit, 71 | sum(net_unblended_cost_amount) as cost, 72 | region 73 | from 74 | aws_cost_usage 75 | where 76 | granularity = 'MONTHLY' 77 | and dimension_type_1 = 'SERVICE' 78 | and dimension_type_2 = 'RECORD_TYPE' 79 | and dimension_2 not in ('Credit') 80 | and period_start >= date_trunc('month', current_date - interval '1' month) 81 | and period_start < date_trunc('month', current_date ) 82 | group by 83 | 1,2,3,4,5,unit,region 84 | ) 85 | select 86 | case 87 | when prev_month.service_name is null then 'arn:' || base_month.partition || ':::' || base_month.account_id || ':cost/' || base_month.service 88 | else 'arn:' || prev_month.partition || ':::' || prev_month.account_id || ':cost/' || prev_month.service 89 | end as resource, 90 | case 91 | when base_month.cost is null then 'info' 92 | when prev_month.cost is null then 'ok' 93 | -- adjust this value to change threshold for the alarm 94 | when (prev_month.cost - base_month.cost) > $1 then 'alarm' 95 | else 'ok' 96 | end as status, 97 | case 98 | when base_month.cost is null then prev_month.service_name || ' usage is new this month with a spend of ' || round(cast(prev_month.cost as numeric), 2) || ' ' || prev_month.unit 99 | when prev_month.cost is null then 'No usage billing for ' || base_month.service_name || ' in current month.' 100 | when abs(prev_month.cost - base_month.cost) < 0.01 then prev_month.service_name || ' has remained flat.' 101 | when prev_month.cost > base_month.cost then prev_month.service_name || ' usage has increased by ' || round(cast((prev_month.cost - base_month.cost) as numeric), 2) || ' ' || prev_month.unit 102 | else prev_month.service_name || ' usage has decreased (' || round(cast((base_month.cost - prev_month.cost) as numeric), 2) || ') ' || prev_month.unit 103 | end as reason 104 | ${replace(local.common_dimensions_qualifier_sql, "__QUALIFIER__", "prev_month.")} 105 | from 106 | base_month 107 | full outer join prev_month on base_month.service_name = prev_month.service_name 108 | where 109 | prev_month.cost != base_month.cost 110 | order by 111 | (prev_month.cost - base_month.cost) desc; 112 | EOQ 113 | } 114 | -------------------------------------------------------------------------------- /controls/lambda.pp: -------------------------------------------------------------------------------- 1 | locals { 2 | lambda_common_tags = merge(local.aws_thrifty_common_tags, { 3 | service = "AWS/Lambda" 4 | }) 5 | } 6 | 7 | benchmark "lambda" { 8 | title = "Lambda Checks" 9 | description = "Thrifty developers ensure their Lambda functions are optimized." 10 | documentation = file("./controls/docs/lambda.md") 11 | 12 | children = [ 13 | control.lambda_function_excessive_timeout, 14 | control.lambda_function_high_error_rate, 15 | control.lambda_function_with_graviton 16 | ] 17 | 18 | tags = merge(local.lambda_common_tags, { 19 | type = "Benchmark" 20 | }) 21 | } 22 | 23 | control "lambda_function_high_error_rate" { 24 | title = "Are there any lambda functions with high error rate?" 25 | description = "Function errors may result in retries that incur extra charges. The control checks for functions with an error rate of more than 10% a day in one of the last 7 days." 26 | severity = "low" 27 | tags = merge(local.lambda_common_tags, { 28 | class = "managed" 29 | }) 30 | 31 | sql = <<-EOQ 32 | with error_rate as ( 33 | select 34 | errors.name as name, 35 | sum(errors.sum)/sum(invocations.sum)*100 as error_rate 36 | from 37 | aws_lambda_function_metric_errors_daily as errors , aws_lambda_function_metric_invocations_daily as invocations 38 | where 39 | date_part('day', now() - errors.timestamp) <=7 and errors.name = invocations.name 40 | group by 41 | errors.name 42 | ) 43 | select 44 | arn as resource, 45 | case 46 | when error_rate is null then 'error' 47 | when error_rate > 10 then 'alarm' 48 | else 'ok' 49 | end as status, 50 | case 51 | when error_rate is null then 'CloudWatch Lambda function metrics not available for ' || title || '.' 52 | else title || ' error rate is ' || error_rate || '% the last ' || '7 days.' 53 | end as reason 54 | ${local.tag_dimensions_sql} 55 | ${local.common_dimensions_sql} 56 | from 57 | aws_lambda_function f 58 | left join error_rate as er on f.name = er.name; 59 | EOQ 60 | } 61 | 62 | control "lambda_function_excessive_timeout" { 63 | title = "Are there any lambda functions with excessive timeout?" 64 | description = "Excessive timeouts result in retries and additional execution time for the function, incurring request charges and billed duration. The control checks for functions with a timeout rate of more than 10% a day in one of the last 7 days." 65 | severity = "low" 66 | tags = merge(local.lambda_common_tags, { 67 | class = "managed" 68 | }) 69 | 70 | sql = <<-EOQ 71 | with lambda_duration as ( 72 | select 73 | name, 74 | avg(average:: numeric) as avg_duration 75 | from 76 | aws_lambda_function_metric_duration_daily 77 | where 78 | date_part('day', now() - timestamp) <=7 79 | group by 80 | name 81 | ) 82 | select 83 | arn as resource, 84 | case 85 | when avg_duration is null then 'error' 86 | when ((timeout :: numeric*1000) - avg_duration)/(timeout :: numeric*1000) > 0.1 then 'alarm' 87 | else 'ok' 88 | end as status, 89 | case 90 | when avg_duration is null then 'CloudWatch lambda metrics not available for ' || title || '.' 91 | else title || ' Timeout of ' || timeout::numeric*1000 || ' milliseconds is ' || round(((timeout :: numeric*1000)-avg_duration)/(timeout :: numeric*1000)*100,1) || '% more as compared to average of ' || round(avg_duration,0) || ' milliseconds.' 92 | end as reason 93 | ${local.tag_dimensions_sql} 94 | ${local.common_dimensions_sql} 95 | from 96 | aws_lambda_function f 97 | left join lambda_duration as d on f.name = d.name; 98 | EOQ 99 | } 100 | 101 | control "lambda_function_with_graviton" { 102 | title = "Are there any lambda functions without graviton processor?" 103 | description = "With graviton processor (arm64 - 64-bit ARM architecture), you can save money in two ways. First, your functions run more efficiently due to the Graviton architecture. Second, you pay less for the time that they run. In fact, Lambda functions powered by Graviton are designed to deliver up to 19 percent better performance at 20 percent lower cost." 104 | severity = "low" 105 | 106 | tags = merge(local.lambda_common_tags, { 107 | class = "deprecated" 108 | }) 109 | 110 | sql = <<-EOQ 111 | select 112 | arn as resource, 113 | case 114 | when architecture = 'arm64' then 'ok' 115 | else 'alarm' 116 | end as status, 117 | case 118 | when architecture = 'arm64' then title || ' is using Graviton processor.' 119 | else title || ' is not using Graviton processor.' 120 | end as reason 121 | ${local.tag_dimensions_sql} 122 | ${local.common_dimensions_sql} 123 | from 124 | aws_lambda_function, 125 | jsonb_array_elements_text(architectures) as architecture; 126 | EOQ 127 | 128 | } 129 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AWS Thrifty Mod for Powerpipe 2 | 3 | An AWS cost savings and waste checking tool. 4 | 5 | Run checks in a dashboard: 6 | 7 | ![image](https://raw.githubusercontent.com/turbot/steampipe-mod-aws-thrifty/main/docs/aws_thrifty_dashboard.png) 8 | 9 | Or in a terminal: 10 | 11 | ![image](https://raw.githubusercontent.com/turbot/steampipe-mod-aws-thrifty/main/docs/aws_thrifty_mod_terminal.png) 12 | 13 | ## Documentation 14 | 15 | - **[Benchmarks and controls →](https://hub.powerpipe.io/mods/turbot/aws_thrifty/controls)** 16 | - **[Named queries →](https://hub.powerpipe.io/mods/turbot/aws_thrifty/queries)** 17 | 18 | ## Getting Started 19 | 20 | ### Installation 21 | 22 | Install Powerpipe (https://powerpipe.io/downloads), or use Brew: 23 | 24 | ```sh 25 | brew install turbot/tap/powerpipe 26 | ``` 27 | 28 | This mod also requires [Steampipe](https://steampipe.io) with the [AWS plugin](https://hub.steampipe.io/plugins/turbot/aws) as the data source. Install Steampipe (https://steampipe.io/downloads), or use Brew: 29 | 30 | ```sh 31 | brew install turbot/tap/steampipe 32 | steampipe plugin install aws 33 | ``` 34 | 35 | Steampipe will automatically use your default AWS credentials. Optionally, you can [setup multiple accounts](https://hub.steampipe.io/plugins/turbot/aws#multi-account-connections) or [customize AWS credentials](https://hub.steampipe.io/plugins/turbot/aws#configuring-aws-credentials). 36 | 37 | Finally, install the mod: 38 | 39 | ```sh 40 | mkdir dashboards 41 | cd dashboards 42 | powerpipe mod init 43 | powerpipe mod install github.com/turbot/steampipe-mod-aws-thrifty 44 | ``` 45 | 46 | ### Browsing Dashboards 47 | 48 | Start Steampipe as the data source: 49 | 50 | ```sh 51 | steampipe service start 52 | ``` 53 | 54 | Start the dashboard server: 55 | 56 | ```sh 57 | powerpipe server 58 | ``` 59 | 60 | Browse and view your dashboards at **http://localhost:9033**. 61 | 62 | ### Running Checks in Your Terminal 63 | 64 | Instead of running benchmarks in a dashboard, you can also run them within your 65 | terminal with the `powerpipe benchmark` command: 66 | 67 | List available benchmarks: 68 | 69 | ```sh 70 | powerpipe benchmark list 71 | ``` 72 | 73 | Run a benchmark: 74 | 75 | ```sh 76 | powerpipe benchmark run ec2 77 | ``` 78 | 79 | Different output formats are also available, for more information please see 80 | [Output Formats](https://powerpipe.io/docs/reference/cli/benchmark#output-formats). 81 | 82 | ### Configure Variables 83 | 84 | Several benchmarks have [input variables](https://powerpipe.io/docs/build/mod-variables#input-variables) that can be configured to better match your environment and requirements. Each variable has a default defined in its source file, e.g., `controls/rds.sp`, but these can be overwritten in several ways: 85 | 86 | It's easiest to setup your vars file, starting with the sample: 87 | 88 | ```sh 89 | cp powerpipe.ppvars.example powerpipe.ppvars 90 | vi powerpipe.ppvars 91 | ``` 92 | 93 | Alternatively you can pass variables on the command line: 94 | 95 | ```sh 96 | powerpipe benchmark run ec2 --var=ec2_running_instance_age_max_days=90 97 | ``` 98 | 99 | Or through environment variables: 100 | 101 | ```sh 102 | export PP_VAR_ec2_running_instance_age_max_days=90 103 | powerpipe control run long_running_ec2_instances 104 | ``` 105 | 106 | These are only some of the ways you can set variables. For a full list, please see [Passing Input Variables](https://powerpipe.io/docs/build/mod-variables#passing-input-variables). 107 | 108 | ### Common and Tag Dimensions 109 | 110 | The benchmark queries use common properties (like `account_id`, `connection_name` and `region`) and tags that are defined in the form of a default list of strings in the `variables.sp` file. These properties can be overwritten in several ways: 111 | 112 | It's easiest to setup your vars file, starting with the sample: 113 | 114 | ```sh 115 | cp powerpipe.ppvars.example powerpipe.ppvars 116 | vi powerpipe.ppvars 117 | ``` 118 | 119 | Alternatively you can pass variables on the command line: 120 | 121 | ```sh 122 | powerpipe benchmark run cloudfront --var 'tag_dimensions=["Environment", "Owner"]' 123 | ``` 124 | 125 | Or through environment variables: 126 | 127 | ```sh 128 | export PP_VAR_common_dimensions='["account_id", "connection_name", "region"]' 129 | export PP_VAR_tag_dimensions='["Environment", "Owner"]' 130 | powerpipe benchmark run cloudfront 131 | ``` 132 | 133 | ## Open Source & Contributing 134 | 135 | This repository is published under the [Apache 2.0 license](https://www.apache.org/licenses/LICENSE-2.0). Please see our [code of conduct](https://github.com/turbot/.github/blob/main/CODE_OF_CONDUCT.md). We look forward to collaborating with you! 136 | 137 | [Steampipe](https://steampipe.io) and [Powerpipe](https://powerpipe.io) are products produced from this open source software, exclusively by [Turbot HQ, Inc](https://turbot.com). They are distributed under our commercial terms. Others are allowed to make their own distribution of the software, but cannot use any of the Turbot trademarks, cloud services, etc. You can learn more in our [Open Source FAQ](https://turbot.com/open-source). 138 | 139 | ## Get Involved 140 | 141 | **[Join #powerpipe on Slack →](https://turbot.com/community/join)** 142 | 143 | Want to help but don't know where to start? Pick up one of the `help wanted` issues: 144 | 145 | - [Powerpipe](https://github.com/turbot/powerpipe/labels/help%20wanted) 146 | - [AWS Thrifty Mod](https://github.com/turbot/steampipe-mod-aws-thrifty/labels/help%20wanted) 147 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # AWS Thrifty Mod 2 | 3 | Be Thrifty on AWS! This mod checks for unused resources and opportunities to optimize your spend on AWS. 4 | 5 | 6 | 7 | 8 | 9 | ## Documentation 10 | 11 | - **[Benchmarks and controls →](https://hub.powerpipe.io/mods/turbot/aws_thrifty/controls)** 12 | - **[Named queries →](https://hub.powerpipe.io/mods/turbot/aws_thrifty/queries)** 13 | 14 | ## Getting Started 15 | 16 | ### Installation 17 | 18 | Install Powerpipe (https://powerpipe.io/downloads), or use Brew: 19 | 20 | ```sh 21 | brew install turbot/tap/powerpipe 22 | ``` 23 | 24 | This mod also requires [Steampipe](https://steampipe.io) with the [AWS plugin](https://hub.steampipe.io/plugins/turbot/aws) as the data source. Install Steampipe (https://steampipe.io/downloads), or use Brew: 25 | 26 | ```sh 27 | brew install turbot/tap/steampipe 28 | steampipe plugin install aws 29 | ``` 30 | 31 | Steampipe will automatically use your default AWS credentials. Optionally, you can [setup multiple accounts](https://hub.steampipe.io/plugins/turbot/aws#multi-account-connections) or [customize AWS credentials](https://hub.steampipe.io/plugins/turbot/aws#configuring-aws-credentials). 32 | 33 | Finally, install the mod: 34 | 35 | ```sh 36 | mkdir dashboards 37 | cd dashboards 38 | powerpipe mod init 39 | powerpipe mod install github.com/turbot/steampipe-mod-aws-thrifty 40 | ``` 41 | 42 | ### Browsing Dashboards 43 | 44 | Start Steampipe as the data source: 45 | 46 | ```sh 47 | steampipe service start 48 | ``` 49 | 50 | Start the dashboard server: 51 | 52 | ```sh 53 | powerpipe server 54 | ``` 55 | 56 | Browse and view your dashboards at **http://localhost:9033**. 57 | 58 | ### Running Checks in Your Terminal 59 | 60 | Instead of running benchmarks in a dashboard, you can also run them within your 61 | terminal with the `powerpipe benchmark` command: 62 | 63 | List available benchmarks: 64 | 65 | ```sh 66 | powerpipe benchmark list 67 | ``` 68 | 69 | Run a benchmark: 70 | 71 | ```sh 72 | powerpipe benchmark run ec2 73 | ``` 74 | 75 | Different output formats are also available, for more information please see 76 | [Output Formats](https://powerpipe.io/docs/reference/cli/benchmark#output-formats). 77 | 78 | ### Configure Variables 79 | 80 | Several benchmarks have [input variables](https://powerpipe.io/docs/build/mod-variables#input-variables) that can be configured to better match your environment and requirements. Each variable has a default defined in its source file, e.g., `controls/rds.sp`, but these can be overwritten in several ways: 81 | 82 | It's easiest to setup your vars file, starting with the sample: 83 | 84 | ```sh 85 | cp powerpipe.ppvars.example powerpipe.ppvars 86 | vi powerpipe.ppvars 87 | ``` 88 | 89 | Alternatively you can pass variables on the command line: 90 | 91 | ```sh 92 | powerpipe benchmark run ec2 --var=ec2_running_instance_age_max_days=90 93 | ``` 94 | 95 | Or through environment variables: 96 | 97 | ```sh 98 | export PP_VAR_ec2_running_instance_age_max_days=90 99 | powerpipe control run long_running_ec2_instances 100 | ``` 101 | 102 | These are only some of the ways you can set variables. For a full list, please see [Passing Input Variables](https://powerpipe.io/docs/build/mod-variables#passing-input-variables). 103 | 104 | ### Common and Tag Dimensions 105 | 106 | The benchmark queries use common properties (like `account_id`, `connection_name` and `region`) and tags that are defined in the form of a default list of strings in the `variables.sp` file. These properties can be overwritten in several ways: 107 | 108 | It's easiest to setup your vars file, starting with the sample: 109 | 110 | ```sh 111 | cp powerpipe.ppvars.example powerpipe.ppvars 112 | vi powerpipe.ppvars 113 | ``` 114 | 115 | Alternatively you can pass variables on the command line: 116 | 117 | ```sh 118 | powerpipe benchmark run cloudfront --var 'tag_dimensions=["Environment", "Owner"]' 119 | ``` 120 | 121 | Or through environment variables: 122 | 123 | ```sh 124 | export PP_VAR_common_dimensions='["account_id", "connection_name", "region"]' 125 | export PP_VAR_tag_dimensions='["Environment", "Owner"]' 126 | powerpipe benchmark run cloudfront 127 | ``` 128 | 129 | ## Open Source & Contributing 130 | 131 | This repository is published under the [Apache 2.0 license](https://www.apache.org/licenses/LICENSE-2.0). Please see our [code of conduct](https://github.com/turbot/.github/blob/main/CODE_OF_CONDUCT.md). We look forward to collaborating with you! 132 | 133 | [Steampipe](https://steampipe.io) and [Powerpipe](https://powerpipe.io) are products produced from this open source software, exclusively by [Turbot HQ, Inc](https://turbot.com). They are distributed under our commercial terms. Others are allowed to make their own distribution of the software, but cannot use any of the Turbot trademarks, cloud services, etc. You can learn more in our [Open Source FAQ](https://turbot.com/open-source). 134 | 135 | ## Get Involved 136 | 137 | **[Join #powerpipe on Slack →](https://turbot.com/community/join)** 138 | 139 | Want to help but don't know where to start? Pick up one of the `help wanted` issues: 140 | 141 | - [Powerpipe](https://github.com/turbot/powerpipe/labels/help%20wanted) 142 | - [AWS Thrifty Mod](https://github.com/turbot/steampipe-mod-aws-thrifty/labels/help%20wanted) 143 | -------------------------------------------------------------------------------- /controls/ecs.pp: -------------------------------------------------------------------------------- 1 | variable "ecs_cluster_avg_cpu_utilization_high" { 2 | type = number 3 | description = "The average CPU utilization required for clusters to be considered frequently used. This value should be higher than ecs_cluster_avg_cpu_utilization_low." 4 | default = 35 5 | } 6 | 7 | variable "ecs_cluster_avg_cpu_utilization_low" { 8 | type = number 9 | description = "The average CPU utilization required for clusters to be considered infrequently used. This value should be lower than ecs_cluster_avg_cpu_utilization_high." 10 | default = 20 11 | } 12 | 13 | locals { 14 | ecs_common_tags = merge(local.aws_thrifty_common_tags, { 15 | service = "AWS/ECS" 16 | }) 17 | } 18 | 19 | benchmark "ecs" { 20 | title = "ECS Checks" 21 | description = "Thrifty developers checks under-utilized ECS clusters and ECS service without autoscaling configuration." 22 | documentation = file("./controls/docs/ecs.md") 23 | children = [ 24 | control.ecs_cluster_container_instance_with_graviton, 25 | control.ecs_cluster_low_utilization, 26 | control.ecs_service_without_autoscaling 27 | ] 28 | 29 | tags = merge(local.ecs_common_tags, { 30 | type = "Benchmark" 31 | }) 32 | } 33 | 34 | control "ecs_cluster_container_instance_with_graviton" { 35 | title = "ECS cluster container instances without graviton processor should be reviewed" 36 | description = "With graviton processor (arm64 - 64-bit ARM architecture), you can save money in two ways. First, your functions run more efficiently due to the Graviton architecture. Second, you pay less for the time that they run. In fact, Lambda functions powered by Graviton are designed to deliver up to 19 percent better performance at 20 percent lower cost." 37 | severity = "low" 38 | 39 | tags = merge(local.ecs_common_tags, { 40 | class = "deprecated" 41 | }) 42 | 43 | sql = <<-EOQ 44 | select 45 | c.arn as resource, 46 | case 47 | when i.platform = 'windows' then 'skip' 48 | when i.architecture = 'arm64' then 'ok' 49 | else 'alarm' 50 | end as status, 51 | case 52 | when i.platform = 'windows' then i.title || ' is windows type machine.' 53 | when i.architecture = 'arm64' then i.title || ' is using Graviton processor.' 54 | else i.title || ' is not using Graviton processor.' 55 | end as reason 56 | ${replace(local.tag_dimensions_qualifier_sql, "__QUALIFIER__", "c.")} 57 | ${replace(local.common_dimensions_qualifier_sql, "__QUALIFIER__", "c.")} 58 | from 59 | aws_ecs_container_instance as c 60 | left join aws_ec2_instance as i on c.ec2_instance_id = i.instance_id; 61 | EOQ 62 | } 63 | 64 | control "ecs_cluster_low_utilization" { 65 | title = "ECS clusters with low CPU utilization should be reviewed" 66 | description = "Resize or eliminate under utilized clusters." 67 | severity = "low" 68 | 69 | param "ecs_cluster_avg_cpu_utilization_low" { 70 | description = "The average CPU utilization required for clusters to be considered infrequently used. This value should be lower than ecs_cluster_avg_cpu_utilization_high." 71 | default = var.ecs_cluster_avg_cpu_utilization_low 72 | } 73 | 74 | param "ecs_cluster_avg_cpu_utilization_high" { 75 | description = "The average CPU utilization required for clusters to be considered frequently used. This value should be higher than ecs_cluster_avg_cpu_utilization_low." 76 | default = var.ecs_cluster_avg_cpu_utilization_high 77 | } 78 | 79 | tags = merge(local.ecs_common_tags, { 80 | class = "unused" 81 | }) 82 | 83 | sql = <<-EOQ 84 | with ecs_cluster_utilization as ( 85 | select 86 | cluster_name, 87 | round(cast(sum(maximum)/count(maximum) as numeric), 1) as avg_max, 88 | count(maximum) days 89 | from 90 | aws_ecs_cluster_metric_cpu_utilization_daily 91 | where 92 | date_part('day', now() - timestamp) <= 30 93 | group by 94 | cluster_name 95 | ) 96 | select 97 | i.cluster_name as resource, 98 | case 99 | when avg_max is null then 'error' 100 | when avg_max < $1 then 'alarm' 101 | when avg_max < $2 then 'info' 102 | else 'ok' 103 | end as status, 104 | case 105 | when avg_max is null then 'CloudWatch metrics not available for ' || title || '.' 106 | else title || ' is averaging ' || avg_max || '% max utilization over the last ' || days || ' days.' 107 | end as reason 108 | ${local.tag_dimensions_sql} 109 | ${local.common_dimensions_sql} 110 | from 111 | aws_ecs_cluster as i 112 | left join ecs_cluster_utilization as u on u.cluster_name = i.cluster_name; 113 | EOQ 114 | } 115 | 116 | control "ecs_service_without_autoscaling" { 117 | title = "ECS service should use autoscaling policy" 118 | description = "ECS service should use autoscaling policy to improve service performance in a cost-efficient way." 119 | severity = "low" 120 | 121 | tags = merge(local.ecs_common_tags, { 122 | class = "managed" 123 | }) 124 | 125 | sql = <<-EOQ 126 | with service_with_autoscaling as ( 127 | select 128 | distinct split_part(t.resource_id, '/', 2) as cluster_name, 129 | split_part(t.resource_id, '/', 3) as service_name 130 | from 131 | aws_appautoscaling_target as t 132 | where 133 | t.service_namespace = 'ecs' 134 | ) 135 | select 136 | s.arn as resource, 137 | case 138 | when s.launch_type != 'FARGATE' then 'skip' 139 | when a.service_name is null then 'alarm' 140 | else 'ok' 141 | end as status, 142 | case 143 | when s.launch_type != 'FARGATE' then s.title || ' task not running on FARGATE.' 144 | when a.service_name is null then s.title || ' autoscaling disabled.' 145 | else s.title || ' autoscaling enabled.' 146 | end as reason 147 | ${local.tag_dimensions_sql} 148 | ${replace(local.common_dimensions_qualifier_sql, "__QUALIFIER__", "s.")} 149 | from 150 | aws_ecs_service as s 151 | left join service_with_autoscaling as a on s.service_name = a.service_name and a.cluster_name = split_part(s.cluster_arn, '/', 2); 152 | EOQ 153 | } 154 | -------------------------------------------------------------------------------- /controls/redshift.pp: -------------------------------------------------------------------------------- 1 | variable "redshift_cluster_avg_cpu_utilization_high" { 2 | type = number 3 | description = "The average CPU utilization required for clusters to be considered frequently used. This value should be higher than redshift_cluster_avg_cpu_utilization_low." 4 | default = 35 5 | } 6 | 7 | variable "redshift_cluster_avg_cpu_utilization_low" { 8 | type = number 9 | description = "The average CPU utilization required for clusters to be considered infrequently used. This value should be lower than redshift_cluster_avg_cpu_utilization_high." 10 | default = 20 11 | } 12 | 13 | variable "redshift_running_cluster_age_max_days" { 14 | type = number 15 | description = "The maximum number of days clusters are allowed to run." 16 | default = 90 17 | } 18 | 19 | variable "redshift_running_cluster_age_warning_days" { 20 | type = number 21 | description = "The number of days clusters can be running before sending a warning." 22 | default = 30 23 | } 24 | 25 | locals { 26 | redshift_common_tags = merge(local.aws_thrifty_common_tags, { 27 | service = "AWS/Redshift" 28 | }) 29 | } 30 | 31 | benchmark "redshift" { 32 | title = "Redshift Checks" 33 | description = "Thrifty developers check their long running Redshift clusters are associated with reserved nodes." 34 | documentation = file("./controls/docs/redshift.md") 35 | children = [ 36 | control.redshift_cluster_low_utilization, 37 | control.redshift_cluster_max_age, 38 | control.redshift_cluster_schedule_pause_resume_enabled 39 | ] 40 | 41 | tags = merge(local.redshift_common_tags, { 42 | type = "Benchmark" 43 | }) 44 | } 45 | 46 | control "redshift_cluster_max_age" { 47 | title = "Long running Redshift clusters should have reserved nodes purchased for them" 48 | description = "Long running clusters should be associated with reserved nodes, which provide a significant discount." 49 | severity = "low" 50 | 51 | param "redshift_running_cluster_age_max_days" { 52 | description = "The maximum number of days clusters are allowed to run." 53 | default = var.redshift_running_cluster_age_max_days 54 | } 55 | 56 | param "redshift_running_cluster_age_warning_days" { 57 | description = "The number of days clusters can be running before sending a warning." 58 | default = var.redshift_running_cluster_age_warning_days 59 | } 60 | 61 | tags = merge(local.redshift_common_tags, { 62 | class = "managed" 63 | }) 64 | 65 | sql = <<-EOQ 66 | select 67 | arn as resource, 68 | case 69 | when date_part('day', now() - cluster_create_time) > $1 then 'alarm' 70 | when date_part('day', now() - cluster_create_time) > $2 then 'info' 71 | else 'ok' 72 | end as status, 73 | title || ' created on ' || cluster_create_time || ' (' || date_part('day', now() - cluster_create_time) || ' days).' 74 | as reason 75 | ${local.tag_dimensions_sql} 76 | ${local.common_dimensions_sql} 77 | from 78 | aws_redshift_cluster; 79 | EOQ 80 | } 81 | 82 | control "redshift_cluster_schedule_pause_resume_enabled" { 83 | title = "Redshift cluster paused resume should be enabled" 84 | description = "Redshift cluster paused resume should be enabled to easily suspend on-demand billing while the cluster is not being used." 85 | severity = "low" 86 | tags = merge(local.redshift_common_tags, { 87 | class = "managed" 88 | }) 89 | 90 | sql = <<-EOQ 91 | with cluster_pause_enabled as ( 92 | select 93 | arn, 94 | s -> 'TargetAction' -> 'PauseCluster' ->> 'ClusterIdentifier' as pause_cluster 95 | from 96 | aws_redshift_cluster, 97 | jsonb_array_elements(scheduled_actions) as s 98 | where 99 | s -> 'TargetAction' -> 'PauseCluster' ->> 'ClusterIdentifier' is not null 100 | ), 101 | cluster_resume_enabled as ( 102 | select 103 | arn, 104 | s -> 'TargetAction' -> 'ResumeCluster' ->> 'ClusterIdentifier' as resume_cluster 105 | from 106 | aws_redshift_cluster, 107 | jsonb_array_elements(scheduled_actions) as s 108 | where 109 | s -> 'TargetAction' -> 'ResumeCluster' ->> 'ClusterIdentifier' is not null 110 | ), 111 | pause_and_resume_enabled as ( 112 | select 113 | p.arn 114 | from 115 | cluster_pause_enabled as p 116 | left join cluster_resume_enabled as r on r.arn = p.arn 117 | where 118 | p.pause_cluster = r.resume_cluster 119 | ) 120 | select 121 | a.arn as resource, 122 | case 123 | when b.arn is not null then 'ok' 124 | else 'info' 125 | end as status, 126 | case 127 | when b.arn is not null then a.title || ' pause-resume action enabled.' 128 | else a.title || ' pause-resume action not enabled.' 129 | end as reason 130 | ${local.tag_dimensions_sql} 131 | ${replace(local.common_dimensions_qualifier_sql, "__QUALIFIER__", "a.")} 132 | from 133 | aws_redshift_cluster as a 134 | left join pause_and_resume_enabled as b on a.arn = b.arn; 135 | EOQ 136 | } 137 | 138 | control "redshift_cluster_low_utilization" { 139 | title = "Redshift cluster with low CPU utilization should be reviewed" 140 | description = "Resize or eliminate under utilized clusters." 141 | severity = "low" 142 | 143 | param "redshift_cluster_avg_cpu_utilization_low" { 144 | description = "The average CPU utilization required for clusters to be considered infrequently used. This value should be lower than redshift_cluster_avg_cpu_utilization_high." 145 | default = var.redshift_cluster_avg_cpu_utilization_low 146 | } 147 | 148 | param "redshift_cluster_avg_cpu_utilization_high" { 149 | description = "The average CPU utilization required for clusters to be considered frequently used. This value should be higher than redshift_cluster_avg_cpu_utilization_low." 150 | default = var.redshift_cluster_avg_cpu_utilization_high 151 | } 152 | 153 | tags = merge(local.redshift_common_tags, { 154 | class = "unused" 155 | }) 156 | 157 | sql = <<-EOQ 158 | with redshift_cluster_utilization as ( 159 | select 160 | cluster_identifier, 161 | round(cast(sum(maximum)/count(maximum) as numeric), 1) as avg_max, 162 | count(maximum) days 163 | from 164 | aws_redshift_cluster_metric_cpu_utilization_daily 165 | where 166 | date_part('day', now() - timestamp) <= 30 167 | group by 168 | cluster_identifier 169 | ) 170 | select 171 | i.cluster_identifier as resource, 172 | case 173 | when avg_max is null then 'error' 174 | when avg_max < $1 then 'alarm' 175 | when avg_max < $2 then 'info' 176 | else 'ok' 177 | end as status, 178 | case 179 | when avg_max is null then 'CloudWatch metrics not available for ' || title || '.' 180 | else title || ' is averaging ' || avg_max || '% max utilization over the last ' || days || ' days.' 181 | end as reason 182 | ${local.tag_dimensions_sql} 183 | ${local.common_dimensions_sql} 184 | from 185 | aws_redshift_cluster as i 186 | left join redshift_cluster_utilization as u on u.cluster_identifier = i.cluster_identifier; 187 | EOQ 188 | } 189 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v1.1.0 [2025-06-04] 2 | 3 | _What's new?_ 4 | 5 | - New controls added: 6 | - `apigateway_stage_with_caching_disabled` ([#186](https://github.com/turbot/steampipe-mod-aws-thrifty/pull/186)) 7 | - `dynamodb_table_without_autoscaling` ([#187](https://github.com/turbot/steampipe-mod-aws-thrifty/pull/187)) 8 | - `ecr_repository_unused_images` ([#183](https://github.com/turbot/steampipe-mod-aws-thrifty/pull/183)) 9 | - `rds_unused_snapshots` ([#183](https://github.com/turbot/steampipe-mod-aws-thrifty/pull/183)) 10 | 11 | ## v1.0.1 [2024-10-24] 12 | 13 | _Bug fixes_ 14 | 15 | - Renamed `steampipe.spvars.example` files to `powerpipe.ppvars.example` and updated documentation. ([#180](https://github.com/turbot/steampipe-mod-aws-thrifty/pull/180)) 16 | 17 | ## v1.0.0 [2024-10-22] 18 | 19 | This mod now requires [Powerpipe](https://powerpipe.io). [Steampipe](https://steampipe.io) users should check the [migration guide](https://powerpipe.io/blog/migrating-from-steampipe). 20 | 21 | ## v0.29 [2024-03-27] 22 | 23 | _What's new?_ 24 | 25 | - New control added: 26 | - `rds_mysql_postresql_db_no_unsupported_version` ([#174](https://github.com/turbot/steampipe-mod-aws-thrifty/pull/174)) 27 | 28 | ## v0.28 [2024-04-06] 29 | 30 | _Powerpipe_ 31 | 32 | [Powerpipe](https://powerpipe.io) is now the preferred way to run this mod! [Migrating from Steampipe →](https://powerpipe.io/blog/migrating-from-steampipe) 33 | 34 | All v0.x versions of this mod will work in both Steampipe and Powerpipe, but v1.0.0 onwards will be in Powerpipe format only. 35 | 36 | _Enhancements_ 37 | 38 | - Focus documentation on Powerpipe commands. 39 | - Show how to combine Powerpipe mods with Steampipe plugins. 40 | 41 | ## v0.27 [2024-01-22] 42 | 43 | _Bug fixes_ 44 | 45 | - Fixed the `low_iops_ebs_volumes` control to now suggest converting `io1` and `io2` volumes to `GP3` volumes, when the base `IOPS` is less than `16,000` instead of `3000`. ([#167](https://github.com/turbot/steampipe-mod-aws-thrifty/pull/167)) 46 | 47 | ## v0.26 [2023-11-03] 48 | 49 | _Breaking changes_ 50 | 51 | - Updated the plugin dependency section of the mod to use `min_version` instead of `version`. ([#161](https://github.com/turbot/steampipe-mod-aws-thrifty/pull/161)) 52 | - Renamed the control `lambda_function_with_graviton2` to `lambda_function_with_graviton` in order to maintain consistency. ([#158](https://github.com/turbot/steampipe-mod-aws-thrifty/pull/158)) (Thanks [@bluedoors](https://github.com/bluedoors) for the contribution!) 53 | 54 | ## v0.25 [2023-07-28] 55 | 56 | _What's new?_ 57 | 58 | - Added the following controls to check which resources are using non-graviton processors: ([#144](https://github.com/turbot/steampipe-mod-aws-thrifty/pull/144)) 59 | - `ec2_instance_with_graviton` 60 | - `ecs_cluster_container_instance_with_graviton` 61 | - `eks_node_group_with_graviton` 62 | - `rds_db_instance_with_graviton` 63 | 64 | ## v0.24 [2023-07-24] 65 | 66 | _Bug fixes_ 67 | 68 | - Fixed the inline query of the `vpc_nat_gateway_unused` control to correctly list out the unused NAT gateways that should be deleted. ([#150](https://github.com/turbot/steampipe-mod-aws-thrifty/pull/150)) 69 | 70 | ## v0.23 [2023-07-13] 71 | 72 | _Bug fixes_ 73 | 74 | - Fixed the inline query of the `multiple_global_trails` control to remove redundant global trails when organization trails are in use. ([#141](https://github.com/turbot/steampipe-mod-aws-thrifty/pull/141)) 75 | - Fixed the inline query of the `ebs_snapshot_max_age` control to correctly list out the old EBS snapshots that should be deleted if not required. ([#147](https://github.com/turbot/steampipe-mod-aws-thrifty/pull/147)) 76 | 77 | ## v0.22 [2023-06-14] 78 | 79 | _Bug fixes_ 80 | 81 | - Fixed the inline query of the `secretsmanager_secret_unused` control to correctly verify if a secret has remained unused for a specified duration. ([#138](https://github.com/turbot/steampipe-mod-aws-thrifty/pull/138)) 82 | 83 | ## v0.21 [2023-05-11] 84 | 85 | _Bug fixes_ 86 | 87 | - Fixed the inline query of `ebs_snapshot_max_age` control to correctly query `aws_ebs_snapshot` table instead of `aws_secretsmanager_secret` table. ([#129](https://github.com/turbot/steampipe-mod-aws-thrifty/pull/129)) 88 | 89 | ## v0.20 [2023-03-22] 90 | 91 | _Enhancements_ 92 | 93 | - Added the column alias to `connection_name` common dimension to avoid having any `?column?` column names due to unaliased columns. 94 | 95 | _Bug fixes_ 96 | 97 | - Fixed the formatting of `vpc_nat_gateway_unused` control's query on [hub.steampipe.io](https://hub.steampipe.io/mods/turbot/aws_thrifty). ([#126](https://github.com/turbot/steampipe-mod-aws-thrifty/pull/126)) 98 | 99 | ## v0.19 [2023-02-03] 100 | 101 | _What's new?_ 102 | 103 | - Added `tags` as dimensions to group and filter findings. (see [var.tag_dimensions](https://hub.steampipe.io/mods/turbot/aws_thrifty/variables)) ([#112](https://github.com/turbot/steampipe-mod-aws-thrifty/pull/112)) 104 | - Added `connection_name` in the common dimensions to group and filter findings. (see [var.common_dimensions](https://hub.steampipe.io/mods/turbot/aws_thrifty/variables)) ([#112](https://github.com/turbot/steampipe-mod-aws-thrifty/pull/112)) 105 | 106 | ## v0.18 [2022-12-27] 107 | 108 | _Bug fixes_ 109 | 110 | - Fixed typo in the [Usage](https://hub.steampipe.io/mods/turbot/aws_thrifty#usage) section of `docs/index.md` to use `steampipe check control.instances_with_low_utilization` instead of `steampipe check control.control.instances_with_low_utilization`. ([#115](https://github.com/turbot/steampipe-mod-aws-thrifty/pull/115)) (Thanks [@PranavPeshwe](https://github.com/PranavPeshwe) for the contribution!) 111 | 112 | ## v0.17 [2022-11-04] 113 | 114 | _Enhancements_ 115 | 116 | - Updated `ec2_gateway_lb_unused` and `ec2_network_lb_unused` queries to correctly handle empty column data. ([#109](https://github.com/turbot/steampipe-mod-aws-thrifty/pull/109)) 117 | 118 | _Dependencies_ 119 | 120 | - AWS plugin `v0.81.0` or higher is now required. 121 | 122 | ## v0.16 [2022-11-03] 123 | 124 | _Bug fixes_ 125 | 126 | - Fixed the `ec2_classic_lb_unused` query to handle the `instances` column correctly when empty in the `aws_ec2_classic_load_balancer` table. ([#106](https://github.com/turbot/steampipe-mod-aws-thrifty/pull/106)) (Thanks [@JoshRosen](https://github.com/JoshRosen) for the fix!) 127 | 128 | ## v0.15 [2022-10-27] 129 | 130 | _What's new?_ 131 | 132 | - New benchmarks added: 133 | - Route 53 Checks (`steampipe check benchmark.route53`) ([#83](https://github.com/turbot/steampipe-mod-aws-thrifty/pull/83)) 134 | - Secrets Manager Checks (`steampipe check benchmark.secretsmanager`) ([#85](https://github.com/turbot/steampipe-mod-aws-thrifty/pull/85)) 135 | - New controls added: 136 | - ec2_instance_older_generation ([#79](https://github.com/turbot/steampipe-mod-aws-thrifty/pull/79)) 137 | - lambda_function_with_graviton2 ([#82](https://github.com/turbot/steampipe-mod-aws-thrifty/pull/82)) 138 | 139 | _Enhancements_ 140 | 141 | - Updated the `unattached_ebs_volumes` query to handle the `attachments` column correctly when empty in the `aws_ebs_volume` table. ([#102](https://github.com/turbot/steampipe-mod-aws-thrifty/pull/102)) 142 | 143 | _Bug fixes_ 144 | 145 | - Fixed the `low_utilization_ec2_instance` query to check for max(average) instead of avg(max) utilization. ([#78](https://github.com/turbot/steampipe-mod-aws-thrifty/pull/78)) 146 | 147 | _Dependencies_ 148 | 149 | - AWS plugin `v0.80.0` or higher is now required. ([#104](https://github.com/turbot/steampipe-mod-aws-thrifty/pull/104)) 150 | 151 | ## v0.14 [2022-05-09] 152 | 153 | _Enhancements_ 154 | 155 | - Updated docs/index.md and README with new dashboard screenshots and latest format. ([#75](https://github.com/turbot/steampipe-mod-aws-thrifty/pull/75)) 156 | 157 | ## v0.13 [2022-04-27] 158 | 159 | _Enhancements_ 160 | 161 | - Added `category`, `service`, and `type` tags to benchmarks and controls. ([#72](https://github.com/turbot/steampipe-mod-aws-thrifty/pull/72)) 162 | 163 | ## v0.12 [2022-04-06] 164 | 165 | _Bug fixes_ 166 | 167 | - Fixed the `old_ebs_snapshots` query to correctly evaluate the age of the snapshots ([#69](https://github.com/turbot/steampipe-mod-aws-thrifty/pull/69)) 168 | 169 | ## v0.11 [2022-03-29] 170 | 171 | _What's new?_ 172 | 173 | - Added default values to all variables (set to the same values in `steampipe.spvars.example`) 174 | - Added `*.spvars` and `*.auto.spvars` files to `.gitignore` 175 | - Renamed `steampipe.spvars` to `steampipe.spvars.example`, so the variable default values will be used initially. To use this example file instead, copy `steampipe.spvars.example` as a new file `steampipe.spvars`, and then modify the variable values in it. For more information on how to set variable values, please see [Input Variable Configuration](https://hub.steampipe.io/mods/turbot/aws_thrifty#configuration). 176 | 177 | ## v0.10 [2021-11-15] 178 | 179 | _Enhancements_ 180 | 181 | - `docs/index.md` file now includes the console output image 182 | 183 | ## v0.9 [2021-09-30] 184 | 185 | _What's new?_ 186 | 187 | - Added: Input variables have been added to CloudWatch, Cost Explorer, DynamoDB, EBS, EC2, ECS, ElastiCache, RDS, and Redshift controls to allow different thresholds to be passed in. To get started, please see [AWS Thrifty Configuration](https://hub.steampipe.io/mods/turbot/aws_thrifty#configuration). For a list of variables and their default values, please see [steampipe.spvars](https://github.com/turbot/steampipe-mod-aws-thrifty/blob/main/steampipe.spvars). 188 | 189 | ## v0.8 [2021-09-09] 190 | 191 | _Enhancements_ 192 | 193 | - Lambda benchmark control and query names have been updated to maintain consistency 194 | 195 | _Bug fixes_ 196 | 197 | - The broken reference links in the Lambda benchmark document have been removed 198 | 199 | ## v0.7 [2021-09-07] 200 | 201 | _What's new?_ 202 | 203 | - Added initial Lambda benchmark and controls 204 | 205 | - New controls added: 206 | - lambda_excessive_timeout 207 | - lambda_high_error_rate 208 | 209 | ## v0.6 [2021-08-25] 210 | 211 | _What's new?_ 212 | 213 | - Added initial CloudFront, ECS and EMR benchmarks and controls along with new controls for the Redshift and the EC2 benchmarks 214 | 215 | - New controls added: 216 | - cloudfront_distribution_pricing_class 217 | - ec2_reserved_instance_lease_expiration_30_days 218 | - ecs_cluster_low_utilization 219 | - ecs_service_without_autoscaling 220 | - emr_cluster_instance_prev_gen 221 | - emr_cluster_is_idle_30_minutes 222 | - redshift_cluster_low_utilization 223 | - redshift_cluster_schedule_pause_resume_enabled 224 | 225 | ## v0.5 [2021-07-23] 226 | 227 | _What's new?_ 228 | 229 | - New controls added: 230 | - ec2_application_lb_unused 231 | - ec2_classic_lb_unused 232 | - ec2_gateway_lb_unused 233 | - ec2_network_lb_unused 234 | - elasticache_cluster_age_90_days 235 | - redshift_cluster_age_90_days 236 | - vpc_nat_gateway_unused 237 | 238 | _Enhancements_ 239 | 240 | - Updated: Service benchmark docs now link to query pages instead of the GitHub repository code for default thresholds for more reliable linking 241 | 242 | ## v0.4 [2021-05-28] 243 | 244 | _Bug fixes_ 245 | 246 | - Minor fixes in the docs 247 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /controls/ebs.pp: -------------------------------------------------------------------------------- 1 | variable "ebs_snapshot_age_max_days" { 2 | type = number 3 | description = "The maximum number of days snapshots can be retained." 4 | default = 90 5 | } 6 | 7 | variable "ebs_volume_avg_read_write_ops_high" { 8 | type = number 9 | description = "The number of average read/write ops required for volumes to be considered frequently used. This value should be higher than ebs_volume_avg_read_write_ops_low." 10 | default = 500 11 | } 12 | 13 | variable "ebs_volume_avg_read_write_ops_low" { 14 | type = number 15 | description = "The number of average read/write ops required for volumes to be considered infrequently used. This value should be lower than ebs_volume_avg_read_write_ops_high." 16 | default = 100 17 | } 18 | 19 | variable "ebs_volume_max_iops" { 20 | type = number 21 | description = "The maximum IOPS allowed for volumes." 22 | default = 32000 23 | } 24 | 25 | variable "ebs_volume_max_size_gb" { 26 | type = number 27 | description = "The maximum size (GB) allowed for volumes." 28 | default = 100 29 | } 30 | 31 | locals { 32 | ebs_common_tags = merge(local.aws_thrifty_common_tags, { 33 | service = "AWS/EBS" 34 | }) 35 | } 36 | 37 | benchmark "ebs" { 38 | title = "EBS Checks" 39 | description = "Thrifty developers keep a careful eye for unused and under-utilized EBS volumes." 40 | documentation = file("./controls/docs/ebs.md") 41 | children = [ 42 | control.ebs_snapshot_max_age, 43 | control.ebs_volumes_on_stopped_instances, 44 | control.ebs_with_low_usage, 45 | control.gp2_volumes, 46 | control.high_iops_ebs_volumes, 47 | control.io1_volumes, 48 | control.large_ebs_volumes, 49 | control.low_iops_ebs_volumes, 50 | control.unattached_ebs_volumes 51 | ] 52 | 53 | tags = merge(local.ebs_common_tags, { 54 | type = "Benchmark" 55 | }) 56 | } 57 | 58 | control "gp2_volumes" { 59 | title = "Still using gp2 EBS volumes? Should use gp3 instead." 60 | description = "EBS gp2 volumes are more costly and lower performance than gp3." 61 | severity = "low" 62 | tags = merge(local.ebs_common_tags, { 63 | class = "deprecated" 64 | }) 65 | 66 | sql = <<-EOQ 67 | select 68 | arn as resource, 69 | case 70 | when volume_type = 'gp2' then 'alarm' 71 | when volume_type = 'gp3' then 'ok' 72 | else 'skip' 73 | end as status, 74 | volume_id || ' type is ' || volume_type || '.' as reason 75 | ${local.tag_dimensions_sql} 76 | ${local.common_dimensions_sql} 77 | from 78 | aws_ebs_volume; 79 | EOQ 80 | } 81 | 82 | control "io1_volumes" { 83 | title = "Still using io1 EBS volumes? Should use io2 instead." 84 | description = "io1 Volumes are less reliable than io2 for same cost." 85 | severity = "low" 86 | tags = merge(local.ebs_common_tags, { 87 | class = "deprecated" 88 | }) 89 | 90 | sql = <<-EOQ 91 | select 92 | arn as resource, 93 | case 94 | when volume_type = 'io1' then 'alarm' 95 | when volume_type = 'io2' then 'ok' 96 | else 'skip' 97 | end as status, 98 | volume_id || ' type is ' || volume_type || '.' as reason 99 | ${local.tag_dimensions_sql} 100 | ${local.common_dimensions_sql} 101 | from 102 | aws_ebs_volume; 103 | EOQ 104 | } 105 | 106 | control "unattached_ebs_volumes" { 107 | title = "Are there any unattached EBS volumes?" 108 | description = "Unattached EBS volumes render little usage, are expensive to maintain and should be reviewed." 109 | severity = "low" 110 | tags = merge(local.ebs_common_tags, { 111 | class = "unused" 112 | }) 113 | sql = <<-EOQ 114 | select 115 | arn as resource, 116 | case 117 | when jsonb_array_length(attachments) > 0 then 'ok' 118 | else 'alarm' 119 | end as status, 120 | case 121 | when jsonb_array_length(attachments) > 0 then volume_id || ' has attachments.' 122 | else volume_id || ' has no attachments.' 123 | end as reason 124 | ${local.tag_dimensions_sql} 125 | ${local.common_dimensions_sql} 126 | from 127 | aws_ebs_volume; 128 | EOQ 129 | } 130 | 131 | control "large_ebs_volumes" { 132 | title = "EBS volumes should be resized if too large" 133 | description = "Large EBS volumes are unusual, expensive and should be reviewed." 134 | severity = "low" 135 | 136 | param "ebs_volume_max_size_gb" { 137 | description = "The maximum size (GB) allowed for volumes." 138 | default = var.ebs_volume_max_size_gb 139 | } 140 | 141 | tags = merge(local.ebs_common_tags, { 142 | class = "deprecated" 143 | }) 144 | 145 | sql = <<-EOQ 146 | select 147 | arn as resource, 148 | case 149 | when size <= $1 then 'ok' 150 | else 'alarm' 151 | end as status, 152 | volume_id || ' is ' || size || 'GB.' as reason 153 | ${local.tag_dimensions_sql} 154 | ${local.common_dimensions_sql} 155 | from 156 | aws_ebs_volume; 157 | EOQ 158 | } 159 | 160 | control "high_iops_ebs_volumes" { 161 | title = "EBS volumes with high IOPS should be resized if too large" 162 | description = "High IOPS io1 and io2 volumes are costly and usage should be reviewed." 163 | severity = "low" 164 | 165 | param "ebs_volume_max_iops" { 166 | description = "The maximum IOPS allowed for volumes." 167 | default = var.ebs_volume_max_iops 168 | } 169 | 170 | tags = merge(local.ebs_common_tags, { 171 | class = "deprecated" 172 | }) 173 | 174 | sql = <<-EOQ 175 | select 176 | arn as resource, 177 | case 178 | when volume_type not in ('io1', 'io2') then 'skip' 179 | when iops > $1 then 'alarm' 180 | else 'ok' 181 | end as status, 182 | case 183 | when volume_type not in ('io1', 'io2') then volume_id || ' type is ' || volume_type || '.' 184 | else volume_id || ' has ' || iops || ' iops.' 185 | end as reason 186 | ${local.tag_dimensions_sql} 187 | ${local.common_dimensions_sql} 188 | from 189 | aws_ebs_volume; 190 | EOQ 191 | } 192 | 193 | control "low_iops_ebs_volumes" { 194 | title = "What provisioned IOPS volumes would be better as GP3?" 195 | description = "GP3 provides 16k base IOPS performance, don't use more costly io1 & io2 volumes." 196 | severity = "low" 197 | tags = merge(local.ebs_common_tags, { 198 | class = "management" 199 | }) 200 | 201 | sql = <<-EOQ 202 | select 203 | arn as resource, 204 | case 205 | when volume_type not in ('io1', 'io2') then 'skip' 206 | when iops <= 16000 then 'alarm' 207 | else 'ok' 208 | end as status, 209 | case 210 | when volume_type not in ('io1', 'io2') then volume_id || ' type is ' || volume_type || '.' 211 | when iops <= 16000 then volume_id || ' only has ' || iops || ' iops.' 212 | else volume_id || ' has ' || iops || ' iops.' 213 | end as reason 214 | ${local.tag_dimensions_sql} 215 | ${local.common_dimensions_sql} 216 | from 217 | aws_ebs_volume; 218 | EOQ 219 | } 220 | 221 | control "ebs_volumes_on_stopped_instances" { 222 | title = "EBS volumes attached to stopped instances should be reviewed" 223 | description = "Instances that are stopped may no longer need any attached EBS volumes" 224 | severity = "low" 225 | tags = merge(local.ebs_common_tags, { 226 | class = "deprecated" 227 | }) 228 | 229 | sql = <<-EOQ 230 | with vols_and_instances as ( 231 | select 232 | v.arn, 233 | v._ctx, 234 | v.volume_id, 235 | i.instance_id, 236 | v.region, 237 | v.account_id, 238 | sum( 239 | case 240 | when i.instance_state = 'stopped' then 0 241 | else 1 242 | end 243 | ) as running_instances 244 | from 245 | aws_ebs_volume as v 246 | left join jsonb_array_elements(v.attachments) as va on true 247 | left join aws_ec2_instance as i on va ->> 'InstanceId' = i.instance_id 248 | group by 249 | v.arn, 250 | v._ctx, 251 | v.volume_id, 252 | i.instance_id, 253 | i.instance_id, 254 | v.region, 255 | v.account_id 256 | ) 257 | select 258 | arn as resource, 259 | case 260 | when running_instances > 0 then 'ok' 261 | else 'alarm' 262 | end as status, 263 | volume_id || ' is attached to ' || running_instances || ' running instances.' as reason 264 | ${local.common_dimensions_sql} 265 | from 266 | vols_and_instances; 267 | EOQ 268 | } 269 | 270 | control "ebs_with_low_usage" { 271 | title = "Are there any EBS volumes with low usage?" 272 | description = "Volumes that are unused should be archived and deleted" 273 | severity = "low" 274 | 275 | param "ebs_volume_avg_read_write_ops_low" { 276 | description = "The number of average read/write ops required for volumes to be considered infrequently used. This value should be lower than ebs_volume_avg_read_write_ops_high." 277 | default = var.ebs_volume_avg_read_write_ops_low 278 | } 279 | 280 | param "ebs_volume_avg_read_write_ops_high" { 281 | description = "The number of average read/write ops required for volumes to be considered frequently used. This value should be higher than ebs_volume_avg_read_write_ops_low." 282 | default = var.ebs_volume_avg_read_write_ops_high 283 | } 284 | 285 | tags = merge(local.ebs_common_tags, { 286 | class = "unused" 287 | }) 288 | 289 | sql = <<-EOQ 290 | with ebs_usage as ( 291 | select 292 | partition, 293 | account_id, 294 | _ctx, 295 | region, 296 | volume_id, 297 | round(avg(max)) as avg_max, 298 | count(max) as days 299 | from ( 300 | ( 301 | select 302 | partition, 303 | account_id, 304 | _ctx, 305 | region, 306 | volume_id, 307 | cast(maximum as numeric) as max 308 | from 309 | aws_ebs_volume_metric_read_ops_daily 310 | where 311 | date_part('day', now() - timestamp) <= 30 312 | ) 313 | UNION 314 | ( 315 | select 316 | partition, 317 | account_id, 318 | _ctx, 319 | region, 320 | volume_id, 321 | cast(maximum as numeric) as max 322 | from 323 | aws_ebs_volume_metric_write_ops_daily 324 | where 325 | date_part('day', now() - timestamp) <= 30 326 | ) 327 | ) as read_and_write_ops 328 | group by 1,2,3,4,5 329 | ) 330 | select 331 | 'arn:' || partition || ':ec2:' || region || ':' || account_id || ':volume/' || volume_id as resource, 332 | case 333 | when avg_max <= $1 then 'alarm' 334 | when avg_max <= $2 then 'info' 335 | else 'ok' 336 | end as status, 337 | volume_id || ' is averaging ' || avg_max || ' read and write ops over the last ' || days || ' days.' as reason 338 | ${local.common_dimensions_sql} 339 | from 340 | ebs_usage; 341 | EOQ 342 | } 343 | 344 | control "ebs_snapshot_max_age" { 345 | title = "Old EBS snapshots should be deleted if not required" 346 | description = "Old EBS snapshots are likely unnecessary and costly to maintain." 347 | severity = "low" 348 | 349 | param "ebs_snapshot_age_max_days" { 350 | description = "The maximum number of days snapshots can be retained." 351 | default = var.ebs_snapshot_age_max_days 352 | } 353 | 354 | tags = merge(local.ebs_common_tags, { 355 | class = "unused" 356 | }) 357 | 358 | sql = <<-EOQ 359 | select 360 | arn as resource, 361 | case 362 | when start_time > (current_timestamp - ($1::int || ' days')::interval) then 'ok' 363 | else 'alarm' 364 | end as status, 365 | snapshot_id || ' created at ' || start_time || '.' as reason 366 | ${local.tag_dimensions_sql} 367 | ${local.common_dimensions_sql} 368 | from 369 | aws_ebs_snapshot; 370 | EOQ 371 | } 372 | -------------------------------------------------------------------------------- /controls/rds.pp: -------------------------------------------------------------------------------- 1 | variable "rds_db_instance_avg_connections" { 2 | type = number 3 | description = "The minimum number of average connections per day required for DB instances to be considered in-use." 4 | default = 2 5 | } 6 | 7 | variable "rds_db_instance_avg_cpu_utilization_high" { 8 | type = number 9 | description = "The average CPU utilization required for DB instances to be considered frequently used. This value should be higher than rds_db_instance_avg_cpu_utilization_low." 10 | default = 50 11 | } 12 | 13 | variable "rds_db_instance_avg_cpu_utilization_low" { 14 | type = number 15 | description = "The average CPU utilization required for DB instances to be considered infrequently used. This value should be lower than rds_db_instance_avg_cpu_utilization_high." 16 | default = 25 17 | } 18 | 19 | variable "rds_running_db_instance_age_max_days" { 20 | type = number 21 | description = "The maximum number of days DB instances are allowed to run." 22 | default = 90 23 | } 24 | 25 | variable "rds_running_db_instance_age_warning_days" { 26 | type = number 27 | description = "The number of days DB instances can be running before sending a warning." 28 | default = 30 29 | } 30 | 31 | variable "rds_snapshot_unused_max_days" { 32 | type = number 33 | description = "The maximum number of days an RDS snapshot can be retained after its source DB instance is deleted." 34 | default = 30 35 | } 36 | 37 | locals { 38 | rds_common_tags = merge(local.aws_thrifty_common_tags, { 39 | service = "AWS/RDS" 40 | }) 41 | } 42 | 43 | benchmark "rds" { 44 | title = "RDS Checks" 45 | description = "Thrifty developers eliminate unused and under-utilized RDS instances." 46 | documentation = file("./controls/docs/rds.md") 47 | children = [ 48 | control.latest_rds_instance_types, 49 | control.long_running_rds_db_instances, 50 | control.rds_db_instance_with_graviton, 51 | control.rds_db_low_connection_count, 52 | control.rds_db_low_utilization, 53 | control.rds_mysql_postresql_db_no_unsupported_version, 54 | control.rds_unused_snapshots 55 | ] 56 | 57 | tags = merge(local.rds_common_tags, { 58 | type = "Benchmark" 59 | }) 60 | } 61 | 62 | control "long_running_rds_db_instances" { 63 | title = "Long running RDS DBs should have reserved instances purchased for them" 64 | description = "Long running database servers should be associated with a reserve instance." 65 | severity = "low" 66 | 67 | param "rds_running_db_instance_age_max_days" { 68 | description = "The maximum number of days DB instances are allowed to run." 69 | default = var.rds_running_db_instance_age_max_days 70 | } 71 | 72 | param "rds_running_db_instance_age_warning_days" { 73 | description = "The number of days DB instances can be running before sending a warning." 74 | default = var.rds_running_db_instance_age_warning_days 75 | } 76 | 77 | tags = merge(local.rds_common_tags, { 78 | class = "managed" 79 | }) 80 | 81 | sql = <<-EOQ 82 | select 83 | arn as resource, 84 | case 85 | when date_part('day', now()-create_time) > $1 then 'alarm' 86 | when date_part('day', now()-create_time) > $2 then 'info' 87 | else 'ok' 88 | end as status, 89 | title || ' has been in use for ' || date_part('day', now()-create_time) || ' days.' as reason 90 | ${local.tag_dimensions_sql} 91 | ${local.common_dimensions_sql} 92 | from 93 | aws_rds_db_instance; 94 | EOQ 95 | } 96 | 97 | control "latest_rds_instance_types" { 98 | title = "Are there RDS instances using previous gen instance types?" 99 | description = "M5 and T3 instance types are less costly than previous generations" 100 | severity = "low" 101 | tags = merge(local.rds_common_tags, { 102 | class = "managed" 103 | }) 104 | 105 | sql = <<-EOQ 106 | select 107 | arn as resource, 108 | case 109 | when class like '%.t2.%' then 'alarm' 110 | when class like '%.m3.%' then 'alarm' 111 | when class like '%.m4.%' then 'alarm' 112 | when class like '%.m5.%' then 'ok' 113 | when class like '%.t3.%' then 'ok' 114 | else 'info' 115 | end as status, 116 | title || ' has a ' || class || ' instance class.' as reason 117 | ${local.tag_dimensions_sql} 118 | ${local.common_dimensions_sql} 119 | from 120 | aws_rds_db_instance; 121 | EOQ 122 | } 123 | 124 | control "rds_db_low_connection_count" { 125 | title = "RDS DB instances with a low number connections per day should be reviewed" 126 | description = "DB instances having less usage in last 30 days should be reviewed." 127 | severity = "high" 128 | 129 | param "rds_db_instance_avg_connections" { 130 | description = "The minimum number of average connections per day required for DB instances to be considered in-use." 131 | default = var.rds_db_instance_avg_connections 132 | } 133 | 134 | tags = merge(local.rds_common_tags, { 135 | class = "unused" 136 | }) 137 | 138 | sql = <<-EOQ 139 | with rds_db_usage as ( 140 | select 141 | db_instance_identifier, 142 | round(sum(maximum)/count(maximum)) as avg_max, 143 | count(maximum) days 144 | from 145 | aws_rds_db_instance_metric_connections_daily 146 | where 147 | date_part('day', now() - timestamp) <= 30 148 | group by 149 | db_instance_identifier 150 | ) 151 | select 152 | arn as resource, 153 | case 154 | when avg_max is null then 'error' 155 | when avg_max = 0 then 'alarm' 156 | when avg_max < $1 then 'info' 157 | else 'ok' 158 | end as status, 159 | case 160 | when avg_max is null then 'CloudWatch metrics not available for ' || title || '.' 161 | when avg_max = 0 then title || ' has not been connected to in the last ' || days || ' days.' 162 | else title || ' is averaging ' || avg_max || ' max connections/day in the last ' || days || ' days.' 163 | end as reason 164 | ${local.tag_dimensions_sql} 165 | ${local.common_dimensions_sql} 166 | from 167 | aws_rds_db_instance i 168 | left join rds_db_usage as u on u.db_instance_identifier = i.db_instance_identifier; 169 | EOQ 170 | } 171 | 172 | control "rds_db_low_utilization" { 173 | title = "RDS DB instance having low CPU utilization should be reviewed" 174 | description = "DB instances may be oversized for their usage." 175 | severity = "low" 176 | 177 | param "rds_db_instance_avg_cpu_utilization_low" { 178 | description = "The average CPU utilization required for DB instances to be considered infrequently used. This value should be lower than rds_db_instance_avg_cpu_utilization_high." 179 | default = var.rds_db_instance_avg_cpu_utilization_low 180 | } 181 | 182 | param "rds_db_instance_avg_cpu_utilization_high" { 183 | description = "The average CPU utilization required for DB instances to be considered frequently used. This value should be higher than rds_db_instance_avg_cpu_utilization_low." 184 | default = var.rds_db_instance_avg_cpu_utilization_high 185 | } 186 | 187 | tags = merge(local.rds_common_tags, { 188 | class = "unused" 189 | }) 190 | 191 | sql = <<-EOQ 192 | with rds_db_usage as ( 193 | select 194 | db_instance_identifier, 195 | round(cast(sum(maximum)/count(maximum) as numeric), 1) as avg_max, 196 | count(maximum) days 197 | from 198 | aws_rds_db_instance_metric_cpu_utilization_daily 199 | where 200 | date_part('day', now() - timestamp) <= 30 201 | group by 202 | db_instance_identifier 203 | ) 204 | select 205 | arn as resource, 206 | case 207 | when avg_max is null then 'error' 208 | when avg_max <= $1 then 'alarm' 209 | when avg_max <= $2 then 'info' 210 | else 'ok' 211 | end as status, 212 | case 213 | when avg_max is null then 'CloudWatch metrics not available for ' || title || '.' 214 | else title || ' is averaging ' || avg_max || '% max utilization over the last ' || days || ' days.' 215 | end as reason 216 | ${local.tag_dimensions_sql} 217 | ${local.common_dimensions_sql} 218 | from 219 | aws_rds_db_instance i 220 | left join rds_db_usage as u on u.db_instance_identifier = i.db_instance_identifier; 221 | EOQ 222 | } 223 | 224 | control "rds_db_instance_with_graviton" { 225 | title = "RDS DB instances without graviton processor should be reviewed" 226 | description = "With graviton processor (arm64 - 64-bit ARM architecture), you can save money in two ways. First, your functions run more efficiently due to the Graviton architecture. Second, you pay less for the time that they run. In fact, Lambda functions powered by Graviton are designed to deliver up to 19 percent better performance at 20 percent lower cost." 227 | severity = "low" 228 | 229 | tags = merge(local.rds_common_tags, { 230 | class = "deprecated" 231 | }) 232 | 233 | sql = <<-EOQ 234 | select 235 | arn as resource, 236 | case 237 | when class like 'db.%g%.%' then 'ok' 238 | else 'alarm' 239 | end as status, 240 | case 241 | when class like 'db.%g%.%' then title || ' is using Graviton processor.' 242 | else title || ' is not using Graviton processor.' 243 | end as reason 244 | ${local.tag_dimensions_sql} 245 | ${local.common_dimensions_sql} 246 | from 247 | aws_rds_db_instance; 248 | EOQ 249 | } 250 | 251 | control "rds_mysql_postresql_db_no_unsupported_version" { 252 | title = "RDS MySQL and PostgreSQL DB instances with unsupported version should be reviewed" 253 | description = "MySQL 5.7 and PostgreSQL 11 database instances running on Amazon Aurora and Amazon Relational Database Service (Amazon RDS) will be automatically enrolled into Amazon RDS Extended Support. This automatic enrollment may mean that you will experience higher charges when RDS Extended Support begins. You can avoid these charges by upgrading your database to a newer DB version." 254 | severity = "low" 255 | 256 | tags = merge(local.rds_common_tags, { 257 | class = "deprecated" 258 | }) 259 | 260 | sql = <<-EOQ 261 | select 262 | arn as resource, 263 | engine_version, 264 | engine, 265 | case 266 | when not engine ilike any (array ['%mysql%', '%postgres%']) then 'skip' 267 | when 268 | (engine like '%mysql' and engine_version like '5.7.%' ) 269 | or (engine like '%postgres%' and engine_version like '11.%') then 'alarm' 270 | else 'ok' 271 | end as status, 272 | case 273 | when not engine ilike any (array ['%mysql%', '%postgres%']) then title || ' is of ' || engine || ' engine type.' 274 | when 275 | (engine like '%mysql' and engine_version like '5.7.%' ) 276 | or (engine like '%postgres%' and engine_version like '11.%') then title || ' is using RDS Extended Support.' 277 | else title || ' is not using RDS Extended Support.' 278 | end as reason 279 | ${local.tag_dimensions_sql} 280 | ${local.common_dimensions_sql} 281 | from 282 | aws_rds_db_instance; 283 | EOQ 284 | } 285 | 286 | control "rds_unused_snapshots" { 287 | title = "RDS snapshots without source DB instances should be reviewed" 288 | description = "RDS snapshots whose source DB instances no longer exist may be unnecessary and should be reviewed for deletion to reduce costs." 289 | severity = "low" 290 | 291 | param "rds_snapshot_unused_max_days" { 292 | description = "The maximum number of days an RDS snapshot can be retained after its source DB instance is deleted." 293 | default = var.rds_snapshot_unused_max_days 294 | } 295 | 296 | tags = merge(local.rds_common_tags, { 297 | class = "unused" 298 | }) 299 | 300 | sql = <<-EOQ 301 | with snapshot_age as ( 302 | select 303 | db_snapshot_identifier, 304 | date_part('day', now() - create_time) as age_in_days, 305 | db_instance_identifier, 306 | create_time, 307 | status 308 | from 309 | aws_rds_db_snapshot 310 | where 311 | status = 'available' 312 | ), 313 | instance_exists as ( 314 | select 315 | db_instance_identifier 316 | from 317 | aws_rds_db_instance 318 | ) 319 | select 320 | s.arn as resource, 321 | case 322 | when s.status != 'available' then 'skip' 323 | when i.db_instance_identifier is null and a.age_in_days > $1 then 'alarm' 324 | when i.db_instance_identifier is null then 'info' 325 | else 'ok' 326 | end as status, 327 | case 328 | when s.status != 'available' then s.title || ' is in ' || s.status || ' status.' 329 | when i.db_instance_identifier is null and a.age_in_days > $1 then s.title || ' is ' || a.age_in_days || ' days old and its source DB instance no longer exists.' 330 | when i.db_instance_identifier is null then s.title || ' source DB instance no longer exists but snapshot is only ' || a.age_in_days || ' days old.' 331 | else s.title || ' has an existing source DB instance.' 332 | end as reason 333 | ${local.tag_dimensions_sql} 334 | ${local.common_dimensions_sql} 335 | from 336 | aws_rds_db_snapshot s 337 | left join snapshot_age a on a.db_snapshot_identifier = s.db_snapshot_identifier 338 | left join instance_exists i on i.db_instance_identifier = a.db_instance_identifier; 339 | EOQ 340 | } 341 | 342 | 343 | -------------------------------------------------------------------------------- /controls/ec2.pp: -------------------------------------------------------------------------------- 1 | variable "ec2_instance_allowed_types" { 2 | type = list(string) 3 | description = "A list of allowed instance types. PostgreSQL wildcards are supported." 4 | default = ["%.nano", "%.micro", "%.small", "%.medium", "%.large", "%.xlarge", "%._xlarge"] 5 | } 6 | 7 | variable "ec2_instance_avg_cpu_utilization_high" { 8 | type = number 9 | description = "The average CPU utilization required for instances to be considered frequently used. This value should be higher than ec2_instance_avg_cpu_utilization_low." 10 | default = 35 11 | } 12 | 13 | variable "ec2_instance_avg_cpu_utilization_low" { 14 | type = number 15 | description = "The average CPU utilization required for instances to be considered infrequently used. This value should be lower than ec2_instance_avg_cpu_utilization_high." 16 | default = 20 17 | } 18 | 19 | variable "ec2_reserved_instance_expiration_warning_days" { 20 | type = number 21 | description = "The number of days reserved instances can be running before sending a warning." 22 | default = 30 23 | } 24 | 25 | variable "ec2_running_instance_age_max_days" { 26 | type = number 27 | description = "The maximum number of days instances are allowed to run." 28 | default = 90 29 | } 30 | 31 | locals { 32 | ec2_common_tags = merge(local.aws_thrifty_common_tags, { 33 | service = "AWS/EC2" 34 | }) 35 | } 36 | 37 | benchmark "ec2" { 38 | title = "EC2 Checks" 39 | description = "Thrifty developers eliminate unused and under-utilized EC2 instances." 40 | documentation = file("./controls/docs/ec2.md") 41 | children = [ 42 | control.ec2_application_lb_unused, 43 | control.ec2_classic_lb_unused, 44 | control.ec2_gateway_lb_unused, 45 | control.ec2_instance_older_generation, 46 | control.ec2_instance_with_graviton, 47 | control.ec2_network_lb_unused, 48 | control.ec2_reserved_instance_lease_expiration_days, 49 | control.instances_with_low_utilization, 50 | control.large_ec2_instances, 51 | control.long_running_ec2_instances 52 | ] 53 | 54 | tags = merge(local.ec2_common_tags, { 55 | type = "Benchmark" 56 | }) 57 | } 58 | 59 | control "ec2_application_lb_unused" { 60 | title = "Application load balancers having no targets attached should be deleted" 61 | description = "Application load balancers with no targets attached still cost money and should be deleted." 62 | severity = "low" 63 | tags = merge(local.ec2_common_tags, { 64 | class = "unused" 65 | }) 66 | 67 | sql = <<-EOQ 68 | with target_resource as ( 69 | select 70 | load_balancer_arn, 71 | target_health_descriptions, 72 | target_type 73 | from 74 | aws_ec2_target_group, 75 | jsonb_array_elements_text(load_balancer_arns) as load_balancer_arn 76 | ) 77 | select 78 | a.arn as resource, 79 | case 80 | when b.load_balancer_arn is null then 'alarm' 81 | else 'ok' 82 | end as status, 83 | case 84 | when b.load_balancer_arn is null then a.title || ' has no target registered.' 85 | else a.title || ' has registered target of type ' || b.target_type || '.' 86 | end as reason 87 | ${local.tag_dimensions_sql} 88 | ${replace(local.common_dimensions_qualifier_sql, "__QUALIFIER__", "a.")} 89 | from 90 | aws_ec2_application_load_balancer a 91 | left join target_resource b on a.arn = b.load_balancer_arn; 92 | EOQ 93 | } 94 | 95 | control "ec2_classic_lb_unused" { 96 | title = "Classic load balancers having no instances attached should be deleted" 97 | description = "Classic load balancers with no instances attached still cost money should be deleted." 98 | severity = "low" 99 | tags = merge(local.ec2_common_tags, { 100 | class = "unused" 101 | }) 102 | 103 | sql = <<-EOQ 104 | select 105 | arn as resource, 106 | case 107 | when jsonb_array_length(instances) > 0 then 'ok' 108 | else 'alarm' 109 | end as status, 110 | case 111 | when jsonb_array_length(instances) > 0 then title || ' has registered instances.' 112 | else title || ' has no instances registered.' 113 | end as reason 114 | ${local.tag_dimensions_sql} 115 | ${local.common_dimensions_sql} 116 | from 117 | aws_ec2_classic_load_balancer; 118 | EOQ 119 | } 120 | 121 | control "ec2_gateway_lb_unused" { 122 | title = "Gateway load balancers having no targets attached should be deleted" 123 | description = "Gateway load balancers with no targets attached still cost money and should be deleted." 124 | severity = "low" 125 | tags = merge(local.ec2_common_tags, { 126 | class = "unused" 127 | }) 128 | 129 | sql = <<-EOQ 130 | with target_resource as ( 131 | select 132 | load_balancer_arn, 133 | target_health_descriptions, 134 | target_type 135 | from 136 | aws_ec2_target_group, 137 | jsonb_array_elements_text(load_balancer_arns) as load_balancer_arn 138 | ) 139 | select 140 | a.arn as resource, 141 | case 142 | when jsonb_array_length(b.target_health_descriptions) = 0 then 'alarm' 143 | else 'ok' 144 | end as status, 145 | case 146 | when jsonb_array_length(b.target_health_descriptions) = 0 then a.title || ' has no target registered.' 147 | else a.title || ' has registered target of type' || ' ' || b.target_type || '.' 148 | end as reason 149 | ${local.tag_dimensions_sql} 150 | ${replace(local.common_dimensions_qualifier_sql, "__QUALIFIER__", "a.")} 151 | from 152 | aws_ec2_gateway_load_balancer a 153 | left join target_resource b on a.arn = b.load_balancer_arn; 154 | EOQ 155 | 156 | } 157 | 158 | control "ec2_network_lb_unused" { 159 | title = "Network load balancers having no targets attached should be deleted" 160 | description = "Network load balancers with no targets attached still cost money and should be deleted." 161 | severity = "low" 162 | tags = merge(local.ec2_common_tags, { 163 | class = "unused" 164 | }) 165 | 166 | sql = <<-EOQ 167 | with target_resource as ( 168 | select 169 | load_balancer_arn, 170 | target_health_descriptions, 171 | target_type 172 | from 173 | aws_ec2_target_group, 174 | jsonb_array_elements_text(load_balancer_arns) as load_balancer_arn 175 | ) 176 | select 177 | a.arn as resource, 178 | case 179 | when jsonb_array_length(b.target_health_descriptions) = 0 then 'alarm' 180 | else 'ok' 181 | end as status, 182 | case 183 | when jsonb_array_length(b.target_health_descriptions) = 0 then a.title || ' has no target registered.' 184 | else a.title || ' has registered target of type' || ' ' || b.target_type || '.' 185 | end as reason 186 | ${local.tag_dimensions_sql} 187 | ${replace(local.common_dimensions_qualifier_sql, "__QUALIFIER__", "a.")} 188 | from 189 | aws_ec2_network_load_balancer a 190 | left join target_resource b on a.arn = b.load_balancer_arn; 191 | EOQ 192 | } 193 | 194 | control "large_ec2_instances" { 195 | title = "Large EC2 instances should be reviewed" 196 | description = "Large EC2 instances are unusual, expensive and should be reviewed." 197 | severity = "low" 198 | 199 | param "ec2_instance_allowed_types" { 200 | description = "A list of allowed instance types. PostgreSQL wildcards are supported." 201 | default = var.ec2_instance_allowed_types 202 | } 203 | 204 | tags = merge(local.ec2_common_tags, { 205 | class = "deprecated" 206 | }) 207 | 208 | sql = <<-EOQ 209 | select 210 | arn as resource, 211 | case 212 | when instance_state not in ('running', 'pending', 'rebooting') then 'info' 213 | when instance_type like any ($1) then 'ok' 214 | else 'alarm' 215 | end as status, 216 | title || ' has type ' || instance_type || ' and is ' || instance_state || '.' as reason 217 | ${local.tag_dimensions_sql} 218 | ${local.common_dimensions_sql} 219 | from 220 | aws_ec2_instance; 221 | EOQ 222 | } 223 | 224 | control "long_running_ec2_instances" { 225 | title = "Long running EC2 instances should be reviewed" 226 | description = "Instances should ideally be ephemeral and rehydrated frequently, check why these instances have been running for so long." 227 | severity = "low" 228 | 229 | param "ec2_running_instance_age_max_days" { 230 | description = "The maximum number of days instances are allowed to run." 231 | default = var.ec2_running_instance_age_max_days 232 | } 233 | 234 | tags = merge(local.ec2_common_tags, { 235 | class = "deprecated" 236 | }) 237 | 238 | sql = <<-EOQ 239 | select 240 | arn as resource, 241 | case 242 | when date_part('day', now()-launch_time) > $1 then 'alarm' 243 | else 'ok' 244 | end as status, 245 | title || ' has been running ' || date_part('day', now()-launch_time) || ' days.' as reason 246 | ${local.tag_dimensions_sql} 247 | ${local.common_dimensions_sql} 248 | from 249 | aws_ec2_instance 250 | where 251 | -- Instance is running 252 | instance_state in ('running', 'pending', 'rebooting'); 253 | EOQ 254 | } 255 | 256 | control "instances_with_low_utilization" { 257 | title = "Which EC2 instances have very low CPU utilization?" 258 | description = "Resize or eliminate under utilized instances." 259 | severity = "low" 260 | 261 | param "ec2_instance_avg_cpu_utilization_low" { 262 | description = "The average CPU utilization required for instances to be considered infrequently used. This value should be lower than ec2_instance_avg_cpu_utilization_high." 263 | default = var.ec2_instance_avg_cpu_utilization_low 264 | } 265 | 266 | param "ec2_instance_avg_cpu_utilization_high" { 267 | description = "The average CPU utilization required for instances to be considered frequently used. This value should be higher than ec2_instance_avg_cpu_utilization_low." 268 | default = var.ec2_instance_avg_cpu_utilization_high 269 | } 270 | 271 | tags = merge(local.ec2_common_tags, { 272 | class = "unused" 273 | }) 274 | 275 | sql = <<-EOQ 276 | with ec2_instance_utilization as ( 277 | select 278 | instance_id, 279 | max(average) as avg_max, 280 | count(average) days 281 | from 282 | aws_ec2_instance_metric_cpu_utilization_daily 283 | where 284 | date_part('day', now() - timestamp) <= 30 285 | group by 286 | instance_id 287 | ) 288 | select 289 | arn as resource, 290 | case 291 | when avg_max is null then 'error' 292 | when avg_max < $1 then 'alarm' 293 | when avg_max < $2 then 'info' 294 | else 'ok' 295 | end as status, 296 | case 297 | when avg_max is null then 'CloudWatch metrics not available for ' || title || '.' 298 | else title || ' is averaging ' || avg_max || '% max utilization over the last ' || days || ' days.' 299 | end as reason 300 | ${local.tag_dimensions_sql} 301 | ${local.common_dimensions_sql} 302 | from 303 | aws_ec2_instance i 304 | left join ec2_instance_utilization as u on u.instance_id = i.instance_id; 305 | EOQ 306 | } 307 | 308 | control "ec2_reserved_instance_lease_expiration_days" { 309 | title = "EC2 reserved instances scheduled for expiration should be reviewed" 310 | description = "EC2 reserved instances that are scheduled for expiration or have expired in the preceding 30 days should be reviewed." 311 | severity = "low" 312 | 313 | param "ec2_reserved_instance_expiration_warning_days" { 314 | description = "The number of days reserved instances can be running before sending a warning." 315 | default = var.ec2_reserved_instance_expiration_warning_days 316 | } 317 | 318 | tags = merge(local.ec2_common_tags, { 319 | class = "managed" 320 | }) 321 | 322 | sql = <<-EOQ 323 | select 324 | reserved_instance_id as resource, 325 | case 326 | when date_part('day', end_time - now()) <= $1 then 'alarm' 327 | else 'ok' 328 | end as status, 329 | title || ' lease expires in ' || date_part('day', end_time-now()) || ' days.' as reason 330 | ${local.tag_dimensions_sql} 331 | ${local.common_dimensions_sql} 332 | from 333 | aws_ec2_reserved_instance; 334 | EOQ 335 | } 336 | 337 | control "ec2_instance_older_generation" { 338 | title = "EC2 instances should not use older generation t2, m3, and m4 instance types" 339 | description = "EC2 instances should not use older generation t2, m3, and m4 instance types as t3 and m5 are more cost effective." 340 | severity = "low" 341 | tags = merge(local.ec2_common_tags, { 342 | class = "unused" 343 | }) 344 | 345 | sql = <<-EOQ 346 | select 347 | arn as resource, 348 | case 349 | when instance_type like 't2.%' or instance_type like 'm3.%' or instance_type like 'm4.%' then 'alarm' 350 | else 'ok' 351 | end as status, 352 | title || ' has used ' || instance_type || '.' as reason 353 | ${local.tag_dimensions_sql} 354 | ${local.common_dimensions_sql} 355 | from 356 | aws_ec2_instance; 357 | EOQ 358 | } 359 | 360 | control "ec2_instance_with_graviton" { 361 | title = "EC2 instances without graviton processor should be reviewed" 362 | description = "With graviton processor (arm64 - 64-bit ARM architecture), you can save money in two ways. First, your functions run more efficiently due to the Graviton architecture. Second, you pay less for the time that they run. In fact, Lambda functions powered by Graviton are designed to deliver up to 19 percent better performance at 20 percent lower cost." 363 | severity = "low" 364 | 365 | tags = merge(local.ec2_common_tags, { 366 | class = "deprecated" 367 | }) 368 | 369 | sql = <<-EOQ 370 | select 371 | arn as resource, 372 | case 373 | when platform = 'windows' then 'skip' 374 | when architecture = 'arm64' then 'ok' 375 | else 'alarm' 376 | end as status, 377 | case 378 | when platform = 'windows' then title || ' is windows type machine.' 379 | when architecture = 'arm64' then title || ' is using Graviton processor.' 380 | else title || ' is not using Graviton processor.' 381 | end as reason 382 | ${local.tag_dimensions_sql} 383 | ${local.common_dimensions_sql} 384 | from 385 | aws_ec2_instance; 386 | EOQ 387 | } 388 | --------------------------------------------------------------------------------