From aff9c3f399507b88cee6a4969b84d9156cfb5566 Mon Sep 17 00:00:00 2001 From: Danny Steenman Date: Fri, 27 Mar 2026 19:24:28 +0100 Subject: [PATCH 1/2] feat(rules): add AWS IaC cost review rules --- .changeset/aws-iac-cost-rules-rules.md | 5 + .changeset/aws-iac-cost-rules-sdk.md | 5 + docs/reference/rule-ids.md | 31 +- .../aws/dynamodb/autoscaling-range-fixed.ts | 46 + packages/rules/src/aws/dynamodb/index.ts | 8 +- packages/rules/src/aws/ebs/gp3-extra-iops.ts | 25 + .../rules/src/aws/ebs/gp3-extra-throughput.ts | 28 + packages/rules/src/aws/ebs/index.ts | 4 + .../aws/ec2/detailed-monitoring-enabled.ts | 25 + packages/rules/src/aws/ec2/index.ts | 2 + packages/rules/src/aws/ecr/index.ts | 8 +- .../ecr/missing-tagged-image-retention-cap.ts | 25 + .../aws/ecr/missing-untagged-image-expiry.ts | 25 + .../src/aws/ecs/service-autoscaling-policy.ts | 29 +- packages/rules/src/aws/lambda/index.ts | 2 + .../provisioned-concurrency-configured.ts | 25 + packages/rules/src/aws/rds/index.ts | 2 + ...performance-insights-extended-retention.ts | 40 + .../rules/src/aws/redshift/pause-resume.ts | 24 +- packages/rules/src/aws/s3/index.ts | 2 + packages/rules/src/aws/s3/shared.ts | 5 + ...ioned-bucket-noncurrent-version-cleanup.ts | 27 + packages/rules/src/index.ts | 4 + packages/rules/src/shared/metadata.ts | 57 +- .../dynamodb-autoscaling-range-fixed.test.ts | 69 ++ ...dynamodb-table-without-autoscaling.test.ts | 4 + .../rules/test/ebs-gp3-extra-iops.test.ts | 57 ++ .../test/ebs-gp3-extra-throughput.test.ts | 58 ++ .../rules/test/ebs-high-iops-volume.test.ts | 1 + packages/rules/test/ebs-large-volume.test.ts | 1 + .../rules/test/ebs-low-iops-volume.test.ts | 1 + .../ec2-detailed-monitoring-enabled.test.ts | 53 ++ .../rules/test/ec2-graviton-review.test.ts | 1 + .../rules/test/ec2-large-instance.test.ts | 1 + .../test/ec2-preferred-instance-type.test.ts | 1 + .../test/ecr-missing-lifecycle-policy.test.ts | 2 + ...missing-tagged-image-retention-cap.test.ts | 70 ++ .../ecr-missing-untagged-image-expiry.test.ts | 79 ++ .../ecs-service-autoscaling-policy.test.ts | 87 +- packages/rules/test/exports.test.ts | 13 + ...provisioned-concurrency-configured.test.ts | 57 ++ .../rules/test/rds-graviton-review.test.ts | 2 + ...rmance-insights-extended-retention.test.ts | 60 ++ .../test/rds-preferred-instance-class.test.ts | 2 + .../rds-unsupported-engine-version.test.ts | 2 + .../rules/test/redshift-pause-resume.test.ts | 61 +- packages/rules/test/rule-metadata.test.ts | 6 +- ...-incomplete-multipart-upload-abort.test.ts | 2 + .../test/s3-missing-lifecycle-config.test.ts | 2 + .../s3-storage-class-optimization.test.ts | 2 + ...-bucket-noncurrent-version-cleanup.test.ts | 63 ++ .../test/volume-type-current-gen.test.ts | 1 + .../sdk/src/providers/aws/static-registry.ts | 678 +++++++++++++- packages/sdk/test/exports.test.ts | 504 +--------- .../sdk/test/providers/aws-static.test.ts | 861 ++++++++++++++++++ 55 files changed, 2767 insertions(+), 488 deletions(-) create mode 100644 .changeset/aws-iac-cost-rules-rules.md create mode 100644 .changeset/aws-iac-cost-rules-sdk.md create mode 100644 packages/rules/src/aws/dynamodb/autoscaling-range-fixed.ts create mode 100644 packages/rules/src/aws/ebs/gp3-extra-iops.ts create mode 100644 packages/rules/src/aws/ebs/gp3-extra-throughput.ts create mode 100644 packages/rules/src/aws/ec2/detailed-monitoring-enabled.ts create mode 100644 packages/rules/src/aws/ecr/missing-tagged-image-retention-cap.ts create mode 100644 packages/rules/src/aws/ecr/missing-untagged-image-expiry.ts create mode 100644 packages/rules/src/aws/lambda/provisioned-concurrency-configured.ts create mode 100644 packages/rules/src/aws/rds/performance-insights-extended-retention.ts create mode 100644 packages/rules/src/aws/s3/versioned-bucket-noncurrent-version-cleanup.ts create mode 100644 packages/rules/test/dynamodb-autoscaling-range-fixed.test.ts create mode 100644 packages/rules/test/ebs-gp3-extra-iops.test.ts create mode 100644 packages/rules/test/ebs-gp3-extra-throughput.test.ts create mode 100644 packages/rules/test/ec2-detailed-monitoring-enabled.test.ts create mode 100644 packages/rules/test/ecr-missing-tagged-image-retention-cap.test.ts create mode 100644 packages/rules/test/ecr-missing-untagged-image-expiry.test.ts create mode 100644 packages/rules/test/lambda-provisioned-concurrency-configured.test.ts create mode 100644 packages/rules/test/rds-performance-insights-extended-retention.test.ts create mode 100644 packages/rules/test/s3-versioned-bucket-noncurrent-version-cleanup.test.ts diff --git a/.changeset/aws-iac-cost-rules-rules.md b/.changeset/aws-iac-cost-rules-rules.md new file mode 100644 index 0000000..ec6012c --- /dev/null +++ b/.changeset/aws-iac-cost-rules-rules.md @@ -0,0 +1,5 @@ +--- +"@cloudburn/rules": minor +--- + +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. diff --git a/.changeset/aws-iac-cost-rules-sdk.md b/.changeset/aws-iac-cost-rules-sdk.md new file mode 100644 index 0000000..4bdd461 --- /dev/null +++ b/.changeset/aws-iac-cost-rules-sdk.md @@ -0,0 +1,5 @@ +--- +"@cloudburn/sdk": minor +--- + +Add AWS static IaC dataset support for new cost review rules across S3, ECR, EBS, EC2, DynamoDB, ECS, Lambda, RDS, and Redshift. diff --git a/docs/reference/rule-ids.md b/docs/reference/rule-ids.md index 0b6fc32..16d8e78 100644 --- a/docs/reference/rule-ids.md +++ b/docs/reference/rule-ids.md @@ -30,6 +30,7 @@ Format: `CLDBRN-{PROVIDER}-{SERVICE}-{N}` | `CLDBRN-AWS-DYNAMODB-1` | DynamoDB Table Stale Data | dynamodb | discovery | Implemented | | `CLDBRN-AWS-DYNAMODB-2` | DynamoDB Table Without Autoscaling | dynamodb | discovery, iac | Implemented | | `CLDBRN-AWS-DYNAMODB-3` | DynamoDB Table Unused | dynamodb | discovery | Implemented | +| `CLDBRN-AWS-DYNAMODB-4` | DynamoDB Autoscaling Range Fixed | dynamodb | iac | Implemented | | `CLDBRN-AWS-EC2-1` | EC2 Instance Type Not Preferred | ec2 | iac, discovery | Implemented | | `CLDBRN-AWS-EC2-2` | S3 Interface VPC Endpoint Used | ec2 | iac | Implemented | | `CLDBRN-AWS-EC2-3` | Elastic IP Address Unassociated | ec2 | discovery, iac | Implemented | @@ -39,9 +40,10 @@ Format: `CLDBRN-{PROVIDER}-{SERVICE}-{N}` | `CLDBRN-AWS-EC2-7` | EC2 Reserved Instance Expiring | ec2 | discovery | Implemented | | `CLDBRN-AWS-EC2-8` | EC2 Instance Large Size | ec2 | discovery, iac | Implemented | | `CLDBRN-AWS-EC2-9` | EC2 Instance Long Running | ec2 | discovery | Implemented | +| `CLDBRN-AWS-EC2-10` | EC2 Instance Detailed Monitoring Enabled | ec2 | iac | Implemented | | `CLDBRN-AWS-ECS-1` | ECS Container Instance Without Graviton | ecs | discovery | Implemented | | `CLDBRN-AWS-ECS-2` | ECS Cluster Low CPU Utilization | ecs | discovery | Implemented | -| `CLDBRN-AWS-ECS-3` | ECS Service Missing Autoscaling Policy | ecs | discovery | Implemented | +| `CLDBRN-AWS-ECS-3` | ECS Service Missing Autoscaling Policy | ecs | discovery, iac | Implemented | | `CLDBRN-AWS-EBS-1` | EBS Volume Type Not Current Generation | ebs | discovery, iac | Implemented | | `CLDBRN-AWS-EBS-2` | EBS Volume Unattached | ebs | discovery | Implemented | | `CLDBRN-AWS-EBS-3` | EBS Volume Attached To Stopped Instances | ebs | discovery | Implemented | @@ -49,7 +51,11 @@ Format: `CLDBRN-{PROVIDER}-{SERVICE}-{N}` | `CLDBRN-AWS-EBS-5` | EBS Volume High Provisioned IOPS | ebs | discovery, iac | Implemented | | `CLDBRN-AWS-EBS-6` | EBS Volume Low Provisioned IOPS On io1/io2 | ebs | discovery, iac | Implemented | | `CLDBRN-AWS-EBS-7` | EBS Snapshot Max Age Exceeded | ebs | discovery | Implemented | +| `CLDBRN-AWS-EBS-8` | EBS gp3 Volume Extra Throughput Provisioned | ebs | iac | Implemented | +| `CLDBRN-AWS-EBS-9` | EBS gp3 Volume Extra IOPS Provisioned | ebs | iac | Implemented | | `CLDBRN-AWS-ECR-1` | ECR Repository Missing Lifecycle Policy | ecr | iac, discovery | Implemented | +| `CLDBRN-AWS-ECR-2` | ECR Lifecycle Policy Missing Untagged Image Expiry | ecr | iac | Implemented | +| `CLDBRN-AWS-ECR-3` | ECR Lifecycle Policy Missing Tagged Image Retention Cap | ecr | iac | Implemented | | `CLDBRN-AWS-EKS-1` | EKS Node Group Without Graviton | eks | discovery, iac | Implemented | | `CLDBRN-AWS-ELASTICACHE-1` | ElastiCache Cluster Missing Reserved Coverage | elasticache | discovery | Implemented | | `CLDBRN-AWS-ELASTICACHE-2` | ElastiCache Cluster Idle | elasticache | discovery | Implemented | @@ -67,19 +73,22 @@ Format: `CLDBRN-{PROVIDER}-{SERVICE}-{N}` | `CLDBRN-AWS-RDS-5` | RDS DB Instance Low CPU Utilization | rds | discovery | Implemented | | `CLDBRN-AWS-RDS-6` | RDS DB Instance Unsupported Engine Version | rds | discovery, iac | Implemented | | `CLDBRN-AWS-RDS-7` | RDS Snapshot Without Source DB Instance | rds | discovery | Implemented | +| `CLDBRN-AWS-RDS-8` | RDS Performance Insights Extended Retention | rds | iac | Implemented | | `CLDBRN-AWS-REDSHIFT-1` | Redshift Cluster Low CPU Utilization | redshift | discovery | Implemented | | `CLDBRN-AWS-REDSHIFT-2` | Redshift Cluster Missing Reserved Coverage | redshift | discovery | Implemented | -| `CLDBRN-AWS-REDSHIFT-3` | Redshift Cluster Pause Resume Not Enabled | redshift | discovery | Implemented | +| `CLDBRN-AWS-REDSHIFT-3` | Redshift Cluster Pause Resume Not Enabled | redshift | discovery, iac | Implemented | | `CLDBRN-AWS-ROUTE53-1` | Route 53 Record Higher TTL | route53 | discovery, iac | Implemented | | `CLDBRN-AWS-ROUTE53-2` | Route 53 Health Check Unused | route53 | discovery, iac | Implemented | | `CLDBRN-AWS-S3-1` | S3 Missing Lifecycle Configuration | s3 | iac, discovery | Implemented | | `CLDBRN-AWS-S3-2` | S3 Bucket Storage Class Not Optimized | s3 | iac, discovery | Implemented | | `CLDBRN-AWS-S3-3` | S3 Incomplete Multipart Upload Abort Configuration | s3 | iac, discovery | Implemented | +| `CLDBRN-AWS-S3-4` | S3 Versioned Bucket Missing Noncurrent Version Cleanup | s3 | iac | Implemented | | `CLDBRN-AWS-SECRETSMANAGER-1` | Secrets Manager Secret Unused | secretsmanager | discovery | Implemented | | `CLDBRN-AWS-LAMBDA-1` | Lambda Cost Optimal Architecture | lambda | iac, discovery | Implemented | | `CLDBRN-AWS-LAMBDA-2` | Lambda Function High Error Rate | lambda | discovery | Implemented | | `CLDBRN-AWS-LAMBDA-3` | Lambda Function Excessive Timeout | lambda | discovery | Implemented | | `CLDBRN-AWS-LAMBDA-4` | Lambda Function Memory Overprovisioned | lambda | discovery | Implemented | +| `CLDBRN-AWS-LAMBDA-5` | Lambda Provisioned Concurrency Configured | lambda | iac | Implemented | `CLDBRN-AWS-APIGATEWAY-1` flags REST API stages when `cacheClusterEnabled` is not explicitly `true`. @@ -113,6 +122,8 @@ Format: `CLDBRN-{PROVIDER}-{SERVICE}-{N}` `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. +`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. + `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. `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}` `CLDBRN-AWS-EC2-9` flags only instances with a parsed launch timestamp at least 180 days old. +`CLDBRN-AWS-EC2-10` flags IaC-defined instances only when detailed monitoring is explicitly enabled. + `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. `CLDBRN-AWS-ECS-2` flags only ECS clusters with a complete 14-day `AWS/ECS` CPU history and an average below `10%`. `CLDBRN-AWS-ECS-3` flags only active `REPLICA` ECS services and requires both a scalable target and at least one scaling policy. +`CLDBRN-AWS-EBS-8` flags only `gp3` volumes whose provisioned throughput is above the included `125 MiB/s` baseline. + +`CLDBRN-AWS-EBS-9` flags only `gp3` volumes whose provisioned or defaulted IOPS exceed the included `3000` baseline. + +`CLDBRN-AWS-ECR-2` reviews only repositories with a lifecycle policy and flags them when the statically parsed policy does not expire untagged images. + +`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. + `CLDBRN-AWS-EKS-1` flags only managed node groups with classifiable non-Arm instance families. Arm AMIs and unclassified node groups are skipped. `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}` `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. +`CLDBRN-AWS-LAMBDA-5` flags explicit provisioned concurrency configuration when provisioned concurrent executions are greater than zero. + `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. `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}` `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. +`CLDBRN-AWS-RDS-8` flags only DB instances with Performance Insights enabled and a retention period above the included 7-day baseline. + `CLDBRN-AWS-REDSHIFT-1` reviews only `available` clusters and treats a 14-day average `CPUUtilization` of 10% or lower as low utilization. `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}` `CLDBRN-AWS-S3-3` flags buckets when no enabled lifecycle rule aborts incomplete multipart uploads within 7 days. +`CLDBRN-AWS-S3-4` flags only versioned buckets and requires either noncurrent-version expiration or transition cleanup to avoid unbounded version growth. + `CLDBRN-AWS-SECRETSMANAGER-1` flags secrets with no `lastAccessedDate` and secrets whose parsed last access is at least `90` days old. **Status key:** diff --git a/packages/rules/src/aws/dynamodb/autoscaling-range-fixed.ts b/packages/rules/src/aws/dynamodb/autoscaling-range-fixed.ts new file mode 100644 index 0000000..e9e0403 --- /dev/null +++ b/packages/rules/src/aws/dynamodb/autoscaling-range-fixed.ts @@ -0,0 +1,46 @@ +import { createFinding, createFindingMatch, createRule } from '../../shared/helpers.js'; + +const RULE_ID = 'CLDBRN-AWS-DYNAMODB-4'; +const RULE_SERVICE = 'dynamodb'; +const RULE_MESSAGE = 'Provisioned DynamoDB autoscaling should allow capacity to change.'; + +const hasFixedRange = (minCapacity: number | null | undefined, maxCapacity: number | null | undefined): boolean => + typeof minCapacity === 'number' && typeof maxCapacity === 'number' && minCapacity === maxCapacity; + +/** Flag provisioned-capacity DynamoDB tables whose table autoscaling min and max capacity are identical. */ +export const dynamoDbAutoscalingRangeFixedRule = createRule({ + id: RULE_ID, + name: 'DynamoDB Autoscaling Range Fixed', + description: 'Flag provisioned-capacity DynamoDB tables whose table autoscaling min and max capacity are identical.', + message: RULE_MESSAGE, + provider: 'aws', + service: RULE_SERVICE, + supports: ['iac'], + staticDependencies: ['aws-dynamodb-tables', 'aws-dynamodb-autoscaling'], + evaluateStatic: ({ resources }) => { + const autoscalingByTable = new Map( + resources + .get('aws-dynamodb-autoscaling') + .filter((table) => table.tableName !== null) + .map((table) => [table.tableName, table] as const), + ); + const findings = resources + .get('aws-dynamodb-tables') + .filter((table) => table.billingMode === 'PROVISIONED' && table.tableName !== null) + .filter((table) => { + const autoscaling = autoscalingByTable.get(table.tableName); + + if (!autoscaling) { + return false; + } + + return ( + hasFixedRange(autoscaling.readMinCapacity, autoscaling.readMaxCapacity) || + hasFixedRange(autoscaling.writeMinCapacity, autoscaling.writeMaxCapacity) + ); + }) + .map((table) => createFindingMatch(table.resourceId, undefined, undefined, table.location)); + + return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'iac', findings); + }, +}); diff --git a/packages/rules/src/aws/dynamodb/index.ts b/packages/rules/src/aws/dynamodb/index.ts index a5e6726..9fcf10e 100644 --- a/packages/rules/src/aws/dynamodb/index.ts +++ b/packages/rules/src/aws/dynamodb/index.ts @@ -1,6 +1,12 @@ +import { dynamoDbAutoscalingRangeFixedRule } from './autoscaling-range-fixed.js'; import { dynamoDbStaleTableDataRule } from './stale-table-data.js'; import { dynamoDbTableWithoutAutoscalingRule } from './table-without-autoscaling.js'; import { dynamoDbUnusedTableRule } from './unused-table.js'; // Intent: aggregate AWS DynamoDB rule definitions. -export const dynamodbRules = [dynamoDbStaleTableDataRule, dynamoDbTableWithoutAutoscalingRule, dynamoDbUnusedTableRule]; +export const dynamodbRules = [ + dynamoDbStaleTableDataRule, + dynamoDbTableWithoutAutoscalingRule, + dynamoDbUnusedTableRule, + dynamoDbAutoscalingRangeFixedRule, +]; diff --git a/packages/rules/src/aws/ebs/gp3-extra-iops.ts b/packages/rules/src/aws/ebs/gp3-extra-iops.ts new file mode 100644 index 0000000..3393d78 --- /dev/null +++ b/packages/rules/src/aws/ebs/gp3-extra-iops.ts @@ -0,0 +1,25 @@ +import { createFinding, createFindingMatch, createRule } from '../../shared/helpers.js'; + +const RULE_ID = 'CLDBRN-AWS-EBS-9'; +const RULE_SERVICE = 'ebs'; +const RULE_MESSAGE = 'EBS gp3 volumes should avoid paid IOPS above the included baseline unless required.'; + +/** Flag gp3 volumes that provision IOPS above the included 3000 baseline. */ +export const ebsGp3ExtraIopsRule = createRule({ + id: RULE_ID, + name: 'EBS gp3 Volume Extra IOPS Provisioned', + description: 'Flag gp3 volumes that provision IOPS above the included 3000 baseline.', + message: RULE_MESSAGE, + provider: 'aws', + service: RULE_SERVICE, + supports: ['iac'], + staticDependencies: ['aws-ebs-volumes'], + evaluateStatic: ({ resources }) => { + const findings = resources + .get('aws-ebs-volumes') + .filter((volume) => volume.volumeType === 'gp3' && volume.iops !== null && volume.iops > 3000) + .map((volume) => createFindingMatch(volume.resourceId, undefined, undefined, volume.location)); + + return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'iac', findings); + }, +}); diff --git a/packages/rules/src/aws/ebs/gp3-extra-throughput.ts b/packages/rules/src/aws/ebs/gp3-extra-throughput.ts new file mode 100644 index 0000000..87e2941 --- /dev/null +++ b/packages/rules/src/aws/ebs/gp3-extra-throughput.ts @@ -0,0 +1,28 @@ +import { createFinding, createFindingMatch, createRule } from '../../shared/helpers.js'; + +const RULE_ID = 'CLDBRN-AWS-EBS-8'; +const RULE_SERVICE = 'ebs'; +const RULE_MESSAGE = 'EBS gp3 volumes should avoid paid throughput above the included baseline unless required.'; + +/** Flag gp3 volumes that provision throughput above the included 125 MiB/s baseline. */ +export const ebsGp3ExtraThroughputRule = createRule({ + id: RULE_ID, + name: 'EBS gp3 Volume Extra Throughput Provisioned', + description: 'Flag gp3 volumes that provision throughput above the included 125 MiB/s baseline.', + message: RULE_MESSAGE, + provider: 'aws', + service: RULE_SERVICE, + supports: ['iac'], + staticDependencies: ['aws-ebs-volumes'], + evaluateStatic: ({ resources }) => { + const findings = resources + .get('aws-ebs-volumes') + .filter( + (volume) => + volume.volumeType === 'gp3' && typeof volume.throughputMiBps === 'number' && volume.throughputMiBps > 125, + ) + .map((volume) => createFindingMatch(volume.resourceId, undefined, undefined, volume.location)); + + return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'iac', findings); + }, +}); diff --git a/packages/rules/src/aws/ebs/index.ts b/packages/rules/src/aws/ebs/index.ts index 6138de3..cb1e2e1 100644 --- a/packages/rules/src/aws/ebs/index.ts +++ b/packages/rules/src/aws/ebs/index.ts @@ -1,4 +1,6 @@ import { ebsAttachedToStoppedInstancesRule } from './attached-to-stopped-instances.js'; +import { ebsGp3ExtraIopsRule } from './gp3-extra-iops.js'; +import { ebsGp3ExtraThroughputRule } from './gp3-extra-throughput.js'; import { ebsHighIopsVolumeRule } from './high-iops-volume.js'; import { ebsLargeVolumeRule } from './large-volume.js'; import { ebsLowIopsVolumeRule } from './low-iops-volume.js'; @@ -15,4 +17,6 @@ export const ebsRules = [ ebsHighIopsVolumeRule, ebsLowIopsVolumeRule, ebsSnapshotMaxAgeRule, + ebsGp3ExtraThroughputRule, + ebsGp3ExtraIopsRule, ]; diff --git a/packages/rules/src/aws/ec2/detailed-monitoring-enabled.ts b/packages/rules/src/aws/ec2/detailed-monitoring-enabled.ts new file mode 100644 index 0000000..11ab42a --- /dev/null +++ b/packages/rules/src/aws/ec2/detailed-monitoring-enabled.ts @@ -0,0 +1,25 @@ +import { createFinding, createFindingMatch, createRule } from '../../shared/helpers.js'; + +const RULE_ID = 'CLDBRN-AWS-EC2-10'; +const RULE_SERVICE = 'ec2'; +const RULE_MESSAGE = 'EC2 instances should review detailed monitoring because it adds CloudWatch cost.'; + +/** Flag EC2 instances that explicitly enable detailed monitoring. */ +export const ec2DetailedMonitoringEnabledRule = createRule({ + id: RULE_ID, + name: 'EC2 Instance Detailed Monitoring Enabled', + description: 'Flag EC2 instances that explicitly enable detailed monitoring.', + message: RULE_MESSAGE, + provider: 'aws', + service: RULE_SERVICE, + supports: ['iac'], + staticDependencies: ['aws-ec2-instances'], + evaluateStatic: ({ resources }) => { + const findings = resources + .get('aws-ec2-instances') + .filter((instance) => instance.detailedMonitoringEnabled) + .map((instance) => createFindingMatch(instance.resourceId, undefined, undefined, instance.location)); + + return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'iac', findings); + }, +}); diff --git a/packages/rules/src/aws/ec2/index.ts b/packages/rules/src/aws/ec2/index.ts index 5e7828f..7bef350 100644 --- a/packages/rules/src/aws/ec2/index.ts +++ b/packages/rules/src/aws/ec2/index.ts @@ -1,3 +1,4 @@ +import { ec2DetailedMonitoringEnabledRule } from './detailed-monitoring-enabled.js'; import { ec2GravitonReviewRule } from './graviton-review.js'; import { ec2InactiveVpcInterfaceEndpointRule } from './inactive-vpc-interface-endpoint.js'; import { ec2LargeInstanceRule } from './large-instance.js'; @@ -19,4 +20,5 @@ export const ec2Rules = [ ec2ReservedInstanceExpiringRule, ec2LargeInstanceRule, ec2LongRunningInstanceRule, + ec2DetailedMonitoringEnabledRule, ]; diff --git a/packages/rules/src/aws/ecr/index.ts b/packages/rules/src/aws/ecr/index.ts index 1511535..1f2be8e 100644 --- a/packages/rules/src/aws/ecr/index.ts +++ b/packages/rules/src/aws/ecr/index.ts @@ -1,4 +1,10 @@ import { ecrMissingLifecyclePolicyRule } from './missing-lifecycle-policy.js'; +import { ecrMissingTaggedImageRetentionCapRule } from './missing-tagged-image-retention-cap.js'; +import { ecrMissingUntaggedImageExpiryRule } from './missing-untagged-image-expiry.js'; /** Aggregate AWS ECR rule definitions. */ -export const ecrRules = [ecrMissingLifecyclePolicyRule]; +export const ecrRules = [ + ecrMissingLifecyclePolicyRule, + ecrMissingUntaggedImageExpiryRule, + ecrMissingTaggedImageRetentionCapRule, +]; diff --git a/packages/rules/src/aws/ecr/missing-tagged-image-retention-cap.ts b/packages/rules/src/aws/ecr/missing-tagged-image-retention-cap.ts new file mode 100644 index 0000000..9780dec --- /dev/null +++ b/packages/rules/src/aws/ecr/missing-tagged-image-retention-cap.ts @@ -0,0 +1,25 @@ +import { createFinding, createFindingMatch, createRule } from '../../shared/helpers.js'; + +const RULE_ID = 'CLDBRN-AWS-ECR-3'; +const RULE_SERVICE = 'ecr'; +const RULE_MESSAGE = 'ECR repositories should cap tagged image retention.'; + +/** Flag ECR repositories whose statically parsed lifecycle policy does not cap tagged image retention. */ +export const ecrMissingTaggedImageRetentionCapRule = createRule({ + id: RULE_ID, + name: 'ECR Lifecycle Policy Missing Tagged Image Retention Cap', + description: 'Flag ECR repositories whose lifecycle policy does not cap tagged image retention.', + message: RULE_MESSAGE, + provider: 'aws', + service: RULE_SERVICE, + supports: ['iac'], + staticDependencies: ['aws-ecr-repositories'], + evaluateStatic: ({ resources }) => { + const findings = resources + .get('aws-ecr-repositories') + .filter((repository) => repository.hasLifecyclePolicy && repository.hasTaggedImageRetentionCap === false) + .map((repository) => createFindingMatch(repository.resourceId, undefined, undefined, repository.location)); + + return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'iac', findings); + }, +}); diff --git a/packages/rules/src/aws/ecr/missing-untagged-image-expiry.ts b/packages/rules/src/aws/ecr/missing-untagged-image-expiry.ts new file mode 100644 index 0000000..3353ee1 --- /dev/null +++ b/packages/rules/src/aws/ecr/missing-untagged-image-expiry.ts @@ -0,0 +1,25 @@ +import { createFinding, createFindingMatch, createRule } from '../../shared/helpers.js'; + +const RULE_ID = 'CLDBRN-AWS-ECR-2'; +const RULE_SERVICE = 'ecr'; +const RULE_MESSAGE = 'ECR repositories should expire untagged images.'; + +/** Flag ECR repositories whose statically parsed lifecycle policy does not expire untagged images. */ +export const ecrMissingUntaggedImageExpiryRule = createRule({ + id: RULE_ID, + name: 'ECR Lifecycle Policy Missing Untagged Image Expiry', + description: 'Flag ECR repositories whose lifecycle policy does not expire untagged images.', + message: RULE_MESSAGE, + provider: 'aws', + service: RULE_SERVICE, + supports: ['iac'], + staticDependencies: ['aws-ecr-repositories'], + evaluateStatic: ({ resources }) => { + const findings = resources + .get('aws-ecr-repositories') + .filter((repository) => repository.hasLifecyclePolicy && repository.hasUntaggedImageExpiry === false) + .map((repository) => createFindingMatch(repository.resourceId, undefined, undefined, repository.location)); + + return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'iac', findings); + }, +}); diff --git a/packages/rules/src/aws/ecs/service-autoscaling-policy.ts b/packages/rules/src/aws/ecs/service-autoscaling-policy.ts index f70e6b1..a75d017 100644 --- a/packages/rules/src/aws/ecs/service-autoscaling-policy.ts +++ b/packages/rules/src/aws/ecs/service-autoscaling-policy.ts @@ -4,6 +4,8 @@ const RULE_ID = 'CLDBRN-AWS-ECS-3'; const RULE_SERVICE = 'ecs'; const RULE_MESSAGE = 'Active REPLICA ECS services should use an autoscaling policy.'; +const createStaticScopeKey = (clusterName: string, serviceName: string): string => `${clusterName}/${serviceName}`; + /** Flag active REPLICA ECS services that are missing autoscaling coverage. */ export const ecsServiceAutoscalingPolicyRule = createRule({ id: RULE_ID, @@ -13,8 +15,9 @@ export const ecsServiceAutoscalingPolicyRule = createRule({ message: RULE_MESSAGE, provider: 'aws', service: RULE_SERVICE, - supports: ['discovery'], + supports: ['discovery', 'iac'], discoveryDependencies: ['aws-ecs-services', 'aws-ecs-autoscaling'], + staticDependencies: ['aws-ecs-services', 'aws-ecs-autoscaling'], evaluateLive: ({ resources }) => { const autoscalingByServiceArn = new Map( resources.get('aws-ecs-autoscaling').map((service) => [service.serviceArn, service] as const), @@ -32,4 +35,28 @@ export const ecsServiceAutoscalingPolicyRule = createRule({ return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'discovery', findings); }, + evaluateStatic: ({ resources }) => { + const autoscalingByService = new Map( + resources + .get('aws-ecs-autoscaling') + .filter( + (service): service is typeof service & { clusterName: string; serviceName: string } => + service.clusterName !== null && service.serviceName !== null, + ) + .map((service) => [createStaticScopeKey(service.clusterName, service.serviceName), service] as const), + ); + const findings = resources + .get('aws-ecs-services') + .filter( + (service): service is typeof service & { clusterName: string; serviceName: string } => + service.clusterName !== null && service.serviceName !== null && service.schedulingStrategy === 'REPLICA', + ) + .filter((service) => { + const autoscaling = autoscalingByService.get(createStaticScopeKey(service.clusterName, service.serviceName)); + return autoscaling ? !autoscaling.hasScalableTarget || !autoscaling.hasScalingPolicy : true; + }) + .map((service) => createFindingMatch(service.resourceId, undefined, undefined, service.location)); + + return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'iac', findings); + }, }); diff --git a/packages/rules/src/aws/lambda/index.ts b/packages/rules/src/aws/lambda/index.ts index 4583455..eb6f73c 100644 --- a/packages/rules/src/aws/lambda/index.ts +++ b/packages/rules/src/aws/lambda/index.ts @@ -2,6 +2,7 @@ import { lambdaCostOptimalArchitectureRule } from './cost-optimal-architecture.j import { lambdaExcessiveTimeoutRule } from './excessive-timeout.js'; import { lambdaHighErrorRateRule } from './high-error-rate.js'; import { lambdaMemoryOverprovisioningRule } from './memory-overprovisioning.js'; +import { lambdaProvisionedConcurrencyConfiguredRule } from './provisioned-concurrency-configured.js'; // Intent: aggregate AWS Lambda rule definitions. export const lambdaRules = [ @@ -9,4 +10,5 @@ export const lambdaRules = [ lambdaHighErrorRateRule, lambdaExcessiveTimeoutRule, lambdaMemoryOverprovisioningRule, + lambdaProvisionedConcurrencyConfiguredRule, ]; diff --git a/packages/rules/src/aws/lambda/provisioned-concurrency-configured.ts b/packages/rules/src/aws/lambda/provisioned-concurrency-configured.ts new file mode 100644 index 0000000..82d2e5c --- /dev/null +++ b/packages/rules/src/aws/lambda/provisioned-concurrency-configured.ts @@ -0,0 +1,25 @@ +import { createFinding, createFindingMatch, createRule } from '../../shared/helpers.js'; + +const RULE_ID = 'CLDBRN-AWS-LAMBDA-5'; +const RULE_SERVICE = 'lambda'; +const RULE_MESSAGE = 'Lambda provisioned concurrency should be reviewed for steady low-latency demand.'; + +/** Flag explicit Lambda provisioned concurrency configuration. */ +export const lambdaProvisionedConcurrencyConfiguredRule = createRule({ + id: RULE_ID, + name: 'Lambda Provisioned Concurrency Configured', + description: 'Flag explicit Lambda provisioned concurrency configuration for cost review.', + message: RULE_MESSAGE, + provider: 'aws', + service: RULE_SERVICE, + supports: ['iac'], + staticDependencies: ['aws-lambda-provisioned-concurrency'], + evaluateStatic: ({ resources }) => { + const findings = resources + .get('aws-lambda-provisioned-concurrency') + .filter((config) => config.provisionedConcurrentExecutions !== null && config.provisionedConcurrentExecutions > 0) + .map((config) => createFindingMatch(config.resourceId, undefined, undefined, config.location)); + + return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'iac', findings); + }, +}); diff --git a/packages/rules/src/aws/rds/index.ts b/packages/rules/src/aws/rds/index.ts index 3f76fd7..c643340 100644 --- a/packages/rules/src/aws/rds/index.ts +++ b/packages/rules/src/aws/rds/index.ts @@ -1,6 +1,7 @@ import { rdsGravitonReviewRule } from './graviton-review.js'; import { rdsIdleInstanceRule } from './idle-instance.js'; import { rdsLowCpuUtilizationRule } from './low-cpu-utilization.js'; +import { rdsPerformanceInsightsExtendedRetentionRule } from './performance-insights-extended-retention.js'; import { rdsPreferredInstanceClassRule } from './preferred-instance-classes.js'; import { rdsReservedCoverageRule } from './reserved-coverage.js'; import { rdsUnsupportedEngineVersionRule } from './unsupported-engine-version.js'; @@ -16,4 +17,5 @@ export const rdsRules = [ rdsLowCpuUtilizationRule, rdsUnsupportedEngineVersionRule, rdsUnusedSnapshotsRule, + rdsPerformanceInsightsExtendedRetentionRule, ]; diff --git a/packages/rules/src/aws/rds/performance-insights-extended-retention.ts b/packages/rules/src/aws/rds/performance-insights-extended-retention.ts new file mode 100644 index 0000000..8ba9230 --- /dev/null +++ b/packages/rules/src/aws/rds/performance-insights-extended-retention.ts @@ -0,0 +1,40 @@ +import { createFinding, createFindingMatch, createRule } from '../../shared/helpers.js'; + +const RULE_ID = 'CLDBRN-AWS-RDS-8'; +const RULE_SERVICE = 'rds'; +const RULE_MESSAGE = + 'RDS Performance Insights should use the included 7-day retention unless longer retention is required.'; + +const hasExtendedRetention = (enabled: boolean | null | undefined, retention: number | null | undefined): boolean => { + if (enabled !== true) { + return false; + } + + if (retention === null) { + return false; + } + + return (retention ?? 7) > 7; +}; + +/** Flag DB instances that enable Performance Insights retention beyond the included 7-day period. */ +export const rdsPerformanceInsightsExtendedRetentionRule = createRule({ + id: RULE_ID, + name: 'RDS Performance Insights Extended Retention', + description: 'Flag DB instances that enable Performance Insights retention beyond the included 7-day period.', + message: RULE_MESSAGE, + provider: 'aws', + service: RULE_SERVICE, + supports: ['iac'], + staticDependencies: ['aws-rds-instances'], + evaluateStatic: ({ resources }) => { + const findings = resources + .get('aws-rds-instances') + .filter((instance) => + hasExtendedRetention(instance.performanceInsightsEnabled, instance.performanceInsightsRetentionPeriod), + ) + .map((instance) => createFindingMatch(instance.resourceId, undefined, undefined, instance.location)); + + return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'iac', findings); + }, +}); diff --git a/packages/rules/src/aws/redshift/pause-resume.ts b/packages/rules/src/aws/redshift/pause-resume.ts index 1286033..87520d3 100644 --- a/packages/rules/src/aws/redshift/pause-resume.ts +++ b/packages/rules/src/aws/redshift/pause-resume.ts @@ -20,6 +20,17 @@ const isPauseResumeEligible = (cluster: { cluster.multiAz?.toLowerCase() !== 'enabled' && cluster.vpcId !== undefined; +const isStaticPauseResumeEligible = (cluster: { + automatedSnapshotRetentionPeriod?: number | null; + hasVpc: boolean; + hsmEnabled: boolean | null; + multiAz: boolean | null; +}): boolean => + (cluster.automatedSnapshotRetentionPeriod ?? 0) > 0 && + cluster.hasVpc && + cluster.hsmEnabled !== true && + cluster.multiAz !== true; + /** Flag eligible Redshift clusters that do not have both pause and resume schedules. */ export const redshiftPauseResumeRule = createRule({ id: RULE_ID, @@ -28,8 +39,9 @@ export const redshiftPauseResumeRule = createRule({ message: RULE_MESSAGE, provider: 'aws', service: RULE_SERVICE, - supports: ['discovery'], + supports: ['discovery', 'iac'], discoveryDependencies: ['aws-redshift-clusters'], + staticDependencies: ['aws-redshift-clusters'], evaluateLive: ({ resources }) => { const findings = resources .get('aws-redshift-clusters') @@ -38,4 +50,14 @@ export const redshiftPauseResumeRule = createRule({ return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'discovery', findings); }, + evaluateStatic: ({ resources }) => { + const findings = resources + .get('aws-redshift-clusters') + .filter( + (cluster) => isStaticPauseResumeEligible(cluster) && (!cluster.hasPauseSchedule || !cluster.hasResumeSchedule), + ) + .map((cluster) => createFindingMatch(cluster.resourceId, undefined, undefined, cluster.location)); + + return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'iac', findings); + }, }); diff --git a/packages/rules/src/aws/s3/index.ts b/packages/rules/src/aws/s3/index.ts index 5d38124..c9ff1ea 100644 --- a/packages/rules/src/aws/s3/index.ts +++ b/packages/rules/src/aws/s3/index.ts @@ -1,10 +1,12 @@ import { s3IncompleteMultipartUploadAbortRule } from './incomplete-multipart-upload-abort.js'; import { s3MissingLifecycleConfigRule } from './missing-lifecycle-config.js'; import { s3StorageClassOptimizationRule } from './storage-class-optimization.js'; +import { s3VersionedBucketNoncurrentVersionCleanupRule } from './versioned-bucket-noncurrent-version-cleanup.js'; /** Aggregate AWS S3 rule definitions. */ export const s3Rules = [ s3MissingLifecycleConfigRule, s3StorageClassOptimizationRule, s3IncompleteMultipartUploadAbortRule, + s3VersionedBucketNoncurrentVersionCleanupRule, ]; diff --git a/packages/rules/src/aws/s3/shared.ts b/packages/rules/src/aws/s3/shared.ts index 5ebeaa8..aa88af5 100644 --- a/packages/rules/src/aws/s3/shared.ts +++ b/packages/rules/src/aws/s3/shared.ts @@ -21,6 +21,11 @@ export const hasMissingStorageClassOptimization = (bucket: AwsS3BucketAnalysisFl !bucket.hasIntelligentTieringTransition && !bucket.hasAlternativeStorageClassTransition; +/** Returns whether a versioned S3 bucket should be flagged for missing noncurrent-version cleanup. */ +export const hasMissingNoncurrentVersionCleanup = ( + bucket: Pick, +): boolean => bucket.versioningEnabled === true && bucket.hasNoncurrentVersionCleanup !== true; + /** Creates a live finding target for a discovered S3 bucket analysis. */ export const createLiveS3BucketFindingMatch = (bucket: AwsS3BucketAnalysis) => createFindingMatch(bucket.bucketName, bucket.region, bucket.accountId); diff --git a/packages/rules/src/aws/s3/versioned-bucket-noncurrent-version-cleanup.ts b/packages/rules/src/aws/s3/versioned-bucket-noncurrent-version-cleanup.ts new file mode 100644 index 0000000..46cd26f --- /dev/null +++ b/packages/rules/src/aws/s3/versioned-bucket-noncurrent-version-cleanup.ts @@ -0,0 +1,27 @@ +import { createFinding, createRule } from '../../shared/helpers.js'; +import { createStaticS3BucketFindingMatch, hasMissingNoncurrentVersionCleanup } from './shared.js'; + +const RULE_ID = 'CLDBRN-AWS-S3-4'; +const RULE_SERVICE = 's3'; +const RULE_MESSAGE = 'Versioned S3 buckets should define noncurrent-version cleanup.'; + +/** Flag versioned S3 buckets that define no noncurrent-version expiration or transition cleanup. */ +export const s3VersionedBucketNoncurrentVersionCleanupRule = createRule({ + id: RULE_ID, + name: 'S3 Versioned Bucket Missing Noncurrent Version Cleanup', + description: + 'Flag versioned S3 buckets that do not define noncurrent-version expiration or transition lifecycle cleanup.', + message: RULE_MESSAGE, + provider: 'aws', + service: RULE_SERVICE, + supports: ['iac'], + staticDependencies: ['aws-s3-bucket-analyses'], + evaluateStatic: ({ resources }) => { + const findings = resources + .get('aws-s3-bucket-analyses') + .filter((bucket) => hasMissingNoncurrentVersionCleanup(bucket)) + .map((bucket) => createStaticS3BucketFindingMatch(bucket)); + + return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'iac', findings); + }, +}); diff --git a/packages/rules/src/index.ts b/packages/rules/src/index.ts index 345b286..55a1658 100644 --- a/packages/rules/src/index.ts +++ b/packages/rules/src/index.ts @@ -77,10 +77,14 @@ export type { AwsStaticEc2Instance, AwsStaticEc2VpcEndpoint, AwsStaticEcrRepository, + AwsStaticEcsService, + AwsStaticEcsServiceAutoscaling, AwsStaticEksNodegroup, AwsStaticEmrCluster, AwsStaticLambdaFunction, + AwsStaticLambdaProvisionedConcurrency, AwsStaticRdsInstance, + AwsStaticRedshiftCluster, AwsStaticRoute53HealthCheck, AwsStaticRoute53Record, AwsStaticS3BucketAnalysis, diff --git a/packages/rules/src/shared/metadata.ts b/packages/rules/src/shared/metadata.ts index f770f94..fdba14a 100644 --- a/packages/rules/src/shared/metadata.ts +++ b/packages/rules/src/shared/metadata.ts @@ -603,6 +603,8 @@ export type SharedDatasetKey = | 'aws-dynamodb-autoscaling' | 'aws-dynamodb-tables' | 'aws-ebs-volumes' + | 'aws-ecs-autoscaling' + | 'aws-ecs-services' | 'aws-ecr-repositories' | 'aws-ec2-elastic-ips' | 'aws-ec2-instances' @@ -610,6 +612,7 @@ export type SharedDatasetKey = | 'aws-emr-clusters' | 'aws-lambda-functions' | 'aws-rds-instances' + | 'aws-redshift-clusters' | 'aws-route53-health-checks' | 'aws-route53-records' | 'aws-s3-bucket-analyses'; @@ -722,7 +725,7 @@ export type DiscoveryDatasetMap = { }; /** Rule-facing static IaC dataset key exposed through the evaluation context. */ -export type StaticDatasetKey = SharedDatasetKey | 'aws-ec2-vpc-endpoints'; +export type StaticDatasetKey = SharedDatasetKey | 'aws-ec2-vpc-endpoints' | 'aws-lambda-provisioned-concurrency'; /** Normalized static API Gateway stage dataset entry. */ export type AwsStaticApiGatewayStage = { @@ -759,6 +762,10 @@ export type AwsStaticDynamoDbAutoscaling = { tableName: string | null; hasReadTarget: boolean; hasWriteTarget: boolean; + readMinCapacity?: number | null; + readMaxCapacity?: number | null; + writeMinCapacity?: number | null; + writeMaxCapacity?: number | null; }; /** Normalized static EBS volume dataset entry with a precomputed finding target. */ @@ -766,6 +773,7 @@ export type AwsStaticEbsVolume = { resourceId: string; sizeGiB: number | null; iops: number | null; + throughputMiBps?: number | null; volumeType: string | null; location?: SourceLocation; }; @@ -774,12 +782,15 @@ export type AwsStaticEbsVolume = { export type AwsStaticEcrRepository = { resourceId: string; hasLifecyclePolicy: boolean; + hasTaggedImageRetentionCap?: boolean | null; + hasUntaggedImageExpiry?: boolean | null; location?: SourceLocation; }; /** Normalized static EC2 instance dataset entry with a precomputed finding target. */ export type AwsStaticEc2Instance = { resourceId: string; + detailedMonitoringEnabled?: boolean; instanceType: string | null; location?: SourceLocation; }; @@ -827,6 +838,8 @@ export type AwsStaticRdsInstance = { instanceClass: string | null; engine: string | null; engineVersion: string | null; + performanceInsightsEnabled?: boolean | null; + performanceInsightsRetentionPeriod?: number | null | undefined; location?: SourceLocation; }; @@ -837,6 +850,42 @@ export type AwsStaticLambdaFunction = { location?: SourceLocation; }; +/** Normalized static Lambda provisioned concurrency dataset entry. */ +export type AwsStaticLambdaProvisionedConcurrency = { + resourceId: string; + provisionedConcurrentExecutions: number | null; + location?: SourceLocation; +}; + +/** Normalized static ECS service dataset entry. */ +export type AwsStaticEcsService = { + resourceId: string; + clusterName: string | null; + serviceName: string | null; + schedulingStrategy: string | null; + location?: SourceLocation; +}; + +/** Normalized static ECS autoscaling dataset entry. */ +export type AwsStaticEcsServiceAutoscaling = { + clusterName: string | null; + serviceName: string | null; + hasScalableTarget: boolean; + hasScalingPolicy: boolean; +}; + +/** Normalized static Redshift cluster dataset entry. */ +export type AwsStaticRedshiftCluster = { + resourceId: string; + automatedSnapshotRetentionPeriod: number | null | undefined; + hasPauseSchedule: boolean; + hasResumeSchedule: boolean; + hasVpc: boolean; + hsmEnabled: boolean | null; + multiAz: boolean | null; + location?: SourceLocation; +}; + /** Normalized static EC2 VPC endpoint dataset entry with preselected source location. */ export type AwsStaticEc2VpcEndpoint = { resourceId: string; @@ -847,7 +896,9 @@ export type AwsStaticEc2VpcEndpoint = { /** Aggregated static S3 bucket analysis dataset entry. */ export type AwsStaticS3BucketAnalysis = AwsS3BucketAnalysisFlags & { + hasNoncurrentVersionCleanup?: boolean; resourceId: string; + versioningEnabled?: boolean | null; location?: SourceLocation; }; @@ -860,13 +911,17 @@ export type StaticDatasetMap = { 'aws-dynamodb-tables': AwsStaticDynamoDbTable[]; 'aws-ebs-volumes': AwsStaticEbsVolume[]; 'aws-ecr-repositories': AwsStaticEcrRepository[]; + 'aws-ecs-autoscaling': AwsStaticEcsServiceAutoscaling[]; + 'aws-ecs-services': AwsStaticEcsService[]; 'aws-ec2-elastic-ips': AwsStaticEc2ElasticIp[]; 'aws-ec2-instances': AwsStaticEc2Instance[]; 'aws-eks-nodegroups': AwsStaticEksNodegroup[]; 'aws-emr-clusters': AwsStaticEmrCluster[]; 'aws-lambda-functions': AwsStaticLambdaFunction[]; + 'aws-lambda-provisioned-concurrency': AwsStaticLambdaProvisionedConcurrency[]; 'aws-ec2-vpc-endpoints': AwsStaticEc2VpcEndpoint[]; 'aws-rds-instances': AwsStaticRdsInstance[]; + 'aws-redshift-clusters': AwsStaticRedshiftCluster[]; 'aws-route53-health-checks': AwsStaticRoute53HealthCheck[]; 'aws-route53-records': AwsStaticRoute53Record[]; 'aws-s3-bucket-analyses': AwsStaticS3BucketAnalysis[]; diff --git a/packages/rules/test/dynamodb-autoscaling-range-fixed.test.ts b/packages/rules/test/dynamodb-autoscaling-range-fixed.test.ts new file mode 100644 index 0000000..f0c307a --- /dev/null +++ b/packages/rules/test/dynamodb-autoscaling-range-fixed.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { dynamoDbAutoscalingRangeFixedRule } from '../src/aws/dynamodb/autoscaling-range-fixed.js'; +import type { AwsStaticDynamoDbAutoscaling, AwsStaticDynamoDbTable } from '../src/index.js'; +import { StaticResourceBag } from '../src/index.js'; + +const createTable = (overrides: Partial = {}): AwsStaticDynamoDbTable => ({ + billingMode: 'PROVISIONED', + location: { + path: 'main.tf', + line: 1, + column: 1, + }, + resourceId: 'aws_dynamodb_table.orders', + tableName: 'orders', + ...overrides, +}); + +const createAutoscaling = (overrides: Partial = {}): AwsStaticDynamoDbAutoscaling => ({ + hasReadTarget: true, + hasWriteTarget: true, + readMaxCapacity: 10, + readMinCapacity: 10, + tableName: 'orders', + writeMaxCapacity: 100, + writeMinCapacity: 5, + ...overrides, +}); + +describe('dynamoDbAutoscalingRangeFixedRule', () => { + it('flags provisioned tables whose autoscaling range is fixed', () => { + const finding = dynamoDbAutoscalingRangeFixedRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-dynamodb-autoscaling': [createAutoscaling()], + 'aws-dynamodb-tables': [createTable()], + }), + }); + + expect(finding).toEqual({ + ruleId: 'CLDBRN-AWS-DYNAMODB-4', + service: 'dynamodb', + source: 'iac', + message: 'Provisioned DynamoDB autoscaling should allow capacity to change.', + findings: [ + { + location: { + path: 'main.tf', + line: 1, + column: 1, + }, + resourceId: 'aws_dynamodb_table.orders', + }, + ], + }); + }); + + it('skips pay-per-request tables or tables with a real autoscaling range', () => { + const finding = dynamoDbAutoscalingRangeFixedRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-dynamodb-autoscaling': [createAutoscaling({ readMaxCapacity: 20 })], + 'aws-dynamodb-tables': [ + createTable(), + createTable({ resourceId: 'OrdersOnDemand', billingMode: 'PAY_PER_REQUEST', tableName: 'ondemand' }), + ], + }), + }); + + expect(finding).toBeNull(); + }); +}); diff --git a/packages/rules/test/dynamodb-table-without-autoscaling.test.ts b/packages/rules/test/dynamodb-table-without-autoscaling.test.ts index f513028..c69ef3c 100644 --- a/packages/rules/test/dynamodb-table-without-autoscaling.test.ts +++ b/packages/rules/test/dynamodb-table-without-autoscaling.test.ts @@ -41,6 +41,10 @@ const createStaticAutoscaling = ( tableName: 'orders', hasReadTarget: true, hasWriteTarget: true, + readMinCapacity: null, + readMaxCapacity: null, + writeMinCapacity: null, + writeMaxCapacity: null, ...overrides, }); diff --git a/packages/rules/test/ebs-gp3-extra-iops.test.ts b/packages/rules/test/ebs-gp3-extra-iops.test.ts new file mode 100644 index 0000000..c21ad7f --- /dev/null +++ b/packages/rules/test/ebs-gp3-extra-iops.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { ebsGp3ExtraIopsRule } from '../src/aws/ebs/gp3-extra-iops.js'; +import type { AwsStaticEbsVolume } from '../src/index.js'; +import { StaticResourceBag } from '../src/index.js'; + +const createVolume = (overrides: Partial = {}): AwsStaticEbsVolume => ({ + iops: 6000, + location: { + path: 'main.tf', + line: 4, + column: 3, + }, + resourceId: 'aws_ebs_volume.data', + sizeGiB: 100, + volumeType: 'gp3', + ...overrides, +}); + +describe('ebsGp3ExtraIopsRule', () => { + it('flags gp3 volumes with paid iops above the baseline', () => { + const finding = ebsGp3ExtraIopsRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-ebs-volumes': [createVolume()], + }), + }); + + expect(finding).toEqual({ + ruleId: 'CLDBRN-AWS-EBS-9', + service: 'ebs', + source: 'iac', + message: 'EBS gp3 volumes should avoid paid IOPS above the included baseline unless required.', + findings: [ + { + location: { + path: 'main.tf', + line: 4, + column: 3, + }, + resourceId: 'aws_ebs_volume.data', + }, + ], + }); + }); + + it('skips gp3 volumes at the baseline or non-gp3 volumes', () => { + const finding = ebsGp3ExtraIopsRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-ebs-volumes': [ + createVolume({ iops: 3000 }), + createVolume({ resourceId: 'DataVolume', iops: 6000, volumeType: 'io2' }), + ], + }), + }); + + expect(finding).toBeNull(); + }); +}); diff --git a/packages/rules/test/ebs-gp3-extra-throughput.test.ts b/packages/rules/test/ebs-gp3-extra-throughput.test.ts new file mode 100644 index 0000000..5d1f79a --- /dev/null +++ b/packages/rules/test/ebs-gp3-extra-throughput.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { ebsGp3ExtraThroughputRule } from '../src/aws/ebs/gp3-extra-throughput.js'; +import type { AwsStaticEbsVolume } from '../src/index.js'; +import { StaticResourceBag } from '../src/index.js'; + +const createVolume = (overrides: Partial = {}): AwsStaticEbsVolume => ({ + iops: 3000, + location: { + path: 'main.tf', + line: 4, + column: 3, + }, + resourceId: 'aws_ebs_volume.data', + sizeGiB: 100, + throughputMiBps: 250, + volumeType: 'gp3', + ...overrides, +}); + +describe('ebsGp3ExtraThroughputRule', () => { + it('flags gp3 volumes with paid throughput above the baseline', () => { + const finding = ebsGp3ExtraThroughputRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-ebs-volumes': [createVolume()], + }), + }); + + expect(finding).toEqual({ + ruleId: 'CLDBRN-AWS-EBS-8', + service: 'ebs', + source: 'iac', + message: 'EBS gp3 volumes should avoid paid throughput above the included baseline unless required.', + findings: [ + { + location: { + path: 'main.tf', + line: 4, + column: 3, + }, + resourceId: 'aws_ebs_volume.data', + }, + ], + }); + }); + + it('skips gp3 volumes at the baseline or non-gp3 volumes', () => { + const finding = ebsGp3ExtraThroughputRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-ebs-volumes': [ + createVolume({ throughputMiBps: 125 }), + createVolume({ resourceId: 'DataVolume', throughputMiBps: 300, volumeType: 'io2' }), + ], + }), + }); + + expect(finding).toBeNull(); + }); +}); diff --git a/packages/rules/test/ebs-high-iops-volume.test.ts b/packages/rules/test/ebs-high-iops-volume.test.ts index cebbeb9..e46175b 100644 --- a/packages/rules/test/ebs-high-iops-volume.test.ts +++ b/packages/rules/test/ebs-high-iops-volume.test.ts @@ -19,6 +19,7 @@ const createStaticVolume = (overrides: Partial = {}): AwsSta volumeType: 'io2', sizeGiB: 200, iops: 40000, + throughputMiBps: null, ...overrides, }); diff --git a/packages/rules/test/ebs-large-volume.test.ts b/packages/rules/test/ebs-large-volume.test.ts index 9836a7b..6b530ef 100644 --- a/packages/rules/test/ebs-large-volume.test.ts +++ b/packages/rules/test/ebs-large-volume.test.ts @@ -19,6 +19,7 @@ const createStaticVolume = (overrides: Partial = {}): AwsSta volumeType: 'gp3', sizeGiB: 200, iops: 3000, + throughputMiBps: 125, ...overrides, }); diff --git a/packages/rules/test/ebs-low-iops-volume.test.ts b/packages/rules/test/ebs-low-iops-volume.test.ts index beea1d5..a71e90a 100644 --- a/packages/rules/test/ebs-low-iops-volume.test.ts +++ b/packages/rules/test/ebs-low-iops-volume.test.ts @@ -19,6 +19,7 @@ const createStaticVolume = (overrides: Partial = {}): AwsSta volumeType: 'io1', sizeGiB: 200, iops: 16000, + throughputMiBps: null, ...overrides, }); diff --git a/packages/rules/test/ec2-detailed-monitoring-enabled.test.ts b/packages/rules/test/ec2-detailed-monitoring-enabled.test.ts new file mode 100644 index 0000000..383f522 --- /dev/null +++ b/packages/rules/test/ec2-detailed-monitoring-enabled.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { ec2DetailedMonitoringEnabledRule } from '../src/aws/ec2/detailed-monitoring-enabled.js'; +import type { AwsStaticEc2Instance } from '../src/index.js'; +import { StaticResourceBag } from '../src/index.js'; + +const createInstance = (overrides: Partial = {}): AwsStaticEc2Instance => ({ + detailedMonitoringEnabled: true, + instanceType: 'm7i.large', + location: { + path: 'main.tf', + line: 2, + column: 3, + }, + resourceId: 'aws_instance.app', + ...overrides, +}); + +describe('ec2DetailedMonitoringEnabledRule', () => { + it('flags instances that explicitly enable detailed monitoring', () => { + const finding = ec2DetailedMonitoringEnabledRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-ec2-instances': [createInstance()], + }), + }); + + expect(finding).toEqual({ + ruleId: 'CLDBRN-AWS-EC2-10', + service: 'ec2', + source: 'iac', + message: 'EC2 instances should review detailed monitoring because it adds CloudWatch cost.', + findings: [ + { + location: { + path: 'main.tf', + line: 2, + column: 3, + }, + resourceId: 'aws_instance.app', + }, + ], + }); + }); + + it('skips instances that do not enable detailed monitoring', () => { + const finding = ec2DetailedMonitoringEnabledRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-ec2-instances': [createInstance({ detailedMonitoringEnabled: false })], + }), + }); + + expect(finding).toBeNull(); + }); +}); diff --git a/packages/rules/test/ec2-graviton-review.test.ts b/packages/rules/test/ec2-graviton-review.test.ts index 4b9fa29..ef85a54 100644 --- a/packages/rules/test/ec2-graviton-review.test.ts +++ b/packages/rules/test/ec2-graviton-review.test.ts @@ -13,6 +13,7 @@ const createInstance = (overrides: Partial = {}): AwsEc2Instance }); const createStaticInstance = (overrides: Partial = {}): AwsStaticEc2Instance => ({ + detailedMonitoringEnabled: false, resourceId: 'aws_instance.app', instanceType: 'm7i.large', ...overrides, diff --git a/packages/rules/test/ec2-large-instance.test.ts b/packages/rules/test/ec2-large-instance.test.ts index 7abc529..b4c9ebb 100644 --- a/packages/rules/test/ec2-large-instance.test.ts +++ b/packages/rules/test/ec2-large-instance.test.ts @@ -12,6 +12,7 @@ const createInstance = (overrides: Partial = {}): AwsEc2Instance }); const createStaticInstance = (overrides: Partial = {}): AwsStaticEc2Instance => ({ + detailedMonitoringEnabled: false, resourceId: 'aws_instance.app', instanceType: 'm7i.2xlarge', ...overrides, diff --git a/packages/rules/test/ec2-preferred-instance-type.test.ts b/packages/rules/test/ec2-preferred-instance-type.test.ts index dc67d2a..ed24160 100644 --- a/packages/rules/test/ec2-preferred-instance-type.test.ts +++ b/packages/rules/test/ec2-preferred-instance-type.test.ts @@ -22,6 +22,7 @@ const createDiscoveredResource = (overrides: Partial = {} }); const createStaticInstance = (overrides: Partial = {}): AwsStaticEc2Instance => ({ + detailedMonitoringEnabled: false, instanceType: 'm4.large', location: { path: 'main.tf', diff --git a/packages/rules/test/ecr-missing-lifecycle-policy.test.ts b/packages/rules/test/ecr-missing-lifecycle-policy.test.ts index 4d92437..3ca000c 100644 --- a/packages/rules/test/ecr-missing-lifecycle-policy.test.ts +++ b/packages/rules/test/ecr-missing-lifecycle-policy.test.ts @@ -14,6 +14,8 @@ const createRepository = (overrides: Partial = {}): AwsEcrRepo const createStaticRepository = (overrides: Partial = {}): AwsStaticEcrRepository => ({ hasLifecyclePolicy: false, + hasTaggedImageRetentionCap: null, + hasUntaggedImageExpiry: null, location: { path: 'main.tf', line: 4, diff --git a/packages/rules/test/ecr-missing-tagged-image-retention-cap.test.ts b/packages/rules/test/ecr-missing-tagged-image-retention-cap.test.ts new file mode 100644 index 0000000..0a1c086 --- /dev/null +++ b/packages/rules/test/ecr-missing-tagged-image-retention-cap.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { ecrMissingTaggedImageRetentionCapRule } from '../src/aws/ecr/missing-tagged-image-retention-cap.js'; +import type { AwsStaticEcrRepository } from '../src/index.js'; +import { StaticResourceBag } from '../src/index.js'; + +const createRepository = (overrides: Partial = {}): AwsStaticEcrRepository => ({ + hasLifecyclePolicy: true, + hasTaggedImageRetentionCap: false, + hasUntaggedImageExpiry: true, + location: { + path: 'main.tf', + line: 4, + column: 3, + }, + resourceId: 'aws_ecr_repository.app', + ...overrides, +}); + +describe('ecrMissingTaggedImageRetentionCapRule', () => { + it('flags Terraform repositories whose lifecycle policy does not cap tagged image retention', () => { + const finding = ecrMissingTaggedImageRetentionCapRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-ecr-repositories': [createRepository()], + }), + }); + + expect(finding?.findings?.[0]?.resourceId).toBe('aws_ecr_repository.app'); + }); + + it('flags CloudFormation repositories whose lifecycle policy does not cap tagged image retention', () => { + const finding = ecrMissingTaggedImageRetentionCapRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-ecr-repositories': [ + createRepository({ + location: { + path: 'template.yaml', + line: 6, + column: 5, + }, + resourceId: 'AppRepository', + }), + ], + }), + }); + + expect(finding?.findings?.[0]?.resourceId).toBe('AppRepository'); + }); + + it('skips repositories without lifecycle policies or with unknown coverage', () => { + const missingPolicy = ecrMissingTaggedImageRetentionCapRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-ecr-repositories': [createRepository({ hasLifecyclePolicy: false })], + }), + }); + const unknownCoverage = ecrMissingTaggedImageRetentionCapRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-ecr-repositories': [createRepository({ hasTaggedImageRetentionCap: null })], + }), + }); + const covered = ecrMissingTaggedImageRetentionCapRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-ecr-repositories': [createRepository({ hasTaggedImageRetentionCap: true })], + }), + }); + + expect(missingPolicy).toBeNull(); + expect(unknownCoverage).toBeNull(); + expect(covered).toBeNull(); + }); +}); diff --git a/packages/rules/test/ecr-missing-untagged-image-expiry.test.ts b/packages/rules/test/ecr-missing-untagged-image-expiry.test.ts new file mode 100644 index 0000000..2a0c43a --- /dev/null +++ b/packages/rules/test/ecr-missing-untagged-image-expiry.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { ecrMissingUntaggedImageExpiryRule } from '../src/aws/ecr/missing-untagged-image-expiry.js'; +import type { AwsStaticEcrRepository } from '../src/index.js'; +import { StaticResourceBag } from '../src/index.js'; + +const createRepository = (overrides: Partial = {}): AwsStaticEcrRepository => ({ + hasLifecyclePolicy: true, + hasTaggedImageRetentionCap: true, + hasUntaggedImageExpiry: false, + location: { + path: 'main.tf', + line: 4, + column: 3, + }, + resourceId: 'aws_ecr_repository.app', + ...overrides, +}); + +describe('ecrMissingUntaggedImageExpiryRule', () => { + it('flags Terraform repositories whose lifecycle policy does not expire untagged images', () => { + const finding = ecrMissingUntaggedImageExpiryRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-ecr-repositories': [createRepository()], + }), + }); + + expect(finding?.findings).toEqual([ + { + resourceId: 'aws_ecr_repository.app', + location: { + path: 'main.tf', + line: 4, + column: 3, + }, + }, + ]); + }); + + it('flags CloudFormation repositories whose lifecycle policy does not expire untagged images', () => { + const finding = ecrMissingUntaggedImageExpiryRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-ecr-repositories': [ + createRepository({ + location: { + path: 'template.yaml', + line: 6, + column: 5, + }, + resourceId: 'AppRepository', + }), + ], + }), + }); + + expect(finding?.findings?.[0]?.resourceId).toBe('AppRepository'); + }); + + it('skips repositories without lifecycle policies or with unknown coverage', () => { + const missingPolicy = ecrMissingUntaggedImageExpiryRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-ecr-repositories': [createRepository({ hasLifecyclePolicy: false })], + }), + }); + const unknownCoverage = ecrMissingUntaggedImageExpiryRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-ecr-repositories': [createRepository({ hasUntaggedImageExpiry: null })], + }), + }); + const covered = ecrMissingUntaggedImageExpiryRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-ecr-repositories': [createRepository({ hasUntaggedImageExpiry: true })], + }), + }); + + expect(missingPolicy).toBeNull(); + expect(unknownCoverage).toBeNull(); + expect(covered).toBeNull(); + }); +}); diff --git a/packages/rules/test/ecs-service-autoscaling-policy.test.ts b/packages/rules/test/ecs-service-autoscaling-policy.test.ts index 61d1caf..94f7328 100644 --- a/packages/rules/test/ecs-service-autoscaling-policy.test.ts +++ b/packages/rules/test/ecs-service-autoscaling-policy.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from 'vitest'; import { ecsServiceAutoscalingPolicyRule } from '../src/aws/ecs/service-autoscaling-policy.js'; -import type { AwsEcsService, AwsEcsServiceAutoscaling } from '../src/index.js'; -import { LiveResourceBag } from '../src/index.js'; +import type { + AwsEcsService, + AwsEcsServiceAutoscaling, + AwsStaticEcsService, + AwsStaticEcsServiceAutoscaling, +} from '../src/index.js'; +import { LiveResourceBag, StaticResourceBag } from '../src/index.js'; const createService = (overrides: Partial = {}): AwsEcsService => ({ accountId: '123456789012', @@ -27,6 +32,29 @@ const createAutoscaling = (overrides: Partial = {}): A ...overrides, }); +const createStaticService = (overrides: Partial = {}): AwsStaticEcsService => ({ + clusterName: 'production', + location: { + path: 'main.tf', + line: 5, + column: 3, + }, + resourceId: 'aws_ecs_service.web', + schedulingStrategy: 'REPLICA', + serviceName: 'web', + ...overrides, +}); + +const createStaticAutoscaling = ( + overrides: Partial = {}, +): AwsStaticEcsServiceAutoscaling => ({ + clusterName: 'production', + hasScalableTarget: true, + hasScalingPolicy: true, + serviceName: 'web', + ...overrides, +}); + describe('ecsServiceAutoscalingPolicyRule', () => { it('flags active REPLICA services without full autoscaling coverage', () => { const finding = ecsServiceAutoscalingPolicyRule.evaluateLive?.({ @@ -87,4 +115,59 @@ describe('ecsServiceAutoscalingPolicyRule', () => { expect(finding).toBeNull(); }); + + it('flags static REPLICA services without full autoscaling coverage', () => { + const finding = ecsServiceAutoscalingPolicyRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-ecs-services': [createStaticService()], + 'aws-ecs-autoscaling': [createStaticAutoscaling({ hasScalingPolicy: false })], + }), + }); + + expect(finding).toEqual({ + ruleId: 'CLDBRN-AWS-ECS-3', + service: 'ecs', + source: 'iac', + message: 'Active REPLICA ECS services should use an autoscaling policy.', + findings: [ + { + location: { + path: 'main.tf', + line: 5, + column: 3, + }, + resourceId: 'aws_ecs_service.web', + }, + ], + }); + }); + + it('flags static services with missing autoscaling coverage and skips unresolved identities', () => { + const finding = ecsServiceAutoscalingPolicyRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-ecs-services': [ + createStaticService(), + createStaticService({ clusterName: null, resourceId: 'UnresolvedService', serviceName: 'api' }), + ], + 'aws-ecs-autoscaling': [], + }), + }); + + expect(finding).toEqual({ + ruleId: 'CLDBRN-AWS-ECS-3', + service: 'ecs', + source: 'iac', + message: 'Active REPLICA ECS services should use an autoscaling policy.', + findings: [ + { + location: { + path: 'main.tf', + line: 5, + column: 3, + }, + resourceId: 'aws_ecs_service.web', + }, + ], + }); + }); }); diff --git a/packages/rules/test/exports.test.ts b/packages/rules/test/exports.test.ts index 5538bd2..f7d9a09 100644 --- a/packages/rules/test/exports.test.ts +++ b/packages/rules/test/exports.test.ts @@ -67,6 +67,7 @@ describe('rule exports', () => { 'CLDBRN-AWS-DYNAMODB-1', 'CLDBRN-AWS-DYNAMODB-2', 'CLDBRN-AWS-DYNAMODB-3', + 'CLDBRN-AWS-DYNAMODB-4', 'CLDBRN-AWS-EC2-2', 'CLDBRN-AWS-EC2-3', 'CLDBRN-AWS-EC2-4', @@ -75,6 +76,7 @@ describe('rule exports', () => { 'CLDBRN-AWS-EC2-7', 'CLDBRN-AWS-EC2-8', 'CLDBRN-AWS-EC2-9', + 'CLDBRN-AWS-EC2-10', 'CLDBRN-AWS-ECS-1', 'CLDBRN-AWS-ECS-2', 'CLDBRN-AWS-ECS-3', @@ -82,9 +84,13 @@ describe('rule exports', () => { 'CLDBRN-AWS-EBS-5', 'CLDBRN-AWS-EBS-6', 'CLDBRN-AWS-EBS-7', + 'CLDBRN-AWS-EBS-8', + 'CLDBRN-AWS-EBS-9', 'CLDBRN-AWS-EBS-2', 'CLDBRN-AWS-EBS-3', 'CLDBRN-AWS-ECR-1', + 'CLDBRN-AWS-ECR-2', + 'CLDBRN-AWS-ECR-3', 'CLDBRN-AWS-EKS-1', 'CLDBRN-AWS-ELASTICACHE-1', 'CLDBRN-AWS-ELASTICACHE-2', @@ -98,12 +104,14 @@ describe('rule exports', () => { 'CLDBRN-AWS-LAMBDA-2', 'CLDBRN-AWS-LAMBDA-3', 'CLDBRN-AWS-LAMBDA-4', + 'CLDBRN-AWS-LAMBDA-5', 'CLDBRN-AWS-RDS-2', 'CLDBRN-AWS-RDS-3', 'CLDBRN-AWS-RDS-4', 'CLDBRN-AWS-RDS-5', 'CLDBRN-AWS-RDS-6', 'CLDBRN-AWS-RDS-7', + 'CLDBRN-AWS-RDS-8', 'CLDBRN-AWS-REDSHIFT-1', 'CLDBRN-AWS-REDSHIFT-2', 'CLDBRN-AWS-REDSHIFT-3', @@ -112,6 +120,7 @@ describe('rule exports', () => { 'CLDBRN-AWS-S3-1', 'CLDBRN-AWS-S3-2', 'CLDBRN-AWS-S3-3', + 'CLDBRN-AWS-S3-4', 'CLDBRN-AWS-SECRETSMANAGER-1', ]), ); @@ -285,7 +294,11 @@ describe('rule exports', () => { }; const rdsInstance: AwsStaticRdsInstance = { + engine: null, + engineVersion: null, instanceClass: 'db.m8g.large', + performanceInsightsEnabled: false, + performanceInsightsRetentionPeriod: undefined, resourceId: 'aws_db_instance.current', }; diff --git a/packages/rules/test/lambda-provisioned-concurrency-configured.test.ts b/packages/rules/test/lambda-provisioned-concurrency-configured.test.ts new file mode 100644 index 0000000..cd360e1 --- /dev/null +++ b/packages/rules/test/lambda-provisioned-concurrency-configured.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { lambdaProvisionedConcurrencyConfiguredRule } from '../src/aws/lambda/provisioned-concurrency-configured.js'; +import type { AwsStaticLambdaProvisionedConcurrency } from '../src/index.js'; +import { StaticResourceBag } from '../src/index.js'; + +const createConfig = ( + overrides: Partial = {}, +): AwsStaticLambdaProvisionedConcurrency => ({ + location: { + path: 'main.tf', + line: 5, + column: 3, + }, + provisionedConcurrentExecutions: 10, + resourceId: 'aws_lambda_provisioned_concurrency_config.worker', + ...overrides, +}); + +describe('lambdaProvisionedConcurrencyConfiguredRule', () => { + it('flags explicit provisioned concurrency configuration', () => { + const finding = lambdaProvisionedConcurrencyConfiguredRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-lambda-provisioned-concurrency': [createConfig()], + }), + }); + + expect(finding).toEqual({ + ruleId: 'CLDBRN-AWS-LAMBDA-5', + service: 'lambda', + source: 'iac', + message: 'Lambda provisioned concurrency should be reviewed for steady low-latency demand.', + findings: [ + { + location: { + path: 'main.tf', + line: 5, + column: 3, + }, + resourceId: 'aws_lambda_provisioned_concurrency_config.worker', + }, + ], + }); + }); + + it('skips empty or zero-valued provisioned concurrency configuration', () => { + const finding = lambdaProvisionedConcurrencyConfiguredRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-lambda-provisioned-concurrency': [ + createConfig({ provisionedConcurrentExecutions: 0 }), + createConfig({ resourceId: 'WorkerAlias', provisionedConcurrentExecutions: null }), + ], + }), + }); + + expect(finding).toBeNull(); + }); +}); diff --git a/packages/rules/test/rds-graviton-review.test.ts b/packages/rules/test/rds-graviton-review.test.ts index 01baa8e..f2f486f 100644 --- a/packages/rules/test/rds-graviton-review.test.ts +++ b/packages/rules/test/rds-graviton-review.test.ts @@ -21,6 +21,8 @@ const createStaticInstance = (overrides: Partial = {}): Aw instanceClass: 'db.m6i.large', engine: 'mysql', engineVersion: '8.0.39', + performanceInsightsEnabled: false, + performanceInsightsRetentionPeriod: undefined, ...overrides, }); diff --git a/packages/rules/test/rds-performance-insights-extended-retention.test.ts b/packages/rules/test/rds-performance-insights-extended-retention.test.ts new file mode 100644 index 0000000..088e39f --- /dev/null +++ b/packages/rules/test/rds-performance-insights-extended-retention.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { rdsPerformanceInsightsExtendedRetentionRule } from '../src/aws/rds/performance-insights-extended-retention.js'; +import type { AwsStaticRdsInstance } from '../src/index.js'; +import { StaticResourceBag } from '../src/index.js'; + +const createInstance = (overrides: Partial = {}): AwsStaticRdsInstance => ({ + engine: 'postgres', + engineVersion: '16.1', + instanceClass: 'db.r7g.large', + location: { + path: 'main.tf', + line: 4, + column: 3, + }, + performanceInsightsEnabled: true, + performanceInsightsRetentionPeriod: 93, + resourceId: 'aws_db_instance.app', + ...overrides, +}); + +describe('rdsPerformanceInsightsExtendedRetentionRule', () => { + it('flags Performance Insights retention beyond the included 7-day period', () => { + const finding = rdsPerformanceInsightsExtendedRetentionRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-rds-instances': [createInstance()], + }), + }); + + expect(finding).toEqual({ + ruleId: 'CLDBRN-AWS-RDS-8', + service: 'rds', + source: 'iac', + message: 'RDS Performance Insights should use the included 7-day retention unless longer retention is required.', + findings: [ + { + location: { + path: 'main.tf', + line: 4, + column: 3, + }, + resourceId: 'aws_db_instance.app', + }, + ], + }); + }); + + it('skips disabled insights, default retention, or unresolved retention', () => { + const finding = rdsPerformanceInsightsExtendedRetentionRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-rds-instances': [ + createInstance({ performanceInsightsEnabled: false }), + createInstance({ resourceId: 'DefaultRetention', performanceInsightsRetentionPeriod: undefined }), + createInstance({ resourceId: 'UnknownRetention', performanceInsightsRetentionPeriod: null }), + ], + }), + }); + + expect(finding).toBeNull(); + }); +}); diff --git a/packages/rules/test/rds-preferred-instance-class.test.ts b/packages/rules/test/rds-preferred-instance-class.test.ts index 77ce1d0..8e36c9e 100644 --- a/packages/rules/test/rds-preferred-instance-class.test.ts +++ b/packages/rules/test/rds-preferred-instance-class.test.ts @@ -35,6 +35,8 @@ const createStaticRdsInstance = (overrides: Partial = {}): line: 6, column: 3, }, + performanceInsightsEnabled: false, + performanceInsightsRetentionPeriod: undefined, resourceId: 'aws_db_instance.legacy', ...overrides, }); diff --git a/packages/rules/test/rds-unsupported-engine-version.test.ts b/packages/rules/test/rds-unsupported-engine-version.test.ts index 5c5b41c..9dd83a9 100644 --- a/packages/rules/test/rds-unsupported-engine-version.test.ts +++ b/packages/rules/test/rds-unsupported-engine-version.test.ts @@ -21,6 +21,8 @@ const createStaticInstance = (overrides: Partial = {}): Aw instanceClass: 'db.m6i.large', engine: 'mysql', engineVersion: '5.7.44', + performanceInsightsEnabled: false, + performanceInsightsRetentionPeriod: undefined, ...overrides, }); diff --git a/packages/rules/test/redshift-pause-resume.test.ts b/packages/rules/test/redshift-pause-resume.test.ts index 634d7e5..5705238 100644 --- a/packages/rules/test/redshift-pause-resume.test.ts +++ b/packages/rules/test/redshift-pause-resume.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { redshiftPauseResumeRule } from '../src/aws/redshift/pause-resume.js'; -import type { AwsRedshiftCluster } from '../src/index.js'; -import { LiveResourceBag } from '../src/index.js'; +import type { AwsRedshiftCluster, AwsStaticRedshiftCluster } from '../src/index.js'; +import { LiveResourceBag, StaticResourceBag } from '../src/index.js'; const createCluster = (overrides: Partial = {}): AwsRedshiftCluster => ({ accountId: '123456789012', @@ -20,6 +20,22 @@ const createCluster = (overrides: Partial = {}): AwsRedshift ...overrides, }); +const createStaticCluster = (overrides: Partial = {}): AwsStaticRedshiftCluster => ({ + automatedSnapshotRetentionPeriod: 1, + hasPauseSchedule: false, + hasResumeSchedule: true, + hasVpc: true, + hsmEnabled: false, + location: { + path: 'main.tf', + line: 6, + column: 3, + }, + multiAz: false, + resourceId: 'aws_redshift_cluster.warehouse', + ...overrides, +}); + describe('redshiftPauseResumeRule', () => { it('flags eligible clusters that are missing a pause or resume schedule', () => { const finding = redshiftPauseResumeRule.evaluateLive?.({ @@ -70,4 +86,45 @@ describe('redshiftPauseResumeRule', () => { expect(finding).toBeNull(); }); + + it('flags eligible static clusters that are missing a pause or resume schedule', () => { + const finding = redshiftPauseResumeRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-redshift-clusters': [createStaticCluster()], + }), + }); + + expect(finding).toEqual({ + ruleId: 'CLDBRN-AWS-REDSHIFT-3', + service: 'redshift', + source: 'iac', + message: 'Redshift clusters should enable both pause and resume schedules when eligible.', + findings: [ + { + location: { + path: 'main.tf', + line: 6, + column: 3, + }, + resourceId: 'aws_redshift_cluster.warehouse', + }, + ], + }); + }); + + it('skips ineligible static clusters or clusters that already have both schedules', () => { + const finding = redshiftPauseResumeRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-redshift-clusters': [ + createStaticCluster({ hasPauseSchedule: true, hasResumeSchedule: true, resourceId: 'covered' }), + createStaticCluster({ automatedSnapshotRetentionPeriod: 0, resourceId: 'snapshot-disabled' }), + createStaticCluster({ hasVpc: false, resourceId: 'classic-cluster' }), + createStaticCluster({ hsmEnabled: true, resourceId: 'hsm-cluster' }), + createStaticCluster({ multiAz: true, resourceId: 'multi-az-cluster' }), + ], + }), + }); + + expect(finding).toBeNull(); + }); }); diff --git a/packages/rules/test/rule-metadata.test.ts b/packages/rules/test/rule-metadata.test.ts index 78705b1..2c2cd5f 100644 --- a/packages/rules/test/rule-metadata.test.ts +++ b/packages/rules/test/rule-metadata.test.ts @@ -550,8 +550,9 @@ describe('rule metadata', () => { message: 'Active REPLICA ECS services should use an autoscaling policy.', provider: 'aws', service: 'ecs', - supports: ['discovery'], + supports: ['discovery', 'iac'], discoveryDependencies: ['aws-ecs-services', 'aws-ecs-autoscaling'], + staticDependencies: ['aws-ecs-services', 'aws-ecs-autoscaling'], }); }); @@ -899,8 +900,9 @@ describe('rule metadata', () => { message: 'Redshift clusters should enable both pause and resume schedules when eligible.', provider: 'aws', service: 'redshift', - supports: ['discovery'], + supports: ['discovery', 'iac'], discoveryDependencies: ['aws-redshift-clusters'], + staticDependencies: ['aws-redshift-clusters'], }); }); diff --git a/packages/rules/test/s3-incomplete-multipart-upload-abort.test.ts b/packages/rules/test/s3-incomplete-multipart-upload-abort.test.ts index 559f804..9e83357 100644 --- a/packages/rules/test/s3-incomplete-multipart-upload-abort.test.ts +++ b/packages/rules/test/s3-incomplete-multipart-upload-abort.test.ts @@ -10,6 +10,7 @@ const createBucketAnalysis = (overrides: Partial = {} hasIntelligentTieringConfiguration: false, hasIntelligentTieringTransition: false, hasLifecycleSignal: false, + hasNoncurrentVersionCleanup: false, hasUnclassifiedTransition: false, location: { path: 'main.tf', @@ -17,6 +18,7 @@ const createBucketAnalysis = (overrides: Partial = {} column: 1, }, resourceId: 'aws_s3_bucket.logs', + versioningEnabled: false, ...overrides, }); diff --git a/packages/rules/test/s3-missing-lifecycle-config.test.ts b/packages/rules/test/s3-missing-lifecycle-config.test.ts index 22cdb45..ae2d55e 100644 --- a/packages/rules/test/s3-missing-lifecycle-config.test.ts +++ b/packages/rules/test/s3-missing-lifecycle-config.test.ts @@ -10,6 +10,7 @@ const createBucketAnalysis = (overrides: Partial = {} hasIntelligentTieringConfiguration: false, hasIntelligentTieringTransition: false, hasLifecycleSignal: false, + hasNoncurrentVersionCleanup: false, hasUnclassifiedTransition: false, location: { path: 'main.tf', @@ -17,6 +18,7 @@ const createBucketAnalysis = (overrides: Partial = {} column: 1, }, resourceId: 'aws_s3_bucket.logs', + versioningEnabled: false, ...overrides, }); diff --git a/packages/rules/test/s3-storage-class-optimization.test.ts b/packages/rules/test/s3-storage-class-optimization.test.ts index 25d7fbe..9d78afb 100644 --- a/packages/rules/test/s3-storage-class-optimization.test.ts +++ b/packages/rules/test/s3-storage-class-optimization.test.ts @@ -10,6 +10,7 @@ const createBucketAnalysis = (overrides: Partial = {} hasIntelligentTieringConfiguration: false, hasIntelligentTieringTransition: false, hasLifecycleSignal: false, + hasNoncurrentVersionCleanup: false, hasUnclassifiedTransition: false, location: { path: 'main.tf', @@ -17,6 +18,7 @@ const createBucketAnalysis = (overrides: Partial = {} column: 1, }, resourceId: 'aws_s3_bucket.logs', + versioningEnabled: false, ...overrides, }); diff --git a/packages/rules/test/s3-versioned-bucket-noncurrent-version-cleanup.test.ts b/packages/rules/test/s3-versioned-bucket-noncurrent-version-cleanup.test.ts new file mode 100644 index 0000000..63bb5f7 --- /dev/null +++ b/packages/rules/test/s3-versioned-bucket-noncurrent-version-cleanup.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { s3VersionedBucketNoncurrentVersionCleanupRule } from '../src/aws/s3/versioned-bucket-noncurrent-version-cleanup.js'; +import type { AwsStaticS3BucketAnalysis } from '../src/index.js'; +import { StaticResourceBag } from '../src/index.js'; + +const createBucket = (overrides: Partial = {}): AwsStaticS3BucketAnalysis => ({ + hasAbortIncompleteMultipartUploadAfter7Days: false, + hasAlternativeStorageClassTransition: false, + hasCostFocusedLifecycle: false, + hasIntelligentTieringConfiguration: false, + hasIntelligentTieringTransition: false, + hasLifecycleSignal: false, + hasNoncurrentVersionCleanup: false, + hasUnclassifiedTransition: false, + location: { + path: 'main.tf', + line: 1, + column: 1, + }, + resourceId: 'aws_s3_bucket.logs', + versioningEnabled: true, + ...overrides, +}); + +describe('s3VersionedBucketNoncurrentVersionCleanupRule', () => { + it('flags versioned buckets without noncurrent cleanup', () => { + const finding = s3VersionedBucketNoncurrentVersionCleanupRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-s3-bucket-analyses': [createBucket()], + }), + }); + + expect(finding).toEqual({ + ruleId: 'CLDBRN-AWS-S3-4', + service: 's3', + source: 'iac', + message: 'Versioned S3 buckets should define noncurrent-version cleanup.', + findings: [ + { + location: { + path: 'main.tf', + line: 1, + column: 1, + }, + resourceId: 'aws_s3_bucket.logs', + }, + ], + }); + }); + + it('skips buckets with noncurrent cleanup or no versioning', () => { + const finding = s3VersionedBucketNoncurrentVersionCleanupRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-s3-bucket-analyses': [ + createBucket({ hasNoncurrentVersionCleanup: true }), + createBucket({ resourceId: 'LogsBucket', versioningEnabled: false }), + ], + }), + }); + + expect(finding).toBeNull(); + }); +}); diff --git a/packages/rules/test/volume-type-current-gen.test.ts b/packages/rules/test/volume-type-current-gen.test.ts index 101d611..01d2ea0 100644 --- a/packages/rules/test/volume-type-current-gen.test.ts +++ b/packages/rules/test/volume-type-current-gen.test.ts @@ -23,6 +23,7 @@ const createStaticVolume = (overrides: Partial = {}): AwsSta column: 3, }, resourceId: 'aws_ebs_volume.gp2_data', + throughputMiBps: 125, volumeType: 'gp2', ...overrides, }); diff --git a/packages/sdk/src/providers/aws/static-registry.ts b/packages/sdk/src/providers/aws/static-registry.ts index 49a868d..d6a4f21 100644 --- a/packages/sdk/src/providers/aws/static-registry.ts +++ b/packages/sdk/src/providers/aws/static-registry.ts @@ -9,10 +9,14 @@ import type { AwsStaticEc2Instance, AwsStaticEc2VpcEndpoint, AwsStaticEcrRepository, + AwsStaticEcsService, + AwsStaticEcsServiceAutoscaling, AwsStaticEksNodegroup, AwsStaticEmrCluster, AwsStaticLambdaFunction, + AwsStaticLambdaProvisionedConcurrency, AwsStaticRdsInstance, + AwsStaticRedshiftCluster, AwsStaticRoute53HealthCheck, AwsStaticRoute53Record, AwsStaticS3BucketAnalysis, @@ -43,10 +47,14 @@ const CLOUDFORMATION_CLOUDWATCH_LOG_GROUP_TYPE = 'AWS::Logs::LogGroup'; const TERRAFORM_DYNAMODB_TABLE_TYPE = 'aws_dynamodb_table'; const CLOUDFORMATION_DYNAMODB_TABLE_TYPE = 'AWS::DynamoDB::Table'; const TERRAFORM_APPAUTOSCALING_TARGET_TYPE = 'aws_appautoscaling_target'; +const TERRAFORM_APPAUTOSCALING_POLICY_TYPE = 'aws_appautoscaling_policy'; const CLOUDFORMATION_SCALABLE_TARGET_TYPE = 'AWS::ApplicationAutoScaling::ScalableTarget'; +const CLOUDFORMATION_SCALING_POLICY_TYPE = 'AWS::ApplicationAutoScaling::ScalingPolicy'; const TERRAFORM_ECR_REPOSITORY_TYPE = 'aws_ecr_repository'; const TERRAFORM_ECR_LIFECYCLE_POLICY_TYPE = 'aws_ecr_lifecycle_policy'; const CLOUDFORMATION_ECR_REPOSITORY_TYPE = 'AWS::ECR::Repository'; +const TERRAFORM_ECS_SERVICE_TYPE = 'aws_ecs_service'; +const CLOUDFORMATION_ECS_SERVICE_TYPE = 'AWS::ECS::Service'; const TERRAFORM_EIP_TYPE = 'aws_eip'; const TERRAFORM_EIP_ASSOCIATION_TYPE = 'aws_eip_association'; const CLOUDFORMATION_EIP_TYPE = 'AWS::EC2::EIP'; @@ -65,14 +73,25 @@ const CLOUDFORMATION_ROUTE53_RECORD_SET_TYPE = 'AWS::Route53::RecordSet'; const CLOUDFORMATION_ROUTE53_RECORD_SET_GROUP_TYPE = 'AWS::Route53::RecordSetGroup'; const CLOUDFORMATION_ROUTE53_HEALTH_CHECK_TYPE = 'AWS::Route53::HealthCheck'; const TERRAFORM_LAMBDA_TYPE = 'aws_lambda_function'; +const TERRAFORM_LAMBDA_PROVISIONED_CONCURRENCY_TYPE = 'aws_lambda_provisioned_concurrency_config'; +const CLOUDFORMATION_LAMBDA_ALIAS_TYPE = 'AWS::Lambda::Alias'; const CLOUDFORMATION_LAMBDA_TYPE = 'AWS::Lambda::Function'; +const TERRAFORM_REDSHIFT_CLUSTER_TYPE = 'aws_redshift_cluster'; +const TERRAFORM_REDSHIFT_SCHEDULED_ACTION_TYPE = 'aws_redshift_scheduled_action'; +const CLOUDFORMATION_REDSHIFT_CLUSTER_TYPE = 'AWS::Redshift::Cluster'; +const CLOUDFORMATION_REDSHIFT_SCHEDULED_ACTION_TYPE = 'AWS::Redshift::ScheduledAction'; const TERRAFORM_VPC_ENDPOINT_TYPE = 'aws_vpc_endpoint'; const CLOUDFORMATION_VPC_ENDPOINT_TYPE = 'AWS::EC2::VPCEndpoint'; const TERRAFORM_BUCKET_TYPE = 'aws_s3_bucket'; const TERRAFORM_LIFECYCLE_TYPE = 'aws_s3_bucket_lifecycle_configuration'; const TERRAFORM_INTELLIGENT_TIERING_TYPE = 'aws_s3_bucket_intelligent_tiering_configuration'; +const TERRAFORM_BUCKET_VERSIONING_TYPE = 'aws_s3_bucket_versioning'; const CLOUDFORMATION_BUCKET_TYPE = 'AWS::S3::Bucket'; const DIRECT_BUCKET_REFERENCE_PATTERN = /^\$?\{?aws_s3_bucket\.([A-Za-z0-9_-]+)\.(?:id|bucket)\}?$/u; +const DIRECT_ECS_CLUSTER_REFERENCE_PATTERN = /^\$?\{?aws_ecs_cluster\.([A-Za-z0-9_-]+)\.(?:id|arn|name)\}?$/u; +const DIRECT_REDSHIFT_CLUSTER_REFERENCE_PATTERN = + /^\$?\{?aws_redshift_cluster\.([A-Za-z0-9_-]+)\.(?:id|cluster_identifier)\}?$/u; +const ECS_SERVICE_RESOURCE_ID_PATTERN = /^service\/([^/]+)\/([^/]+)$/u; const DIRECT_ECR_REPOSITORY_REFERENCE_PATTERN = /^\$?\{?aws_ecr_repository\.([A-Za-z0-9_-]+)\.(?:id|name)\}?$/u; const DIRECT_EIP_REFERENCE_PATTERN = /^\$?\{?aws_eip\.([A-Za-z0-9_-]+)\.(?:id|allocation_id)\}?$/u; const DIRECT_ROUTE53_HEALTH_CHECK_REFERENCE_PATTERN = @@ -142,6 +161,23 @@ const getLiteralStringArray = (value: unknown): string[] | null => { const toRecordArray = (value: unknown): Record[] => Array.isArray(value) ? value.filter((entry): entry is Record => isRecord(entry)) : []; +const parseJsonRecord = (value: unknown): Record | null => { + if (isRecord(value)) { + return value; + } + + if (typeof value !== 'string') { + return null; + } + + try { + const parsed = JSON.parse(value); + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +}; + const getCloudFormationLogicalIdReference = (value: unknown): string | null => { if (!isRecord(value)) { return null; @@ -197,6 +233,253 @@ const getTerraformEcrRepositoryReferenceKey = (value: unknown): string | null => return value.toLowerCase(); }; +const getTerraformEcsServiceIdentity = (value: unknown): { clusterName: string; serviceName: string } | null => { + const literal = getLiteralExactString(value); + const match = literal ? ECS_SERVICE_RESOURCE_ID_PATTERN.exec(literal) : null; + + if (!match) { + return null; + } + + const [, clusterName, serviceName] = match; + + return clusterName && serviceName ? { clusterName, serviceName } : null; +}; + +const getTerraformEcsClusterName = (value: unknown): string | null => { + if (typeof value === 'string') { + const match = DIRECT_ECS_CLUSTER_REFERENCE_PATTERN.exec(value); + + if (match?.[1]) { + return match[1]; + } + } + + const literal = getLiteralExactString(value); + + if (!literal) { + return null; + } + + if (literal.startsWith('arn:')) { + const clusterName = literal.split('/').at(-1); + return clusterName && clusterName.length > 0 ? clusterName : null; + } + + return literal; +}; + +const getTerraformRedshiftClusterIdentifier = (value: unknown): string | null => { + if (typeof value === 'string') { + const match = DIRECT_REDSHIFT_CLUSTER_REFERENCE_PATTERN.exec(value); + + if (match?.[1]) { + return match[1]; + } + } + + return getLiteralExactString(value); +}; + +const getEcrLifecyclePolicyTraits = ( + policyText: unknown, +): { hasTaggedImageRetentionCap: boolean | null; hasUntaggedImageExpiry: boolean | null } => { + const policy = parseJsonRecord(policyText); + + if (!policy) { + return { + hasTaggedImageRetentionCap: null, + hasUntaggedImageExpiry: null, + }; + } + + const rules = toRecordArray(policy.rules ?? policy.Rules); + let hasTaggedImageRetentionCap = false; + let hasUntaggedImageExpiry = false; + + for (const rule of rules) { + const selection = isRecord(rule.selection) ? rule.selection : isRecord(rule.Selection) ? rule.Selection : null; + const action = isRecord(rule.action) ? rule.action : isRecord(rule.Action) ? rule.Action : null; + + if (!selection || !action) { + continue; + } + + const tagStatus = getLiteralString(selection.tagStatus ?? selection.TagStatus); + const actionType = getLiteralString(action.type ?? action.Type); + + if (actionType !== 'expire') { + continue; + } + + if (tagStatus === 'untagged' || tagStatus === 'any') { + hasUntaggedImageExpiry = true; + } + + if (tagStatus === 'tagged' || tagStatus === 'any') { + const countType = getLiteralString(selection.countType ?? selection.CountType); + const countNumber = getLiteralNumberish(selection.countNumber ?? selection.CountNumber); + + if ( + (countType === 'imagecountmorethan' || countType === 'sinceimagepushed') && + countNumber !== null && + countNumber > 0 + ) { + hasTaggedImageRetentionCap = true; + } + } + } + + return { + hasTaggedImageRetentionCap, + hasUntaggedImageExpiry, + }; +}; + +const hasLifecycleRuleNoncurrentVersionCleanup = (rule: Record): boolean => + toRecordArray(rule.noncurrent_version_expiration).length > 0 || + toRecordArray(rule.noncurrent_version_transition).length > 0 || + isRecord(rule.NoncurrentVersionExpiration) || + toRecordArray(rule.NoncurrentVersionTransitions).length > 0; + +const isEnabledS3VersioningStatus = (value: unknown): boolean => + typeof value === 'string' && value.toLowerCase() === 'enabled'; + +const getTerraformInlineS3VersioningEnabled = (resource: IaCResource): boolean | null => { + const versioning = toRecordArray(resource.attributes.versioning)[0]; + + if (!versioning) { + return false; + } + + const enabled = getLiteralBoolean(versioning.enabled); + + return enabled; +}; + +const hasTerraformS3VersioningEnabled = ( + versioningConfigurations: IaCResource[], + identityKeys: Set, +): boolean | null => { + let matchedAny = false; + let resolvedValue: boolean | null = false; + + for (const resource of versioningConfigurations) { + const targetKey = getTerraformBucketReferenceKey(resource.attributes.bucket); + + if (!targetKey || !identityKeys.has(targetKey)) { + continue; + } + + matchedAny = true; + const versioningConfiguration = toRecordArray(resource.attributes.versioning_configuration)[0]; + const status = versioningConfiguration?.status; + + if (status === undefined) { + resolvedValue = false; + continue; + } + + const literalStatus = getLiteralExactString(status); + + if (!literalStatus) { + return null; + } + + resolvedValue = isEnabledS3VersioningStatus(literalStatus); + } + + return matchedAny ? resolvedValue : false; +}; + +const getCloudFormationScalingTargetReference = (value: unknown): string | null => + getCloudFormationLogicalIdReference(value); + +const getStaticEcsAutoscalingEntry = ( + autoscalingByService: Map, + clusterName: string, + serviceName: string, +): AwsStaticEcsServiceAutoscaling => { + const key = `${clusterName}/${serviceName}`; + const existing = autoscalingByService.get(key) ?? { + clusterName, + hasScalableTarget: false, + hasScalingPolicy: false, + serviceName, + }; + + autoscalingByService.set(key, existing); + return existing; +}; + +const getTerraformRedshiftActionTarget = ( + resource: IaCResource, +): { actionType: 'pause' | 'resume'; clusterIdentifier: string } | null => { + const targetAction = toRecordArray(resource.attributes.target_action)[0]; + + if (!targetAction) { + return null; + } + + const pauseCluster = toRecordArray(targetAction.pause_cluster)[0]; + + if (pauseCluster) { + const clusterIdentifier = + getTerraformRedshiftClusterIdentifier(pauseCluster.cluster_identifier) ?? + (() => { + const reference = DIRECT_REDSHIFT_CLUSTER_REFERENCE_PATTERN.exec( + typeof pauseCluster.cluster_identifier === 'string' ? pauseCluster.cluster_identifier : '', + )?.[1]; + + return reference ? `${TERRAFORM_REDSHIFT_CLUSTER_TYPE}.${reference}` : null; + })(); + return clusterIdentifier ? { actionType: 'pause', clusterIdentifier } : null; + } + + const resumeCluster = toRecordArray(targetAction.resume_cluster)[0]; + + if (resumeCluster) { + const clusterIdentifier = + getTerraformRedshiftClusterIdentifier(resumeCluster.cluster_identifier) ?? + (() => { + const reference = DIRECT_REDSHIFT_CLUSTER_REFERENCE_PATTERN.exec( + typeof resumeCluster.cluster_identifier === 'string' ? resumeCluster.cluster_identifier : '', + )?.[1]; + + return reference ? `${TERRAFORM_REDSHIFT_CLUSTER_TYPE}.${reference}` : null; + })(); + return clusterIdentifier ? { actionType: 'resume', clusterIdentifier } : null; + } + + return null; +}; + +const getCloudFormationRedshiftActionTarget = ( + resource: IaCResource, +): { actionType: 'pause' | 'resume'; clusterIdentifier: string } | null => { + const properties = isRecord(resource.attributes.Properties) ? resource.attributes.Properties : undefined; + const targetAction = isRecord(properties?.TargetAction) ? properties.TargetAction : undefined; + const pauseCluster = isRecord(targetAction?.PauseCluster) ? targetAction.PauseCluster : undefined; + + if (pauseCluster) { + const clusterIdentifier = + getLiteralExactString(pauseCluster.ClusterIdentifier) ?? + getCloudFormationLogicalIdReference(pauseCluster.ClusterIdentifier); + return clusterIdentifier ? { actionType: 'pause', clusterIdentifier } : null; + } + + const resumeCluster = isRecord(targetAction?.ResumeCluster) ? targetAction.ResumeCluster : undefined; + + if (resumeCluster) { + const clusterIdentifier = + getLiteralExactString(resumeCluster.ClusterIdentifier) ?? + getCloudFormationLogicalIdReference(resumeCluster.ClusterIdentifier); + return clusterIdentifier ? { actionType: 'resume', clusterIdentifier } : null; + } + + return null; +}; + const getTerraformLifecycleRules = ( lifecycleConfigurations: IaCResource[], identityKeys: Set, @@ -229,6 +512,7 @@ const createTerraformS3BucketAnalysis = ( bucket: IaCResource, lifecycleConfigurations: IaCResource[], intelligentTieringConfigurations: IaCResource[], + versioningConfigurations: IaCResource[], ): AwsStaticS3BucketAnalysis | null => { const literalBucketName = getLiteralString(bucket.attributes.bucket); const identityKeys = new Set([toStaticResourceId(bucket)]); @@ -243,7 +527,22 @@ const createTerraformS3BucketAnalysis = ( ]; return { + hasNoncurrentVersionCleanup: lifecycleRules.some((rule) => hasLifecycleRuleNoncurrentVersionCleanup(rule)), resourceId: toStaticResourceId(bucket), + versioningEnabled: (() => { + const linkedVersioningEnabled = hasTerraformS3VersioningEnabled(versioningConfigurations, identityKeys); + const inlineVersioningEnabled = getTerraformInlineS3VersioningEnabled(bucket); + + if (linkedVersioningEnabled === true || inlineVersioningEnabled === true) { + return true; + } + + if (linkedVersioningEnabled === null || inlineVersioningEnabled === null) { + return null; + } + + return false; + })(), location: bucket.location, ...buildS3BucketAnalysisFlags( lifecycleRules, @@ -259,9 +558,20 @@ const createCloudFormationS3BucketAnalysis = (bucket: IaCResource): AwsStaticS3B : undefined; const lifecycleRules = toRecordArray(lifecycleConfiguration?.Rules); const intelligentTieringConfigurations = toRecordArray(properties?.IntelligentTieringConfigurations); + const versioningStatus = isRecord(properties?.VersioningConfiguration) + ? properties.VersioningConfiguration.Status + : undefined; return { + hasNoncurrentVersionCleanup: lifecycleRules.some((rule) => hasLifecycleRuleNoncurrentVersionCleanup(rule)), resourceId: toStaticResourceId(bucket), + versioningEnabled: + versioningStatus === undefined + ? false + : (() => { + const literalStatus = getLiteralExactString(versioningStatus); + return literalStatus ? isEnabledS3VersioningStatus(literalStatus) : null; + })(), location: bucket.location, ...buildS3BucketAnalysisFlags(lifecycleRules, intelligentTieringConfigurations), }; @@ -283,6 +593,18 @@ const createTerraformEcrRepository = ( const targetKey = getTerraformEcrRepositoryReferenceKey(lifecyclePolicy.attributes.repository); return targetKey !== null && identityKeys.has(targetKey); }), + ...(() => { + const lifecyclePolicy = lifecyclePolicies.find((candidate) => { + const targetKey = getTerraformEcrRepositoryReferenceKey(candidate.attributes.repository); + return targetKey !== null && identityKeys.has(targetKey); + }); + const traits = lifecyclePolicy ? getEcrLifecyclePolicyTraits(lifecyclePolicy.attributes.policy) : undefined; + + return { + hasTaggedImageRetentionCap: traits?.hasTaggedImageRetentionCap ?? null, + hasUntaggedImageExpiry: traits?.hasUntaggedImageExpiry ?? null, + }; + })(), location: repository.location, resourceId: toStaticResourceId(repository), }; @@ -293,6 +615,9 @@ const createCloudFormationEcrRepository = (repository: IaCResource): AwsStaticEc return { hasLifecyclePolicy: isRecord(properties?.LifecyclePolicy), + ...getEcrLifecyclePolicyTraits( + isRecord(properties?.LifecyclePolicy) ? properties.LifecyclePolicy.LifecyclePolicyText : undefined, + ), location: repository.location, resourceId: toStaticResourceId(repository), }; @@ -508,14 +833,30 @@ const loadStaticDynamoDbAutoscaling = (resources: IaCResource[]): AwsStaticDynam tableName, hasReadTarget: false, hasWriteTarget: false, + readMinCapacity: null, + readMaxCapacity: null, + writeMinCapacity: null, + writeMaxCapacity: null, }; + const minCapacity = + resource.type === TERRAFORM_APPAUTOSCALING_TARGET_TYPE + ? getLiteralNumberish(resource.attributes.min_capacity) + : getLiteralNumberish(properties?.MinCapacity); + const maxCapacity = + resource.type === TERRAFORM_APPAUTOSCALING_TARGET_TYPE + ? getLiteralNumberish(resource.attributes.max_capacity) + : getLiteralNumberish(properties?.MaxCapacity); if (scalableDimension === 'dynamodb:table:ReadCapacityUnits') { entry.hasReadTarget = true; + entry.readMinCapacity = minCapacity; + entry.readMaxCapacity = maxCapacity; } if (scalableDimension === 'dynamodb:table:WriteCapacityUnits') { entry.hasWriteTarget = true; + entry.writeMinCapacity = minCapacity; + entry.writeMaxCapacity = maxCapacity; } autoscalingByTable.set(tableName, entry); @@ -540,6 +881,13 @@ const loadStaticEbsVolumes = (resources: IaCResource[]): AwsStaticEbsVolume[] => ? resource.attributes.Properties.Size : undefined, ), + throughputMiBps: getLiteralNumber( + resource.type === TERRAFORM_EBS_VOLUME_TYPE + ? resource.attributes.throughput + : isRecord(resource.attributes.Properties) + ? resource.attributes.Properties.Throughput + : undefined, + ), resourceId: toStaticResourceId(resource), volumeType: getLiteralString( resource.type === TERRAFORM_EBS_VOLUME_TYPE @@ -548,7 +896,7 @@ const loadStaticEbsVolumes = (resources: IaCResource[]): AwsStaticEbsVolume[] => ? resource.attributes.Properties.VolumeType : undefined, ), - location: pickLocation(resource, ['type', 'Properties.VolumeType']), + location: pickLocation(resource, ['type', 'throughput', 'Properties.VolumeType', 'Properties.Throughput']), })); const loadStaticEcrRepositories = (resources: IaCResource[]): AwsStaticEcrRepository[] => { @@ -715,6 +1063,14 @@ const loadStaticRoute53HealthChecks = (resources: IaCResource[]): AwsStaticRoute const loadStaticEc2Instances = (resources: IaCResource[]): AwsStaticEc2Instance[] => resources.map((resource) => ({ + detailedMonitoringEnabled: + getLiteralBoolean( + resource.type === TERRAFORM_INSTANCE_TYPE + ? resource.attributes.monitoring + : isRecord(resource.attributes.Properties) + ? resource.attributes.Properties.Monitoring + : undefined, + ) ?? false, resourceId: toStaticResourceId(resource), instanceType: getLiteralString( resource.type === TERRAFORM_INSTANCE_TYPE @@ -723,7 +1079,12 @@ const loadStaticEc2Instances = (resources: IaCResource[]): AwsStaticEc2Instance[ ? resource.attributes.Properties.InstanceType : undefined, ), - location: pickLocation(resource, ['instance_type', 'Properties.InstanceType']), + location: pickLocation(resource, [ + 'instance_type', + 'monitoring', + 'Properties.InstanceType', + 'Properties.Monitoring', + ]), })); const loadStaticRdsInstances = (resources: IaCResource[]): AwsStaticRdsInstance[] => @@ -750,7 +1111,30 @@ const loadStaticRdsInstances = (resources: IaCResource[]): AwsStaticRdsInstance[ ? resource.attributes.Properties.DBInstanceClass : undefined, ), - location: pickLocation(resource, ['instance_class', 'Properties.DBInstanceClass']), + performanceInsightsEnabled: + resource.type === TERRAFORM_RDS_INSTANCE_TYPE + ? (getLiteralBoolean(resource.attributes.performance_insights_enabled) ?? false) + : isRecord(resource.attributes.Properties) + ? (getLiteralBoolean(resource.attributes.Properties.EnablePerformanceInsights) ?? false) + : false, + performanceInsightsRetentionPeriod: + resource.type === TERRAFORM_RDS_INSTANCE_TYPE + ? resource.attributes.performance_insights_retention_period === undefined + ? undefined + : getLiteralNumberish(resource.attributes.performance_insights_retention_period) + : isRecord(resource.attributes.Properties) + ? resource.attributes.Properties.PerformanceInsightsRetentionPeriod === undefined + ? undefined + : getLiteralNumberish(resource.attributes.Properties.PerformanceInsightsRetentionPeriod) + : undefined, + location: pickLocation(resource, [ + 'instance_class', + 'performance_insights_enabled', + 'performance_insights_retention_period', + 'Properties.DBInstanceClass', + 'Properties.EnablePerformanceInsights', + 'Properties.PerformanceInsightsRetentionPeriod', + ]), })); const loadStaticLambdaFunctions = (resources: IaCResource[]): AwsStaticLambdaFunction[] => @@ -766,6 +1150,257 @@ const loadStaticLambdaFunctions = (resources: IaCResource[]): AwsStaticLambdaFun location: pickLocation(resource, ['architectures', 'Properties.Architectures']), })); +const loadStaticLambdaProvisionedConcurrency = (resources: IaCResource[]): AwsStaticLambdaProvisionedConcurrency[] => + resources.flatMap((resource) => { + if (resource.type === TERRAFORM_LAMBDA_PROVISIONED_CONCURRENCY_TYPE) { + return [ + { + location: pickLocation(resource, ['provisioned_concurrent_executions']), + provisionedConcurrentExecutions: getLiteralNumberish(resource.attributes.provisioned_concurrent_executions), + resourceId: toStaticResourceId(resource), + }, + ]; + } + + if (resource.type === CLOUDFORMATION_LAMBDA_ALIAS_TYPE) { + const properties = isRecord(resource.attributes.Properties) ? resource.attributes.Properties : undefined; + const concurrencyConfig = isRecord(properties?.ProvisionedConcurrencyConfig) + ? properties.ProvisionedConcurrencyConfig + : undefined; + + if (!concurrencyConfig) { + return []; + } + + return [ + { + location: pickLocation(resource, ['Properties.ProvisionedConcurrencyConfig.ProvisionedConcurrentExecutions']), + provisionedConcurrentExecutions: getLiteralNumberish(concurrencyConfig.ProvisionedConcurrentExecutions), + resourceId: toStaticResourceId(resource), + }, + ]; + } + + return []; + }); + +const loadStaticEcsServices = (resources: IaCResource[]): AwsStaticEcsService[] => + resources.flatMap((resource) => { + if (resource.type === TERRAFORM_ECS_SERVICE_TYPE) { + return [ + { + clusterName: getTerraformEcsClusterName(resource.attributes.cluster), + location: pickLocation(resource, ['cluster', 'name', 'scheduling_strategy']), + resourceId: toStaticResourceId(resource), + schedulingStrategy: getLiteralUpperString(resource.attributes.scheduling_strategy) ?? 'REPLICA', + serviceName: getLiteralExactString(resource.attributes.name), + }, + ]; + } + + if (resource.type !== CLOUDFORMATION_ECS_SERVICE_TYPE) { + return []; + } + + const properties = isRecord(resource.attributes.Properties) ? resource.attributes.Properties : undefined; + const clusterName = getTerraformEcsClusterName(properties?.Cluster); + const serviceName = getLiteralExactString(properties?.ServiceName); + + return [ + { + clusterName, + location: pickLocation(resource, [ + 'Properties.Cluster', + 'Properties.ServiceName', + 'Properties.SchedulingStrategy', + ]), + resourceId: toStaticResourceId(resource), + schedulingStrategy: getLiteralUpperString(properties?.SchedulingStrategy) ?? 'REPLICA', + serviceName, + }, + ]; + }); + +const loadStaticEcsAutoscaling = (resources: IaCResource[]): AwsStaticEcsServiceAutoscaling[] => { + const autoscalingByService = new Map(); + const cloudFormationTargetsByLogicalId = new Map(); + + for (const resource of resources) { + const properties = isRecord(resource.attributes.Properties) ? resource.attributes.Properties : undefined; + + if ( + resource.type === TERRAFORM_APPAUTOSCALING_TARGET_TYPE || + resource.type === CLOUDFORMATION_SCALABLE_TARGET_TYPE + ) { + const serviceIdentity = + resource.type === TERRAFORM_APPAUTOSCALING_TARGET_TYPE + ? getTerraformEcsServiceIdentity(resource.attributes.resource_id) + : getTerraformEcsServiceIdentity(properties?.ResourceId); + const scalableDimension = + resource.type === TERRAFORM_APPAUTOSCALING_TARGET_TYPE + ? getLiteralExactString(resource.attributes.scalable_dimension) + : getLiteralExactString(properties?.ScalableDimension); + const serviceNamespace = + resource.type === TERRAFORM_APPAUTOSCALING_TARGET_TYPE + ? getLiteralExactString(resource.attributes.service_namespace) + : getLiteralExactString(properties?.ServiceNamespace); + + if (serviceIdentity && scalableDimension === 'ecs:service:DesiredCount' && serviceNamespace === 'ecs') { + getStaticEcsAutoscalingEntry( + autoscalingByService, + serviceIdentity.clusterName, + serviceIdentity.serviceName, + ).hasScalableTarget = true; + + if (resource.type === CLOUDFORMATION_SCALABLE_TARGET_TYPE) { + cloudFormationTargetsByLogicalId.set(resource.name, serviceIdentity); + } + } + + continue; + } + + if (resource.type === TERRAFORM_APPAUTOSCALING_POLICY_TYPE) { + const serviceIdentity = getTerraformEcsServiceIdentity(resource.attributes.resource_id); + const scalableDimension = getLiteralExactString(resource.attributes.scalable_dimension); + const serviceNamespace = getLiteralExactString(resource.attributes.service_namespace); + + if (serviceIdentity && scalableDimension === 'ecs:service:DesiredCount' && serviceNamespace === 'ecs') { + getStaticEcsAutoscalingEntry( + autoscalingByService, + serviceIdentity.clusterName, + serviceIdentity.serviceName, + ).hasScalingPolicy = true; + } + + continue; + } + + if (resource.type === CLOUDFORMATION_SCALING_POLICY_TYPE) { + const targetRef = getCloudFormationScalingTargetReference(properties?.ScalingTargetId); + const serviceIdentity = targetRef ? cloudFormationTargetsByLogicalId.get(targetRef) : undefined; + + if (serviceIdentity) { + getStaticEcsAutoscalingEntry( + autoscalingByService, + serviceIdentity.clusterName, + serviceIdentity.serviceName, + ).hasScalingPolicy = true; + } + } + } + + return [...autoscalingByService.values()]; +}; + +const loadStaticRedshiftClusters = (resources: IaCResource[]): AwsStaticRedshiftCluster[] => { + const schedulesByCluster = new Map(); + + for (const resource of resources) { + const target = + resource.type === TERRAFORM_REDSHIFT_SCHEDULED_ACTION_TYPE + ? getTerraformRedshiftActionTarget(resource) + : resource.type === CLOUDFORMATION_REDSHIFT_SCHEDULED_ACTION_TYPE + ? getCloudFormationRedshiftActionTarget(resource) + : null; + + if (!target) { + continue; + } + + const entry = schedulesByCluster.get(target.clusterIdentifier) ?? { + hasPauseSchedule: false, + hasResumeSchedule: false, + }; + + if (target.actionType === 'pause') { + entry.hasPauseSchedule = true; + } + + if (target.actionType === 'resume') { + entry.hasResumeSchedule = true; + } + + schedulesByCluster.set(target.clusterIdentifier, entry); + } + + return resources.flatMap((resource) => { + if (resource.type !== TERRAFORM_REDSHIFT_CLUSTER_TYPE && resource.type !== CLOUDFORMATION_REDSHIFT_CLUSTER_TYPE) { + return []; + } + + const properties = isRecord(resource.attributes.Properties) ? resource.attributes.Properties : undefined; + const identityKeys = new Set([resource.name, toStaticResourceId(resource)]); + const clusterIdentifier = + resource.type === TERRAFORM_REDSHIFT_CLUSTER_TYPE + ? getTerraformRedshiftClusterIdentifier(resource.attributes.cluster_identifier) + : getLiteralExactString(properties?.ClusterIdentifier); + + if (clusterIdentifier) { + identityKeys.add(clusterIdentifier); + } + + const schedules = [...identityKeys].reduce( + (combined, key) => { + const schedule = schedulesByCluster.get(key); + + if (!schedule) { + return combined; + } + + return { + hasPauseSchedule: combined.hasPauseSchedule || schedule.hasPauseSchedule, + hasResumeSchedule: combined.hasResumeSchedule || schedule.hasResumeSchedule, + }; + }, + { + hasPauseSchedule: false, + hasResumeSchedule: false, + }, + ); + + return [ + { + automatedSnapshotRetentionPeriod: + resource.type === TERRAFORM_REDSHIFT_CLUSTER_TYPE + ? resource.attributes.automated_snapshot_retention_period === undefined + ? undefined + : getLiteralNumberish(resource.attributes.automated_snapshot_retention_period) + : properties?.AutomatedSnapshotRetentionPeriod === undefined + ? undefined + : getLiteralNumberish(properties?.AutomatedSnapshotRetentionPeriod), + hasPauseSchedule: schedules.hasPauseSchedule, + hasResumeSchedule: schedules.hasResumeSchedule, + hasVpc: + resource.type === TERRAFORM_REDSHIFT_CLUSTER_TYPE + ? getLiteralExactString(resource.attributes.cluster_subnet_group_name) !== null + : getLiteralExactString(properties?.ClusterSubnetGroupName) !== null, + hsmEnabled: + resource.type === TERRAFORM_REDSHIFT_CLUSTER_TYPE + ? getLiteralExactString(resource.attributes.hsm_client_certificate_identifier) !== null || + getLiteralExactString(resource.attributes.hsm_configuration_identifier) !== null + : getLiteralExactString(properties?.HsmClientCertificateIdentifier) !== null || + getLiteralExactString(properties?.HsmConfigurationIdentifier) !== null, + location: pickLocation(resource, [ + 'cluster_identifier', + 'cluster_subnet_group_name', + 'multi_az', + 'automated_snapshot_retention_period', + 'Properties.ClusterIdentifier', + 'Properties.ClusterSubnetGroupName', + 'Properties.MultiAZ', + 'Properties.AutomatedSnapshotRetentionPeriod', + ]), + multiAz: + resource.type === TERRAFORM_REDSHIFT_CLUSTER_TYPE + ? getLiteralBoolean(resource.attributes.multi_az) + : getLiteralBoolean(properties?.MultiAZ), + resourceId: toStaticResourceId(resource), + }, + ]; + }); +}; + const loadStaticEc2VpcEndpoints = (resources: IaCResource[]): AwsStaticEc2VpcEndpoint[] => resources.map((resource) => ({ resourceId: toStaticResourceId(resource), @@ -796,6 +1431,7 @@ const loadStaticS3BucketAnalyses = (resources: IaCResource[]): AwsStaticS3Bucket const intelligentTieringConfigurations = resources.filter( (resource) => resource.type === TERRAFORM_INTELLIGENT_TIERING_TYPE, ); + const versioningConfigurations = resources.filter((resource) => resource.type === TERRAFORM_BUCKET_VERSIONING_TYPE); return resources.flatMap((resource) => { if (resource.type === TERRAFORM_BUCKET_TYPE) { @@ -803,6 +1439,7 @@ const loadStaticS3BucketAnalyses = (resources: IaCResource[]): AwsStaticS3Bucket resource, lifecycleConfigurations, intelligentTieringConfigurations, + versioningConfigurations, ); return analysis ? [analysis] : []; @@ -863,6 +1500,23 @@ const awsStaticDatasetRegistry: Record ({ supports: ['iac'], }); +const RULE_ID_PATTERN = /^CLDBRN-([A-Z0-9]+)-([A-Z0-9]+)-(\d+)$/; + +const toComparableRuleMetadata = (rule: Pick) => ({ + description: rule.description, + id: rule.id, + provider: rule.provider, + service: rule.service, + supports: rule.supports, +}); + +const sortRulesForMetadata = >(rules: TRule[]): TRule[] => + [...rules].sort((left, right) => { + const leftMatch = RULE_ID_PATTERN.exec(left.id); + const rightMatch = RULE_ID_PATTERN.exec(right.id); + + if (!leftMatch || !rightMatch) { + return left.id.localeCompare(right.id); + } + + const [, leftProvider, leftService, leftSuffix] = leftMatch; + const [, rightProvider, rightService, rightSuffix] = rightMatch; + + return ( + leftProvider.localeCompare(rightProvider) || + leftService.localeCompare(rightService) || + Number.parseInt(leftSuffix, 10) - Number.parseInt(rightSuffix, 10) + ); + }); + describe('sdk exports', () => { it('exports the autodetect parser from the package root', () => { expect(parseIaC).toBeTypeOf('function'); }); it('exports built-in rule metadata in stable provider/service/id order', () => { - expect( - builtInRuleMetadata.map((rule) => ({ - description: rule.description, - id: rule.id, - provider: rule.provider, - service: rule.service, - supports: rule.supports, - })), - ).toEqual([ - { - description: 'Flag API Gateway REST API stages with caching disabled.', - id: 'CLDBRN-AWS-APIGATEWAY-1', - provider: 'aws', - service: 'apigateway', - supports: ['discovery', 'iac'], - }, - { - description: 'Flag CloudFront distributions using PriceClass_All when a cheaper price class may suffice.', - id: 'CLDBRN-AWS-CLOUDFRONT-1', - provider: 'aws', - service: 'cloudfront', - supports: ['discovery', 'iac'], - }, - { - description: 'Flag CloudFront distributions with fewer than 100 requests over the last 30 days.', - id: 'CLDBRN-AWS-CLOUDFRONT-2', - provider: 'aws', - service: 'cloudfront', - supports: ['discovery'], - }, - { - description: 'Flag redundant multi-region CloudTrail trails when more than one trail covers the same account.', - id: 'CLDBRN-AWS-CLOUDTRAIL-1', - provider: 'aws', - service: 'cloudtrail', - supports: ['discovery'], - }, - { - description: 'Flag redundant single-region CloudTrail trails when more than one trail covers the same region.', - id: 'CLDBRN-AWS-CLOUDTRAIL-2', - provider: 'aws', - service: 'cloudtrail', - supports: ['discovery'], - }, - { - description: 'Flag CloudWatch log groups that do not define retention and are not delivery-managed.', - id: 'CLDBRN-AWS-CLOUDWATCH-1', - provider: 'aws', - service: 'cloudwatch', - supports: ['discovery', 'iac'], - }, - { - description: - 'Flag CloudWatch log streams that have never received events or whose last ingestion was more than 90 days ago outside delivery-managed log groups.', - id: 'CLDBRN-AWS-CLOUDWATCH-2', - provider: 'aws', - service: 'cloudwatch', - supports: ['discovery'], - }, - { - description: 'Flag CloudWatch log groups storing at least 1 GB when they define no metric filters.', - id: 'CLDBRN-AWS-CLOUDWATCH-3', - provider: 'aws', - service: 'cloudwatch', - supports: ['discovery'], - }, - { - description: 'Flag services with significant cost increases between the last two full months.', - id: 'CLDBRN-AWS-COSTEXPLORER-1', - provider: 'aws', - service: 'costexplorer', - supports: ['discovery'], - }, - { - description: 'Flag AWS accounts that do not have any AWS Budgets configured.', - id: 'CLDBRN-AWS-COSTGUARDRAILS-1', - provider: 'aws', - service: 'costguardrails', - supports: ['discovery'], - }, - { - description: 'Flag AWS accounts that do not have any Cost Anomaly Detection monitors configured.', - id: 'CLDBRN-AWS-COSTGUARDRAILS-2', - provider: 'aws', - service: 'costguardrails', - supports: ['discovery'], - }, - { - description: 'Flag DynamoDB tables with no data changes exceeding a threshold (default 90 days).', - id: 'CLDBRN-AWS-DYNAMODB-1', - provider: 'aws', - service: 'dynamodb', - supports: ['discovery'], - }, - { - description: 'Flag provisioned-capacity DynamoDB tables without auto-scaling configured.', - id: 'CLDBRN-AWS-DYNAMODB-2', - provider: 'aws', - service: 'dynamodb', - supports: ['discovery', 'iac'], - }, - { - description: 'Flag provisioned DynamoDB tables with no consumed read or write capacity over the last 30 days.', - id: 'CLDBRN-AWS-DYNAMODB-3', - provider: 'aws', - service: 'dynamodb', - supports: ['discovery'], - }, - { - description: - 'Flag EBS volumes using previous-generation storage types when a current-generation replacement exists.', - id: 'CLDBRN-AWS-EBS-1', - provider: 'aws', - service: 'ebs', - supports: ['discovery', 'iac'], - }, - { - description: 'Flag EBS volumes that are not attached to any EC2 instance.', - id: 'CLDBRN-AWS-EBS-2', - provider: 'aws', - service: 'ebs', - supports: ['discovery'], - }, - { - description: 'Flag EBS volumes whose attached EC2 instances are all in the stopped state.', - id: 'CLDBRN-AWS-EBS-3', - provider: 'aws', - service: 'ebs', - supports: ['discovery'], - }, - { - description: 'Flag EBS volumes larger than 100 GiB so their provisioned size can be reviewed intentionally.', - id: 'CLDBRN-AWS-EBS-4', - provider: 'aws', - service: 'ebs', - supports: ['discovery', 'iac'], - }, - { - description: 'Flag io1 and io2 EBS volumes with provisioned IOPS above 32000.', - id: 'CLDBRN-AWS-EBS-5', - provider: 'aws', - service: 'ebs', - supports: ['discovery', 'iac'], - }, - { - description: 'Flag io1 and io2 EBS volumes at 16000 IOPS or below as gp3 review candidates.', - id: 'CLDBRN-AWS-EBS-6', - provider: 'aws', - service: 'ebs', - supports: ['discovery', 'iac'], - }, - { - description: 'Flag completed EBS snapshots older than 90 days.', - id: 'CLDBRN-AWS-EBS-7', - provider: 'aws', - service: 'ebs', - supports: ['discovery'], - }, - { - description: 'Flag direct EC2 instances that do not use curated preferred instance types.', - id: 'CLDBRN-AWS-EC2-1', - provider: 'aws', - service: 'ec2', - supports: ['iac', 'discovery'], - }, - { - description: 'Flag S3 interface endpoints when a gateway endpoint is the cheaper in-VPC option.', - id: 'CLDBRN-AWS-EC2-2', - provider: 'aws', - service: 'ec2', - supports: ['iac'], - }, - { - description: 'Flag Elastic IP allocations that are not associated with an EC2 resource.', - id: 'CLDBRN-AWS-EC2-3', - provider: 'aws', - service: 'ec2', - supports: ['discovery', 'iac'], - }, - { - description: 'Flag interface VPC endpoints that have processed no traffic in the last 30 days.', - id: 'CLDBRN-AWS-EC2-4', - provider: 'aws', - service: 'ec2', - supports: ['discovery'], - }, - { - description: - 'Flag EC2 instances whose CPU and network usage stay below the low-utilization threshold for at least 4 of the previous 14 days.', - id: 'CLDBRN-AWS-EC2-5', - provider: 'aws', - service: 'ec2', - supports: ['discovery'], - }, - { - description: - 'Flag EC2 instances that still run on non-Graviton families when a clear Arm-based equivalent exists.', - id: 'CLDBRN-AWS-EC2-6', - provider: 'aws', - service: 'ec2', - supports: ['discovery', 'iac'], - }, - { - description: 'Flag active EC2 reserved instances whose end date is within the next 60 days.', - id: 'CLDBRN-AWS-EC2-7', - provider: 'aws', - service: 'ec2', - supports: ['discovery'], - }, - { - description: 'Flag EC2 instances that are sized at 2xlarge or above so they can be right-sized intentionally.', - id: 'CLDBRN-AWS-EC2-8', - provider: 'aws', - service: 'ec2', - supports: ['discovery', 'iac'], - }, - { - description: 'Flag EC2 instances whose launch time is at least 180 days old.', - id: 'CLDBRN-AWS-EC2-9', - provider: 'aws', - service: 'ec2', - supports: ['discovery'], - }, - { - description: 'Flag ECR repositories that do not define a lifecycle policy.', - id: 'CLDBRN-AWS-ECR-1', - provider: 'aws', - service: 'ecr', - supports: ['iac', 'discovery'], - }, - { - description: - 'Flag ECS container instances backed by EC2 instance types that still run on non-Graviton families when a clear Arm-based equivalent exists.', - id: 'CLDBRN-AWS-ECS-1', - provider: 'aws', - service: 'ecs', - supports: ['discovery'], - }, - { - description: 'Flag ECS clusters whose average CPU utilization stays below 10% over the previous 14 days.', - id: 'CLDBRN-AWS-ECS-2', - provider: 'aws', - service: 'ecs', - supports: ['discovery'], - }, - { - description: - 'Flag active REPLICA ECS services that do not have an Application Auto Scaling target and scaling policy.', - id: 'CLDBRN-AWS-ECS-3', - provider: 'aws', - service: 'ecs', - supports: ['discovery'], - }, - { - description: - 'Flag EKS node groups that still use non-Graviton instance families when a clear Arm-based equivalent exists.', - id: 'CLDBRN-AWS-EKS-1', - provider: 'aws', - service: 'eks', - supports: ['discovery', 'iac'], - }, - { - description: 'Flag long-running ElastiCache clusters that do not have matching active reserved-node coverage.', - id: 'CLDBRN-AWS-ELASTICACHE-1', - provider: 'aws', - service: 'elasticache', - supports: ['discovery'], - }, - { - description: - 'Flag available ElastiCache clusters whose 14-day average cache hit rate stays below 5% and average current connections stay below 2.', - id: 'CLDBRN-AWS-ELASTICACHE-2', - provider: 'aws', - service: 'elasticache', - supports: ['discovery'], - }, - { - description: 'Flag Application Load Balancers that have no attached target groups or no registered targets.', - id: 'CLDBRN-AWS-ELB-1', - provider: 'aws', - service: 'elb', - supports: ['discovery'], - }, - { - description: 'Flag Classic Load Balancers that have zero attached instances.', - id: 'CLDBRN-AWS-ELB-2', - provider: 'aws', - service: 'elb', - supports: ['discovery'], - }, - { - description: 'Flag Gateway Load Balancers that have no attached target groups or no registered targets.', - id: 'CLDBRN-AWS-ELB-3', - provider: 'aws', - service: 'elb', - supports: ['discovery'], - }, - { - description: 'Flag Network Load Balancers that have no attached target groups or no registered targets.', - id: 'CLDBRN-AWS-ELB-4', - provider: 'aws', - service: 'elb', - supports: ['discovery'], - }, - { - description: 'Flag load balancers whose 14-day average request count stays below 10 requests per day.', - id: 'CLDBRN-AWS-ELB-5', - provider: 'aws', - service: 'elb', - supports: ['discovery'], - }, - { - description: 'Flag EMR clusters that still use previous-generation EC2 instance types.', - id: 'CLDBRN-AWS-EMR-1', - provider: 'aws', - service: 'emr', - supports: ['discovery', 'iac'], - }, - { - description: 'Flag active EMR clusters whose `IsIdle` metric stays true for at least 30 minutes.', - id: 'CLDBRN-AWS-EMR-2', - provider: 'aws', - service: 'emr', - supports: ['discovery'], - }, - { - description: 'Recommend arm64 architecture when compatible.', - id: 'CLDBRN-AWS-LAMBDA-1', - provider: 'aws', - service: 'lambda', - supports: ['iac', 'discovery'], - }, - { - description: 'Flag Lambda functions whose 7-day error rate is greater than 10%.', - id: 'CLDBRN-AWS-LAMBDA-2', - provider: 'aws', - service: 'lambda', - supports: ['discovery'], - }, - { - description: - 'Flag Lambda functions whose configured timeout is at least 30 seconds and 5x their 7-day average duration.', - id: 'CLDBRN-AWS-LAMBDA-3', - provider: 'aws', - service: 'lambda', - supports: ['discovery'], - }, - { - description: - 'Flag Lambda functions above 256 MB whose observed 7-day average duration uses less than 30% of the configured timeout.', - id: 'CLDBRN-AWS-LAMBDA-4', - provider: 'aws', - service: 'lambda', - supports: ['discovery'], - }, - { - description: 'Flag RDS DB instances that do not use curated preferred instance classes.', - id: 'CLDBRN-AWS-RDS-1', - provider: 'aws', - service: 'rds', - supports: ['iac', 'discovery'], - }, - { - description: 'Flag RDS DB instances that have no database connections in the last 7 days.', - id: 'CLDBRN-AWS-RDS-2', - provider: 'aws', - service: 'rds', - supports: ['discovery'], - }, - { - description: 'Flag long-running RDS DB instances that do not have matching active reserved-instance coverage.', - id: 'CLDBRN-AWS-RDS-3', - provider: 'aws', - service: 'rds', - supports: ['discovery'], - }, - { - description: - 'Flag RDS DB instances that still use non-Graviton instance families when a clear Graviton-based equivalent exists.', - id: 'CLDBRN-AWS-RDS-4', - provider: 'aws', - service: 'rds', - supports: ['discovery', 'iac'], - }, - { - description: 'Flag available RDS DB instances whose 30-day average CPU stays at or below 10%.', - id: 'CLDBRN-AWS-RDS-5', - provider: 'aws', - service: 'rds', - supports: ['discovery'], - }, - { - description: - 'Flag RDS MySQL 5.7 and PostgreSQL 11 DB instances that can incur extended support charges until they are upgraded.', - id: 'CLDBRN-AWS-RDS-6', - provider: 'aws', - service: 'rds', - supports: ['discovery', 'iac'], - }, - { - description: 'Flag RDS snapshots older than 30 days whose source DB instance no longer exists.', - id: 'CLDBRN-AWS-RDS-7', - provider: 'aws', - service: 'rds', - supports: ['discovery'], - }, - { - description: 'Flag available Redshift clusters whose 14-day average CPU stays at or below 10%.', - id: 'CLDBRN-AWS-REDSHIFT-1', - provider: 'aws', - service: 'redshift', - supports: ['discovery'], - }, - { - description: 'Flag long-running Redshift clusters that do not have matching active reserved-node coverage.', - id: 'CLDBRN-AWS-REDSHIFT-2', - provider: 'aws', - service: 'redshift', - supports: ['discovery'], - }, - { - description: 'Flag eligible Redshift clusters that do not have both pause and resume schedules configured.', - id: 'CLDBRN-AWS-REDSHIFT-3', - provider: 'aws', - service: 'redshift', - supports: ['discovery'], - }, - { - description: 'Flag Route 53 records with TTL below 3600 seconds.', - id: 'CLDBRN-AWS-ROUTE53-1', - provider: 'aws', - service: 'route53', - supports: ['discovery', 'iac'], - }, - { - description: 'Flag Route 53 health checks not associated with any DNS record.', - id: 'CLDBRN-AWS-ROUTE53-2', - provider: 'aws', - service: 'route53', - supports: ['discovery', 'iac'], - }, - { - description: 'Ensure S3 buckets define lifecycle management policies.', - id: 'CLDBRN-AWS-S3-1', - provider: 'aws', - service: 's3', - supports: ['iac', 'discovery'], - }, - { - description: - 'Recommend Intelligent-Tiering or another explicit storage-class transition for lifecycle-managed buckets.', - id: 'CLDBRN-AWS-S3-2', - provider: 'aws', - service: 's3', - supports: ['iac', 'discovery'], - }, - { - description: - 'Ensure S3 buckets define an enabled lifecycle rule that aborts incomplete multipart uploads within 7 days.', - id: 'CLDBRN-AWS-S3-3', - provider: 'aws', - service: 's3', - supports: ['iac', 'discovery'], - }, - { - description: 'Flag Secrets Manager secrets not accessed within a threshold (default 90 days).', - id: 'CLDBRN-AWS-SECRETSMANAGER-1', - provider: 'aws', - service: 'secretsmanager', - supports: ['discovery'], - }, - ]); + const expected = sortRulesForMetadata(awsRules).map((rule) => toComparableRuleMetadata(rule)); + + expect(builtInRuleMetadata.map((rule) => toComparableRuleMetadata(rule))).toEqual(expected); }); it('sorts numeric rule suffixes in numeric order within the same service', () => { diff --git a/packages/sdk/test/providers/aws-static.test.ts b/packages/sdk/test/providers/aws-static.test.ts index f3d2f13..3ea0c43 100644 --- a/packages/sdk/test/providers/aws-static.test.ts +++ b/packages/sdk/test/providers/aws-static.test.ts @@ -99,11 +99,13 @@ describe('loadAwsStaticResources', () => { }, resourceId: 'aws_ebs_volume.logs', sizeGiB: null, + throughputMiBps: null, volumeType: 'gp2', }, ]); expect(result.resources.get('aws-ec2-instances')).toEqual([ { + detailedMonitoringEnabled: false, instanceType: 'c6i.large', location: { path: 'template.yaml', @@ -168,6 +170,8 @@ describe('loadAwsStaticResources', () => { line: 5, column: 3, }, + performanceInsightsEnabled: false, + performanceInsightsRetentionPeriod: undefined, resourceId: 'aws_db_instance.legacy', }, { @@ -179,6 +183,8 @@ describe('loadAwsStaticResources', () => { line: 9, column: 7, }, + performanceInsightsEnabled: false, + performanceInsightsRetentionPeriod: undefined, resourceId: 'Database', }, ]); @@ -258,6 +264,7 @@ describe('loadAwsStaticResources', () => { }, resourceId: 'aws_ebs_volume.logs', sizeGiB: 200, + throughputMiBps: null, volumeType: 'io2', }, { @@ -269,6 +276,7 @@ describe('loadAwsStaticResources', () => { }, resourceId: 'DataVolume', sizeGiB: 500, + throughputMiBps: null, volumeType: 'io1', }, ]); @@ -348,6 +356,8 @@ describe('loadAwsStaticResources', () => { line: 5, column: 3, }, + performanceInsightsEnabled: false, + performanceInsightsRetentionPeriod: undefined, resourceId: 'aws_db_instance.legacy', }, { @@ -359,6 +369,8 @@ describe('loadAwsStaticResources', () => { line: 9, column: 7, }, + performanceInsightsEnabled: false, + performanceInsightsRetentionPeriod: undefined, resourceId: 'Database', }, ]); @@ -665,7 +677,11 @@ describe('loadAwsStaticResources', () => { { hasReadTarget: true, hasWriteTarget: true, + readMaxCapacity: null, + readMinCapacity: null, tableName: 'orders', + writeMaxCapacity: null, + writeMinCapacity: null, }, ]); }); @@ -1131,6 +1147,8 @@ describe('loadAwsStaticResources', () => { expect(result.resources.get('aws-ecr-repositories')).toEqual([ { hasLifecyclePolicy: true, + hasTaggedImageRetentionCap: null, + hasUntaggedImageExpiry: null, location: { path: 'main.tf', line: 1, @@ -1140,6 +1158,8 @@ describe('loadAwsStaticResources', () => { }, { hasLifecyclePolicy: true, + hasTaggedImageRetentionCap: false, + hasUntaggedImageExpiry: false, location: { path: 'template.yaml', line: 10, @@ -1149,6 +1169,8 @@ describe('loadAwsStaticResources', () => { }, { hasLifecyclePolicy: false, + hasTaggedImageRetentionCap: null, + hasUntaggedImageExpiry: null, location: { path: 'template.yaml', line: 18, @@ -1191,6 +1213,8 @@ describe('loadAwsStaticResources', () => { expect(result.resources.get('aws-ecr-repositories')).toEqual([ { hasLifecyclePolicy: true, + hasTaggedImageRetentionCap: null, + hasUntaggedImageExpiry: null, location: { path: 'main.tf', line: 1, @@ -1233,6 +1257,8 @@ describe('loadAwsStaticResources', () => { expect(result.resources.get('aws-ecr-repositories')).toEqual([ { hasLifecyclePolicy: true, + hasTaggedImageRetentionCap: null, + hasUntaggedImageExpiry: null, location: { path: 'main.tf', line: 1, @@ -1340,6 +1366,7 @@ describe('aws static dataset registry', () => { }, resourceId: 'aws_ebs_volume.logs', sizeGiB: null, + throughputMiBps: null, volumeType: 'gp2', }, { @@ -1351,6 +1378,7 @@ describe('aws static dataset registry', () => { }, resourceId: 'DataVolume', sizeGiB: null, + throughputMiBps: null, volumeType: 'gp3', }, ]); @@ -1401,6 +1429,8 @@ describe('aws static dataset registry', () => { ).toEqual([ { hasLifecyclePolicy: true, + hasTaggedImageRetentionCap: null, + hasUntaggedImageExpiry: null, location: { path: 'main.tf', line: 2, @@ -1410,6 +1440,8 @@ describe('aws static dataset registry', () => { }, { hasLifecyclePolicy: true, + hasTaggedImageRetentionCap: false, + hasUntaggedImageExpiry: false, location: { path: 'template.yaml', line: 7, @@ -1442,6 +1474,7 @@ describe('aws static dataset registry', () => { ]), ).toEqual([ { + detailedMonitoringEnabled: false, instanceType: null, location: { path: 'main.tf', @@ -1557,6 +1590,8 @@ describe('aws static dataset registry', () => { line: 4, column: 3, }, + performanceInsightsEnabled: false, + performanceInsightsRetentionPeriod: undefined, resourceId: 'aws_db_instance.legacy', }, { @@ -1568,6 +1603,8 @@ describe('aws static dataset registry', () => { line: 11, column: 7, }, + performanceInsightsEnabled: false, + performanceInsightsRetentionPeriod: undefined, resourceId: 'Database', }, ]); @@ -1660,6 +1697,7 @@ describe('aws static dataset registry', () => { hasIntelligentTieringConfiguration: false, hasIntelligentTieringTransition: true, hasLifecycleSignal: true, + hasNoncurrentVersionCleanup: false, hasUnclassifiedTransition: false, location: { path: 'main.tf', @@ -1667,6 +1705,7 @@ describe('aws static dataset registry', () => { column: 1, }, resourceId: 'aws_s3_bucket.logs', + versioningEnabled: false, }, ]); }); @@ -1716,6 +1755,7 @@ describe('aws static dataset registry', () => { hasIntelligentTieringConfiguration: false, hasIntelligentTieringTransition: false, hasLifecycleSignal: true, + hasNoncurrentVersionCleanup: false, hasUnclassifiedTransition: true, location: { path: 'main.tf', @@ -1723,6 +1763,7 @@ describe('aws static dataset registry', () => { column: 1, }, resourceId: 'aws_s3_bucket.logs', + versioningEnabled: false, }, ]); }); @@ -1768,6 +1809,7 @@ describe('aws static dataset registry', () => { hasIntelligentTieringConfiguration: false, hasIntelligentTieringTransition: false, hasLifecycleSignal: true, + hasNoncurrentVersionCleanup: false, hasUnclassifiedTransition: true, location: { path: 'template.yaml', @@ -1775,6 +1817,7 @@ describe('aws static dataset registry', () => { column: 3, }, resourceId: 'LogsBucket', + versioningEnabled: false, }, ]); }); @@ -1823,6 +1866,7 @@ describe('aws static dataset registry', () => { hasIntelligentTieringConfiguration: false, hasIntelligentTieringTransition: false, hasLifecycleSignal: true, + hasNoncurrentVersionCleanup: false, hasUnclassifiedTransition: false, location: { path: 'main.tf', @@ -1830,6 +1874,7 @@ describe('aws static dataset registry', () => { column: 1, }, resourceId: 'aws_s3_bucket.logs', + versioningEnabled: false, }, ]); }); @@ -1871,6 +1916,7 @@ describe('aws static dataset registry', () => { hasIntelligentTieringConfiguration: false, hasIntelligentTieringTransition: false, hasLifecycleSignal: true, + hasNoncurrentVersionCleanup: false, hasUnclassifiedTransition: false, location: { path: 'template.yaml', @@ -1878,6 +1924,821 @@ describe('aws static dataset registry', () => { column: 3, }, resourceId: 'LogsBucket', + versioningEnabled: false, + }, + ]); + }); + + it('normalizes gp3 throughput for Terraform and CloudFormation EBS volumes', () => { + const definition = getAwsStaticDatasetDefinition('aws-ebs-volumes'); + + expect( + definition?.load([ + createIaCResource({ + type: 'aws_ebs_volume', + name: 'logs', + attributeLocations: { + type: { + path: 'main.tf', + line: 4, + column: 3, + }, + throughput: { + path: 'main.tf', + line: 5, + column: 3, + }, + }, + attributes: { + throughput: 250, + type: 'gp3', + }, + }), + createIaCResource({ + type: 'AWS::EC2::Volume', + name: 'DataVolume', + attributeLocations: { + 'Properties.VolumeType': { + path: 'template.yaml', + line: 10, + column: 7, + }, + 'Properties.Throughput': { + path: 'template.yaml', + line: 11, + column: 7, + }, + }, + attributes: { + Properties: { + Throughput: 500, + VolumeType: 'gp3', + }, + }, + }), + ]), + ).toEqual([ + { + iops: null, + location: { + path: 'main.tf', + line: 4, + column: 3, + }, + resourceId: 'aws_ebs_volume.logs', + sizeGiB: null, + throughputMiBps: 250, + volumeType: 'gp3', + }, + { + iops: null, + location: { + path: 'template.yaml', + line: 10, + column: 7, + }, + resourceId: 'DataVolume', + sizeGiB: null, + throughputMiBps: 500, + volumeType: 'gp3', + }, + ]); + }); + + it('normalizes ECR lifecycle policy traits for Terraform and CloudFormation repositories', () => { + const definition = getAwsStaticDatasetDefinition('aws-ecr-repositories'); + + expect( + definition?.load([ + createIaCResource({ + type: 'aws_ecr_repository', + name: 'app', + location: { + path: 'main.tf', + line: 1, + column: 1, + }, + attributes: { + name: 'app', + }, + }), + createIaCResource({ + type: 'aws_ecr_lifecycle_policy', + name: 'app', + attributes: { + policy: JSON.stringify({ + rules: [ + { + action: { type: 'expire' }, + selection: { countNumber: 14, countType: 'sinceImagePushed', tagStatus: 'untagged' }, + }, + { + action: { type: 'expire' }, + selection: { countNumber: 20, countType: 'imageCountMoreThan', tagStatus: 'tagged' }, + }, + ], + }), + repository: '${' + 'aws_ecr_repository.app.name}', + }, + }), + createIaCResource({ + type: 'AWS::ECR::Repository', + name: 'LogsRepository', + location: { + path: 'template.yaml', + line: 7, + column: 3, + }, + attributes: { + Properties: { + LifecyclePolicy: { + LifecyclePolicyText: JSON.stringify({ + rules: [ + { + Action: { Type: 'expire' }, + Selection: { CountNumber: 30, CountType: 'sinceImagePushed', TagStatus: 'untagged' }, + }, + { + Action: { Type: 'expire' }, + Selection: { CountNumber: 50, CountType: 'imageCountMoreThan', TagStatus: 'tagged' }, + }, + ], + }), + }, + RepositoryName: 'logs', + }, + }, + }), + ]), + ).toEqual([ + { + hasLifecyclePolicy: true, + hasTaggedImageRetentionCap: true, + hasUntaggedImageExpiry: true, + location: { + path: 'main.tf', + line: 1, + column: 1, + }, + resourceId: 'aws_ecr_repository.app', + }, + { + hasLifecyclePolicy: true, + hasTaggedImageRetentionCap: true, + hasUntaggedImageExpiry: true, + location: { + path: 'template.yaml', + line: 7, + column: 3, + }, + resourceId: 'LogsRepository', + }, + ]); + }); + + it('normalizes EC2 detailed monitoring for Terraform and CloudFormation instances', () => { + const definition = getAwsStaticDatasetDefinition('aws-ec2-instances'); + + expect( + definition?.load([ + createIaCResource({ + type: 'aws_instance', + name: 'app', + attributeLocations: { + instance_type: { + path: 'main.tf', + line: 3, + column: 3, + }, + monitoring: { + path: 'main.tf', + line: 4, + column: 3, + }, + }, + attributes: { + instance_type: 'm7i.large', + monitoring: true, + }, + }), + createIaCResource({ + type: 'AWS::EC2::Instance', + name: 'WorkerInstance', + attributeLocations: { + 'Properties.InstanceType': { + path: 'template.yaml', + line: 8, + column: 7, + }, + 'Properties.Monitoring': { + path: 'template.yaml', + line: 9, + column: 7, + }, + }, + attributes: { + Properties: { + InstanceType: 'c7g.large', + Monitoring: true, + }, + }, + }), + ]), + ).toEqual([ + { + detailedMonitoringEnabled: true, + instanceType: 'm7i.large', + location: { + path: 'main.tf', + line: 3, + column: 3, + }, + resourceId: 'aws_instance.app', + }, + { + detailedMonitoringEnabled: true, + instanceType: 'c7g.large', + location: { + path: 'template.yaml', + line: 8, + column: 7, + }, + resourceId: 'WorkerInstance', + }, + ]); + }); + + it('normalizes DynamoDB autoscaling min and max capacity values', () => { + const definition = getAwsStaticDatasetDefinition('aws-dynamodb-autoscaling'); + + expect( + definition?.load([ + createIaCResource({ + type: 'aws_appautoscaling_target', + name: 'orders_read', + attributes: { + max_capacity: 100, + min_capacity: 10, + resource_id: 'table/orders', + scalable_dimension: 'dynamodb:table:ReadCapacityUnits', + service_namespace: 'dynamodb', + }, + }), + createIaCResource({ + type: 'AWS::ApplicationAutoScaling::ScalableTarget', + name: 'OrdersWriteTarget', + attributes: { + Properties: { + MaxCapacity: 200, + MinCapacity: 20, + ResourceId: 'table/orders', + ScalableDimension: 'dynamodb:table:WriteCapacityUnits', + ServiceNamespace: 'dynamodb', + }, + }, + }), + ]), + ).toEqual([ + { + hasReadTarget: true, + hasWriteTarget: true, + readMaxCapacity: 100, + readMinCapacity: 10, + tableName: 'orders', + writeMaxCapacity: 200, + writeMinCapacity: 20, + }, + ]); + }); + + it('normalizes Lambda provisioned concurrency resources for Terraform and CloudFormation', () => { + const definition = getAwsStaticDatasetDefinition('aws-lambda-provisioned-concurrency'); + + expect( + definition?.load([ + createIaCResource({ + type: 'aws_lambda_provisioned_concurrency_config', + name: 'worker', + attributeLocations: { + provisioned_concurrent_executions: { + path: 'main.tf', + line: 7, + column: 3, + }, + }, + attributes: { + provisioned_concurrent_executions: 5, + }, + }), + createIaCResource({ + type: 'AWS::Lambda::Alias', + name: 'WorkerAlias', + attributeLocations: { + 'Properties.ProvisionedConcurrencyConfig.ProvisionedConcurrentExecutions': { + path: 'template.yaml', + line: 14, + column: 7, + }, + }, + attributes: { + Properties: { + ProvisionedConcurrencyConfig: { + ProvisionedConcurrentExecutions: 12, + }, + }, + }, + }), + ]), + ).toEqual([ + { + location: { + path: 'main.tf', + line: 7, + column: 3, + }, + provisionedConcurrentExecutions: 5, + resourceId: 'aws_lambda_provisioned_concurrency_config.worker', + }, + { + location: { + path: 'template.yaml', + line: 14, + column: 7, + }, + provisionedConcurrentExecutions: 12, + resourceId: 'WorkerAlias', + }, + ]); + }); + + it('normalizes RDS Performance Insights settings for Terraform and CloudFormation', () => { + const definition = getAwsStaticDatasetDefinition('aws-rds-instances'); + + expect( + definition?.load([ + createIaCResource({ + type: 'aws_db_instance', + name: 'app', + attributeLocations: { + instance_class: { + path: 'main.tf', + line: 4, + column: 3, + }, + performance_insights_enabled: { + path: 'main.tf', + line: 5, + column: 3, + }, + performance_insights_retention_period: { + path: 'main.tf', + line: 6, + column: 3, + }, + }, + attributes: { + instance_class: 'db.r7g.large', + performance_insights_enabled: true, + performance_insights_retention_period: 93, + }, + }), + createIaCResource({ + type: 'AWS::RDS::DBInstance', + name: 'Database', + attributeLocations: { + 'Properties.DBInstanceClass': { + path: 'template.yaml', + line: 9, + column: 7, + }, + 'Properties.EnablePerformanceInsights': { + path: 'template.yaml', + line: 10, + column: 7, + }, + 'Properties.PerformanceInsightsRetentionPeriod': { + path: 'template.yaml', + line: 11, + column: 7, + }, + }, + attributes: { + Properties: { + DBInstanceClass: 'db.m7g.large', + EnablePerformanceInsights: true, + PerformanceInsightsRetentionPeriod: 731, + }, + }, + }), + ]), + ).toEqual([ + { + engine: null, + engineVersion: null, + instanceClass: 'db.r7g.large', + location: { + path: 'main.tf', + line: 4, + column: 3, + }, + performanceInsightsEnabled: true, + performanceInsightsRetentionPeriod: 93, + resourceId: 'aws_db_instance.app', + }, + { + engine: null, + engineVersion: null, + instanceClass: 'db.m7g.large', + location: { + path: 'template.yaml', + line: 9, + column: 7, + }, + performanceInsightsEnabled: true, + performanceInsightsRetentionPeriod: 731, + resourceId: 'Database', + }, + ]); + }); + + it('correlates ECS services with autoscaling resources for Terraform and CloudFormation', () => { + const servicesDefinition = getAwsStaticDatasetDefinition('aws-ecs-services'); + const autoscalingDefinition = getAwsStaticDatasetDefinition('aws-ecs-autoscaling'); + const resources = [ + createIaCResource({ + type: 'aws_ecs_service', + name: 'web', + attributeLocations: { + cluster: { + path: 'main.tf', + line: 10, + column: 3, + }, + name: { + path: 'main.tf', + line: 11, + column: 3, + }, + }, + attributes: { + cluster: 'aws_ecs_cluster.production.name', + name: 'web', + }, + }), + createIaCResource({ + type: 'aws_appautoscaling_target', + name: 'web', + attributes: { + resource_id: 'service/production/web', + scalable_dimension: 'ecs:service:DesiredCount', + service_namespace: 'ecs', + }, + }), + createIaCResource({ + type: 'aws_appautoscaling_policy', + name: 'web', + attributes: { + resource_id: 'service/production/web', + scalable_dimension: 'ecs:service:DesiredCount', + service_namespace: 'ecs', + }, + }), + createIaCResource({ + type: 'AWS::ECS::Service', + name: 'ApiService', + attributeLocations: { + 'Properties.Cluster': { + path: 'template.yaml', + line: 20, + column: 7, + }, + 'Properties.ServiceName': { + path: 'template.yaml', + line: 21, + column: 7, + }, + }, + attributes: { + Properties: { + Cluster: 'shared', + ServiceName: 'api', + }, + }, + }), + createIaCResource({ + type: 'AWS::ApplicationAutoScaling::ScalableTarget', + name: 'ApiTarget', + attributes: { + Properties: { + ResourceId: 'service/shared/api', + ScalableDimension: 'ecs:service:DesiredCount', + ServiceNamespace: 'ecs', + }, + }, + }), + createIaCResource({ + type: 'AWS::ApplicationAutoScaling::ScalingPolicy', + name: 'ApiPolicy', + attributes: { + Properties: { + ScalingTargetId: { + Ref: 'ApiTarget', + }, + }, + }, + }), + ]; + + expect(servicesDefinition?.load(resources)).toEqual([ + { + clusterName: 'production', + location: { + path: 'main.tf', + line: 10, + column: 3, + }, + resourceId: 'aws_ecs_service.web', + schedulingStrategy: 'REPLICA', + serviceName: 'web', + }, + { + clusterName: 'shared', + location: { + path: 'template.yaml', + line: 20, + column: 7, + }, + resourceId: 'ApiService', + schedulingStrategy: 'REPLICA', + serviceName: 'api', + }, + ]); + + expect(autoscalingDefinition?.load(resources)).toEqual([ + { + clusterName: 'production', + hasScalableTarget: true, + hasScalingPolicy: true, + serviceName: 'web', + }, + { + clusterName: 'shared', + hasScalableTarget: true, + hasScalingPolicy: true, + serviceName: 'api', + }, + ]); + }); + + it('correlates Redshift clusters with scheduled pause and resume actions', () => { + const definition = getAwsStaticDatasetDefinition('aws-redshift-clusters'); + + expect( + definition?.load([ + createIaCResource({ + type: 'aws_redshift_cluster', + name: 'analytics', + attributeLocations: { + cluster_identifier: { + path: 'main.tf', + line: 5, + column: 3, + }, + cluster_subnet_group_name: { + path: 'main.tf', + line: 6, + column: 3, + }, + }, + attributes: { + automated_snapshot_retention_period: 1, + cluster_identifier: 'warehouse', + cluster_subnet_group_name: 'subnet-group', + }, + }), + createIaCResource({ + type: 'aws_redshift_scheduled_action', + name: 'pause', + attributes: { + target_action: [ + { + pause_cluster: [ + { + cluster_identifier: 'aws_redshift_cluster.analytics.cluster_identifier', + }, + ], + }, + ], + }, + }), + createIaCResource({ + type: 'aws_redshift_scheduled_action', + name: 'resume', + attributes: { + target_action: [ + { + resume_cluster: [ + { + cluster_identifier: 'warehouse', + }, + ], + }, + ], + }, + }), + createIaCResource({ + type: 'AWS::Redshift::Cluster', + name: 'WarehouseCluster', + attributeLocations: { + 'Properties.ClusterIdentifier': { + path: 'template.yaml', + line: 10, + column: 7, + }, + 'Properties.ClusterSubnetGroupName': { + path: 'template.yaml', + line: 11, + column: 7, + }, + }, + attributes: { + Properties: { + AutomatedSnapshotRetentionPeriod: 1, + ClusterIdentifier: 'warehouse-cfn', + ClusterSubnetGroupName: 'subnet-group', + }, + }, + }), + createIaCResource({ + type: 'AWS::Redshift::ScheduledAction', + name: 'PauseAction', + attributes: { + Properties: { + TargetAction: { + PauseCluster: { + ClusterIdentifier: { + Ref: 'WarehouseCluster', + }, + }, + }, + }, + }, + }), + createIaCResource({ + type: 'AWS::Redshift::ScheduledAction', + name: 'ResumeAction', + attributes: { + Properties: { + TargetAction: { + ResumeCluster: { + ClusterIdentifier: { + Ref: 'WarehouseCluster', + }, + }, + }, + }, + }, + }), + ]), + ).toEqual([ + { + automatedSnapshotRetentionPeriod: 1, + hasPauseSchedule: true, + hasResumeSchedule: true, + hasVpc: true, + hsmEnabled: false, + location: { + path: 'main.tf', + line: 5, + column: 3, + }, + multiAz: null, + resourceId: 'aws_redshift_cluster.analytics', + }, + { + automatedSnapshotRetentionPeriod: 1, + hasPauseSchedule: true, + hasResumeSchedule: true, + hasVpc: true, + hsmEnabled: false, + location: { + path: 'template.yaml', + line: 10, + column: 7, + }, + multiAz: null, + resourceId: 'WarehouseCluster', + }, + ]); + }); + + it('builds versioning and noncurrent-version cleanup signals for Terraform and CloudFormation S3 buckets', () => { + const definition = getAwsStaticDatasetDefinition('aws-s3-bucket-analyses'); + + expect( + definition?.load([ + createIaCResource({ + type: 'aws_s3_bucket', + name: 'logs', + location: { + path: 'main.tf', + line: 1, + column: 1, + }, + attributes: { + bucket: 'example-logs', + }, + }), + createIaCResource({ + type: 'aws_s3_bucket_versioning', + name: 'logs', + attributes: { + bucket: '${' + 'aws_s3_bucket.logs.id}', + versioning_configuration: [ + { + status: 'Enabled', + }, + ], + }, + }), + createIaCResource({ + type: 'aws_s3_bucket_lifecycle_configuration', + name: 'logs', + attributes: { + bucket: '${' + 'aws_s3_bucket.logs.id}', + rule: [ + { + noncurrent_version_expiration: [ + { + noncurrent_days: 30, + }, + ], + status: 'Enabled', + }, + ], + }, + }), + createIaCResource({ + type: 'AWS::S3::Bucket', + name: 'LogsBucket', + location: { + path: 'template.yaml', + line: 3, + column: 3, + }, + attributes: { + Properties: { + LifecycleConfiguration: { + Rules: [ + { + NoncurrentVersionExpiration: { + NoncurrentDays: 30, + }, + Status: 'Enabled', + }, + ], + }, + VersioningConfiguration: { + Status: 'Enabled', + }, + }, + }, + }), + ]), + ).toEqual([ + { + hasAbortIncompleteMultipartUploadAfter7Days: false, + hasAlternativeStorageClassTransition: false, + hasCostFocusedLifecycle: true, + hasIntelligentTieringConfiguration: false, + hasIntelligentTieringTransition: false, + hasLifecycleSignal: true, + hasNoncurrentVersionCleanup: true, + hasUnclassifiedTransition: false, + location: { + path: 'main.tf', + line: 1, + column: 1, + }, + resourceId: 'aws_s3_bucket.logs', + versioningEnabled: true, + }, + { + hasAbortIncompleteMultipartUploadAfter7Days: false, + hasAlternativeStorageClassTransition: false, + hasCostFocusedLifecycle: true, + hasIntelligentTieringConfiguration: false, + hasIntelligentTieringTransition: false, + hasLifecycleSignal: true, + hasNoncurrentVersionCleanup: true, + hasUnclassifiedTransition: false, + location: { + path: 'template.yaml', + line: 3, + column: 3, + }, + resourceId: 'LogsBucket', + versioningEnabled: true, }, ]); }); From aa1a6e3b71c437aff3aca70a1b6a513673a5fb3f Mon Sep 17 00:00:00 2001 From: Axon Stone Date: Mon, 30 Mar 2026 15:33:47 +0200 Subject: [PATCH 2/2] 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 --- packages/rules/test/rule-metadata.test.ts | 146 ++++++++++++++++++ .../sdk/src/providers/aws/static-registry.ts | 24 ++- .../sdk/test/providers/aws-static.test.ts | 37 +++++ 3 files changed, 200 insertions(+), 7 deletions(-) diff --git a/packages/rules/test/rule-metadata.test.ts b/packages/rules/test/rule-metadata.test.ts index 2c2cd5f..c4b725c 100644 --- a/packages/rules/test/rule-metadata.test.ts +++ b/packages/rules/test/rule-metadata.test.ts @@ -221,6 +221,23 @@ describe('rule metadata', () => { }); }); + it('defines the expected S3 noncurrent-version-cleanup rule metadata', () => { + const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-S3-4'); + + expect(rule).toBeDefined(); + expect(rule).toMatchObject({ + id: 'CLDBRN-AWS-S3-4', + name: 'S3 Versioned Bucket Missing Noncurrent Version Cleanup', + description: + 'Flag versioned S3 buckets that do not define noncurrent-version expiration or transition lifecycle cleanup.', + message: 'Versioned S3 buckets should define noncurrent-version cleanup.', + provider: 'aws', + service: 's3', + supports: ['iac'], + staticDependencies: ['aws-s3-bucket-analyses'], + }); + }); + it('defines the expected EC2 S3 endpoint rule metadata', () => { const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-EC2-2'); @@ -254,6 +271,38 @@ describe('rule metadata', () => { }); }); + it('defines the expected ECR untagged-image-expiry rule metadata', () => { + const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-ECR-2'); + + expect(rule).toBeDefined(); + expect(rule).toMatchObject({ + id: 'CLDBRN-AWS-ECR-2', + name: 'ECR Lifecycle Policy Missing Untagged Image Expiry', + description: 'Flag ECR repositories whose lifecycle policy does not expire untagged images.', + message: 'ECR repositories should expire untagged images.', + provider: 'aws', + service: 'ecr', + supports: ['iac'], + staticDependencies: ['aws-ecr-repositories'], + }); + }); + + it('defines the expected ECR tagged-image-retention-cap rule metadata', () => { + const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-ECR-3'); + + expect(rule).toBeDefined(); + expect(rule).toMatchObject({ + id: 'CLDBRN-AWS-ECR-3', + name: 'ECR Lifecycle Policy Missing Tagged Image Retention Cap', + description: 'Flag ECR repositories whose lifecycle policy does not cap tagged image retention.', + message: 'ECR repositories should cap tagged image retention.', + provider: 'aws', + service: 'ecr', + supports: ['iac'], + staticDependencies: ['aws-ecr-repositories'], + }); + }); + it('defines the expected ElastiCache reserved-coverage rule metadata', () => { const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-ELASTICACHE-1'); @@ -404,6 +453,38 @@ describe('rule metadata', () => { }); }); + it('defines the expected EBS gp3-extra-throughput rule metadata', () => { + const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-EBS-8'); + + expect(rule).toBeDefined(); + expect(rule).toMatchObject({ + id: 'CLDBRN-AWS-EBS-8', + name: 'EBS gp3 Volume Extra Throughput Provisioned', + description: 'Flag gp3 volumes that provision throughput above the included 125 MiB/s baseline.', + message: 'EBS gp3 volumes should avoid paid throughput above the included baseline unless required.', + provider: 'aws', + service: 'ebs', + supports: ['iac'], + staticDependencies: ['aws-ebs-volumes'], + }); + }); + + it('defines the expected EBS gp3-extra-iops rule metadata', () => { + const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-EBS-9'); + + expect(rule).toBeDefined(); + expect(rule).toMatchObject({ + id: 'CLDBRN-AWS-EBS-9', + name: 'EBS gp3 Volume Extra IOPS Provisioned', + description: 'Flag gp3 volumes that provision IOPS above the included 3000 baseline.', + message: 'EBS gp3 volumes should avoid paid IOPS above the included baseline unless required.', + provider: 'aws', + service: 'ebs', + supports: ['iac'], + staticDependencies: ['aws-ebs-volumes'], + }); + }); + it('defines the expected EC2 unassociated-elastic-ip rule metadata', () => { const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-EC2-3'); @@ -623,6 +704,22 @@ describe('rule metadata', () => { }); }); + it('defines the expected EC2 detailed-monitoring rule metadata', () => { + const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-EC2-10'); + + expect(rule).toBeDefined(); + expect(rule).toMatchObject({ + id: 'CLDBRN-AWS-EC2-10', + name: 'EC2 Instance Detailed Monitoring Enabled', + description: 'Flag EC2 instances that explicitly enable detailed monitoring.', + message: 'EC2 instances should review detailed monitoring because it adds CloudWatch cost.', + provider: 'aws', + service: 'ec2', + supports: ['iac'], + staticDependencies: ['aws-ec2-instances'], + }); + }); + it('defines the expected ELB ALB-without-targets rule metadata', () => { const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-ELB-1'); @@ -757,6 +854,22 @@ describe('rule metadata', () => { }); }); + it('defines the expected Lambda provisioned-concurrency rule metadata', () => { + const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-LAMBDA-5'); + + expect(rule).toBeDefined(); + expect(rule).toMatchObject({ + id: 'CLDBRN-AWS-LAMBDA-5', + name: 'Lambda Provisioned Concurrency Configured', + description: 'Flag explicit Lambda provisioned concurrency configuration for cost review.', + message: 'Lambda provisioned concurrency should be reviewed for steady low-latency demand.', + provider: 'aws', + service: 'lambda', + supports: ['iac'], + staticDependencies: ['aws-lambda-provisioned-concurrency'], + }); + }); + it('defines the expected RDS idle-instance rule metadata', () => { const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-RDS-2'); @@ -857,6 +970,22 @@ describe('rule metadata', () => { }); }); + it('defines the expected RDS performance-insights-retention rule metadata', () => { + const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-RDS-8'); + + expect(rule).toBeDefined(); + expect(rule).toMatchObject({ + id: 'CLDBRN-AWS-RDS-8', + name: 'RDS Performance Insights Extended Retention', + description: 'Flag DB instances that enable Performance Insights retention beyond the included 7-day period.', + message: 'RDS Performance Insights should use the included 7-day retention unless longer retention is required.', + provider: 'aws', + service: 'rds', + supports: ['iac'], + staticDependencies: ['aws-rds-instances'], + }); + }); + it('defines the expected Redshift low-cpu rule metadata', () => { const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-REDSHIFT-1'); @@ -1054,6 +1183,23 @@ describe('rule metadata', () => { }); }); + it('defines the expected DynamoDB autoscaling-range-fixed rule metadata', () => { + const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-DYNAMODB-4'); + + expect(rule).toBeDefined(); + expect(rule).toMatchObject({ + id: 'CLDBRN-AWS-DYNAMODB-4', + name: 'DynamoDB Autoscaling Range Fixed', + description: + 'Flag provisioned-capacity DynamoDB tables whose table autoscaling min and max capacity are identical.', + message: 'Provisioned DynamoDB autoscaling should allow capacity to change.', + provider: 'aws', + service: 'dynamodb', + supports: ['iac'], + staticDependencies: ['aws-dynamodb-tables', 'aws-dynamodb-autoscaling'], + }); + }); + it('defines the expected Route 53 higher-ttl rule metadata', () => { const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-ROUTE53-1'); diff --git a/packages/sdk/src/providers/aws/static-registry.ts b/packages/sdk/src/providers/aws/static-registry.ts index d6a4f21..b8d4f95 100644 --- a/packages/sdk/src/providers/aws/static-registry.ts +++ b/packages/sdk/src/providers/aws/static-registry.ts @@ -1224,6 +1224,7 @@ const loadStaticEcsServices = (resources: IaCResource[]): AwsStaticEcsService[] const loadStaticEcsAutoscaling = (resources: IaCResource[]): AwsStaticEcsServiceAutoscaling[] => { const autoscalingByService = new Map(); const cloudFormationTargetsByLogicalId = new Map(); + const pendingCloudFormationPolicyTargets: string[] = []; for (const resource of resources) { const properties = isRecord(resource.attributes.Properties) ? resource.attributes.Properties : undefined; @@ -1278,18 +1279,27 @@ const loadStaticEcsAutoscaling = (resources: IaCResource[]): AwsStaticEcsService if (resource.type === CLOUDFORMATION_SCALING_POLICY_TYPE) { const targetRef = getCloudFormationScalingTargetReference(properties?.ScalingTargetId); - const serviceIdentity = targetRef ? cloudFormationTargetsByLogicalId.get(targetRef) : undefined; - if (serviceIdentity) { - getStaticEcsAutoscalingEntry( - autoscalingByService, - serviceIdentity.clusterName, - serviceIdentity.serviceName, - ).hasScalingPolicy = true; + if (targetRef) { + pendingCloudFormationPolicyTargets.push(targetRef); } } } + for (const targetRef of pendingCloudFormationPolicyTargets) { + const serviceIdentity = cloudFormationTargetsByLogicalId.get(targetRef); + + if (!serviceIdentity) { + continue; + } + + getStaticEcsAutoscalingEntry( + autoscalingByService, + serviceIdentity.clusterName, + serviceIdentity.serviceName, + ).hasScalingPolicy = true; + } + return [...autoscalingByService.values()]; }; diff --git a/packages/sdk/test/providers/aws-static.test.ts b/packages/sdk/test/providers/aws-static.test.ts index 3ea0c43..3d8b916 100644 --- a/packages/sdk/test/providers/aws-static.test.ts +++ b/packages/sdk/test/providers/aws-static.test.ts @@ -2490,6 +2490,43 @@ describe('aws static dataset registry', () => { ]); }); + it('correlates CloudFormation ECS autoscaling when the scaling policy appears before the scalable target', () => { + const definition = getAwsStaticDatasetDefinition('aws-ecs-autoscaling'); + const resources = [ + createIaCResource({ + type: 'AWS::ApplicationAutoScaling::ScalingPolicy', + name: 'ApiPolicy', + attributes: { + Properties: { + ScalingTargetId: { + Ref: 'ApiTarget', + }, + }, + }, + }), + createIaCResource({ + type: 'AWS::ApplicationAutoScaling::ScalableTarget', + name: 'ApiTarget', + attributes: { + Properties: { + ResourceId: 'service/shared/api', + ScalableDimension: 'ecs:service:DesiredCount', + ServiceNamespace: 'ecs', + }, + }, + }), + ]; + + expect(definition?.load(resources)).toEqual([ + { + clusterName: 'shared', + hasScalableTarget: true, + hasScalingPolicy: true, + serviceName: 'api', + }, + ]); + }); + it('correlates Redshift clusters with scheduled pause and resume actions', () => { const definition = getAwsStaticDatasetDefinition('aws-redshift-clusters');