├── .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 | 
8 |
9 | Or in a terminal:
10 |
11 | 
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 |
--------------------------------------------------------------------------------