Skip to content

Commit 8cd3b28

Browse files
feat(rules): add AWS IaC cost review rules (#52)
* feat(rules): add AWS IaC cost review rules * fix: address PR review findings - make CloudFormation ECS autoscaling correlation independent of resource order - add regression coverage for reversed ScalingPolicy and ScalableTarget ordering - add metadata coverage for the new AWS cost rules --------- Co-authored-by: Axon Stone <axonstone@towardsthecloud.com>
1 parent af33760 commit 8cd3b28

File tree

55 files changed

+2960
-488
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

55 files changed

+2960
-488
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cloudburn/rules": minor
3+
---
4+
5+
Add new AWS IaC cost review rules for versioned S3 cleanup, ECR lifecycle quality, gp3 tuning, EC2 detailed monitoring, DynamoDB autoscaling ranges, Lambda provisioned concurrency, and RDS Performance Insights retention, and extend ECS and Redshift rules to support IaC.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cloudburn/sdk": minor
3+
---
4+
5+
Add AWS static IaC dataset support for new cost review rules across S3, ECR, EBS, EC2, DynamoDB, ECS, Lambda, RDS, and Redshift.

docs/reference/rule-ids.md

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ Format: `CLDBRN-{PROVIDER}-{SERVICE}-{N}`
3030
| `CLDBRN-AWS-DYNAMODB-1` | DynamoDB Table Stale Data | dynamodb | discovery | Implemented |
3131
| `CLDBRN-AWS-DYNAMODB-2` | DynamoDB Table Without Autoscaling | dynamodb | discovery, iac | Implemented |
3232
| `CLDBRN-AWS-DYNAMODB-3` | DynamoDB Table Unused | dynamodb | discovery | Implemented |
33+
| `CLDBRN-AWS-DYNAMODB-4` | DynamoDB Autoscaling Range Fixed | dynamodb | iac | Implemented |
3334
| `CLDBRN-AWS-EC2-1` | EC2 Instance Type Not Preferred | ec2 | iac, discovery | Implemented |
3435
| `CLDBRN-AWS-EC2-2` | S3 Interface VPC Endpoint Used | ec2 | iac | Implemented |
3536
| `CLDBRN-AWS-EC2-3` | Elastic IP Address Unassociated | ec2 | discovery, iac | Implemented |
@@ -39,17 +40,22 @@ Format: `CLDBRN-{PROVIDER}-{SERVICE}-{N}`
3940
| `CLDBRN-AWS-EC2-7` | EC2 Reserved Instance Expiring | ec2 | discovery | Implemented |
4041
| `CLDBRN-AWS-EC2-8` | EC2 Instance Large Size | ec2 | discovery, iac | Implemented |
4142
| `CLDBRN-AWS-EC2-9` | EC2 Instance Long Running | ec2 | discovery | Implemented |
43+
| `CLDBRN-AWS-EC2-10` | EC2 Instance Detailed Monitoring Enabled | ec2 | iac | Implemented |
4244
| `CLDBRN-AWS-ECS-1` | ECS Container Instance Without Graviton | ecs | discovery | Implemented |
4345
| `CLDBRN-AWS-ECS-2` | ECS Cluster Low CPU Utilization | ecs | discovery | Implemented |
44-
| `CLDBRN-AWS-ECS-3` | ECS Service Missing Autoscaling Policy | ecs | discovery | Implemented |
46+
| `CLDBRN-AWS-ECS-3` | ECS Service Missing Autoscaling Policy | ecs | discovery, iac | Implemented |
4547
| `CLDBRN-AWS-EBS-1` | EBS Volume Type Not Current Generation | ebs | discovery, iac | Implemented |
4648
| `CLDBRN-AWS-EBS-2` | EBS Volume Unattached | ebs | discovery | Implemented |
4749
| `CLDBRN-AWS-EBS-3` | EBS Volume Attached To Stopped Instances | ebs | discovery | Implemented |
4850
| `CLDBRN-AWS-EBS-4` | EBS Volume Large Size | ebs | discovery, iac | Implemented |
4951
| `CLDBRN-AWS-EBS-5` | EBS Volume High Provisioned IOPS | ebs | discovery, iac | Implemented |
5052
| `CLDBRN-AWS-EBS-6` | EBS Volume Low Provisioned IOPS On io1/io2 | ebs | discovery, iac | Implemented |
5153
| `CLDBRN-AWS-EBS-7` | EBS Snapshot Max Age Exceeded | ebs | discovery | Implemented |
54+
| `CLDBRN-AWS-EBS-8` | EBS gp3 Volume Extra Throughput Provisioned | ebs | iac | Implemented |
55+
| `CLDBRN-AWS-EBS-9` | EBS gp3 Volume Extra IOPS Provisioned | ebs | iac | Implemented |
5256
| `CLDBRN-AWS-ECR-1` | ECR Repository Missing Lifecycle Policy | ecr | iac, discovery | Implemented |
57+
| `CLDBRN-AWS-ECR-2` | ECR Lifecycle Policy Missing Untagged Image Expiry | ecr | iac | Implemented |
58+
| `CLDBRN-AWS-ECR-3` | ECR Lifecycle Policy Missing Tagged Image Retention Cap | ecr | iac | Implemented |
5359
| `CLDBRN-AWS-EKS-1` | EKS Node Group Without Graviton | eks | discovery, iac | Implemented |
5460
| `CLDBRN-AWS-ELASTICACHE-1` | ElastiCache Cluster Missing Reserved Coverage | elasticache | discovery | Implemented |
5561
| `CLDBRN-AWS-ELASTICACHE-2` | ElastiCache Cluster Idle | elasticache | discovery | Implemented |
@@ -67,19 +73,22 @@ Format: `CLDBRN-{PROVIDER}-{SERVICE}-{N}`
6773
| `CLDBRN-AWS-RDS-5` | RDS DB Instance Low CPU Utilization | rds | discovery | Implemented |
6874
| `CLDBRN-AWS-RDS-6` | RDS DB Instance Unsupported Engine Version | rds | discovery, iac | Implemented |
6975
| `CLDBRN-AWS-RDS-7` | RDS Snapshot Without Source DB Instance | rds | discovery | Implemented |
76+
| `CLDBRN-AWS-RDS-8` | RDS Performance Insights Extended Retention | rds | iac | Implemented |
7077
| `CLDBRN-AWS-REDSHIFT-1` | Redshift Cluster Low CPU Utilization | redshift | discovery | Implemented |
7178
| `CLDBRN-AWS-REDSHIFT-2` | Redshift Cluster Missing Reserved Coverage | redshift | discovery | Implemented |
72-
| `CLDBRN-AWS-REDSHIFT-3` | Redshift Cluster Pause Resume Not Enabled | redshift | discovery | Implemented |
79+
| `CLDBRN-AWS-REDSHIFT-3` | Redshift Cluster Pause Resume Not Enabled | redshift | discovery, iac | Implemented |
7380
| `CLDBRN-AWS-ROUTE53-1` | Route 53 Record Higher TTL | route53 | discovery, iac | Implemented |
7481
| `CLDBRN-AWS-ROUTE53-2` | Route 53 Health Check Unused | route53 | discovery, iac | Implemented |
7582
| `CLDBRN-AWS-S3-1` | S3 Missing Lifecycle Configuration | s3 | iac, discovery | Implemented |
7683
| `CLDBRN-AWS-S3-2` | S3 Bucket Storage Class Not Optimized | s3 | iac, discovery | Implemented |
7784
| `CLDBRN-AWS-S3-3` | S3 Incomplete Multipart Upload Abort Configuration | s3 | iac, discovery | Implemented |
85+
| `CLDBRN-AWS-S3-4` | S3 Versioned Bucket Missing Noncurrent Version Cleanup | s3 | iac | Implemented |
7886
| `CLDBRN-AWS-SECRETSMANAGER-1` | Secrets Manager Secret Unused | secretsmanager | discovery | Implemented |
7987
| `CLDBRN-AWS-LAMBDA-1` | Lambda Cost Optimal Architecture | lambda | iac, discovery | Implemented |
8088
| `CLDBRN-AWS-LAMBDA-2` | Lambda Function High Error Rate | lambda | discovery | Implemented |
8189
| `CLDBRN-AWS-LAMBDA-3` | Lambda Function Excessive Timeout | lambda | discovery | Implemented |
8290
| `CLDBRN-AWS-LAMBDA-4` | Lambda Function Memory Overprovisioned | lambda | discovery | Implemented |
91+
| `CLDBRN-AWS-LAMBDA-5` | Lambda Provisioned Concurrency Configured | lambda | iac | Implemented |
8392

8493
`CLDBRN-AWS-APIGATEWAY-1` flags REST API stages when `cacheClusterEnabled` is not explicitly `true`.
8594

@@ -113,6 +122,8 @@ Format: `CLDBRN-{PROVIDER}-{SERVICE}-{N}`
113122

114123
`CLDBRN-AWS-DYNAMODB-3` reviews only provisioned-capacity tables and flags them when 30 days of consumed read and write capacity both sum to zero.
115124

125+
`CLDBRN-AWS-DYNAMODB-4` reviews only provisioned-capacity tables and flags them when statically resolved read or write autoscaling ranges have identical min and max capacity values.
126+
116127
`CLDBRN-AWS-EC2-6` flags only families with a curated Graviton-equivalent path. Instances without architecture metadata or outside the curated family set are skipped.
117128

118129
`CLDBRN-AWS-EC2-7` reviews only active reserved instances with an `endTime` inside the next 60 days.
@@ -121,12 +132,22 @@ Format: `CLDBRN-{PROVIDER}-{SERVICE}-{N}`
121132

122133
`CLDBRN-AWS-EC2-9` flags only instances with a parsed launch timestamp at least 180 days old.
123134

135+
`CLDBRN-AWS-EC2-10` flags IaC-defined instances only when detailed monitoring is explicitly enabled.
136+
124137
`CLDBRN-AWS-ECS-1` flags only EC2-backed container instances whose instance families have a curated Graviton-equivalent path. Fargate and unclassified backing instances are skipped.
125138

126139
`CLDBRN-AWS-ECS-2` flags only ECS clusters with a complete 14-day `AWS/ECS` CPU history and an average below `10%`.
127140

128141
`CLDBRN-AWS-ECS-3` flags only active `REPLICA` ECS services and requires both a scalable target and at least one scaling policy.
129142

143+
`CLDBRN-AWS-EBS-8` flags only `gp3` volumes whose provisioned throughput is above the included `125 MiB/s` baseline.
144+
145+
`CLDBRN-AWS-EBS-9` flags only `gp3` volumes whose provisioned or defaulted IOPS exceed the included `3000` baseline.
146+
147+
`CLDBRN-AWS-ECR-2` reviews only repositories with a lifecycle policy and flags them when the statically parsed policy does not expire untagged images.
148+
149+
`CLDBRN-AWS-ECR-3` reviews only repositories with a lifecycle policy and flags them when the statically parsed policy does not cap tagged image retention.
150+
130151
`CLDBRN-AWS-EKS-1` flags only managed node groups with classifiable non-Arm instance families. Arm AMIs and unclassified node groups are skipped.
131152

132153
`CLDBRN-AWS-ELASTICACHE-1` reviews only `available` clusters with a parsed create time at least 180 days old and requires active reserved-node capacity on the same node type, preferring exact engine matches when ElastiCache reports them.
@@ -147,6 +168,8 @@ Format: `CLDBRN-{PROVIDER}-{SERVICE}-{N}`
147168

148169
`CLDBRN-AWS-LAMBDA-4` reviews only functions configured above `256 MB`, requires invocation history, and flags them when the observed 7-day average duration uses less than `30%` of the configured timeout.
149170

171+
`CLDBRN-AWS-LAMBDA-5` flags explicit provisioned concurrency configuration when provisioned concurrent executions are greater than zero.
172+
150173
`CLDBRN-AWS-RDS-3` reviews only `available` DB instances with a parsed create time at least 180 days old and requires active reserved-instance coverage on the same instance class, deployment mode, and normalized engine when AWS reports it.
151174

152175
`CLDBRN-AWS-RDS-4` flags only curated non-Graviton RDS families with a clear Graviton migration path. Existing Graviton classes and unclassified families are skipped.
@@ -157,6 +180,8 @@ Format: `CLDBRN-{PROVIDER}-{SERVICE}-{N}`
157180

158181
`CLDBRN-AWS-RDS-7` flags only snapshots whose source DB instance no longer exists and whose parsed create time is at least `30` days old.
159182

183+
`CLDBRN-AWS-RDS-8` flags only DB instances with Performance Insights enabled and a retention period above the included 7-day baseline.
184+
160185
`CLDBRN-AWS-REDSHIFT-1` reviews only `available` clusters and treats a 14-day average `CPUUtilization` of 10% or lower as low utilization.
161186

162187
`CLDBRN-AWS-REDSHIFT-2` reviews only `available` clusters with a parsed create time at least 180 days old and requires active reserved-node coverage for the same node type.
@@ -169,6 +194,8 @@ Format: `CLDBRN-{PROVIDER}-{SERVICE}-{N}`
169194

170195
`CLDBRN-AWS-S3-3` flags buckets when no enabled lifecycle rule aborts incomplete multipart uploads within 7 days.
171196

197+
`CLDBRN-AWS-S3-4` flags only versioned buckets and requires either noncurrent-version expiration or transition cleanup to avoid unbounded version growth.
198+
172199
`CLDBRN-AWS-SECRETSMANAGER-1` flags secrets with no `lastAccessedDate` and secrets whose parsed last access is at least `90` days old.
173200

174201
**Status key:**
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { createFinding, createFindingMatch, createRule } from '../../shared/helpers.js';
2+
3+
const RULE_ID = 'CLDBRN-AWS-DYNAMODB-4';
4+
const RULE_SERVICE = 'dynamodb';
5+
const RULE_MESSAGE = 'Provisioned DynamoDB autoscaling should allow capacity to change.';
6+
7+
const hasFixedRange = (minCapacity: number | null | undefined, maxCapacity: number | null | undefined): boolean =>
8+
typeof minCapacity === 'number' && typeof maxCapacity === 'number' && minCapacity === maxCapacity;
9+
10+
/** Flag provisioned-capacity DynamoDB tables whose table autoscaling min and max capacity are identical. */
11+
export const dynamoDbAutoscalingRangeFixedRule = createRule({
12+
id: RULE_ID,
13+
name: 'DynamoDB Autoscaling Range Fixed',
14+
description: 'Flag provisioned-capacity DynamoDB tables whose table autoscaling min and max capacity are identical.',
15+
message: RULE_MESSAGE,
16+
provider: 'aws',
17+
service: RULE_SERVICE,
18+
supports: ['iac'],
19+
staticDependencies: ['aws-dynamodb-tables', 'aws-dynamodb-autoscaling'],
20+
evaluateStatic: ({ resources }) => {
21+
const autoscalingByTable = new Map(
22+
resources
23+
.get('aws-dynamodb-autoscaling')
24+
.filter((table) => table.tableName !== null)
25+
.map((table) => [table.tableName, table] as const),
26+
);
27+
const findings = resources
28+
.get('aws-dynamodb-tables')
29+
.filter((table) => table.billingMode === 'PROVISIONED' && table.tableName !== null)
30+
.filter((table) => {
31+
const autoscaling = autoscalingByTable.get(table.tableName);
32+
33+
if (!autoscaling) {
34+
return false;
35+
}
36+
37+
return (
38+
hasFixedRange(autoscaling.readMinCapacity, autoscaling.readMaxCapacity) ||
39+
hasFixedRange(autoscaling.writeMinCapacity, autoscaling.writeMaxCapacity)
40+
);
41+
})
42+
.map((table) => createFindingMatch(table.resourceId, undefined, undefined, table.location));
43+
44+
return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'iac', findings);
45+
},
46+
});
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1+
import { dynamoDbAutoscalingRangeFixedRule } from './autoscaling-range-fixed.js';
12
import { dynamoDbStaleTableDataRule } from './stale-table-data.js';
23
import { dynamoDbTableWithoutAutoscalingRule } from './table-without-autoscaling.js';
34
import { dynamoDbUnusedTableRule } from './unused-table.js';
45

56
// Intent: aggregate AWS DynamoDB rule definitions.
6-
export const dynamodbRules = [dynamoDbStaleTableDataRule, dynamoDbTableWithoutAutoscalingRule, dynamoDbUnusedTableRule];
7+
export const dynamodbRules = [
8+
dynamoDbStaleTableDataRule,
9+
dynamoDbTableWithoutAutoscalingRule,
10+
dynamoDbUnusedTableRule,
11+
dynamoDbAutoscalingRangeFixedRule,
12+
];
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { createFinding, createFindingMatch, createRule } from '../../shared/helpers.js';
2+
3+
const RULE_ID = 'CLDBRN-AWS-EBS-9';
4+
const RULE_SERVICE = 'ebs';
5+
const RULE_MESSAGE = 'EBS gp3 volumes should avoid paid IOPS above the included baseline unless required.';
6+
7+
/** Flag gp3 volumes that provision IOPS above the included 3000 baseline. */
8+
export const ebsGp3ExtraIopsRule = createRule({
9+
id: RULE_ID,
10+
name: 'EBS gp3 Volume Extra IOPS Provisioned',
11+
description: 'Flag gp3 volumes that provision IOPS above the included 3000 baseline.',
12+
message: RULE_MESSAGE,
13+
provider: 'aws',
14+
service: RULE_SERVICE,
15+
supports: ['iac'],
16+
staticDependencies: ['aws-ebs-volumes'],
17+
evaluateStatic: ({ resources }) => {
18+
const findings = resources
19+
.get('aws-ebs-volumes')
20+
.filter((volume) => volume.volumeType === 'gp3' && volume.iops !== null && volume.iops > 3000)
21+
.map((volume) => createFindingMatch(volume.resourceId, undefined, undefined, volume.location));
22+
23+
return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'iac', findings);
24+
},
25+
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { createFinding, createFindingMatch, createRule } from '../../shared/helpers.js';
2+
3+
const RULE_ID = 'CLDBRN-AWS-EBS-8';
4+
const RULE_SERVICE = 'ebs';
5+
const RULE_MESSAGE = 'EBS gp3 volumes should avoid paid throughput above the included baseline unless required.';
6+
7+
/** Flag gp3 volumes that provision throughput above the included 125 MiB/s baseline. */
8+
export const ebsGp3ExtraThroughputRule = createRule({
9+
id: RULE_ID,
10+
name: 'EBS gp3 Volume Extra Throughput Provisioned',
11+
description: 'Flag gp3 volumes that provision throughput above the included 125 MiB/s baseline.',
12+
message: RULE_MESSAGE,
13+
provider: 'aws',
14+
service: RULE_SERVICE,
15+
supports: ['iac'],
16+
staticDependencies: ['aws-ebs-volumes'],
17+
evaluateStatic: ({ resources }) => {
18+
const findings = resources
19+
.get('aws-ebs-volumes')
20+
.filter(
21+
(volume) =>
22+
volume.volumeType === 'gp3' && typeof volume.throughputMiBps === 'number' && volume.throughputMiBps > 125,
23+
)
24+
.map((volume) => createFindingMatch(volume.resourceId, undefined, undefined, volume.location));
25+
26+
return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'iac', findings);
27+
},
28+
});

packages/rules/src/aws/ebs/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { ebsAttachedToStoppedInstancesRule } from './attached-to-stopped-instances.js';
2+
import { ebsGp3ExtraIopsRule } from './gp3-extra-iops.js';
3+
import { ebsGp3ExtraThroughputRule } from './gp3-extra-throughput.js';
24
import { ebsHighIopsVolumeRule } from './high-iops-volume.js';
35
import { ebsLargeVolumeRule } from './large-volume.js';
46
import { ebsLowIopsVolumeRule } from './low-iops-volume.js';
@@ -15,4 +17,6 @@ export const ebsRules = [
1517
ebsHighIopsVolumeRule,
1618
ebsLowIopsVolumeRule,
1719
ebsSnapshotMaxAgeRule,
20+
ebsGp3ExtraThroughputRule,
21+
ebsGp3ExtraIopsRule,
1822
];
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { createFinding, createFindingMatch, createRule } from '../../shared/helpers.js';
2+
3+
const RULE_ID = 'CLDBRN-AWS-EC2-10';
4+
const RULE_SERVICE = 'ec2';
5+
const RULE_MESSAGE = 'EC2 instances should review detailed monitoring because it adds CloudWatch cost.';
6+
7+
/** Flag EC2 instances that explicitly enable detailed monitoring. */
8+
export const ec2DetailedMonitoringEnabledRule = createRule({
9+
id: RULE_ID,
10+
name: 'EC2 Instance Detailed Monitoring Enabled',
11+
description: 'Flag EC2 instances that explicitly enable detailed monitoring.',
12+
message: RULE_MESSAGE,
13+
provider: 'aws',
14+
service: RULE_SERVICE,
15+
supports: ['iac'],
16+
staticDependencies: ['aws-ec2-instances'],
17+
evaluateStatic: ({ resources }) => {
18+
const findings = resources
19+
.get('aws-ec2-instances')
20+
.filter((instance) => instance.detailedMonitoringEnabled)
21+
.map((instance) => createFindingMatch(instance.resourceId, undefined, undefined, instance.location));
22+
23+
return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'iac', findings);
24+
},
25+
});

packages/rules/src/aws/ec2/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ec2DetailedMonitoringEnabledRule } from './detailed-monitoring-enabled.js';
12
import { ec2GravitonReviewRule } from './graviton-review.js';
23
import { ec2InactiveVpcInterfaceEndpointRule } from './inactive-vpc-interface-endpoint.js';
34
import { ec2LargeInstanceRule } from './large-instance.js';
@@ -19,4 +20,5 @@ export const ec2Rules = [
1920
ec2ReservedInstanceExpiringRule,
2021
ec2LargeInstanceRule,
2122
ec2LongRunningInstanceRule,
23+
ec2DetailedMonitoringEnabledRule,
2224
];

0 commit comments

Comments
 (0)