diff --git a/.changeset/swift-parrots-float.md b/.changeset/swift-parrots-float.md new file mode 100644 index 0000000..56b41c1 --- /dev/null +++ b/.changeset/swift-parrots-float.md @@ -0,0 +1,5 @@ +--- +"@cloudburn/rules": minor +--- + +Add IaC evaluation support for high-confidence AWS rules covering EBS sizing and IOPS checks, EC2 instance and Elastic IP reviews, RDS Graviton and engine-version checks, API Gateway stages, CloudFront price classes, CloudWatch log retention, DynamoDB autoscaling, EKS node groups, EMR instance generations, and Route 53 TTL and health-check usage. diff --git a/.changeset/tall-comics-invite.md b/.changeset/tall-comics-invite.md new file mode 100644 index 0000000..12552d9 --- /dev/null +++ b/.changeset/tall-comics-invite.md @@ -0,0 +1,5 @@ +--- +"@cloudburn/sdk": minor +--- + +Add static AWS dataset loaders for the new dual-mode IaC rules, including DynamoDB autoscaling state, Elastic IP association state, EKS node groups, EMR cluster instance types, and Route 53 records and health checks across Terraform and CloudFormation inputs. diff --git a/docs/reference/rule-ids.md b/docs/reference/rule-ids.md index 9364b47..2c3b85f 100644 --- a/docs/reference/rule-ids.md +++ b/docs/reference/rule-ids.md @@ -16,28 +16,28 @@ Format: `CLDBRN-{PROVIDER}-{SERVICE}-{N}` | ID | Name | Service | Supports | Status | | --------------------- | ----------------------------------------- | ------- | -------------- | ----------- | -| `CLDBRN-AWS-APIGATEWAY-1` | API Gateway Stage Caching Disabled | apigateway | discovery | Implemented | -| `CLDBRN-AWS-CLOUDFRONT-1` | CloudFront Distribution Price Class All | cloudfront | discovery | Implemented | +| `CLDBRN-AWS-APIGATEWAY-1` | API Gateway Stage Caching Disabled | apigateway | discovery, iac | Implemented | +| `CLDBRN-AWS-CLOUDFRONT-1` | CloudFront Distribution Price Class All | cloudfront | discovery, iac | Implemented | | `CLDBRN-AWS-CLOUDFRONT-2` | CloudFront Distribution Unused | cloudfront | discovery | Implemented | | `CLDBRN-AWS-CLOUDTRAIL-1` | CloudTrail Redundant Global Trails | cloudtrail | discovery | Implemented | | `CLDBRN-AWS-CLOUDTRAIL-2` | CloudTrail Redundant Regional Trails | cloudtrail | discovery | Implemented | -| `CLDBRN-AWS-CLOUDWATCH-1` | CloudWatch Log Group Missing Retention | cloudwatch | discovery | Implemented | +| `CLDBRN-AWS-CLOUDWATCH-1` | CloudWatch Log Group Missing Retention | cloudwatch | discovery, iac | Implemented | | `CLDBRN-AWS-CLOUDWATCH-2` | CloudWatch Unused Log Streams | cloudwatch | discovery | Implemented | | `CLDBRN-AWS-CLOUDWATCH-3` | CloudWatch Log Group No Metric Filters | cloudwatch | discovery | Implemented | | `CLDBRN-AWS-COSTGUARDRAILS-1` | AWS Budgets Missing | costguardrails | discovery | Implemented | | `CLDBRN-AWS-COSTGUARDRAILS-2` | Cost Anomaly Detection Missing | costguardrails | discovery | Implemented | | `CLDBRN-AWS-COSTEXPLORER-1` | Cost Explorer Full Month Cost Changes | costexplorer | discovery | Implemented | | `CLDBRN-AWS-DYNAMODB-1` | DynamoDB Table Stale Data | dynamodb | discovery | Implemented | -| `CLDBRN-AWS-DYNAMODB-2` | DynamoDB Table Without Autoscaling | 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-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 | Implemented | +| `CLDBRN-AWS-EC2-3` | Elastic IP Address Unassociated | ec2 | discovery, iac | Implemented | | `CLDBRN-AWS-EC2-4` | VPC Interface Endpoint Inactive | ec2 | discovery | Implemented | | `CLDBRN-AWS-EC2-5` | EC2 Instance Low Utilization | ec2 | discovery | Implemented | -| `CLDBRN-AWS-EC2-6` | EC2 Instance Without Graviton | ec2 | discovery | Implemented | +| `CLDBRN-AWS-EC2-6` | EC2 Instance Without Graviton | ec2 | discovery, iac | Implemented | | `CLDBRN-AWS-EC2-7` | EC2 Reserved Instance Expiring | ec2 | discovery | Implemented | -| `CLDBRN-AWS-EC2-8` | EC2 Instance Large Size | 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-ECS-1` | ECS Container Instance Without Graviton | ecs | discovery | Implemented | | `CLDBRN-AWS-ECS-2` | ECS Cluster Low CPU Utilization | ecs | discovery | Implemented | @@ -45,12 +45,12 @@ Format: `CLDBRN-{PROVIDER}-{SERVICE}-{N}` | `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 | -| `CLDBRN-AWS-EBS-4` | EBS Volume Large Size | ebs | discovery | Implemented | -| `CLDBRN-AWS-EBS-5` | EBS Volume High Provisioned IOPS | ebs | discovery | Implemented | -| `CLDBRN-AWS-EBS-6` | EBS Volume Low Provisioned IOPS On io1/io2 | ebs | discovery | Implemented | +| `CLDBRN-AWS-EBS-4` | EBS Volume Large Size | ebs | discovery, iac | Implemented | +| `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-ECR-1` | ECR Repository Missing Lifecycle Policy | ecr | iac, discovery | Implemented | -| `CLDBRN-AWS-EKS-1` | EKS Node Group Without Graviton | eks | discovery | 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 | | `CLDBRN-AWS-ELB-1` | Application Load Balancer Without Targets | elb | discovery | Implemented | @@ -58,20 +58,20 @@ Format: `CLDBRN-{PROVIDER}-{SERVICE}-{N}` | `CLDBRN-AWS-ELB-3` | Gateway Load Balancer Without Targets | elb | discovery | Implemented | | `CLDBRN-AWS-ELB-4` | Network Load Balancer Without Targets | elb | discovery | Implemented | | `CLDBRN-AWS-ELB-5` | Load Balancer Idle | elb | discovery | Implemented | -| `CLDBRN-AWS-EMR-1` | EMR Cluster Previous Generation Instance Types | emr | discovery | Implemented | +| `CLDBRN-AWS-EMR-1` | EMR Cluster Previous Generation Instance Types | emr | discovery, iac | Implemented | | `CLDBRN-AWS-EMR-2` | EMR Cluster Idle | emr | discovery | Implemented | | `CLDBRN-AWS-RDS-1` | RDS Instance Class Not Preferred | rds | iac, discovery | Implemented | | `CLDBRN-AWS-RDS-2` | RDS DB Instance Idle | rds | discovery | Implemented | | `CLDBRN-AWS-RDS-3` | RDS DB Instance Missing Reserved Coverage | rds | discovery | Implemented | -| `CLDBRN-AWS-RDS-4` | RDS DB Instance Without Graviton | rds | discovery | Implemented | +| `CLDBRN-AWS-RDS-4` | RDS DB Instance Without Graviton | rds | discovery, iac | Implemented | | `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 | 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-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-ROUTE53-1` | Route 53 Record Higher TTL | route53 | discovery | Implemented | -| `CLDBRN-AWS-ROUTE53-2` | Route 53 Health Check Unused | route53 | discovery | 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-SECRETSMANAGER-1` | Secrets Manager Secret Unused | secretsmanager | discovery | Implemented | @@ -164,7 +164,7 @@ Format: `CLDBRN-{PROVIDER}-{SERVICE}-{N}` `CLDBRN-AWS-ROUTE53-1` reviews only non-alias records and treats `3600` seconds as the low-TTL floor. -`CLDBRN-AWS-ROUTE53-2` flags only Route 53 health checks that are not referenced by any discovered record set. +`CLDBRN-AWS-ROUTE53-2` flags only Route 53 health checks that are not referenced by any in-scope record set. `CLDBRN-AWS-SECRETSMANAGER-1` flags secrets with no `lastAccessedDate` and secrets whose parsed last access is at least `90` days old. diff --git a/packages/rules/src/aws/apigateway/caching-disabled.ts b/packages/rules/src/aws/apigateway/caching-disabled.ts index 5ec908c..02c7eeb 100644 --- a/packages/rules/src/aws/apigateway/caching-disabled.ts +++ b/packages/rules/src/aws/apigateway/caching-disabled.ts @@ -12,8 +12,9 @@ export const apiGatewayCachingDisabledRule = createRule({ message: RULE_MESSAGE, provider: 'aws', service: RULE_SERVICE, - supports: ['discovery'], + supports: ['discovery', 'iac'], discoveryDependencies: ['aws-apigateway-stages'], + staticDependencies: ['aws-apigateway-stages'], evaluateLive: ({ resources }) => { const findings = resources .get('aws-apigateway-stages') @@ -22,4 +23,12 @@ export const apiGatewayCachingDisabledRule = createRule({ return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'discovery', findings); }, + evaluateStatic: ({ resources }) => { + const findings = resources + .get('aws-apigateway-stages') + .filter((stage) => stage.cacheClusterEnabled === false) + .map((stage) => createFindingMatch(stage.resourceId, undefined, undefined, stage.location)); + + return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'iac', findings); + }, }); diff --git a/packages/rules/src/aws/cloudfront/distribution-pricing-class.ts b/packages/rules/src/aws/cloudfront/distribution-pricing-class.ts index 543fa6c..f37c866 100644 --- a/packages/rules/src/aws/cloudfront/distribution-pricing-class.ts +++ b/packages/rules/src/aws/cloudfront/distribution-pricing-class.ts @@ -12,8 +12,9 @@ export const cloudFrontDistributionPricingClassRule = createRule({ message: RULE_MESSAGE, provider: 'aws', service: RULE_SERVICE, - supports: ['discovery'], + supports: ['discovery', 'iac'], discoveryDependencies: ['aws-cloudfront-distributions'], + staticDependencies: ['aws-cloudfront-distributions'], evaluateLive: ({ resources }) => { const findings = resources .get('aws-cloudfront-distributions') @@ -24,4 +25,12 @@ export const cloudFrontDistributionPricingClassRule = createRule({ return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'discovery', findings); }, + evaluateStatic: ({ resources }) => { + const findings = resources + .get('aws-cloudfront-distributions') + .filter((distribution) => distribution.priceClass === 'PriceClass_All') + .map((distribution) => createFindingMatch(distribution.resourceId, undefined, undefined, distribution.location)); + + return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'iac', findings); + }, }); diff --git a/packages/rules/src/aws/cloudwatch/log-group-retention.ts b/packages/rules/src/aws/cloudwatch/log-group-retention.ts index 2f67402..5c5f4d0 100644 --- a/packages/rules/src/aws/cloudwatch/log-group-retention.ts +++ b/packages/rules/src/aws/cloudwatch/log-group-retention.ts @@ -5,6 +5,11 @@ const RULE_SERVICE = 'cloudwatch'; const RULE_MESSAGE = 'CloudWatch log groups should define a retention policy unless AWS manages lifecycle automatically.'; +const hasMissingRetention = ( + retentionInDays: number | null | undefined, + logGroupClass: string | null | undefined, +): boolean => retentionInDays === undefined && logGroupClass !== 'DELIVERY'; + /** Flag CloudWatch log groups that do not define retention and are not delivery-managed. */ export const cloudWatchLogGroupRetentionRule = createRule({ id: RULE_ID, @@ -13,14 +18,23 @@ export const cloudWatchLogGroupRetentionRule = createRule({ message: RULE_MESSAGE, provider: 'aws', service: RULE_SERVICE, - supports: ['discovery'], + supports: ['discovery', 'iac'], discoveryDependencies: ['aws-cloudwatch-log-groups'], + staticDependencies: ['aws-cloudwatch-log-groups'], evaluateLive: ({ resources }) => { const findings = resources .get('aws-cloudwatch-log-groups') - .filter((logGroup) => logGroup.retentionInDays === undefined && logGroup.logGroupClass !== 'DELIVERY') + .filter((logGroup) => hasMissingRetention(logGroup.retentionInDays, logGroup.logGroupClass)) .map((logGroup) => createFindingMatch(logGroup.logGroupName, logGroup.region, logGroup.accountId)); return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'discovery', findings); }, + evaluateStatic: ({ resources }) => { + const findings = resources + .get('aws-cloudwatch-log-groups') + .filter((logGroup) => hasMissingRetention(logGroup.retentionInDays, logGroup.logGroupClass)) + .map((logGroup) => createFindingMatch(logGroup.resourceId, undefined, undefined, logGroup.location)); + + return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'iac', findings); + }, }); diff --git a/packages/rules/src/aws/dynamodb/table-without-autoscaling.ts b/packages/rules/src/aws/dynamodb/table-without-autoscaling.ts index 193cf32..f2fdd1b 100644 --- a/packages/rules/src/aws/dynamodb/table-without-autoscaling.ts +++ b/packages/rules/src/aws/dynamodb/table-without-autoscaling.ts @@ -15,8 +15,9 @@ export const dynamoDbTableWithoutAutoscalingRule = createRule({ message: RULE_MESSAGE, provider: 'aws', service: RULE_SERVICE, - supports: ['discovery'], + supports: ['discovery', 'iac'], discoveryDependencies: ['aws-dynamodb-tables', 'aws-dynamodb-autoscaling'], + staticDependencies: ['aws-dynamodb-tables', 'aws-dynamodb-autoscaling'], evaluateLive: ({ resources }) => { const autoscalingByTable = new Map( resources @@ -36,4 +37,22 @@ export const dynamoDbTableWithoutAutoscalingRule = createRule({ return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'discovery', findings); }, + 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); + return autoscaling ? !autoscaling.hasReadTarget && !autoscaling.hasWriteTarget : true; + }) + .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/ebs/high-iops-volume.ts b/packages/rules/src/aws/ebs/high-iops-volume.ts index 1db8019..a937dde 100644 --- a/packages/rules/src/aws/ebs/high-iops-volume.ts +++ b/packages/rules/src/aws/ebs/high-iops-volume.ts @@ -7,6 +7,14 @@ const HIGH_IOPS_VOLUME_TYPES = new Set(['io1', 'io2']); // Treat 32k IOPS as the threshold where provisioned io1/io2 volumes merit explicit review. const HIGH_IOPS_THRESHOLD = 32000; +const hasHighProvisionedIops = (volumeType: string | null | undefined, iops: number | null | undefined): boolean => + volumeType !== null && + volumeType !== undefined && + HIGH_IOPS_VOLUME_TYPES.has(volumeType) && + iops !== null && + iops !== undefined && + iops > HIGH_IOPS_THRESHOLD; + /** Flag io1 and io2 EBS volumes provisioned above the high-IOPS threshold. */ export const ebsHighIopsVolumeRule = createRule({ id: RULE_ID, @@ -15,19 +23,23 @@ export const ebsHighIopsVolumeRule = createRule({ message: RULE_MESSAGE, provider: 'aws', service: RULE_SERVICE, - supports: ['discovery'], + supports: ['discovery', 'iac'], discoveryDependencies: ['aws-ebs-volumes'], + staticDependencies: ['aws-ebs-volumes'], evaluateLive: ({ resources }) => { const findings = resources .get('aws-ebs-volumes') - .filter( - (volume) => - HIGH_IOPS_VOLUME_TYPES.has(volume.volumeType) && - volume.iops !== undefined && - volume.iops > HIGH_IOPS_THRESHOLD, - ) + .filter((volume) => hasHighProvisionedIops(volume.volumeType, volume.iops)) .map((volume) => createFindingMatch(volume.volumeId, volume.region, volume.accountId)); return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'discovery', findings); }, + evaluateStatic: ({ resources }) => { + const findings = resources + .get('aws-ebs-volumes') + .filter((volume) => hasHighProvisionedIops(volume.volumeType, volume.iops)) + .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/large-volume.ts b/packages/rules/src/aws/ebs/large-volume.ts index 796c78f..afaea88 100644 --- a/packages/rules/src/aws/ebs/large-volume.ts +++ b/packages/rules/src/aws/ebs/large-volume.ts @@ -6,6 +6,9 @@ const RULE_MESSAGE = 'EBS volumes larger than 100 GiB should be reviewed.'; // Treat volumes above 100 GiB as oversized enough to warrant an explicit cost review. const LARGE_VOLUME_SIZE_THRESHOLD_GIB = 100; +const isLargeEbsVolume = (sizeGiB: number | null | undefined): boolean => + sizeGiB !== null && sizeGiB !== undefined && sizeGiB > LARGE_VOLUME_SIZE_THRESHOLD_GIB; + /** Flag EBS volumes that exceed the large-volume review threshold. */ export const ebsLargeVolumeRule = createRule({ id: RULE_ID, @@ -14,14 +17,23 @@ export const ebsLargeVolumeRule = createRule({ message: RULE_MESSAGE, provider: 'aws', service: RULE_SERVICE, - supports: ['discovery'], + supports: ['discovery', 'iac'], discoveryDependencies: ['aws-ebs-volumes'], + staticDependencies: ['aws-ebs-volumes'], evaluateLive: ({ resources }) => { const findings = resources .get('aws-ebs-volumes') - .filter((volume) => volume.sizeGiB > LARGE_VOLUME_SIZE_THRESHOLD_GIB) + .filter((volume) => isLargeEbsVolume(volume.sizeGiB)) .map((volume) => createFindingMatch(volume.volumeId, volume.region, volume.accountId)); return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'discovery', findings); }, + evaluateStatic: ({ resources }) => { + const findings = resources + .get('aws-ebs-volumes') + .filter((volume) => isLargeEbsVolume(volume.sizeGiB)) + .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/low-iops-volume.ts b/packages/rules/src/aws/ebs/low-iops-volume.ts index 7095063..a98dac1 100644 --- a/packages/rules/src/aws/ebs/low-iops-volume.ts +++ b/packages/rules/src/aws/ebs/low-iops-volume.ts @@ -9,6 +9,14 @@ const GP3_CANDIDATE_VOLUME_TYPES = new Set(['io1', 'io2']); // Use IOPS-only eligibility for gp3 review. Throughput is intentionally out of scope for this heuristic. const GP3_MIGRATION_IOPS_THRESHOLD = 16000; +const isGp3MigrationCandidate = (volumeType: string | null | undefined, iops: number | null | undefined): boolean => + volumeType !== null && + volumeType !== undefined && + GP3_CANDIDATE_VOLUME_TYPES.has(volumeType) && + iops !== null && + iops !== undefined && + iops <= GP3_MIGRATION_IOPS_THRESHOLD; + /** Flag io1 and io2 EBS volumes that fall within the gp3 migration IOPS heuristic. */ export const ebsLowIopsVolumeRule = createRule({ id: RULE_ID, @@ -17,19 +25,23 @@ export const ebsLowIopsVolumeRule = createRule({ message: RULE_MESSAGE, provider: 'aws', service: RULE_SERVICE, - supports: ['discovery'], + supports: ['discovery', 'iac'], discoveryDependencies: ['aws-ebs-volumes'], + staticDependencies: ['aws-ebs-volumes'], evaluateLive: ({ resources }) => { const findings = resources .get('aws-ebs-volumes') - .filter( - (volume) => - GP3_CANDIDATE_VOLUME_TYPES.has(volume.volumeType) && - volume.iops !== undefined && - volume.iops <= GP3_MIGRATION_IOPS_THRESHOLD, - ) + .filter((volume) => isGp3MigrationCandidate(volume.volumeType, volume.iops)) .map((volume) => createFindingMatch(volume.volumeId, volume.region, volume.accountId)); return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'discovery', findings); }, + evaluateStatic: ({ resources }) => { + const findings = resources + .get('aws-ebs-volumes') + .filter((volume) => isGp3MigrationCandidate(volume.volumeType, volume.iops)) + .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/ec2/graviton-review.ts b/packages/rules/src/aws/ec2/graviton-review.ts index 44f9356..f2d7ef2 100644 --- a/packages/rules/src/aws/ec2/graviton-review.ts +++ b/packages/rules/src/aws/ec2/graviton-review.ts @@ -1,5 +1,8 @@ import { createFinding, createFindingMatch, createRule } from '../../shared/helpers.js'; -import { shouldReviewAwsEc2InstanceForGraviton } from './preferred-instance-families.js'; +import { + shouldReviewAwsEc2InstanceForGraviton, + shouldReviewAwsEc2InstanceTypeForGraviton, +} from './preferred-instance-families.js'; const RULE_ID = 'CLDBRN-AWS-EC2-6'; const RULE_SERVICE = 'ec2'; @@ -13,8 +16,9 @@ export const ec2GravitonReviewRule = createRule({ message: RULE_MESSAGE, provider: 'aws', service: RULE_SERVICE, - supports: ['discovery'], + supports: ['discovery', 'iac'], discoveryDependencies: ['aws-ec2-instances'], + staticDependencies: ['aws-ec2-instances'], evaluateLive: ({ resources }) => { const findings = resources .get('aws-ec2-instances') @@ -23,4 +27,15 @@ export const ec2GravitonReviewRule = createRule({ return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'discovery', findings); }, + evaluateStatic: ({ resources }) => { + const findings = resources + .get('aws-ec2-instances') + .filter( + (instance) => + instance.instanceType !== null && shouldReviewAwsEc2InstanceTypeForGraviton(instance.instanceType), + ) + .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/large-instance.ts b/packages/rules/src/aws/ec2/large-instance.ts index ab20058..44e02d7 100644 --- a/packages/rules/src/aws/ec2/large-instance.ts +++ b/packages/rules/src/aws/ec2/large-instance.ts @@ -6,7 +6,11 @@ const RULE_MESSAGE = 'EC2 large instances of 2xlarge or greater should be review // Treat 2xlarge and above as the right-sizing review threshold. const LARGE_INSTANCE_MIN_XLARGE_MULTIPLIER = 2; -const isLargeInstanceSize = (instanceType: string): boolean => { +const isLargeInstanceSize = (instanceType: string | null | undefined): boolean => { + if (!instanceType) { + return false; + } + const size = instanceType.split('.').slice(1).join('.').toLowerCase(); if (!size) { @@ -38,8 +42,9 @@ export const ec2LargeInstanceRule = createRule({ message: RULE_MESSAGE, provider: 'aws', service: RULE_SERVICE, - supports: ['discovery'], + supports: ['discovery', 'iac'], discoveryDependencies: ['aws-ec2-instances'], + staticDependencies: ['aws-ec2-instances'], evaluateLive: ({ resources }) => { const findings = resources .get('aws-ec2-instances') @@ -48,4 +53,12 @@ export const ec2LargeInstanceRule = createRule({ return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'discovery', findings); }, + evaluateStatic: ({ resources }) => { + const findings = resources + .get('aws-ec2-instances') + .filter((instance) => isLargeInstanceSize(instance.instanceType)) + .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/unassociated-elastic-ip.ts b/packages/rules/src/aws/ec2/unassociated-elastic-ip.ts index b31ca02..6038e7c 100644 --- a/packages/rules/src/aws/ec2/unassociated-elastic-ip.ts +++ b/packages/rules/src/aws/ec2/unassociated-elastic-ip.ts @@ -12,8 +12,9 @@ export const ec2UnassociatedElasticIpRule = createRule({ message: RULE_MESSAGE, provider: 'aws', service: RULE_SERVICE, - supports: ['discovery'], + supports: ['discovery', 'iac'], discoveryDependencies: ['aws-ec2-elastic-ips'], + staticDependencies: ['aws-ec2-elastic-ips'], evaluateLive: ({ resources }) => { const findings = resources .get('aws-ec2-elastic-ips') @@ -22,4 +23,12 @@ export const ec2UnassociatedElasticIpRule = createRule({ return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'discovery', findings); }, + evaluateStatic: ({ resources }) => { + const findings = resources + .get('aws-ec2-elastic-ips') + .filter((address) => !address.isAssociated) + .map((address) => createFindingMatch(address.resourceId, undefined, undefined, address.location)); + + return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'iac', findings); + }, }); diff --git a/packages/rules/src/aws/eks/graviton-review.ts b/packages/rules/src/aws/eks/graviton-review.ts index ba4b03f..bf14ad4 100644 --- a/packages/rules/src/aws/eks/graviton-review.ts +++ b/packages/rules/src/aws/eks/graviton-review.ts @@ -43,8 +43,9 @@ export const eksGravitonReviewRule = createRule({ message: RULE_MESSAGE, provider: 'aws', service: RULE_SERVICE, - supports: ['discovery'], + supports: ['discovery', 'iac'], discoveryDependencies: ['aws-eks-nodegroups'], + staticDependencies: ['aws-eks-nodegroups'], evaluateLive: ({ resources }) => { const findings = resources .get('aws-eks-nodegroups') @@ -53,4 +54,12 @@ export const eksGravitonReviewRule = createRule({ return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'discovery', findings); }, + evaluateStatic: ({ resources }) => { + const findings = resources + .get('aws-eks-nodegroups') + .filter((nodegroup) => shouldReviewNodegroupForGraviton(nodegroup.instanceTypes, nodegroup.amiType ?? undefined)) + .map((nodegroup) => createFindingMatch(nodegroup.resourceId, undefined, undefined, nodegroup.location)); + + return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'iac', findings); + }, }); diff --git a/packages/rules/src/aws/emr/previous-generation-instance-types.ts b/packages/rules/src/aws/emr/previous-generation-instance-types.ts index 6c32cee..0bc6f93 100644 --- a/packages/rules/src/aws/emr/previous-generation-instance-types.ts +++ b/packages/rules/src/aws/emr/previous-generation-instance-types.ts @@ -5,6 +5,9 @@ const RULE_ID = 'CLDBRN-AWS-EMR-1'; const RULE_SERVICE = 'emr'; const RULE_MESSAGE = 'EMR clusters using previous-generation instance types should be reviewed.'; +const hasPreviousGenerationInstanceType = (instanceTypes: string[]): boolean => + instanceTypes.some((instanceType) => getAwsEc2PreferredInstanceFamilyState(instanceType) === 'non-preferred'); + /** Flag EMR clusters that still rely on previous-generation EC2 instance types. */ export const emrPreviousGenerationInstanceTypeRule = createRule({ id: RULE_ID, @@ -13,21 +16,27 @@ export const emrPreviousGenerationInstanceTypeRule = createRule({ message: RULE_MESSAGE, provider: 'aws', service: RULE_SERVICE, - supports: ['discovery'], + supports: ['discovery', 'iac'], discoveryDependencies: ['aws-emr-clusters'], + staticDependencies: ['aws-emr-clusters'], evaluateLive: ({ resources }) => { const findings = resources .get('aws-emr-clusters') .filter( (cluster) => // Ended clusters are historical and no longer actionable for instance-family review. - cluster.endDateTime === undefined && - cluster.instanceTypes.some( - (instanceType) => getAwsEc2PreferredInstanceFamilyState(instanceType) === 'non-preferred', - ), + cluster.endDateTime === undefined && hasPreviousGenerationInstanceType(cluster.instanceTypes), ) .map((cluster) => createFindingMatch(cluster.clusterId, cluster.region, cluster.accountId)); return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'discovery', findings); }, + evaluateStatic: ({ resources }) => { + const findings = resources + .get('aws-emr-clusters') + .filter((cluster) => hasPreviousGenerationInstanceType(cluster.instanceTypes)) + .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/rds/graviton-review.ts b/packages/rules/src/aws/rds/graviton-review.ts index 86fc018..4579f4d 100644 --- a/packages/rules/src/aws/rds/graviton-review.ts +++ b/packages/rules/src/aws/rds/graviton-review.ts @@ -14,8 +14,9 @@ export const rdsGravitonReviewRule = createRule({ message: RULE_MESSAGE, provider: 'aws', service: RULE_SERVICE, - supports: ['discovery'], + supports: ['discovery', 'iac'], discoveryDependencies: ['aws-rds-instances'], + staticDependencies: ['aws-rds-instances'], evaluateLive: ({ resources }) => { const findings = resources .get('aws-rds-instances') @@ -28,4 +29,17 @@ export const rdsGravitonReviewRule = createRule({ return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'discovery', findings); }, + evaluateStatic: ({ resources }) => { + const findings = resources + .get('aws-rds-instances') + .filter( + (instance) => + instance.instanceClass !== null && + !isAwsRdsGravitonFamily(instance.instanceClass) && + shouldReviewAwsRdsInstanceClassForGraviton(instance.instanceClass), + ) + .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/rds/unsupported-engine-version.ts b/packages/rules/src/aws/rds/unsupported-engine-version.ts index 7662afa..be043cb 100644 --- a/packages/rules/src/aws/rds/unsupported-engine-version.ts +++ b/packages/rules/src/aws/rds/unsupported-engine-version.ts @@ -5,7 +5,7 @@ const RULE_SERVICE = 'rds'; const RULE_MESSAGE = 'RDS MySQL 5.7 and PostgreSQL 11 DB instances should be upgraded to avoid extended support charges.'; -const isUnsupportedRdsEngineVersion = (engine?: string, engineVersion?: string): boolean => { +const isUnsupportedRdsEngineVersion = (engine?: string | null, engineVersion?: string | null): boolean => { const normalizedEngine = engine?.toLowerCase(); if (!normalizedEngine || !engineVersion) { @@ -27,8 +27,9 @@ export const rdsUnsupportedEngineVersionRule = createRule({ message: RULE_MESSAGE, provider: 'aws', service: RULE_SERVICE, - supports: ['discovery'], + supports: ['discovery', 'iac'], discoveryDependencies: ['aws-rds-instances'], + staticDependencies: ['aws-rds-instances'], evaluateLive: ({ resources }) => { const findings = resources .get('aws-rds-instances') @@ -37,4 +38,12 @@ export const rdsUnsupportedEngineVersionRule = createRule({ return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'discovery', findings); }, + evaluateStatic: ({ resources }) => { + const findings = resources + .get('aws-rds-instances') + .filter((instance) => isUnsupportedRdsEngineVersion(instance.engine, instance.engineVersion)) + .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/route53/health-check-unused.ts b/packages/rules/src/aws/route53/health-check-unused.ts index 6abbb0b..70eecdb 100644 --- a/packages/rules/src/aws/route53/health-check-unused.ts +++ b/packages/rules/src/aws/route53/health-check-unused.ts @@ -12,8 +12,9 @@ export const route53HealthCheckUnusedRule = createRule({ message: RULE_MESSAGE, provider: 'aws', service: RULE_SERVICE, - supports: ['discovery'], + supports: ['discovery', 'iac'], discoveryDependencies: ['aws-route53-health-checks', 'aws-route53-records'], + staticDependencies: ['aws-route53-health-checks', 'aws-route53-records'], evaluateLive: ({ resources }) => { const referencedHealthChecks = new Set( resources.get('aws-route53-records').flatMap((record) => (record.healthCheckId ? [record.healthCheckId] : [])), @@ -31,4 +32,17 @@ export const route53HealthCheckUnusedRule = createRule({ return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'discovery', findings); }, + evaluateStatic: ({ resources }) => { + const referencedHealthChecks = new Set( + resources + .get('aws-route53-records') + .flatMap((record) => (record.referencedHealthCheckResourceId ? [record.referencedHealthCheckResourceId] : [])), + ); + const findings = resources + .get('aws-route53-health-checks') + .filter((healthCheck) => !referencedHealthChecks.has(healthCheck.resourceId)) + .map((healthCheck) => createFindingMatch(healthCheck.resourceId, undefined, undefined, healthCheck.location)); + + return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'iac', findings); + }, }); diff --git a/packages/rules/src/aws/route53/record-higher-ttl.ts b/packages/rules/src/aws/route53/record-higher-ttl.ts index 099f16a..b2bd048 100644 --- a/packages/rules/src/aws/route53/record-higher-ttl.ts +++ b/packages/rules/src/aws/route53/record-higher-ttl.ts @@ -6,6 +6,9 @@ const RULE_MESSAGE = 'Route 53 record sets should generally use TTL values of at // Match the upstream Route 53 guidance that treats one hour as the low-TTL review floor. const LOW_TTL_SECONDS = 3600; +const hasLowTtl = (ttl: number | null | undefined): ttl is number => + ttl !== undefined && ttl !== null && ttl < LOW_TTL_SECONDS; + /** Flag Route 53 record sets whose TTL is lower than the common one-hour baseline. */ export const route53RecordHigherTtlRule = createRule({ id: RULE_ID, @@ -14,8 +17,9 @@ export const route53RecordHigherTtlRule = createRule({ message: RULE_MESSAGE, provider: 'aws', service: RULE_SERVICE, - supports: ['discovery'], + supports: ['discovery', 'iac'], discoveryDependencies: ['aws-route53-zones', 'aws-route53-records'], + staticDependencies: ['aws-route53-records'], evaluateLive: ({ resources }) => { const knownZones = new Set(resources.get('aws-route53-zones').map((zone) => zone.hostedZoneId)); const findings = resources @@ -29,4 +33,12 @@ export const route53RecordHigherTtlRule = createRule({ return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'discovery', findings); }, + evaluateStatic: ({ resources }) => { + const findings = resources + .get('aws-route53-records') + .filter((record) => !record.isAlias && hasLowTtl(record.ttl)) + .map((record) => createFindingMatch(record.resourceId, undefined, undefined, record.location)); + + 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 0ac867f..345b286 100644 --- a/packages/rules/src/index.ts +++ b/packages/rules/src/index.ts @@ -67,12 +67,22 @@ export type { AwsS3BucketAnalysis, AwsS3BucketAnalysisFlags, AwsSecretsManagerSecret, + AwsStaticApiGatewayStage, + AwsStaticCloudFrontDistribution, + AwsStaticCloudWatchLogGroup, + AwsStaticDynamoDbAutoscaling, + AwsStaticDynamoDbTable, AwsStaticEbsVolume, + AwsStaticEc2ElasticIp, AwsStaticEc2Instance, AwsStaticEc2VpcEndpoint, AwsStaticEcrRepository, + AwsStaticEksNodegroup, + AwsStaticEmrCluster, AwsStaticLambdaFunction, AwsStaticRdsInstance, + AwsStaticRoute53HealthCheck, + AwsStaticRoute53Record, AwsStaticS3BucketAnalysis, CloudProvider, DiscoveryDatasetKey, diff --git a/packages/rules/src/shared/metadata.ts b/packages/rules/src/shared/metadata.ts index 3033af2..b876c3c 100644 --- a/packages/rules/src/shared/metadata.ts +++ b/packages/rules/src/shared/metadata.ts @@ -596,11 +596,21 @@ export type AwsDiscoveryCatalog = { /** Rule-facing live discovery dataset key exposed through the evaluation context. */ export type SharedDatasetKey = + | 'aws-apigateway-stages' + | 'aws-cloudfront-distributions' + | 'aws-cloudwatch-log-groups' + | 'aws-dynamodb-autoscaling' + | 'aws-dynamodb-tables' | 'aws-ebs-volumes' | 'aws-ecr-repositories' + | 'aws-ec2-elastic-ips' | 'aws-ec2-instances' + | 'aws-eks-nodegroups' + | 'aws-emr-clusters' | 'aws-lambda-functions' | 'aws-rds-instances' + | 'aws-route53-health-checks' + | 'aws-route53-records' | 'aws-s3-bucket-analyses'; /** Rule-facing live discovery dataset key exposed through the evaluation context. */ @@ -713,9 +723,48 @@ export type DiscoveryDatasetMap = { /** Rule-facing static IaC dataset key exposed through the evaluation context. */ export type StaticDatasetKey = SharedDatasetKey | 'aws-ec2-vpc-endpoints'; +/** Normalized static API Gateway stage dataset entry. */ +export type AwsStaticApiGatewayStage = { + resourceId: string; + cacheClusterEnabled: boolean | null; + location?: SourceLocation; +}; + +/** Normalized static CloudFront distribution dataset entry. */ +export type AwsStaticCloudFrontDistribution = { + resourceId: string; + priceClass: string | null; + location?: SourceLocation; +}; + +/** Normalized static CloudWatch log group dataset entry. */ +export type AwsStaticCloudWatchLogGroup = { + resourceId: string; + retentionInDays: number | null | undefined; + logGroupClass: string | null | undefined; + location?: SourceLocation; +}; + +/** Normalized static DynamoDB table dataset entry. */ +export type AwsStaticDynamoDbTable = { + resourceId: string; + tableName: string | null; + billingMode: 'PAY_PER_REQUEST' | 'PROVISIONED' | null; + location?: SourceLocation; +}; + +/** Normalized static DynamoDB table autoscaling dataset entry. */ +export type AwsStaticDynamoDbAutoscaling = { + tableName: string | null; + hasReadTarget: boolean; + hasWriteTarget: boolean; +}; + /** Normalized static EBS volume dataset entry with a precomputed finding target. */ export type AwsStaticEbsVolume = { resourceId: string; + sizeGiB: number | null; + iops: number | null; volumeType: string | null; location?: SourceLocation; }; @@ -734,10 +783,49 @@ export type AwsStaticEc2Instance = { location?: SourceLocation; }; +/** Normalized static Elastic IP dataset entry with derived association state. */ +export type AwsStaticEc2ElasticIp = { + resourceId: string; + isAssociated: boolean; + location?: SourceLocation; +}; + +/** Normalized static EKS node group dataset entry. */ +export type AwsStaticEksNodegroup = { + resourceId: string; + instanceTypes: string[]; + amiType: string | null; + location?: SourceLocation; +}; + +/** Normalized static EMR cluster dataset entry. */ +export type AwsStaticEmrCluster = { + resourceId: string; + instanceTypes: string[]; + location?: SourceLocation; +}; + +/** Normalized static Route 53 record dataset entry. */ +export type AwsStaticRoute53Record = { + resourceId: string; + isAlias: boolean; + ttl: number | null | undefined; + referencedHealthCheckResourceId: string | null; + location?: SourceLocation; +}; + +/** Normalized static Route 53 health check dataset entry. */ +export type AwsStaticRoute53HealthCheck = { + resourceId: string; + location?: SourceLocation; +}; + /** Normalized static RDS instance dataset entry with a precomputed finding target. */ export type AwsStaticRdsInstance = { resourceId: string; instanceClass: string | null; + engine: string | null; + engineVersion: string | null; location?: SourceLocation; }; @@ -764,12 +852,22 @@ export type AwsStaticS3BucketAnalysis = AwsS3BucketAnalysisFlags & { /** Normalized static datasets available to rule evaluators. */ export type StaticDatasetMap = { + 'aws-apigateway-stages': AwsStaticApiGatewayStage[]; + 'aws-cloudfront-distributions': AwsStaticCloudFrontDistribution[]; + 'aws-cloudwatch-log-groups': AwsStaticCloudWatchLogGroup[]; + 'aws-dynamodb-autoscaling': AwsStaticDynamoDbAutoscaling[]; + 'aws-dynamodb-tables': AwsStaticDynamoDbTable[]; 'aws-ebs-volumes': AwsStaticEbsVolume[]; 'aws-ecr-repositories': AwsStaticEcrRepository[]; + 'aws-ec2-elastic-ips': AwsStaticEc2ElasticIp[]; 'aws-ec2-instances': AwsStaticEc2Instance[]; + 'aws-eks-nodegroups': AwsStaticEksNodegroup[]; + 'aws-emr-clusters': AwsStaticEmrCluster[]; 'aws-lambda-functions': AwsStaticLambdaFunction[]; 'aws-ec2-vpc-endpoints': AwsStaticEc2VpcEndpoint[]; 'aws-rds-instances': AwsStaticRdsInstance[]; + 'aws-route53-health-checks': AwsStaticRoute53HealthCheck[]; + 'aws-route53-records': AwsStaticRoute53Record[]; 'aws-s3-bucket-analyses': AwsStaticS3BucketAnalysis[]; }; diff --git a/packages/rules/test/apigateway-caching-disabled.test.ts b/packages/rules/test/apigateway-caching-disabled.test.ts index ceeec33..d10c42b 100644 --- a/packages/rules/test/apigateway-caching-disabled.test.ts +++ b/packages/rules/test/apigateway-caching-disabled.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { apiGatewayCachingDisabledRule } from '../src/aws/apigateway/caching-disabled.js'; -import type { AwsApiGatewayStage } from '../src/index.js'; -import { LiveResourceBag } from '../src/index.js'; +import type { AwsApiGatewayStage, AwsStaticApiGatewayStage } from '../src/index.js'; +import { LiveResourceBag, StaticResourceBag } from '../src/index.js'; const createStage = (overrides: Partial = {}): AwsApiGatewayStage => ({ accountId: '123456789012', @@ -13,6 +13,12 @@ const createStage = (overrides: Partial = {}): AwsApiGateway ...overrides, }); +const createStaticStage = (overrides: Partial = {}): AwsStaticApiGatewayStage => ({ + resourceId: 'aws_api_gateway_stage.prod', + cacheClusterEnabled: false, + ...overrides, +}); + describe('apiGatewayCachingDisabledRule', () => { it('flags REST API stages with caching disabled', () => { const finding = apiGatewayCachingDisabledRule.evaluateLive?.({ @@ -49,4 +55,34 @@ describe('apiGatewayCachingDisabledRule', () => { expect(finding).toBeNull(); }); + + it('flags static REST API stages with caching disabled', () => { + const finding = apiGatewayCachingDisabledRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-apigateway-stages': [createStaticStage()], + }), + }); + + expect(finding?.findings).toEqual([ + { + resourceId: 'aws_api_gateway_stage.prod', + }, + ]); + }); + + it('does not flag static REST API stages with caching enabled or unknown state', () => { + const enabledFinding = apiGatewayCachingDisabledRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-apigateway-stages': [createStaticStage({ cacheClusterEnabled: true })], + }), + }); + const unknownFinding = apiGatewayCachingDisabledRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-apigateway-stages': [createStaticStage({ cacheClusterEnabled: null })], + }), + }); + + expect(enabledFinding).toBeNull(); + expect(unknownFinding).toBeNull(); + }); }); diff --git a/packages/rules/test/cloudfront-distribution-pricing-class.test.ts b/packages/rules/test/cloudfront-distribution-pricing-class.test.ts index 8c81b3c..8846aa2 100644 --- a/packages/rules/test/cloudfront-distribution-pricing-class.test.ts +++ b/packages/rules/test/cloudfront-distribution-pricing-class.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { cloudFrontDistributionPricingClassRule } from '../src/aws/cloudfront/distribution-pricing-class.js'; -import type { AwsCloudFrontDistribution } from '../src/index.js'; -import { LiveResourceBag } from '../src/index.js'; +import type { AwsCloudFrontDistribution, AwsStaticCloudFrontDistribution } from '../src/index.js'; +import { LiveResourceBag, StaticResourceBag } from '../src/index.js'; const createDistribution = (overrides: Partial = {}): AwsCloudFrontDistribution => ({ accountId: '123456789012', @@ -12,6 +12,14 @@ const createDistribution = (overrides: Partial = {}): ...overrides, }); +const createStaticDistribution = ( + overrides: Partial = {}, +): AwsStaticCloudFrontDistribution => ({ + resourceId: 'aws_cloudfront_distribution.cdn', + priceClass: 'PriceClass_All', + ...overrides, +}); + describe('cloudFrontDistributionPricingClassRule', () => { it('flags distributions using PriceClass_All', () => { const finding = cloudFrontDistributionPricingClassRule.evaluateLive?.({ @@ -48,4 +56,34 @@ describe('cloudFrontDistributionPricingClassRule', () => { expect(finding).toBeNull(); }); + + it('flags static distributions using PriceClass_All', () => { + const finding = cloudFrontDistributionPricingClassRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-cloudfront-distributions': [createStaticDistribution()], + }), + }); + + expect(finding?.findings).toEqual([ + { + resourceId: 'aws_cloudfront_distribution.cdn', + }, + ]); + }); + + it('skips static distributions with a narrower or unknown price class', () => { + const narrowerFinding = cloudFrontDistributionPricingClassRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-cloudfront-distributions': [createStaticDistribution({ priceClass: 'PriceClass_100' })], + }), + }); + const unknownFinding = cloudFrontDistributionPricingClassRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-cloudfront-distributions': [createStaticDistribution({ priceClass: null })], + }), + }); + + expect(narrowerFinding).toBeNull(); + expect(unknownFinding).toBeNull(); + }); }); diff --git a/packages/rules/test/cloudwatch-log-group-retention.test.ts b/packages/rules/test/cloudwatch-log-group-retention.test.ts index 711c387..fb712ba 100644 --- a/packages/rules/test/cloudwatch-log-group-retention.test.ts +++ b/packages/rules/test/cloudwatch-log-group-retention.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { cloudWatchLogGroupRetentionRule } from '../src/aws/cloudwatch/log-group-retention.js'; -import type { AwsCloudWatchLogGroup } from '../src/index.js'; -import { LiveResourceBag } from '../src/index.js'; +import type { AwsCloudWatchLogGroup, AwsStaticCloudWatchLogGroup } from '../src/index.js'; +import { LiveResourceBag, StaticResourceBag } from '../src/index.js'; const createLogGroup = (overrides: Partial = {}): AwsCloudWatchLogGroup => ({ accountId: '123456789012', @@ -11,6 +11,13 @@ const createLogGroup = (overrides: Partial = {}): AwsClou ...overrides, }); +const createStaticLogGroup = (overrides: Partial = {}): AwsStaticCloudWatchLogGroup => ({ + resourceId: 'aws_cloudwatch_log_group.app', + retentionInDays: undefined, + logGroupClass: undefined, + ...overrides, +}); + describe('cloudWatchLogGroupRetentionRule', () => { it('flags log groups without retention configured', () => { const finding = cloudWatchLogGroupRetentionRule.evaluateLive?.({ @@ -68,4 +75,46 @@ describe('cloudWatchLogGroupRetentionRule', () => { expect(finding).toBeNull(); }); + + it('flags static log groups without retention configured', () => { + const finding = cloudWatchLogGroupRetentionRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-cloudwatch-log-groups': [createStaticLogGroup()], + }), + }); + + expect(finding).toEqual({ + ruleId: 'CLDBRN-AWS-CLOUDWATCH-1', + service: 'cloudwatch', + source: 'iac', + message: 'CloudWatch log groups should define a retention policy unless AWS manages lifecycle automatically.', + findings: [ + { + resourceId: 'aws_cloudwatch_log_group.app', + }, + ], + }); + }); + + it('does not flag static log groups with retention, delivery class, or unresolved retention', () => { + const retentionFinding = cloudWatchLogGroupRetentionRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-cloudwatch-log-groups': [createStaticLogGroup({ retentionInDays: 30 })], + }), + }); + const deliveryFinding = cloudWatchLogGroupRetentionRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-cloudwatch-log-groups': [createStaticLogGroup({ logGroupClass: 'DELIVERY' })], + }), + }); + const unknownFinding = cloudWatchLogGroupRetentionRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-cloudwatch-log-groups': [createStaticLogGroup({ retentionInDays: null })], + }), + }); + + expect(retentionFinding).toBeNull(); + expect(deliveryFinding).toBeNull(); + expect(unknownFinding).toBeNull(); + }); }); diff --git a/packages/rules/test/dynamodb-table-without-autoscaling.test.ts b/packages/rules/test/dynamodb-table-without-autoscaling.test.ts index a357bd6..f513028 100644 --- a/packages/rules/test/dynamodb-table-without-autoscaling.test.ts +++ b/packages/rules/test/dynamodb-table-without-autoscaling.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from 'vitest'; import { dynamoDbTableWithoutAutoscalingRule } from '../src/aws/dynamodb/table-without-autoscaling.js'; -import type { AwsDynamoDbAutoscaling, AwsDynamoDbTable } from '../src/index.js'; -import { LiveResourceBag } from '../src/index.js'; +import type { + AwsDynamoDbAutoscaling, + AwsDynamoDbTable, + AwsStaticDynamoDbAutoscaling, + AwsStaticDynamoDbTable, +} from '../src/index.js'; +import { LiveResourceBag, StaticResourceBag } from '../src/index.js'; const createTable = (overrides: Partial = {}): AwsDynamoDbTable => ({ accountId: '123456789012', @@ -23,6 +28,22 @@ const createAutoscaling = (overrides: Partial = {}): Aws ...overrides, }); +const createStaticTable = (overrides: Partial = {}): AwsStaticDynamoDbTable => ({ + resourceId: 'aws_dynamodb_table.orders', + tableName: 'orders', + billingMode: 'PROVISIONED', + ...overrides, +}); + +const createStaticAutoscaling = ( + overrides: Partial = {}, +): AwsStaticDynamoDbAutoscaling => ({ + tableName: 'orders', + hasReadTarget: true, + hasWriteTarget: true, + ...overrides, +}); + describe('dynamoDbTableWithoutAutoscalingRule', () => { it('flags provisioned tables with no table-level autoscaling targets', () => { const finding = dynamoDbTableWithoutAutoscalingRule.evaluateLive?.({ @@ -61,4 +82,44 @@ describe('dynamoDbTableWithoutAutoscalingRule', () => { expect(finding).toBeNull(); }); + + it('flags static provisioned tables with no table-level autoscaling targets', () => { + const finding = dynamoDbTableWithoutAutoscalingRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-dynamodb-autoscaling': [createStaticAutoscaling({ hasReadTarget: false, hasWriteTarget: false })], + 'aws-dynamodb-tables': [createStaticTable()], + }), + }); + + expect(finding?.findings).toEqual([ + { + resourceId: 'aws_dynamodb_table.orders', + }, + ]); + }); + + it('skips static tables when autoscaling exists or billing mode is unknown/on-demand', () => { + const autoscaledFinding = dynamoDbTableWithoutAutoscalingRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-dynamodb-autoscaling': [createStaticAutoscaling()], + 'aws-dynamodb-tables': [createStaticTable()], + }), + }); + const onDemandFinding = dynamoDbTableWithoutAutoscalingRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-dynamodb-autoscaling': [], + 'aws-dynamodb-tables': [createStaticTable({ billingMode: 'PAY_PER_REQUEST' })], + }), + }); + const unknownFinding = dynamoDbTableWithoutAutoscalingRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-dynamodb-autoscaling': [], + 'aws-dynamodb-tables': [createStaticTable({ billingMode: null })], + }), + }); + + expect(autoscaledFinding).toBeNull(); + expect(onDemandFinding).toBeNull(); + expect(unknownFinding).toBeNull(); + }); }); diff --git a/packages/rules/test/ebs-high-iops-volume.test.ts b/packages/rules/test/ebs-high-iops-volume.test.ts index 7bc9f32..cebbeb9 100644 --- a/packages/rules/test/ebs-high-iops-volume.test.ts +++ b/packages/rules/test/ebs-high-iops-volume.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { ebsHighIopsVolumeRule } from '../src/aws/ebs/high-iops-volume.js'; -import type { AwsEbsVolume } from '../src/index.js'; -import { LiveResourceBag } from '../src/index.js'; +import type { AwsEbsVolume, AwsStaticEbsVolume } from '../src/index.js'; +import { LiveResourceBag, StaticResourceBag } from '../src/index.js'; const createVolume = (overrides: Partial = {}): AwsEbsVolume => ({ accountId: '123456789012', @@ -14,6 +14,14 @@ const createVolume = (overrides: Partial = {}): AwsEbsVolume => ({ ...overrides, }); +const createStaticVolume = (overrides: Partial = {}): AwsStaticEbsVolume => ({ + resourceId: 'aws_ebs_volume.logs', + volumeType: 'io2', + sizeGiB: 200, + iops: 40000, + ...overrides, +}); + describe('ebsHighIopsVolumeRule', () => { it('flags io1 and io2 volumes above the high-IOPS review threshold', () => { const finding = ebsHighIopsVolumeRule.evaluateLive?.({ @@ -27,8 +35,9 @@ describe('ebsHighIopsVolumeRule', () => { }), }); - expect(ebsHighIopsVolumeRule.supports).toEqual(['discovery']); + expect(ebsHighIopsVolumeRule.supports).toEqual(['discovery', 'iac']); expect(ebsHighIopsVolumeRule.discoveryDependencies).toEqual(['aws-ebs-volumes']); + expect(ebsHighIopsVolumeRule.staticDependencies).toEqual(['aws-ebs-volumes']); expect(finding).toEqual({ ruleId: 'CLDBRN-AWS-EBS-5', service: 'ebs', @@ -58,4 +67,37 @@ describe('ebsHighIopsVolumeRule', () => { expect(finding).toBeNull(); }); + + it('flags static io1 and io2 volumes above the high-IOPS review threshold', () => { + const finding = ebsHighIopsVolumeRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-ebs-volumes': [createStaticVolume()], + }), + }); + + expect(finding).toEqual({ + ruleId: 'CLDBRN-AWS-EBS-5', + service: 'ebs', + source: 'iac', + message: 'EBS io1 and io2 volumes above 32000 IOPS should be reviewed.', + findings: [ + { + resourceId: 'aws_ebs_volume.logs', + }, + ], + }); + }); + + it('skips static non-io1/io2 volumes or volumes at the threshold', () => { + const finding = ebsHighIopsVolumeRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-ebs-volumes': [ + createStaticVolume({ iops: 32000 }), + createStaticVolume({ resourceId: 'aws_ebs_volume.data', volumeType: 'gp3' }), + ], + }), + }); + + expect(finding).toBeNull(); + }); }); diff --git a/packages/rules/test/ebs-large-volume.test.ts b/packages/rules/test/ebs-large-volume.test.ts index a46786f..9836a7b 100644 --- a/packages/rules/test/ebs-large-volume.test.ts +++ b/packages/rules/test/ebs-large-volume.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { ebsLargeVolumeRule } from '../src/aws/ebs/large-volume.js'; -import type { AwsEbsVolume } from '../src/index.js'; -import { LiveResourceBag } from '../src/index.js'; +import type { AwsEbsVolume, AwsStaticEbsVolume } from '../src/index.js'; +import { LiveResourceBag, StaticResourceBag } from '../src/index.js'; const createVolume = (overrides: Partial = {}): AwsEbsVolume => ({ accountId: '123456789012', @@ -14,6 +14,14 @@ const createVolume = (overrides: Partial = {}): AwsEbsVolume => ({ ...overrides, }); +const createStaticVolume = (overrides: Partial = {}): AwsStaticEbsVolume => ({ + resourceId: 'aws_ebs_volume.logs', + volumeType: 'gp3', + sizeGiB: 200, + iops: 3000, + ...overrides, +}); + describe('ebsLargeVolumeRule', () => { it('flags volumes larger than the oversized-volume threshold', () => { const finding = ebsLargeVolumeRule.evaluateLive?.({ @@ -27,8 +35,9 @@ describe('ebsLargeVolumeRule', () => { }), }); - expect(ebsLargeVolumeRule.supports).toEqual(['discovery']); + expect(ebsLargeVolumeRule.supports).toEqual(['discovery', 'iac']); expect(ebsLargeVolumeRule.discoveryDependencies).toEqual(['aws-ebs-volumes']); + expect(ebsLargeVolumeRule.staticDependencies).toEqual(['aws-ebs-volumes']); expect(finding).toEqual({ ruleId: 'CLDBRN-AWS-EBS-4', service: 'ebs', @@ -58,4 +67,34 @@ describe('ebsLargeVolumeRule', () => { expect(finding).toBeNull(); }); + + it('flags static volumes larger than the oversized-volume threshold', () => { + const finding = ebsLargeVolumeRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-ebs-volumes': [createStaticVolume()], + }), + }); + + expect(finding).toEqual({ + ruleId: 'CLDBRN-AWS-EBS-4', + service: 'ebs', + source: 'iac', + message: 'EBS volumes larger than 100 GiB should be reviewed.', + findings: [ + { + resourceId: 'aws_ebs_volume.logs', + }, + ], + }); + }); + + it('skips static volumes at or below the oversized-volume threshold', () => { + const finding = ebsLargeVolumeRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-ebs-volumes': [createStaticVolume({ sizeGiB: 100 })], + }), + }); + + expect(finding).toBeNull(); + }); }); diff --git a/packages/rules/test/ebs-low-iops-volume.test.ts b/packages/rules/test/ebs-low-iops-volume.test.ts index 5595579..beea1d5 100644 --- a/packages/rules/test/ebs-low-iops-volume.test.ts +++ b/packages/rules/test/ebs-low-iops-volume.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { ebsLowIopsVolumeRule } from '../src/aws/ebs/low-iops-volume.js'; -import type { AwsEbsVolume } from '../src/index.js'; -import { LiveResourceBag } from '../src/index.js'; +import type { AwsEbsVolume, AwsStaticEbsVolume } from '../src/index.js'; +import { LiveResourceBag, StaticResourceBag } from '../src/index.js'; const createVolume = (overrides: Partial = {}): AwsEbsVolume => ({ accountId: '123456789012', @@ -14,6 +14,14 @@ const createVolume = (overrides: Partial = {}): AwsEbsVolume => ({ ...overrides, }); +const createStaticVolume = (overrides: Partial = {}): AwsStaticEbsVolume => ({ + resourceId: 'aws_ebs_volume.logs', + volumeType: 'io1', + sizeGiB: 200, + iops: 16000, + ...overrides, +}); + describe('ebsLowIopsVolumeRule', () => { it('flags io1 and io2 volumes that stay within the gp3 IOPS heuristic', () => { const finding = ebsLowIopsVolumeRule.evaluateLive?.({ @@ -27,8 +35,9 @@ describe('ebsLowIopsVolumeRule', () => { }), }); - expect(ebsLowIopsVolumeRule.supports).toEqual(['discovery']); + expect(ebsLowIopsVolumeRule.supports).toEqual(['discovery', 'iac']); expect(ebsLowIopsVolumeRule.discoveryDependencies).toEqual(['aws-ebs-volumes']); + expect(ebsLowIopsVolumeRule.staticDependencies).toEqual(['aws-ebs-volumes']); expect(finding).toEqual({ ruleId: 'CLDBRN-AWS-EBS-6', service: 'ebs', @@ -58,4 +67,37 @@ describe('ebsLowIopsVolumeRule', () => { expect(finding).toBeNull(); }); + + it('flags static io1 and io2 volumes that stay within the gp3 IOPS heuristic', () => { + const finding = ebsLowIopsVolumeRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-ebs-volumes': [createStaticVolume()], + }), + }); + + expect(finding).toEqual({ + ruleId: 'CLDBRN-AWS-EBS-6', + service: 'ebs', + source: 'iac', + message: 'EBS io1 and io2 volumes at 16000 IOPS or below should be reviewed for gp3.', + findings: [ + { + resourceId: 'aws_ebs_volume.logs', + }, + ], + }); + }); + + it('skips static volumes above the gp3 IOPS heuristic or other volume types', () => { + const finding = ebsLowIopsVolumeRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-ebs-volumes': [ + createStaticVolume({ iops: 16001 }), + createStaticVolume({ resourceId: 'aws_ebs_volume.data', volumeType: 'gp3' }), + ], + }), + }); + + expect(finding).toBeNull(); + }); }); diff --git a/packages/rules/test/ec2-graviton-review.test.ts b/packages/rules/test/ec2-graviton-review.test.ts index 3fd20dd..4b9fa29 100644 --- a/packages/rules/test/ec2-graviton-review.test.ts +++ b/packages/rules/test/ec2-graviton-review.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { ec2GravitonReviewRule } from '../src/aws/ec2/graviton-review.js'; -import type { AwsEc2Instance } from '../src/index.js'; -import { LiveResourceBag } from '../src/index.js'; +import type { AwsEc2Instance, AwsStaticEc2Instance } from '../src/index.js'; +import { LiveResourceBag, StaticResourceBag } from '../src/index.js'; const createInstance = (overrides: Partial = {}): AwsEc2Instance => ({ accountId: '123456789012', @@ -12,6 +12,12 @@ const createInstance = (overrides: Partial = {}): AwsEc2Instance ...overrides, }); +const createStaticInstance = (overrides: Partial = {}): AwsStaticEc2Instance => ({ + resourceId: 'aws_instance.app', + instanceType: 'm7i.large', + ...overrides, +}); + describe('ec2GravitonReviewRule', () => { it('flags non-Graviton instances with a clear Arm equivalent', () => { const finding = ec2GravitonReviewRule.evaluateLive?.({ @@ -40,6 +46,26 @@ describe('ec2GravitonReviewRule', () => { }); }); + it('flags static instances with a clear Arm equivalent from the instance family alone', () => { + const finding = ec2GravitonReviewRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-ec2-instances': [createStaticInstance()], + }), + }); + + expect(finding).toEqual({ + ruleId: 'CLDBRN-AWS-EC2-6', + service: 'ec2', + source: 'iac', + message: 'EC2 instances without a Graviton equivalent in use should be reviewed.', + findings: [ + { + resourceId: 'aws_instance.app', + }, + ], + }); + }); + it('skips instances that already run on Graviton', () => { const finding = ec2GravitonReviewRule.evaluateLive?.({ catalog: { @@ -69,4 +95,20 @@ describe('ec2GravitonReviewRule', () => { expect(finding).toBeNull(); }); + + it('skips static Graviton or unclassified instance families', () => { + const gravitonFinding = ec2GravitonReviewRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-ec2-instances': [createStaticInstance({ instanceType: 'm7g.large' })], + }), + }); + const unclassifiedFinding = ec2GravitonReviewRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-ec2-instances': [createStaticInstance({ instanceType: 'u-12tb1.metal' })], + }), + }); + + expect(gravitonFinding).toBeNull(); + expect(unclassifiedFinding).toBeNull(); + }); }); diff --git a/packages/rules/test/ec2-large-instance.test.ts b/packages/rules/test/ec2-large-instance.test.ts index ab87578..7abc529 100644 --- a/packages/rules/test/ec2-large-instance.test.ts +++ b/packages/rules/test/ec2-large-instance.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { ec2LargeInstanceRule } from '../src/aws/ec2/large-instance.js'; -import type { AwsEc2Instance } from '../src/index.js'; -import { LiveResourceBag } from '../src/index.js'; +import type { AwsEc2Instance, AwsStaticEc2Instance } from '../src/index.js'; +import { LiveResourceBag, StaticResourceBag } from '../src/index.js'; const createInstance = (overrides: Partial = {}): AwsEc2Instance => ({ accountId: '123456789012', @@ -11,6 +11,12 @@ const createInstance = (overrides: Partial = {}): AwsEc2Instance ...overrides, }); +const createStaticInstance = (overrides: Partial = {}): AwsStaticEc2Instance => ({ + resourceId: 'aws_instance.app', + instanceType: 'm7i.2xlarge', + ...overrides, +}); + describe('ec2LargeInstanceRule', () => { it('flags instances sized at 2xlarge and above', () => { const finding = ec2LargeInstanceRule.evaluateLive?.({ @@ -39,6 +45,26 @@ describe('ec2LargeInstanceRule', () => { }); }); + it('flags static instances sized at 2xlarge and above', () => { + const finding = ec2LargeInstanceRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-ec2-instances': [createStaticInstance()], + }), + }); + + expect(finding).toEqual({ + ruleId: 'CLDBRN-AWS-EC2-8', + service: 'ec2', + source: 'iac', + message: 'EC2 large instances of 2xlarge or greater should be reviewed.', + findings: [ + { + resourceId: 'aws_instance.app', + }, + ], + }); + }); + it('skips instances below the large-instance threshold', () => { const finding = ec2LargeInstanceRule.evaluateLive?.({ catalog: { @@ -74,4 +100,14 @@ describe('ec2LargeInstanceRule', () => { }, ]); }); + + it('skips static instances below the large-instance threshold', () => { + const finding = ec2LargeInstanceRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-ec2-instances': [createStaticInstance({ instanceType: 'm7i.xlarge' })], + }), + }); + + expect(finding).toBeNull(); + }); }); diff --git a/packages/rules/test/ec2-unassociated-elastic-ip.test.ts b/packages/rules/test/ec2-unassociated-elastic-ip.test.ts index bf61832..9c3f6ce 100644 --- a/packages/rules/test/ec2-unassociated-elastic-ip.test.ts +++ b/packages/rules/test/ec2-unassociated-elastic-ip.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { ec2UnassociatedElasticIpRule } from '../src/aws/ec2/unassociated-elastic-ip.js'; -import type { AwsEc2ElasticIp } from '../src/index.js'; -import { LiveResourceBag } from '../src/index.js'; +import type { AwsEc2ElasticIp, AwsStaticEc2ElasticIp } from '../src/index.js'; +import { LiveResourceBag, StaticResourceBag } from '../src/index.js'; const createElasticIp = (overrides: Partial = {}): AwsEc2ElasticIp => ({ accountId: '123456789012', @@ -11,6 +11,12 @@ const createElasticIp = (overrides: Partial = {}): AwsEc2Elasti ...overrides, }); +const createStaticElasticIp = (overrides: Partial = {}): AwsStaticEc2ElasticIp => ({ + resourceId: 'aws_eip.public', + isAssociated: false, + ...overrides, +}); + describe('ec2UnassociatedElasticIpRule', () => { it('flags unassociated elastic IPs in discovery mode', () => { const finding = ec2UnassociatedElasticIpRule.evaluateLive?.({ @@ -53,4 +59,34 @@ describe('ec2UnassociatedElasticIpRule', () => { expect(finding).toBeNull(); }); + + it('flags unassociated elastic IPs in static mode', () => { + const finding = ec2UnassociatedElasticIpRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-ec2-elastic-ips': [createStaticElasticIp()], + }), + }); + + expect(finding).toEqual({ + ruleId: 'CLDBRN-AWS-EC2-3', + service: 'ec2', + source: 'iac', + message: 'Elastic IP addresses should not remain unassociated.', + findings: [ + { + resourceId: 'aws_eip.public', + }, + ], + }); + }); + + it('skips associated elastic IPs in static mode', () => { + const finding = ec2UnassociatedElasticIpRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-ec2-elastic-ips': [createStaticElasticIp({ isAssociated: true })], + }), + }); + + expect(finding).toBeNull(); + }); }); diff --git a/packages/rules/test/eks-graviton-review.test.ts b/packages/rules/test/eks-graviton-review.test.ts index ded317a..c4cc637 100644 --- a/packages/rules/test/eks-graviton-review.test.ts +++ b/packages/rules/test/eks-graviton-review.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { eksGravitonReviewRule } from '../src/aws/eks/graviton-review.js'; -import type { AwsEksNodegroup } from '../src/index.js'; -import { LiveResourceBag } from '../src/index.js'; +import type { AwsEksNodegroup, AwsStaticEksNodegroup } from '../src/index.js'; +import { LiveResourceBag, StaticResourceBag } from '../src/index.js'; const createNodegroup = (overrides: Partial = {}): AwsEksNodegroup => ({ accountId: '123456789012', @@ -15,6 +15,13 @@ const createNodegroup = (overrides: Partial = {}): AwsEksNodegr ...overrides, }); +const createStaticNodegroup = (overrides: Partial = {}): AwsStaticEksNodegroup => ({ + amiType: 'AL2023_x86_64_STANDARD', + instanceTypes: ['m7i.large'], + resourceId: 'aws_eks_node_group.workers', + ...overrides, +}); + describe('eksGravitonReviewRule', () => { it('flags reviewable non-Graviton EKS node groups', () => { const finding = eksGravitonReviewRule.evaluateLive?.({ @@ -69,4 +76,48 @@ describe('eksGravitonReviewRule', () => { expect(finding).toBeNull(); }); + + it('flags static reviewable non-Graviton EKS node groups', () => { + const finding = eksGravitonReviewRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-eks-nodegroups': [createStaticNodegroup()], + }), + }); + + expect(finding).toEqual({ + ruleId: 'CLDBRN-AWS-EKS-1', + service: 'eks', + source: 'iac', + message: 'EKS node groups without a Graviton equivalent in use should be reviewed.', + findings: [ + { + resourceId: 'aws_eks_node_group.workers', + }, + ], + }); + }); + + it('skips static Arm, empty, and unclassified node groups conservatively', () => { + const finding = eksGravitonReviewRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-eks-nodegroups': [ + createStaticNodegroup({ + amiType: 'AL2023_ARM_64_STANDARD', + instanceTypes: ['m7g.large'], + resourceId: 'aws_eks_node_group.arm', + }), + createStaticNodegroup({ + instanceTypes: [], + resourceId: 'aws_eks_node_group.empty', + }), + createStaticNodegroup({ + instanceTypes: ['u-12tb1.metal'], + resourceId: 'aws_eks_node_group.special', + }), + ], + }), + }); + + expect(finding).toBeNull(); + }); }); diff --git a/packages/rules/test/emr-previous-generation-instance-types.test.ts b/packages/rules/test/emr-previous-generation-instance-types.test.ts index 82caf44..32a9e43 100644 --- a/packages/rules/test/emr-previous-generation-instance-types.test.ts +++ b/packages/rules/test/emr-previous-generation-instance-types.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { emrPreviousGenerationInstanceTypeRule } from '../src/aws/emr/previous-generation-instance-types.js'; -import type { AwsEmrCluster } from '../src/index.js'; -import { LiveResourceBag } from '../src/index.js'; +import type { AwsEmrCluster, AwsStaticEmrCluster } from '../src/index.js'; +import { LiveResourceBag, StaticResourceBag } from '../src/index.js'; const createCluster = (overrides: Partial = {}): AwsEmrCluster => ({ accountId: '123456789012', @@ -15,6 +15,12 @@ const createCluster = (overrides: Partial = {}): AwsEmrCluster => ...overrides, }); +const createStaticCluster = (overrides: Partial = {}): AwsStaticEmrCluster => ({ + instanceTypes: ['m6i.xlarge'], + resourceId: 'aws_emr_cluster.analytics', + ...overrides, +}); + describe('emrPreviousGenerationInstanceTypeRule', () => { it('flags EMR clusters that still use previous-generation instance types', () => { const finding = emrPreviousGenerationInstanceTypeRule.evaluateLive?.({ @@ -78,4 +84,40 @@ describe('emrPreviousGenerationInstanceTypeRule', () => { expect(finding).toBeNull(); }); + + it('flags static EMR clusters that still use previous-generation instance types', () => { + const finding = emrPreviousGenerationInstanceTypeRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-emr-clusters': [createStaticCluster()], + }), + }); + + expect(finding).toEqual({ + ruleId: 'CLDBRN-AWS-EMR-1', + service: 'emr', + source: 'iac', + message: 'EMR clusters using previous-generation instance types should be reviewed.', + findings: [ + { + resourceId: 'aws_emr_cluster.analytics', + }, + ], + }); + }); + + it('skips static EMR clusters that only use current-generation or unresolved instance types', () => { + const currentFinding = emrPreviousGenerationInstanceTypeRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-emr-clusters': [createStaticCluster({ instanceTypes: ['m8g.xlarge', 'r8g.2xlarge'] })], + }), + }); + const unresolvedFinding = emrPreviousGenerationInstanceTypeRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-emr-clusters': [createStaticCluster({ instanceTypes: [] })], + }), + }); + + expect(currentFinding).toBeNull(); + expect(unresolvedFinding).toBeNull(); + }); }); diff --git a/packages/rules/test/rds-graviton-review.test.ts b/packages/rules/test/rds-graviton-review.test.ts index 4c71bb0..01baa8e 100644 --- a/packages/rules/test/rds-graviton-review.test.ts +++ b/packages/rules/test/rds-graviton-review.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { rdsGravitonReviewRule } from '../src/aws/rds/graviton-review.js'; -import type { AwsRdsInstance } from '../src/index.js'; -import { LiveResourceBag } from '../src/index.js'; +import type { AwsRdsInstance, AwsStaticRdsInstance } from '../src/index.js'; +import { LiveResourceBag, StaticResourceBag } from '../src/index.js'; const createInstance = (overrides: Partial = {}): AwsRdsInstance => ({ accountId: '123456789012', @@ -16,6 +16,14 @@ const createInstance = (overrides: Partial = {}): AwsRdsInstance ...overrides, }); +const createStaticInstance = (overrides: Partial = {}): AwsStaticRdsInstance => ({ + resourceId: 'aws_db_instance.primary', + instanceClass: 'db.m6i.large', + engine: 'mysql', + engineVersion: '8.0.39', + ...overrides, +}); + describe('rdsGravitonReviewRule', () => { it('flags RDS instance classes with a curated Graviton equivalent', () => { const finding = rdsGravitonReviewRule.evaluateLive?.({ @@ -38,6 +46,20 @@ describe('rdsGravitonReviewRule', () => { ]); }); + it('flags static RDS instance classes with a curated Graviton equivalent', () => { + const finding = rdsGravitonReviewRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-rds-instances': [createStaticInstance()], + }), + }); + + expect(finding?.findings).toEqual([ + { + resourceId: 'aws_db_instance.primary', + }, + ]); + }); + it('skips RDS instance classes already using Graviton families', () => { const finding = rdsGravitonReviewRule.evaluateLive?.({ catalog: { @@ -67,4 +89,20 @@ describe('rdsGravitonReviewRule', () => { expect(finding).toBeNull(); }); + + it('skips static Graviton or unclassified RDS instance families', () => { + const gravitonFinding = rdsGravitonReviewRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-rds-instances': [createStaticInstance({ instanceClass: 'db.m7g.large' })], + }), + }); + const unclassifiedFinding = rdsGravitonReviewRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-rds-instances': [createStaticInstance({ instanceClass: 'db.x2g.large' })], + }), + }); + + expect(gravitonFinding).toBeNull(); + expect(unclassifiedFinding).toBeNull(); + }); }); diff --git a/packages/rules/test/rds-unsupported-engine-version.test.ts b/packages/rules/test/rds-unsupported-engine-version.test.ts index 8b0d167..5c5b41c 100644 --- a/packages/rules/test/rds-unsupported-engine-version.test.ts +++ b/packages/rules/test/rds-unsupported-engine-version.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { rdsUnsupportedEngineVersionRule } from '../src/aws/rds/unsupported-engine-version.js'; -import type { AwsRdsInstance } from '../src/index.js'; -import { LiveResourceBag } from '../src/index.js'; +import type { AwsRdsInstance, AwsStaticRdsInstance } from '../src/index.js'; +import { LiveResourceBag, StaticResourceBag } from '../src/index.js'; const createInstance = (overrides: Partial = {}): AwsRdsInstance => ({ accountId: '123456789012', @@ -16,6 +16,14 @@ const createInstance = (overrides: Partial = {}): AwsRdsInstance ...overrides, }); +const createStaticInstance = (overrides: Partial = {}): AwsStaticRdsInstance => ({ + resourceId: 'aws_db_instance.legacy', + instanceClass: 'db.m6i.large', + engine: 'mysql', + engineVersion: '5.7.44', + ...overrides, +}); + describe('rdsUnsupportedEngineVersionRule', () => { it('flags MySQL 5.7 instances that can incur extended support charges', () => { const finding = rdsUnsupportedEngineVersionRule.evaluateLive?.({ @@ -38,6 +46,20 @@ describe('rdsUnsupportedEngineVersionRule', () => { ]); }); + it('flags static MySQL 5.7 instances that can incur extended support charges', () => { + const finding = rdsUnsupportedEngineVersionRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-rds-instances': [createStaticInstance()], + }), + }); + + expect(finding?.findings).toEqual([ + { + resourceId: 'aws_db_instance.legacy', + }, + ]); + }); + it('flags PostgreSQL 11 instances that can incur extended support charges', () => { const finding = rdsUnsupportedEngineVersionRule.evaluateLive?.({ catalog: { @@ -73,4 +95,20 @@ describe('rdsUnsupportedEngineVersionRule', () => { expect(finding).toBeNull(); }); + + it('skips static supported or unknown engine versions', () => { + const supportedFinding = rdsUnsupportedEngineVersionRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-rds-instances': [createStaticInstance({ engineVersion: '8.0.39' })], + }), + }); + const unknownFinding = rdsUnsupportedEngineVersionRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-rds-instances': [createStaticInstance({ engine: null, engineVersion: null })], + }), + }); + + expect(supportedFinding).toBeNull(); + expect(unknownFinding).toBeNull(); + }); }); diff --git a/packages/rules/test/route53-health-check-unused.test.ts b/packages/rules/test/route53-health-check-unused.test.ts index 5b300db..e8f7ef3 100644 --- a/packages/rules/test/route53-health-check-unused.test.ts +++ b/packages/rules/test/route53-health-check-unused.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from 'vitest'; import { route53HealthCheckUnusedRule } from '../src/aws/route53/health-check-unused.js'; -import type { AwsRoute53HealthCheck, AwsRoute53Record } from '../src/index.js'; -import { LiveResourceBag } from '../src/index.js'; +import type { + AwsRoute53HealthCheck, + AwsRoute53Record, + AwsStaticRoute53HealthCheck, + AwsStaticRoute53Record, +} from '../src/index.js'; +import { LiveResourceBag, StaticResourceBag } from '../src/index.js'; const createHealthCheck = (overrides: Partial = {}): AwsRoute53HealthCheck => ({ accountId: '123456789012', @@ -24,6 +29,21 @@ const createRecord = (overrides: Partial = {}): AwsRoute53Reco ...overrides, }); +const createStaticHealthCheck = ( + overrides: Partial = {}, +): AwsStaticRoute53HealthCheck => ({ + resourceId: 'aws_route53_health_check.api', + ...overrides, +}); + +const createStaticRecord = (overrides: Partial = {}): AwsStaticRoute53Record => ({ + isAlias: false, + referencedHealthCheckResourceId: 'aws_route53_health_check.api', + resourceId: 'aws_route53_record.api', + ttl: 300, + ...overrides, +}); + describe('route53HealthCheckUnusedRule', () => { it('flags health checks that are not associated with any record set', () => { const finding = route53HealthCheckUnusedRule.evaluateLive?.({ @@ -61,4 +81,30 @@ describe('route53HealthCheckUnusedRule', () => { expect(finding).toBeNull(); }); + + it('flags static health checks that are not associated with any record set', () => { + const finding = route53HealthCheckUnusedRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-route53-health-checks': [createStaticHealthCheck()], + 'aws-route53-records': [], + }), + }); + + expect(finding?.findings).toEqual([ + { + resourceId: 'aws_route53_health_check.api', + }, + ]); + }); + + it('skips static health checks already referenced by a local record set', () => { + const finding = route53HealthCheckUnusedRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-route53-health-checks': [createStaticHealthCheck()], + 'aws-route53-records': [createStaticRecord()], + }), + }); + + expect(finding).toBeNull(); + }); }); diff --git a/packages/rules/test/route53-record-higher-ttl.test.ts b/packages/rules/test/route53-record-higher-ttl.test.ts index c298729..5977a8e 100644 --- a/packages/rules/test/route53-record-higher-ttl.test.ts +++ b/packages/rules/test/route53-record-higher-ttl.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { route53RecordHigherTtlRule } from '../src/aws/route53/record-higher-ttl.js'; -import type { AwsRoute53Record, AwsRoute53Zone } from '../src/index.js'; -import { LiveResourceBag } from '../src/index.js'; +import type { AwsRoute53Record, AwsRoute53Zone, AwsStaticRoute53Record } from '../src/index.js'; +import { LiveResourceBag, StaticResourceBag } from '../src/index.js'; const createZone = (overrides: Partial = {}): AwsRoute53Zone => ({ accountId: '123456789012', @@ -25,6 +25,14 @@ const createRecord = (overrides: Partial = {}): AwsRoute53Reco ...overrides, }); +const createStaticRecord = (overrides: Partial = {}): AwsStaticRoute53Record => ({ + isAlias: false, + referencedHealthCheckResourceId: null, + resourceId: 'aws_route53_record.api', + ttl: 300, + ...overrides, +}); + describe('route53RecordHigherTtlRule', () => { it('flags non-alias records with a TTL below 3600 seconds', () => { const finding = route53RecordHigherTtlRule.evaluateLive?.({ @@ -62,4 +70,32 @@ describe('route53RecordHigherTtlRule', () => { expect(finding).toBeNull(); }); + + it('flags static non-alias records with a TTL below 3600 seconds', () => { + const finding = route53RecordHigherTtlRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-route53-records': [createStaticRecord()], + }), + }); + + expect(finding?.findings).toEqual([ + { + resourceId: 'aws_route53_record.api', + }, + ]); + }); + + it('skips static alias records and records whose TTL is unknown or already at 3600 seconds', () => { + const finding = route53RecordHigherTtlRule.evaluateStatic?.({ + resources: new StaticResourceBag({ + 'aws-route53-records': [ + createStaticRecord({ isAlias: true, ttl: undefined }), + createStaticRecord({ resourceId: 'aws_route53_record.steady', ttl: 3600 }), + createStaticRecord({ resourceId: 'aws_route53_record.unknown', ttl: null }), + ], + }), + }); + + expect(finding).toBeNull(); + }); }); diff --git a/packages/rules/test/rule-metadata.test.ts b/packages/rules/test/rule-metadata.test.ts index a0dc59b..1ea209a 100644 --- a/packages/rules/test/rule-metadata.test.ts +++ b/packages/rules/test/rule-metadata.test.ts @@ -110,8 +110,9 @@ describe('rule metadata', () => { message: 'CloudWatch log groups should define a retention policy unless AWS manages lifecycle automatically.', provider: 'aws', service: 'cloudwatch', - supports: ['discovery'], + supports: ['discovery', 'iac'], discoveryDependencies: ['aws-cloudwatch-log-groups'], + staticDependencies: ['aws-cloudwatch-log-groups'], }); }); @@ -329,8 +330,9 @@ describe('rule metadata', () => { message: 'EBS volumes larger than 100 GiB should be reviewed.', provider: 'aws', service: 'ebs', - supports: ['discovery'], + supports: ['discovery', 'iac'], discoveryDependencies: ['aws-ebs-volumes'], + staticDependencies: ['aws-ebs-volumes'], }); }); @@ -345,8 +347,9 @@ describe('rule metadata', () => { message: 'EBS io1 and io2 volumes above 32000 IOPS should be reviewed.', provider: 'aws', service: 'ebs', - supports: ['discovery'], + supports: ['discovery', 'iac'], discoveryDependencies: ['aws-ebs-volumes'], + staticDependencies: ['aws-ebs-volumes'], }); }); @@ -361,8 +364,9 @@ describe('rule metadata', () => { message: 'EBS io1 and io2 volumes at 16000 IOPS or below should be reviewed for gp3.', provider: 'aws', service: 'ebs', - supports: ['discovery'], + supports: ['discovery', 'iac'], discoveryDependencies: ['aws-ebs-volumes'], + staticDependencies: ['aws-ebs-volumes'], }); }); @@ -393,8 +397,9 @@ describe('rule metadata', () => { message: 'Elastic IP addresses should not remain unassociated.', provider: 'aws', service: 'ec2', - supports: ['discovery'], + supports: ['discovery', 'iac'], discoveryDependencies: ['aws-ec2-elastic-ips'], + staticDependencies: ['aws-ec2-elastic-ips'], }); }); @@ -409,8 +414,9 @@ describe('rule metadata', () => { message: 'EMR clusters using previous-generation instance types should be reviewed.', provider: 'aws', service: 'emr', - supports: ['discovery'], + supports: ['discovery', 'iac'], discoveryDependencies: ['aws-emr-clusters'], + staticDependencies: ['aws-emr-clusters'], }); }); @@ -475,8 +481,9 @@ describe('rule metadata', () => { message: 'EC2 instances without a Graviton equivalent in use should be reviewed.', provider: 'aws', service: 'ec2', - supports: ['discovery'], + supports: ['discovery', 'iac'], discoveryDependencies: ['aws-ec2-instances'], + staticDependencies: ['aws-ec2-instances'], }); }); @@ -542,8 +549,9 @@ describe('rule metadata', () => { message: 'EKS node groups without a Graviton equivalent in use should be reviewed.', provider: 'aws', service: 'eks', - supports: ['discovery'], + supports: ['discovery', 'iac'], discoveryDependencies: ['aws-eks-nodegroups'], + staticDependencies: ['aws-eks-nodegroups'], }); }); @@ -574,8 +582,9 @@ describe('rule metadata', () => { message: 'EC2 large instances of 2xlarge or greater should be reviewed.', provider: 'aws', service: 'ec2', - supports: ['discovery'], + supports: ['discovery', 'iac'], discoveryDependencies: ['aws-ec2-instances'], + staticDependencies: ['aws-ec2-instances'], }); }); @@ -773,8 +782,9 @@ describe('rule metadata', () => { message: 'RDS DB instances without a Graviton equivalent in use should be reviewed.', provider: 'aws', service: 'rds', - supports: ['discovery'], + supports: ['discovery', 'iac'], discoveryDependencies: ['aws-rds-instances'], + staticDependencies: ['aws-rds-instances'], }); }); @@ -806,8 +816,9 @@ describe('rule metadata', () => { message: 'RDS MySQL 5.7 and PostgreSQL 11 DB instances should be upgraded to avoid extended support charges.', provider: 'aws', service: 'rds', - supports: ['discovery'], + supports: ['discovery', 'iac'], discoveryDependencies: ['aws-rds-instances'], + staticDependencies: ['aws-rds-instances'], }); }); @@ -886,8 +897,9 @@ describe('rule metadata', () => { message: 'API Gateway REST API stages should enable caching when stage caching is available.', provider: 'aws', service: 'apigateway', - supports: ['discovery'], + supports: ['discovery', 'iac'], discoveryDependencies: ['aws-apigateway-stages'], + staticDependencies: ['aws-apigateway-stages'], }); }); @@ -902,8 +914,9 @@ describe('rule metadata', () => { message: 'CloudFront distributions using PriceClass_All should be reviewed for cheaper edge coverage.', provider: 'aws', service: 'cloudfront', - supports: ['discovery'], + supports: ['discovery', 'iac'], discoveryDependencies: ['aws-cloudfront-distributions'], + staticDependencies: ['aws-cloudfront-distributions'], }); }); @@ -999,8 +1012,9 @@ describe('rule metadata', () => { message: 'Provisioned-capacity DynamoDB tables should use auto-scaling.', provider: 'aws', service: 'dynamodb', - supports: ['discovery'], + supports: ['discovery', 'iac'], discoveryDependencies: ['aws-dynamodb-tables', 'aws-dynamodb-autoscaling'], + staticDependencies: ['aws-dynamodb-tables', 'aws-dynamodb-autoscaling'], }); }); @@ -1031,8 +1045,9 @@ describe('rule metadata', () => { message: 'Route 53 record sets should generally use TTL values of at least 3600 seconds.', provider: 'aws', service: 'route53', - supports: ['discovery'], + supports: ['discovery', 'iac'], discoveryDependencies: ['aws-route53-zones', 'aws-route53-records'], + staticDependencies: ['aws-route53-records'], }); }); @@ -1047,8 +1062,9 @@ describe('rule metadata', () => { message: 'Route 53 health checks not associated with any DNS record should be deleted.', provider: 'aws', service: 'route53', - supports: ['discovery'], + supports: ['discovery', 'iac'], discoveryDependencies: ['aws-route53-health-checks', 'aws-route53-records'], + staticDependencies: ['aws-route53-health-checks', 'aws-route53-records'], }); }); diff --git a/packages/sdk/src/providers/aws/static-registry.ts b/packages/sdk/src/providers/aws/static-registry.ts index 514f0b8..49a868d 100644 --- a/packages/sdk/src/providers/aws/static-registry.ts +++ b/packages/sdk/src/providers/aws/static-registry.ts @@ -1,10 +1,20 @@ import type { + AwsStaticApiGatewayStage, + AwsStaticCloudFrontDistribution, + AwsStaticCloudWatchLogGroup, + AwsStaticDynamoDbAutoscaling, + AwsStaticDynamoDbTable, AwsStaticEbsVolume, + AwsStaticEc2ElasticIp, AwsStaticEc2Instance, AwsStaticEc2VpcEndpoint, AwsStaticEcrRepository, + AwsStaticEksNodegroup, + AwsStaticEmrCluster, AwsStaticLambdaFunction, AwsStaticRdsInstance, + AwsStaticRoute53HealthCheck, + AwsStaticRoute53Record, AwsStaticS3BucketAnalysis, IaCResource, SourceLocation, @@ -24,13 +34,36 @@ type AwsStaticDatasetDefinition = const TERRAFORM_EBS_VOLUME_TYPE = 'aws_ebs_volume'; const CLOUDFORMATION_EBS_VOLUME_TYPE = 'AWS::EC2::Volume'; +const TERRAFORM_API_GATEWAY_STAGE_TYPE = 'aws_api_gateway_stage'; +const CLOUDFORMATION_API_GATEWAY_STAGE_TYPE = 'AWS::ApiGateway::Stage'; +const TERRAFORM_CLOUDFRONT_DISTRIBUTION_TYPE = 'aws_cloudfront_distribution'; +const CLOUDFORMATION_CLOUDFRONT_DISTRIBUTION_TYPE = 'AWS::CloudFront::Distribution'; +const TERRAFORM_CLOUDWATCH_LOG_GROUP_TYPE = 'aws_cloudwatch_log_group'; +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 CLOUDFORMATION_SCALABLE_TARGET_TYPE = 'AWS::ApplicationAutoScaling::ScalableTarget'; 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_EIP_TYPE = 'aws_eip'; +const TERRAFORM_EIP_ASSOCIATION_TYPE = 'aws_eip_association'; +const CLOUDFORMATION_EIP_TYPE = 'AWS::EC2::EIP'; +const CLOUDFORMATION_EIP_ASSOCIATION_TYPE = 'AWS::EC2::EIPAssociation'; const TERRAFORM_INSTANCE_TYPE = 'aws_instance'; const CLOUDFORMATION_INSTANCE_TYPE = 'AWS::EC2::Instance'; +const TERRAFORM_EKS_NODE_GROUP_TYPE = 'aws_eks_node_group'; +const CLOUDFORMATION_EKS_NODEGROUP_TYPE = 'AWS::EKS::Nodegroup'; +const TERRAFORM_EMR_CLUSTER_TYPE = 'aws_emr_cluster'; +const CLOUDFORMATION_EMR_CLUSTER_TYPE = 'AWS::EMR::Cluster'; const TERRAFORM_RDS_INSTANCE_TYPE = 'aws_db_instance'; const CLOUDFORMATION_RDS_INSTANCE_TYPE = 'AWS::RDS::DBInstance'; +const TERRAFORM_ROUTE53_RECORD_TYPE = 'aws_route53_record'; +const TERRAFORM_ROUTE53_HEALTH_CHECK_TYPE = 'aws_route53_health_check'; +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 CLOUDFORMATION_LAMBDA_TYPE = 'AWS::Lambda::Function'; const TERRAFORM_VPC_ENDPOINT_TYPE = 'aws_vpc_endpoint'; @@ -41,6 +74,10 @@ const TERRAFORM_INTELLIGENT_TIERING_TYPE = 'aws_s3_bucket_intelligent_tiering_co const CLOUDFORMATION_BUCKET_TYPE = 'AWS::S3::Bucket'; const DIRECT_BUCKET_REFERENCE_PATTERN = /^\$?\{?aws_s3_bucket\.([A-Za-z0-9_-]+)\.(?:id|bucket)\}?$/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 = + /^\$?\{?aws_route53_health_check\.([A-Za-z0-9_-]+)\.(?:id|health_check_id)\}?$/u; +const DYNAMODB_TABLE_RESOURCE_ID_PATTERN = /^table\/([^/]+)$/u; const isCloudFormationResource = (resource: IaCResource): boolean => resource.type.startsWith('AWS::'); @@ -55,6 +92,41 @@ const pickLocation = (resource: IaCResource, attributePaths: string[]): SourceLo const getLiteralString = (value: unknown): string | null => typeof value === 'string' && !value.includes('${') ? value.toLowerCase() : null; +const getLiteralNumber = (value: unknown): number | null => (typeof value === 'number' ? value : null); + +const getLiteralNumberish = (value: unknown): number | null => { + if (typeof value === 'number') { + return value; + } + + if (typeof value !== 'string' || value.includes('${')) { + return null; + } + + const parsedValue = Number(value); + + return Number.isFinite(parsedValue) ? parsedValue : null; +}; + +const getLiteralExactString = (value: unknown): string | null => + typeof value === 'string' && !value.includes('${') ? value : null; + +const getLiteralExactStringArray = (value: unknown): string[] | null => { + if (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string' && !entry.includes('${'))) { + return null; + } + + return [...value]; +}; + +const getLiteralUpperString = (value: unknown): string | null => { + const literal = getLiteralExactString(value); + + return literal ? literal.toUpperCase() : null; +}; + +const getLiteralBoolean = (value: unknown): boolean | null => (typeof value === 'boolean' ? value : null); + const getLiteralStringArray = (value: unknown): string[] | null => { if (value === undefined) { return ['x86_64']; @@ -70,6 +142,29 @@ const getLiteralStringArray = (value: unknown): string[] | null => { const toRecordArray = (value: unknown): Record[] => Array.isArray(value) ? value.filter((entry): entry is Record => isRecord(entry)) : []; +const getCloudFormationLogicalIdReference = (value: unknown): string | null => { + if (!isRecord(value)) { + return null; + } + + if (typeof value.Ref === 'string') { + return value.Ref; + } + + const getAtt = value['Fn::GetAtt']; + + if (typeof getAtt === 'string') { + const [logicalId] = getAtt.split('.', 1); + return logicalId ?? null; + } + + if (Array.isArray(getAtt) && getAtt.length > 0 && typeof getAtt[0] === 'string') { + return getAtt[0]; + } + + return null; +}; + const getTerraformBucketReferenceKey = (value: unknown): string | null => { const literal = getLiteralString(value); @@ -203,8 +298,248 @@ const createCloudFormationEcrRepository = (repository: IaCResource): AwsStaticEc }; }; +const getTerraformDynamoDbTableName = (resource: IaCResource): string | null => + getLiteralExactString(resource.attributes.name); + +const getCloudFormationDynamoDbTableName = (resource: IaCResource): string | null => { + const properties = isRecord(resource.attributes.Properties) ? resource.attributes.Properties : undefined; + return getLiteralExactString(properties?.TableName) ?? resource.name; +}; + +const getLiteralDynamoDbTableNameFromResourceId = (value: unknown): string | null => { + const resourceId = getLiteralExactString(value); + const tableName = resourceId ? DYNAMODB_TABLE_RESOURCE_ID_PATTERN.exec(resourceId)?.[1] : undefined; + + return tableName ?? null; +}; + +const getTerraformElasticIpReferenceKey = (value: unknown): string | null => { + if (typeof value !== 'string') { + return null; + } + + const match = DIRECT_EIP_REFERENCE_PATTERN.exec(value); + const resourceName = match?.[1]; + + return resourceName ? `${TERRAFORM_EIP_TYPE}.${resourceName}` : null; +}; + +const getCloudFormationElasticIpReferenceKey = (value: unknown): string | null => + getCloudFormationLogicalIdReference(value); + +const getStaticDynamoDbBillingMode = (value: unknown): 'PAY_PER_REQUEST' | 'PROVISIONED' | null => { + const billingMode = getLiteralUpperString(value); + + return billingMode === 'PAY_PER_REQUEST' || billingMode === 'PROVISIONED' ? billingMode : null; +}; + +const getTerraformRoute53HealthCheckReferenceKey = (value: unknown): string | null => { + if (typeof value !== 'string') { + return null; + } + + const match = DIRECT_ROUTE53_HEALTH_CHECK_REFERENCE_PATTERN.exec(value); + const resourceName = match?.[1]; + + return resourceName ? `${TERRAFORM_ROUTE53_HEALTH_CHECK_TYPE}.${resourceName}` : null; +}; + +const createStaticRoute53Record = ( + resourceId: string, + location: SourceLocation | undefined, + ttlValue: unknown, + isAlias: boolean, + healthCheckReference: unknown, +): AwsStaticRoute53Record => ({ + isAlias, + location, + referencedHealthCheckResourceId: + getTerraformRoute53HealthCheckReferenceKey(healthCheckReference) ?? + getCloudFormationLogicalIdReference(healthCheckReference), + resourceId, + ttl: ttlValue === undefined ? undefined : getLiteralNumberish(ttlValue), +}); + +const getTerraformEmrInstanceTypes = (resource: IaCResource): string[] => { + const instanceTypes: string[] = []; + + for (const attributeName of ['master_instance_group', 'core_instance_group', 'task_instance_group']) { + for (const group of toRecordArray(resource.attributes[attributeName])) { + const instanceType = getLiteralExactString(group.instance_type); + + if (instanceType) { + instanceTypes.push(instanceType); + } + } + } + + return instanceTypes; +}; + +const getCloudFormationEmrInstanceTypes = (resource: IaCResource): string[] => { + const properties = isRecord(resource.attributes.Properties) ? resource.attributes.Properties : undefined; + const instancesConfig = isRecord(properties?.JobFlowInstancesConfig) ? properties.JobFlowInstancesConfig : undefined; + const instanceTypes: string[] = []; + + for (const attributeName of ['MasterInstanceGroup', 'CoreInstanceGroup', 'TaskInstanceGroup']) { + const instanceGroup = isRecord(instancesConfig?.[attributeName]) ? instancesConfig[attributeName] : undefined; + const instanceType = getLiteralExactString(instanceGroup?.InstanceType); + + if (instanceType) { + instanceTypes.push(instanceType); + } + } + + return instanceTypes; +}; + +const loadStaticApiGatewayStages = (resources: IaCResource[]): AwsStaticApiGatewayStage[] => + resources.map((resource) => { + const rawValue = + resource.type === TERRAFORM_API_GATEWAY_STAGE_TYPE + ? resource.attributes.cache_cluster_enabled + : isRecord(resource.attributes.Properties) + ? resource.attributes.Properties.CacheClusterEnabled + : undefined; + + return { + cacheClusterEnabled: rawValue === undefined ? false : getLiteralBoolean(rawValue), + location: pickLocation(resource, ['cache_cluster_enabled', 'Properties.CacheClusterEnabled']), + resourceId: toStaticResourceId(resource), + }; + }); + +const loadStaticCloudFrontDistributions = (resources: IaCResource[]): AwsStaticCloudFrontDistribution[] => + resources.map((resource) => { + const rawValue = + resource.type === TERRAFORM_CLOUDFRONT_DISTRIBUTION_TYPE + ? resource.attributes.price_class + : isRecord(resource.attributes.Properties) && isRecord(resource.attributes.Properties.DistributionConfig) + ? resource.attributes.Properties.DistributionConfig.PriceClass + : undefined; + + return { + location: pickLocation(resource, ['price_class', 'Properties.DistributionConfig.PriceClass']), + priceClass: rawValue === undefined ? 'PriceClass_All' : getLiteralExactString(rawValue), + resourceId: toStaticResourceId(resource), + }; + }); + +const loadStaticCloudWatchLogGroups = (resources: IaCResource[]): AwsStaticCloudWatchLogGroup[] => + resources.map((resource) => { + const retentionValue = + resource.type === TERRAFORM_CLOUDWATCH_LOG_GROUP_TYPE + ? resource.attributes.retention_in_days + : isRecord(resource.attributes.Properties) + ? resource.attributes.Properties.RetentionInDays + : undefined; + const classValue = + resource.type === TERRAFORM_CLOUDWATCH_LOG_GROUP_TYPE + ? resource.attributes.log_group_class + : isRecord(resource.attributes.Properties) + ? resource.attributes.Properties.LogGroupClass + : undefined; + + return { + location: pickLocation(resource, [ + 'retention_in_days', + 'log_group_class', + 'Properties.RetentionInDays', + 'Properties.LogGroupClass', + ]), + logGroupClass: classValue === undefined ? undefined : getLiteralUpperString(classValue), + resourceId: toStaticResourceId(resource), + retentionInDays: retentionValue === undefined ? undefined : getLiteralNumber(retentionValue), + }; + }); + +const loadStaticDynamoDbTables = (resources: IaCResource[]): AwsStaticDynamoDbTable[] => + resources.flatMap((resource) => { + if (resource.type === TERRAFORM_DYNAMODB_TABLE_TYPE) { + return [ + { + billingMode: getStaticDynamoDbBillingMode(resource.attributes.billing_mode) ?? 'PROVISIONED', + location: pickLocation(resource, ['name', 'billing_mode']), + resourceId: toStaticResourceId(resource), + tableName: getTerraformDynamoDbTableName(resource), + }, + ]; + } + + if (resource.type === CLOUDFORMATION_DYNAMODB_TABLE_TYPE) { + const properties = isRecord(resource.attributes.Properties) ? resource.attributes.Properties : undefined; + + return [ + { + billingMode: getStaticDynamoDbBillingMode(properties?.BillingMode) ?? 'PROVISIONED', + location: pickLocation(resource, ['Properties.TableName', 'Properties.BillingMode']), + resourceId: toStaticResourceId(resource), + tableName: getCloudFormationDynamoDbTableName(resource), + }, + ]; + } + + return []; + }); + +const loadStaticDynamoDbAutoscaling = (resources: IaCResource[]): AwsStaticDynamoDbAutoscaling[] => { + const autoscalingByTable = new Map(); + + for (const resource of resources) { + const properties = isRecord(resource.attributes.Properties) ? resource.attributes.Properties : undefined; + const tableName = + resource.type === TERRAFORM_APPAUTOSCALING_TARGET_TYPE + ? getLiteralDynamoDbTableNameFromResourceId(resource.attributes.resource_id) + : resource.type === CLOUDFORMATION_SCALABLE_TARGET_TYPE + ? getLiteralDynamoDbTableNameFromResourceId(properties?.ResourceId) + : null; + const scalableDimension = + resource.type === TERRAFORM_APPAUTOSCALING_TARGET_TYPE + ? getLiteralExactString(resource.attributes.scalable_dimension) + : resource.type === CLOUDFORMATION_SCALABLE_TARGET_TYPE + ? getLiteralExactString(properties?.ScalableDimension) + : null; + + if (tableName === null || scalableDimension === null) { + continue; + } + + const entry = autoscalingByTable.get(tableName) ?? { + tableName, + hasReadTarget: false, + hasWriteTarget: false, + }; + + if (scalableDimension === 'dynamodb:table:ReadCapacityUnits') { + entry.hasReadTarget = true; + } + + if (scalableDimension === 'dynamodb:table:WriteCapacityUnits') { + entry.hasWriteTarget = true; + } + + autoscalingByTable.set(tableName, entry); + } + + return [...autoscalingByTable.values()]; +}; + const loadStaticEbsVolumes = (resources: IaCResource[]): AwsStaticEbsVolume[] => resources.map((resource) => ({ + iops: getLiteralNumber( + resource.type === TERRAFORM_EBS_VOLUME_TYPE + ? resource.attributes.iops + : isRecord(resource.attributes.Properties) + ? resource.attributes.Properties.Iops + : undefined, + ), + sizeGiB: getLiteralNumber( + resource.type === TERRAFORM_EBS_VOLUME_TYPE + ? resource.attributes.size + : isRecord(resource.attributes.Properties) + ? resource.attributes.Properties.Size + : undefined, + ), resourceId: toStaticResourceId(resource), volumeType: getLiteralString( resource.type === TERRAFORM_EBS_VOLUME_TYPE @@ -232,6 +567,152 @@ const loadStaticEcrRepositories = (resources: IaCResource[]): AwsStaticEcrReposi }); }; +const loadStaticEc2ElasticIps = (resources: IaCResource[]): AwsStaticEc2ElasticIp[] => { + const elasticIps = resources.filter( + (resource) => resource.type === TERRAFORM_EIP_TYPE || resource.type === CLOUDFORMATION_EIP_TYPE, + ); + const associatedResourceIds = new Set(); + + for (const resource of resources) { + if (resource.type === TERRAFORM_EIP_TYPE) { + if (resource.attributes.instance !== undefined || resource.attributes.network_interface !== undefined) { + associatedResourceIds.add(toStaticResourceId(resource)); + } + + continue; + } + + if (resource.type === CLOUDFORMATION_EIP_TYPE) { + const properties = isRecord(resource.attributes.Properties) ? resource.attributes.Properties : undefined; + + if (properties?.InstanceId !== undefined) { + associatedResourceIds.add(toStaticResourceId(resource)); + } + + continue; + } + + if (resource.type === TERRAFORM_EIP_ASSOCIATION_TYPE) { + const referenceKey = getTerraformElasticIpReferenceKey(resource.attributes.allocation_id); + + if (referenceKey !== null) { + associatedResourceIds.add(referenceKey); + } + + continue; + } + + if (resource.type === CLOUDFORMATION_EIP_ASSOCIATION_TYPE) { + const properties = isRecord(resource.attributes.Properties) ? resource.attributes.Properties : undefined; + const referenceKey = getCloudFormationElasticIpReferenceKey(properties?.AllocationId); + + if (referenceKey !== null) { + associatedResourceIds.add(referenceKey); + } + } + } + + return elasticIps.map((resource) => ({ + isAssociated: associatedResourceIds.has(toStaticResourceId(resource)), + location: pickLocation( + resource, + resource.type === TERRAFORM_EIP_TYPE ? ['instance', 'network_interface'] : ['Properties.InstanceId'], + ), + resourceId: toStaticResourceId(resource), + })); +}; + +const loadStaticEksNodegroups = (resources: IaCResource[]): AwsStaticEksNodegroup[] => + resources.map((resource) => ({ + amiType: getLiteralExactString( + resource.type === TERRAFORM_EKS_NODE_GROUP_TYPE + ? resource.attributes.ami_type + : isRecord(resource.attributes.Properties) + ? resource.attributes.Properties.AmiType + : undefined, + ), + instanceTypes: + getLiteralExactStringArray( + resource.type === TERRAFORM_EKS_NODE_GROUP_TYPE + ? resource.attributes.instance_types + : isRecord(resource.attributes.Properties) + ? resource.attributes.Properties.InstanceTypes + : undefined, + ) ?? [], + location: pickLocation(resource, ['ami_type', 'instance_types', 'Properties.AmiType', 'Properties.InstanceTypes']), + resourceId: toStaticResourceId(resource), + })); + +const loadStaticEmrClusters = (resources: IaCResource[]): AwsStaticEmrCluster[] => + resources.map((resource) => ({ + instanceTypes: + resource.type === TERRAFORM_EMR_CLUSTER_TYPE + ? getTerraformEmrInstanceTypes(resource) + : getCloudFormationEmrInstanceTypes(resource), + location: pickLocation(resource, [ + 'master_instance_group', + 'core_instance_group', + 'task_instance_group', + 'Properties.JobFlowInstancesConfig.MasterInstanceGroup.InstanceType', + 'Properties.JobFlowInstancesConfig.CoreInstanceGroup.InstanceType', + 'Properties.JobFlowInstancesConfig.TaskInstanceGroup.InstanceType', + ]), + resourceId: toStaticResourceId(resource), + })); + +const loadStaticRoute53Records = (resources: IaCResource[]): AwsStaticRoute53Record[] => + resources.flatMap((resource) => { + if (resource.type === TERRAFORM_ROUTE53_RECORD_TYPE) { + const aliasRecords = toRecordArray(resource.attributes.alias); + + return [ + createStaticRoute53Record( + toStaticResourceId(resource), + pickLocation(resource, ['ttl', 'alias', 'health_check_id']), + resource.attributes.ttl, + aliasRecords.length > 0, + resource.attributes.health_check_id, + ), + ]; + } + + if (resource.type === CLOUDFORMATION_ROUTE53_RECORD_SET_TYPE) { + const properties = isRecord(resource.attributes.Properties) ? resource.attributes.Properties : undefined; + + return [ + createStaticRoute53Record( + toStaticResourceId(resource), + pickLocation(resource, ['Properties.TTL', 'Properties.AliasTarget', 'Properties.HealthCheckId']), + properties?.TTL, + isRecord(properties?.AliasTarget), + properties?.HealthCheckId, + ), + ]; + } + + if (resource.type === CLOUDFORMATION_ROUTE53_RECORD_SET_GROUP_TYPE) { + const properties = isRecord(resource.attributes.Properties) ? resource.attributes.Properties : undefined; + + return toRecordArray(properties?.RecordSets).map((recordSet, index) => + createStaticRoute53Record( + `${toStaticResourceId(resource)}#${index + 1}`, + pickLocation(resource, ['Properties.RecordSets']), + recordSet.TTL, + isRecord(recordSet.AliasTarget), + recordSet.HealthCheckId, + ), + ); + } + + return []; + }); + +const loadStaticRoute53HealthChecks = (resources: IaCResource[]): AwsStaticRoute53HealthCheck[] => + resources.map((resource) => ({ + location: resource.location, + resourceId: toStaticResourceId(resource), + })); + const loadStaticEc2Instances = (resources: IaCResource[]): AwsStaticEc2Instance[] => resources.map((resource) => ({ resourceId: toStaticResourceId(resource), @@ -247,6 +728,20 @@ const loadStaticEc2Instances = (resources: IaCResource[]): AwsStaticEc2Instance[ const loadStaticRdsInstances = (resources: IaCResource[]): AwsStaticRdsInstance[] => resources.map((resource) => ({ + engine: getLiteralString( + resource.type === TERRAFORM_RDS_INSTANCE_TYPE + ? resource.attributes.engine + : isRecord(resource.attributes.Properties) + ? resource.attributes.Properties.Engine + : undefined, + ), + engineVersion: getLiteralString( + resource.type === TERRAFORM_RDS_INSTANCE_TYPE + ? resource.attributes.engine_version + : isRecord(resource.attributes.Properties) + ? resource.attributes.Properties.EngineVersion + : undefined, + ), resourceId: toStaticResourceId(resource), instanceClass: getLiteralString( resource.type === TERRAFORM_RDS_INSTANCE_TYPE @@ -322,6 +817,36 @@ const loadStaticS3BucketAnalyses = (resources: IaCResource[]): AwsStaticS3Bucket }; const awsStaticDatasetRegistry: Record = { + 'aws-apigateway-stages': { + datasetKey: 'aws-apigateway-stages', + sourceKinds: ['terraform', 'cloudformation'], + resourceTypes: [TERRAFORM_API_GATEWAY_STAGE_TYPE, CLOUDFORMATION_API_GATEWAY_STAGE_TYPE], + load: loadStaticApiGatewayStages, + }, + 'aws-cloudfront-distributions': { + datasetKey: 'aws-cloudfront-distributions', + sourceKinds: ['terraform', 'cloudformation'], + resourceTypes: [TERRAFORM_CLOUDFRONT_DISTRIBUTION_TYPE, CLOUDFORMATION_CLOUDFRONT_DISTRIBUTION_TYPE], + load: loadStaticCloudFrontDistributions, + }, + 'aws-cloudwatch-log-groups': { + datasetKey: 'aws-cloudwatch-log-groups', + sourceKinds: ['terraform', 'cloudformation'], + resourceTypes: [TERRAFORM_CLOUDWATCH_LOG_GROUP_TYPE, CLOUDFORMATION_CLOUDWATCH_LOG_GROUP_TYPE], + load: loadStaticCloudWatchLogGroups, + }, + 'aws-dynamodb-autoscaling': { + datasetKey: 'aws-dynamodb-autoscaling', + sourceKinds: ['terraform', 'cloudformation'], + resourceTypes: [TERRAFORM_APPAUTOSCALING_TARGET_TYPE, CLOUDFORMATION_SCALABLE_TARGET_TYPE], + load: loadStaticDynamoDbAutoscaling, + }, + 'aws-dynamodb-tables': { + datasetKey: 'aws-dynamodb-tables', + sourceKinds: ['terraform', 'cloudformation'], + resourceTypes: [TERRAFORM_DYNAMODB_TABLE_TYPE, CLOUDFORMATION_DYNAMODB_TABLE_TYPE], + load: loadStaticDynamoDbTables, + }, 'aws-ebs-volumes': { datasetKey: 'aws-ebs-volumes', sourceKinds: ['terraform', 'cloudformation'], @@ -338,12 +863,35 @@ const awsStaticDatasetRegistry: Record { id: 'CLDBRN-AWS-APIGATEWAY-1', provider: 'aws', service: 'apigateway', - supports: ['discovery'], + 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'], + supports: ['discovery', 'iac'], }, { description: 'Flag CloudFront distributions with fewer than 100 requests over the last 30 days.', @@ -92,7 +92,7 @@ describe('sdk exports', () => { id: 'CLDBRN-AWS-CLOUDWATCH-1', provider: 'aws', service: 'cloudwatch', - supports: ['discovery'], + supports: ['discovery', 'iac'], }, { description: @@ -142,7 +142,7 @@ describe('sdk exports', () => { id: 'CLDBRN-AWS-DYNAMODB-2', provider: 'aws', service: 'dynamodb', - supports: ['discovery'], + supports: ['discovery', 'iac'], }, { description: 'Flag provisioned DynamoDB tables with no consumed read or write capacity over the last 30 days.', @@ -178,21 +178,21 @@ describe('sdk exports', () => { id: 'CLDBRN-AWS-EBS-4', provider: 'aws', service: 'ebs', - supports: ['discovery'], + 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'], + 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'], + supports: ['discovery', 'iac'], }, { description: 'Flag completed EBS snapshots older than 90 days.', @@ -220,7 +220,7 @@ describe('sdk exports', () => { id: 'CLDBRN-AWS-EC2-3', provider: 'aws', service: 'ec2', - supports: ['discovery'], + supports: ['discovery', 'iac'], }, { description: 'Flag interface VPC endpoints that have processed no traffic in the last 30 days.', @@ -243,7 +243,7 @@ describe('sdk exports', () => { id: 'CLDBRN-AWS-EC2-6', provider: 'aws', service: 'ec2', - supports: ['discovery'], + supports: ['discovery', 'iac'], }, { description: 'Flag active EC2 reserved instances whose end date is within the next 60 days.', @@ -257,7 +257,7 @@ describe('sdk exports', () => { id: 'CLDBRN-AWS-EC2-8', provider: 'aws', service: 'ec2', - supports: ['discovery'], + supports: ['discovery', 'iac'], }, { description: 'Flag EC2 instances whose launch time is at least 180 days old.', @@ -302,7 +302,7 @@ describe('sdk exports', () => { id: 'CLDBRN-AWS-EKS-1', provider: 'aws', service: 'eks', - supports: ['discovery'], + supports: ['discovery', 'iac'], }, { description: 'Flag long-running ElastiCache clusters that do not have matching active reserved-node coverage.', @@ -359,7 +359,7 @@ describe('sdk exports', () => { id: 'CLDBRN-AWS-EMR-1', provider: 'aws', service: 'emr', - supports: ['discovery'], + supports: ['discovery', 'iac'], }, { description: 'Flag active EMR clusters whose `IsIdle` metric stays true for at least 30 minutes.', @@ -425,7 +425,7 @@ describe('sdk exports', () => { id: 'CLDBRN-AWS-RDS-4', provider: 'aws', service: 'rds', - supports: ['discovery'], + supports: ['discovery', 'iac'], }, { description: 'Flag available RDS DB instances whose 30-day average CPU stays at or below 10%.', @@ -440,7 +440,7 @@ describe('sdk exports', () => { id: 'CLDBRN-AWS-RDS-6', provider: 'aws', service: 'rds', - supports: ['discovery'], + supports: ['discovery', 'iac'], }, { description: 'Flag RDS snapshots older than 30 days whose source DB instance no longer exists.', @@ -475,14 +475,14 @@ describe('sdk exports', () => { id: 'CLDBRN-AWS-ROUTE53-1', provider: 'aws', service: 'route53', - supports: ['discovery'], + 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'], + supports: ['discovery', 'iac'], }, { description: 'Ensure S3 buckets define lifecycle management policies.', diff --git a/packages/sdk/test/fixtures/iac-capacity-mixed/main.tf b/packages/sdk/test/fixtures/iac-capacity-mixed/main.tf new file mode 100644 index 0000000..d0fea14 --- /dev/null +++ b/packages/sdk/test/fixtures/iac-capacity-mixed/main.tf @@ -0,0 +1,12 @@ +resource "aws_dynamodb_table" "orders" { + name = "orders" +} + +resource "aws_appautoscaling_target" "orders_read" { + resource_id = "table/orders" + scalable_dimension = "dynamodb:table:ReadCapacityUnits" +} + +resource "aws_dynamodb_table" "logs" { + name = "logs" +} diff --git a/packages/sdk/test/fixtures/iac-capacity-mixed/template.yaml b/packages/sdk/test/fixtures/iac-capacity-mixed/template.yaml new file mode 100644 index 0000000..836b309 --- /dev/null +++ b/packages/sdk/test/fixtures/iac-capacity-mixed/template.yaml @@ -0,0 +1,4 @@ +Resources: + PublicAddress: + Type: AWS::EC2::EIP + Properties: {} diff --git a/packages/sdk/test/fixtures/iac-compute-mixed/main.tf b/packages/sdk/test/fixtures/iac-compute-mixed/main.tf new file mode 100644 index 0000000..e794c95 --- /dev/null +++ b/packages/sdk/test/fixtures/iac-compute-mixed/main.tf @@ -0,0 +1,6 @@ +resource "aws_eks_node_group" "workers" { + cluster_name = "production" + node_group_name = "workers" + ami_type = "AL2023_x86_64_STANDARD" + instance_types = ["m7i.large"] +} diff --git a/packages/sdk/test/fixtures/iac-compute-mixed/template.yaml b/packages/sdk/test/fixtures/iac-compute-mixed/template.yaml new file mode 100644 index 0000000..aed9bd1 --- /dev/null +++ b/packages/sdk/test/fixtures/iac-compute-mixed/template.yaml @@ -0,0 +1,9 @@ +Resources: + LegacyAnalytics: + Type: AWS::EMR::Cluster + Properties: + JobFlowInstancesConfig: + MasterInstanceGroup: + InstanceType: m6i.xlarge + CoreInstanceGroup: + InstanceType: m8g.xlarge diff --git a/packages/sdk/test/fixtures/iac-config-mixed/main.tf b/packages/sdk/test/fixtures/iac-config-mixed/main.tf new file mode 100644 index 0000000..1849cc6 --- /dev/null +++ b/packages/sdk/test/fixtures/iac-config-mixed/main.tf @@ -0,0 +1,9 @@ +resource "aws_api_gateway_stage" "prod" { + rest_api_id = "a1b2c3d4" + stage_name = "prod" + cache_cluster_enabled = false +} + +resource "aws_cloudfront_distribution" "cdn" { + enabled = true +} diff --git a/packages/sdk/test/fixtures/iac-config-mixed/template.yaml b/packages/sdk/test/fixtures/iac-config-mixed/template.yaml new file mode 100644 index 0000000..13e6407 --- /dev/null +++ b/packages/sdk/test/fixtures/iac-config-mixed/template.yaml @@ -0,0 +1,4 @@ +Resources: + MissingRetentionGroup: + Type: AWS::Logs::LogGroup + Properties: {} diff --git a/packages/sdk/test/fixtures/iac-route53-mixed/main.tf b/packages/sdk/test/fixtures/iac-route53-mixed/main.tf new file mode 100644 index 0000000..4c4783c --- /dev/null +++ b/packages/sdk/test/fixtures/iac-route53-mixed/main.tf @@ -0,0 +1,13 @@ +resource "aws_route53_health_check" "api" { + fqdn = "api.example.com" + type = "HTTPS" +} + +resource "aws_route53_record" "api" { + zone_id = "Z1234567890ABC" + name = "api.example.com" + type = "A" + records = ["203.0.113.10"] + ttl = 300 + health_check_id = aws_route53_health_check.api.id +} diff --git a/packages/sdk/test/fixtures/iac-route53-mixed/template.yaml b/packages/sdk/test/fixtures/iac-route53-mixed/template.yaml new file mode 100644 index 0000000..72fbfca --- /dev/null +++ b/packages/sdk/test/fixtures/iac-route53-mixed/template.yaml @@ -0,0 +1,16 @@ +Resources: + UnusedCheck: + Type: AWS::Route53::HealthCheck + Properties: + HealthCheckConfig: + Type: HTTPS + FullyQualifiedDomainName: www.example.com. + AliasRecord: + Type: AWS::Route53::RecordSet + Properties: + HostedZoneId: Z1234567890ABC + Name: www.example.com. + Type: A + AliasTarget: + DNSName: dualstack.alb.amazonaws.com. + HostedZoneId: ZALIAS diff --git a/packages/sdk/test/providers/aws-static.test.ts b/packages/sdk/test/providers/aws-static.test.ts index 87ff550..93aaf0a 100644 --- a/packages/sdk/test/providers/aws-static.test.ts +++ b/packages/sdk/test/providers/aws-static.test.ts @@ -91,12 +91,14 @@ describe('loadAwsStaticResources', () => { expect(result.resources).toBeInstanceOf(StaticResourceBag); expect(result.resources.get('aws-ebs-volumes')).toEqual([ { + iops: null, location: { path: 'main.tf', line: 4, column: 3, }, resourceId: 'aws_ebs_volume.logs', + sizeGiB: null, volumeType: 'gp2', }, ]); @@ -158,6 +160,8 @@ describe('loadAwsStaticResources', () => { }); expect(result.resources.get('aws-rds-instances')).toEqual([ { + engine: null, + engineVersion: null, instanceClass: 'db.m6i.large', location: { path: 'main.tf', @@ -167,6 +171,8 @@ describe('loadAwsStaticResources', () => { resourceId: 'aws_db_instance.legacy', }, { + engine: null, + engineVersion: null, instanceClass: 'db.r7g.large', location: { path: 'template.yaml', @@ -178,6 +184,890 @@ describe('loadAwsStaticResources', () => { ]); }); + it('loads EBS volume size and IOPS for Terraform and CloudFormation resources', async () => { + mockedParseIaC.mockResolvedValue([ + createIaCResource({ + type: 'aws_ebs_volume', + name: 'logs', + attributeLocations: { + size: { + path: 'main.tf', + line: 5, + column: 3, + }, + iops: { + path: 'main.tf', + line: 6, + column: 3, + }, + type: { + path: 'main.tf', + line: 4, + column: 3, + }, + }, + attributes: { + type: 'io2', + size: 200, + iops: 40000, + }, + }), + createIaCResource({ + type: 'AWS::EC2::Volume', + name: 'DataVolume', + attributeLocations: { + 'Properties.VolumeType': { + path: 'template.yaml', + line: 10, + column: 7, + }, + 'Properties.Size': { + path: 'template.yaml', + line: 11, + column: 7, + }, + 'Properties.Iops': { + path: 'template.yaml', + line: 12, + column: 7, + }, + }, + attributes: { + Properties: { + VolumeType: 'io1', + Size: 500, + Iops: 16000, + }, + }, + }), + ]); + + const result = await loadAwsStaticResources('/tmp/iac', [ + createRule({ + staticDependencies: ['aws-ebs-volumes'], + }), + ]); + + expect(result.resources.get('aws-ebs-volumes')).toEqual([ + { + iops: 40000, + location: { + path: 'main.tf', + line: 4, + column: 3, + }, + resourceId: 'aws_ebs_volume.logs', + sizeGiB: 200, + volumeType: 'io2', + }, + { + iops: 16000, + location: { + path: 'template.yaml', + line: 10, + column: 7, + }, + resourceId: 'DataVolume', + sizeGiB: 500, + volumeType: 'io1', + }, + ]); + }); + + it('loads RDS engine metadata for Terraform and CloudFormation resources', async () => { + mockedParseIaC.mockResolvedValue([ + createIaCResource({ + type: 'aws_db_instance', + name: 'legacy', + attributeLocations: { + instance_class: { + path: 'main.tf', + line: 5, + column: 3, + }, + engine: { + path: 'main.tf', + line: 6, + column: 3, + }, + engine_version: { + path: 'main.tf', + line: 7, + column: 3, + }, + }, + attributes: { + instance_class: 'db.m6i.large', + engine: 'mysql', + engine_version: '5.7.44', + }, + }), + createIaCResource({ + type: 'AWS::RDS::DBInstance', + name: 'Database', + attributeLocations: { + 'Properties.DBInstanceClass': { + path: 'template.yaml', + line: 9, + column: 7, + }, + 'Properties.Engine': { + path: 'template.yaml', + line: 10, + column: 7, + }, + 'Properties.EngineVersion': { + path: 'template.yaml', + line: 11, + column: 7, + }, + }, + attributes: { + Properties: { + DBInstanceClass: 'db.r7g.large', + Engine: 'postgres', + EngineVersion: '11.22', + }, + }, + }), + ]); + + const result = await loadAwsStaticResources('/tmp/iac', [ + createRule({ + staticDependencies: ['aws-rds-instances'], + }), + ]); + + expect(result.resources.get('aws-rds-instances')).toEqual([ + { + engine: 'mysql', + engineVersion: '5.7.44', + instanceClass: 'db.m6i.large', + location: { + path: 'main.tf', + line: 5, + column: 3, + }, + resourceId: 'aws_db_instance.legacy', + }, + { + engine: 'postgres', + engineVersion: '11.22', + instanceClass: 'db.r7g.large', + location: { + path: 'template.yaml', + line: 9, + column: 7, + }, + resourceId: 'Database', + }, + ]); + }); + + it('loads API Gateway stages for Terraform and CloudFormation resources', async () => { + mockedParseIaC.mockResolvedValue([ + createIaCResource({ + type: 'aws_api_gateway_stage', + name: 'prod', + attributeLocations: { + cache_cluster_enabled: { + path: 'main.tf', + line: 6, + column: 3, + }, + }, + attributes: { + rest_api_id: 'a1b2c3d4', + stage_name: 'prod', + cache_cluster_enabled: false, + }, + }), + createIaCResource({ + type: 'AWS::ApiGateway::Stage', + name: 'ProdStage', + attributeLocations: { + 'Properties.CacheClusterEnabled': { + path: 'template.yaml', + line: 9, + column: 7, + }, + }, + attributes: { + Properties: { + StageName: 'prod', + RestApiId: 'a1b2c3d4', + CacheClusterEnabled: true, + }, + }, + }), + ]); + + const result = await loadAwsStaticResources('/tmp/iac', [ + createRule({ + staticDependencies: ['aws-apigateway-stages'], + }), + ]); + + expect(result.resources.get('aws-apigateway-stages')).toEqual([ + { + cacheClusterEnabled: false, + location: { + path: 'main.tf', + line: 6, + column: 3, + }, + resourceId: 'aws_api_gateway_stage.prod', + }, + { + cacheClusterEnabled: true, + location: { + path: 'template.yaml', + line: 9, + column: 7, + }, + resourceId: 'ProdStage', + }, + ]); + }); + + it('loads CloudFront distributions and applies the default price class when omitted', async () => { + mockedParseIaC.mockResolvedValue([ + createIaCResource({ + type: 'aws_cloudfront_distribution', + name: 'cdn', + location: { + path: 'main.tf', + line: 1, + column: 1, + }, + attributes: { + enabled: true, + }, + }), + createIaCResource({ + type: 'AWS::CloudFront::Distribution', + name: 'NarrowDistribution', + attributeLocations: { + 'Properties.DistributionConfig.PriceClass': { + path: 'template.yaml', + line: 8, + column: 9, + }, + }, + attributes: { + Properties: { + DistributionConfig: { + PriceClass: 'PriceClass_100', + }, + }, + }, + }), + ]); + + const result = await loadAwsStaticResources('/tmp/iac', [ + createRule({ + staticDependencies: ['aws-cloudfront-distributions'], + }), + ]); + + expect(result.resources.get('aws-cloudfront-distributions')).toEqual([ + { + location: { + path: 'main.tf', + line: 1, + column: 1, + }, + priceClass: 'PriceClass_All', + resourceId: 'aws_cloudfront_distribution.cdn', + }, + { + location: { + path: 'template.yaml', + line: 8, + column: 9, + }, + priceClass: 'PriceClass_100', + resourceId: 'NarrowDistribution', + }, + ]); + }); + + it('loads CloudWatch log groups and preserves unresolved retention values as null', async () => { + mockedParseIaC.mockResolvedValue([ + createIaCResource({ + type: 'aws_cloudwatch_log_group', + name: 'app', + attributeLocations: { + retention_in_days: { + path: 'main.tf', + line: 4, + column: 3, + }, + log_group_class: { + path: 'main.tf', + line: 5, + column: 3, + }, + }, + attributes: { + name: '/aws/lambda/app', + retention_in_days: 30, + log_group_class: 'DELIVERY', + }, + }), + createIaCResource({ + type: 'AWS::Logs::LogGroup', + name: 'MissingRetentionGroup', + location: { + path: 'template.yaml', + line: 3, + column: 3, + }, + attributes: { + Properties: {}, + }, + }), + createIaCResource({ + type: 'AWS::Logs::LogGroup', + name: 'RefRetentionGroup', + attributeLocations: { + 'Properties.RetentionInDays': { + path: 'template.yaml', + line: 10, + column: 7, + }, + }, + attributes: { + Properties: { + RetentionInDays: { + Ref: 'RetentionDays', + }, + }, + }, + }), + ]); + + const result = await loadAwsStaticResources('/tmp/iac', [ + createRule({ + staticDependencies: ['aws-cloudwatch-log-groups'], + }), + ]); + + expect(result.resources.get('aws-cloudwatch-log-groups')).toEqual([ + { + location: { + path: 'main.tf', + line: 4, + column: 3, + }, + logGroupClass: 'DELIVERY', + resourceId: 'aws_cloudwatch_log_group.app', + retentionInDays: 30, + }, + { + location: { + path: 'template.yaml', + line: 3, + column: 3, + }, + logGroupClass: undefined, + resourceId: 'MissingRetentionGroup', + retentionInDays: undefined, + }, + { + location: { + path: 'template.yaml', + line: 10, + column: 7, + }, + logGroupClass: undefined, + resourceId: 'RefRetentionGroup', + retentionInDays: null, + }, + ]); + }); + + it('loads DynamoDB tables and table-level autoscaling state', async () => { + mockedParseIaC.mockResolvedValue([ + createIaCResource({ + type: 'aws_dynamodb_table', + name: 'orders', + attributeLocations: { + name: { + path: 'main.tf', + line: 2, + column: 3, + }, + }, + attributes: { + name: 'orders', + }, + }), + createIaCResource({ + type: 'aws_appautoscaling_target', + name: 'orders_read', + attributes: { + resource_id: 'table/orders', + scalable_dimension: 'dynamodb:table:ReadCapacityUnits', + }, + }), + createIaCResource({ + type: 'aws_appautoscaling_target', + name: 'orders_write', + attributes: { + resource_id: 'table/orders', + scalable_dimension: 'dynamodb:table:WriteCapacityUnits', + }, + }), + createIaCResource({ + type: 'AWS::DynamoDB::Table', + name: 'ProvisionedTable', + location: { + path: 'template.yaml', + line: 3, + column: 3, + }, + attributes: { + Properties: {}, + }, + }), + ]); + + const result = await loadAwsStaticResources('/tmp/iac', [ + createRule({ + staticDependencies: ['aws-dynamodb-tables', 'aws-dynamodb-autoscaling'], + }), + ]); + + expect(result.resources.get('aws-dynamodb-tables')).toEqual([ + { + billingMode: 'PROVISIONED', + location: { + path: 'main.tf', + line: 2, + column: 3, + }, + resourceId: 'aws_dynamodb_table.orders', + tableName: 'orders', + }, + { + billingMode: 'PROVISIONED', + location: { + path: 'template.yaml', + line: 3, + column: 3, + }, + resourceId: 'ProvisionedTable', + tableName: 'ProvisionedTable', + }, + ]); + expect(result.resources.get('aws-dynamodb-autoscaling')).toEqual([ + { + hasReadTarget: true, + hasWriteTarget: true, + tableName: 'orders', + }, + ]); + }); + + it('loads Elastic IP association state from inline and separate resources', async () => { + mockedParseIaC.mockResolvedValue([ + createIaCResource({ + type: 'aws_eip', + name: 'inline', + attributeLocations: { + instance: { + path: 'main.tf', + line: 4, + column: 3, + }, + }, + attributes: { + instance: 'i-1234567890', + }, + }), + createIaCResource({ + type: 'aws_eip', + name: 'detached', + location: { + path: 'main.tf', + line: 7, + column: 1, + }, + attributes: {}, + }), + createIaCResource({ + type: 'aws_eip_association', + name: 'detached_assoc', + attributes: { + allocation_id: 'aws_eip.detached.id', + instance_id: 'i-abcdef1234', + }, + }), + createIaCResource({ + type: 'AWS::EC2::EIP', + name: 'PublicAddress', + location: { + path: 'template.yaml', + line: 3, + column: 3, + }, + attributes: { + Properties: {}, + }, + }), + ]); + + const result = await loadAwsStaticResources('/tmp/iac', [ + createRule({ + staticDependencies: ['aws-ec2-elastic-ips'], + }), + ]); + + expect(result.resources.get('aws-ec2-elastic-ips')).toEqual([ + { + isAssociated: true, + location: { + path: 'main.tf', + line: 4, + column: 3, + }, + resourceId: 'aws_eip.inline', + }, + { + isAssociated: true, + location: { + path: 'main.tf', + line: 7, + column: 1, + }, + resourceId: 'aws_eip.detached', + }, + { + isAssociated: false, + location: { + path: 'template.yaml', + line: 3, + column: 3, + }, + resourceId: 'PublicAddress', + }, + ]); + }); + + it('loads EKS node groups and preserves unresolved instance types as empty', async () => { + mockedParseIaC.mockResolvedValue([ + createIaCResource({ + type: 'aws_eks_node_group', + name: 'workers', + attributeLocations: { + ami_type: { + path: 'main.tf', + line: 5, + column: 3, + }, + instance_types: { + path: 'main.tf', + line: 6, + column: 3, + }, + }, + attributes: { + ami_type: 'AL2023_x86_64_STANDARD', + instance_types: ['m7i.large'], + }, + }), + createIaCResource({ + type: 'AWS::EKS::Nodegroup', + name: 'ArmWorkers', + attributeLocations: { + 'Properties.AmiType': { + path: 'template.yaml', + line: 6, + column: 7, + }, + 'Properties.InstanceTypes': { + path: 'template.yaml', + line: 7, + column: 7, + }, + }, + attributes: { + Properties: { + AmiType: 'AL2023_ARM_64_STANDARD', + InstanceTypes: { Ref: 'NodegroupInstanceTypes' }, + }, + }, + }), + ]); + + const result = await loadAwsStaticResources('/tmp/iac', [ + createRule({ + staticDependencies: ['aws-eks-nodegroups'], + }), + ]); + + expect(result.resources.get('aws-eks-nodegroups')).toEqual([ + { + amiType: 'AL2023_x86_64_STANDARD', + instanceTypes: ['m7i.large'], + location: { + path: 'main.tf', + line: 5, + column: 3, + }, + resourceId: 'aws_eks_node_group.workers', + }, + { + amiType: 'AL2023_ARM_64_STANDARD', + instanceTypes: [], + location: { + path: 'template.yaml', + line: 6, + column: 7, + }, + resourceId: 'ArmWorkers', + }, + ]); + }); + + it('loads EMR clusters from inline instance-group definitions only', async () => { + mockedParseIaC.mockResolvedValue([ + createIaCResource({ + type: 'aws_emr_cluster', + name: 'analytics', + attributeLocations: { + master_instance_group: { + path: 'main.tf', + line: 5, + column: 3, + }, + core_instance_group: { + path: 'main.tf', + line: 9, + column: 3, + }, + }, + attributes: { + master_instance_group: [{ instance_type: 'm6i.xlarge' }], + core_instance_group: [{ instance_type: 'm8g.xlarge' }], + master_instance_fleet: [{ instance_type_configs: [{ instance_type: '$' + '{var.master_type}' }] }], + }, + }), + createIaCResource({ + type: 'AWS::EMR::Cluster', + name: 'LegacyAnalytics', + attributeLocations: { + 'Properties.JobFlowInstancesConfig.MasterInstanceGroup.InstanceType': { + path: 'template.yaml', + line: 6, + column: 11, + }, + 'Properties.JobFlowInstancesConfig.CoreInstanceGroup.InstanceType': { + path: 'template.yaml', + line: 8, + column: 11, + }, + }, + attributes: { + Properties: { + JobFlowInstancesConfig: { + MasterInstanceGroup: { + InstanceType: 'm6i.xlarge', + }, + CoreInstanceGroup: { + InstanceType: 'm8g.xlarge', + }, + TaskInstanceFleets: [ + { + InstanceTypeConfigs: [ + { + InstanceType: { Ref: 'TaskType' }, + }, + ], + }, + ], + }, + }, + }, + }), + ]); + + const result = await loadAwsStaticResources('/tmp/iac', [ + createRule({ + staticDependencies: ['aws-emr-clusters'], + }), + ]); + + expect(result.resources.get('aws-emr-clusters')).toEqual([ + { + instanceTypes: ['m6i.xlarge', 'm8g.xlarge'], + location: { + path: 'main.tf', + line: 5, + column: 3, + }, + resourceId: 'aws_emr_cluster.analytics', + }, + { + instanceTypes: ['m6i.xlarge', 'm8g.xlarge'], + location: { + path: 'template.yaml', + line: 6, + column: 11, + }, + resourceId: 'LegacyAnalytics', + }, + ]); + }); + + it('loads Route 53 records and health checks from Terraform and CloudFormation resources', async () => { + mockedParseIaC.mockResolvedValue([ + createIaCResource({ + type: 'aws_route53_health_check', + name: 'api', + location: { + path: 'main.tf', + line: 1, + column: 1, + }, + attributes: { + fqdn: 'api.example.com.', + type: 'HTTPS', + }, + }), + createIaCResource({ + type: 'aws_route53_record', + name: 'api', + attributeLocations: { + ttl: { + path: 'main.tf', + line: 11, + column: 3, + }, + health_check_id: { + path: 'main.tf', + line: 12, + column: 3, + }, + }, + attributes: { + name: 'api.example.com.', + type: 'A', + ttl: 300, + health_check_id: 'aws_route53_health_check.api.id', + }, + }), + createIaCResource({ + type: 'AWS::Route53::HealthCheck', + name: 'CheckedHealthCheck', + location: { + path: 'template.yaml', + line: 3, + column: 3, + }, + attributes: { + Properties: { + HealthCheckConfig: { + Type: 'HTTPS', + }, + }, + }, + }), + createIaCResource({ + type: 'AWS::Route53::RecordSetGroup', + name: 'RecordsGroup', + attributeLocations: { + 'Properties.RecordSets': { + path: 'template.yaml', + line: 8, + column: 7, + }, + }, + attributes: { + Properties: { + RecordSets: [ + { + Name: 'checked.example.com.', + Type: 'A', + TTL: '60', + HealthCheckId: { Ref: 'CheckedHealthCheck' }, + }, + { + Name: 'alias.example.com.', + Type: 'A', + AliasTarget: { + DNSName: 'dualstack.alb.amazonaws.com', + HostedZoneId: 'ZALIAS', + }, + }, + ], + }, + }, + }), + ]); + + const result = await loadAwsStaticResources('/tmp/iac', [ + createRule({ + staticDependencies: ['aws-route53-records', 'aws-route53-health-checks'], + }), + ]); + + expect(result.resources.get('aws-route53-records')).toEqual([ + { + isAlias: false, + location: { + path: 'main.tf', + line: 11, + column: 3, + }, + referencedHealthCheckResourceId: 'aws_route53_health_check.api', + resourceId: 'aws_route53_record.api', + ttl: 300, + }, + { + isAlias: false, + location: { + path: 'template.yaml', + line: 8, + column: 7, + }, + referencedHealthCheckResourceId: 'CheckedHealthCheck', + resourceId: 'RecordsGroup#1', + ttl: 60, + }, + { + isAlias: true, + location: { + path: 'template.yaml', + line: 8, + column: 7, + }, + referencedHealthCheckResourceId: null, + resourceId: 'RecordsGroup#2', + ttl: undefined, + }, + ]); + expect(result.resources.get('aws-route53-health-checks')).toEqual([ + { + location: { + path: 'main.tf', + line: 1, + column: 1, + }, + resourceId: 'aws_route53_health_check.api', + }, + { + location: { + path: 'template.yaml', + line: 3, + column: 3, + }, + resourceId: 'CheckedHealthCheck', + }, + ]); + }); + it('loads ECR repository datasets for Terraform lifecycle resources and CloudFormation inline policies', async () => { mockedParseIaC.mockResolvedValue([ createIaCResource({ @@ -442,21 +1332,25 @@ describe('aws static dataset registry', () => { ]), ).toEqual([ { + iops: null, location: { path: 'main.tf', line: 4, column: 3, }, resourceId: 'aws_ebs_volume.logs', + sizeGiB: null, volumeType: 'gp2', }, { + iops: null, location: { path: 'template.yaml', line: 10, column: 7, }, resourceId: 'DataVolume', + sizeGiB: null, volumeType: 'gp3', }, ]); @@ -655,6 +1549,8 @@ describe('aws static dataset registry', () => { ]), ).toEqual([ { + engine: null, + engineVersion: null, instanceClass: 'db.m6i.large', location: { path: 'main.tf', @@ -664,6 +1560,8 @@ describe('aws static dataset registry', () => { resourceId: 'aws_db_instance.legacy', }, { + engine: null, + engineVersion: null, instanceClass: null, location: { path: 'template.yaml', diff --git a/packages/sdk/test/scanner.test.ts b/packages/sdk/test/scanner.test.ts index 4b82f8a..e48ee27 100644 --- a/packages/sdk/test/scanner.test.ts +++ b/packages/sdk/test/scanner.test.ts @@ -308,6 +308,22 @@ describe('CloudBurnClient', () => { }, ], }, + { + ruleId: 'CLDBRN-AWS-EC2-6', + service: 'ec2', + source: 'iac', + message: 'EC2 instances without a Graviton equivalent in use should be reviewed.', + findings: [ + { + resourceId: 'aws_instance.web', + location: { + path: 'variables.tf', + line: 14, + column: 3, + }, + }, + ], + }, { ruleId: 'CLDBRN-AWS-EBS-1', service: 'ebs', @@ -324,6 +340,22 @@ describe('CloudBurnClient', () => { }, ], }, + { + ruleId: 'CLDBRN-AWS-EBS-4', + service: 'ebs', + source: 'iac', + message: 'EBS volumes larger than 100 GiB should be reviewed.', + findings: [ + { + resourceId: 'aws_ebs_volume.gp3_data', + location: { + path: 'main.tf', + line: 10, + column: 3, + }, + }, + ], + }, ], }, ], @@ -431,6 +463,22 @@ describe('CloudBurnClient', () => { }, ], }, + { + ruleId: 'CLDBRN-AWS-RDS-4', + service: 'rds', + source: 'iac', + message: 'RDS DB instances without a Graviton equivalent in use should be reviewed.', + findings: [ + { + resourceId: 'aws_db_instance.legacy', + location: { + path: 'main.tf', + line: 4, + column: 3, + }, + }, + ], + }, ], }, ], @@ -472,6 +520,22 @@ describe('CloudBurnClient', () => { }, ], }, + { + ruleId: 'CLDBRN-AWS-RDS-4', + service: 'rds', + source: 'iac', + message: 'RDS DB instances without a Graviton equivalent in use should be reviewed.', + findings: [ + { + resourceId: 'aws_db_instance.legacy', + location: { + path: 'main.tf', + line: 4, + column: 3, + }, + }, + ], + }, ], }, ], @@ -560,6 +624,219 @@ describe('CloudBurnClient', () => { }); }); + it('returns static API Gateway, CloudFront, and CloudWatch findings from mixed IaC resources', async () => { + const scanner = new CloudBurnClient(); + const fixturePath = fileURLToPath(new URL('./fixtures/iac-config-mixed', import.meta.url)); + + const result = await scanner.scanStatic(fixturePath); + + expect(result).toEqual({ + providers: [ + { + provider: 'aws', + rules: [ + { + ruleId: 'CLDBRN-AWS-APIGATEWAY-1', + service: 'apigateway', + source: 'iac', + message: 'API Gateway REST API stages should enable caching when stage caching is available.', + findings: [ + { + resourceId: 'aws_api_gateway_stage.prod', + location: { + path: 'main.tf', + line: 4, + column: 3, + }, + }, + ], + }, + { + ruleId: 'CLDBRN-AWS-CLOUDFRONT-1', + service: 'cloudfront', + source: 'iac', + message: 'CloudFront distributions using PriceClass_All should be reviewed for cheaper edge coverage.', + findings: [ + { + resourceId: 'aws_cloudfront_distribution.cdn', + location: { + path: 'main.tf', + line: 7, + column: 1, + }, + }, + ], + }, + { + ruleId: 'CLDBRN-AWS-CLOUDWATCH-1', + service: 'cloudwatch', + source: 'iac', + message: + 'CloudWatch log groups should define a retention policy unless AWS manages lifecycle automatically.', + findings: [ + { + resourceId: 'MissingRetentionGroup', + location: { + path: 'template.yaml', + line: 2, + column: 3, + }, + }, + ], + }, + ], + }, + ], + }); + }); + + it('returns static DynamoDB and Elastic IP findings from mixed IaC resources', async () => { + const scanner = new CloudBurnClient(); + const fixturePath = fileURLToPath(new URL('./fixtures/iac-capacity-mixed', import.meta.url)); + + const result = await scanner.scanStatic(fixturePath); + + expect(result).toEqual({ + providers: [ + { + provider: 'aws', + rules: [ + { + ruleId: 'CLDBRN-AWS-DYNAMODB-2', + service: 'dynamodb', + source: 'iac', + message: 'Provisioned-capacity DynamoDB tables should use auto-scaling.', + findings: [ + { + resourceId: 'aws_dynamodb_table.logs', + location: { + path: 'main.tf', + line: 11, + column: 3, + }, + }, + ], + }, + { + ruleId: 'CLDBRN-AWS-EC2-3', + service: 'ec2', + source: 'iac', + message: 'Elastic IP addresses should not remain unassociated.', + findings: [ + { + resourceId: 'PublicAddress', + location: { + path: 'template.yaml', + line: 2, + column: 3, + }, + }, + ], + }, + ], + }, + ], + }); + }); + + it('returns static EKS and EMR findings from mixed IaC resources', async () => { + const scanner = new CloudBurnClient(); + const fixturePath = fileURLToPath(new URL('./fixtures/iac-compute-mixed', import.meta.url)); + + const result = await scanner.scanStatic(fixturePath); + + expect(result).toEqual({ + providers: [ + { + provider: 'aws', + rules: [ + { + ruleId: 'CLDBRN-AWS-EKS-1', + service: 'eks', + source: 'iac', + message: 'EKS node groups without a Graviton equivalent in use should be reviewed.', + findings: [ + { + resourceId: 'aws_eks_node_group.workers', + location: { + path: 'main.tf', + line: 4, + column: 3, + }, + }, + ], + }, + { + ruleId: 'CLDBRN-AWS-EMR-1', + service: 'emr', + source: 'iac', + message: 'EMR clusters using previous-generation instance types should be reviewed.', + findings: [ + { + resourceId: 'LegacyAnalytics', + location: { + path: 'template.yaml', + line: 2, + column: 3, + }, + }, + ], + }, + ], + }, + ], + }); + }); + + it('returns static Route 53 findings from mixed IaC resources', async () => { + const scanner = new CloudBurnClient(); + const fixturePath = fileURLToPath(new URL('./fixtures/iac-route53-mixed', import.meta.url)); + + const result = await scanner.scanStatic(fixturePath); + + expect(result).toEqual({ + providers: [ + { + provider: 'aws', + rules: [ + { + ruleId: 'CLDBRN-AWS-ROUTE53-1', + service: 'route53', + source: 'iac', + message: 'Route 53 record sets should generally use TTL values of at least 3600 seconds.', + findings: [ + { + resourceId: 'aws_route53_record.api', + location: { + path: 'main.tf', + line: 11, + column: 3, + }, + }, + ], + }, + { + ruleId: 'CLDBRN-AWS-ROUTE53-2', + service: 'route53', + source: 'iac', + message: 'Route 53 health checks not associated with any DNS record should be deleted.', + findings: [ + { + resourceId: 'UnusedCheck', + location: { + path: 'template.yaml', + line: 2, + column: 3, + }, + }, + ], + }, + ], + }, + ], + }); + }); + it('returns static S3 findings from terraform and cloudformation resources in the same directory', async () => { const scanner = new CloudBurnClient(); const fixturePath = fileURLToPath(new URL('./fixtures/iac-s3-mixed', import.meta.url));