diff --git a/.changeset/steady-flames-bake.md b/.changeset/steady-flames-bake.md new file mode 100644 index 0000000..e8f3518 --- /dev/null +++ b/.changeset/steady-flames-bake.md @@ -0,0 +1,5 @@ +--- +'@cloudburn/rules': minor +--- + +Add new AWS discovery cost rules for Lambda memory overprovisioning, CloudWatch log groups without metric filters, DynamoDB unused tables, missing AWS cost guardrails, idle load balancers, unused CloudFront distributions, and idle ElastiCache clusters. diff --git a/.changeset/tidy-apples-jump.md b/.changeset/tidy-apples-jump.md new file mode 100644 index 0000000..cda06f3 --- /dev/null +++ b/.changeset/tidy-apples-jump.md @@ -0,0 +1,5 @@ +--- +'@cloudburn/sdk': minor +--- + +Add AWS discovery datasets and hydrators for Lambda memory sizing, CloudWatch log metric-filter coverage, DynamoDB table utilization, Budgets and Cost Anomaly Detection summaries, load balancer request activity, CloudFront request activity, and ElastiCache cluster activity. diff --git a/docs/reference/rule-ids.md b/docs/reference/rule-ids.md index 8037c9f..9364b47 100644 --- a/docs/reference/rule-ids.md +++ b/docs/reference/rule-ids.md @@ -18,13 +18,18 @@ Format: `CLDBRN-{PROVIDER}-{SERVICE}-{N}` | --------------------- | ----------------------------------------- | ------- | -------------- | ----------- | | `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-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-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-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 | @@ -47,10 +52,12 @@ Format: `CLDBRN-{PROVIDER}-{SERVICE}-{N}` | `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-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 | | `CLDBRN-AWS-ELB-2` | Classic Load Balancer Without Instances | elb | discovery | Implemented | | `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-2` | EMR Cluster Idle | emr | discovery | Implemented | | `CLDBRN-AWS-RDS-1` | RDS Instance Class Not Preferred | rds | iac, discovery | Implemented | @@ -71,11 +78,14 @@ Format: `CLDBRN-{PROVIDER}-{SERVICE}-{N}` | `CLDBRN-AWS-LAMBDA-1` | Lambda Cost Optimal Architecture | lambda | iac, discovery | Implemented | | `CLDBRN-AWS-LAMBDA-2` | Lambda Function High Error Rate | lambda | discovery | Implemented | | `CLDBRN-AWS-LAMBDA-3` | Lambda Function Excessive Timeout | lambda | discovery | Implemented | +| `CLDBRN-AWS-LAMBDA-4` | Lambda Function Memory Overprovisioned | lambda | discovery | Implemented | `CLDBRN-AWS-APIGATEWAY-1` flags REST API stages when `cacheClusterEnabled` is not explicitly `true`. `CLDBRN-AWS-CLOUDFRONT-1` reviews only distributions using `PriceClass_All`. +`CLDBRN-AWS-CLOUDFRONT-2` requires a complete 30-day `Requests` history and flags only distributions whose total request count stays below `100`. + `CLDBRN-AWS-EBS-1` flags previous-generation EBS volume types (`gp2`, `io1`, and `standard`) and does not flag current-generation HDD families such as `st1` or `sc1`. `CLDBRN-AWS-EBS-4` treats volumes above `100 GiB` as oversized enough to warrant explicit review. @@ -88,12 +98,20 @@ Format: `CLDBRN-{PROVIDER}-{SERVICE}-{N}` `CLDBRN-AWS-CLOUDWATCH-2` flags log streams with no observed event history and log streams whose `lastIngestionTime` is more than 90 days old. Delivery-managed log groups remain exempt. +`CLDBRN-AWS-CLOUDWATCH-3` reviews only log groups storing at least `1 GiB` and flags them when no metric filters are configured. + +`CLDBRN-AWS-COSTGUARDRAILS-1` flags accounts whose AWS Budgets summary reports zero configured budgets. + +`CLDBRN-AWS-COSTGUARDRAILS-2` flags accounts whose Cost Anomaly Detection summary reports zero anomaly monitors. + `CLDBRN-AWS-COSTEXPLORER-1` compares the last two full months and flags only services with an existing prior-month baseline and a cost increase greater than `10` cost units. `CLDBRN-AWS-DYNAMODB-1` flags only tables whose parsed `latestStreamLabel` is older than `90` days. Tables without a stream label are skipped. `CLDBRN-AWS-DYNAMODB-2` reviews only provisioned-capacity tables and flags them when no table-level read or write autoscaling targets are configured. +`CLDBRN-AWS-DYNAMODB-3` reviews only provisioned-capacity tables and flags them when 30 days of consumed read and write capacity both sum to zero. + `CLDBRN-AWS-EC2-6` flags only families with a curated Graviton-equivalent path. Instances without architecture metadata or outside the curated family set are skipped. `CLDBRN-AWS-EC2-7` reviews only active reserved instances with an `endTime` inside the next 60 days. @@ -112,8 +130,12 @@ Format: `CLDBRN-{PROVIDER}-{SERVICE}-{N}` `CLDBRN-AWS-ELASTICACHE-1` reviews only `available` clusters with a parsed create time at least 180 days old and requires active reserved-node capacity on the same node type, preferring exact engine matches when ElastiCache reports them. +`CLDBRN-AWS-ELASTICACHE-2` currently supports Redis and Valkey clusters, requires a complete 14-day metric history, and flags only `available` clusters whose computed hit rate stays below `5%` while average current connections stay below `2`. + `CLDBRN-AWS-ELB-1`, `CLDBRN-AWS-ELB-3`, and `CLDBRN-AWS-ELB-4` flag load balancers with no attached target groups or no registered targets across attached target groups. +`CLDBRN-AWS-ELB-5` requires a complete 14-day `RequestCount` history, treats fewer than `10` requests per day as idle, and skips load balancers already covered by the stricter empty-target cleanup rules. + `CLDBRN-AWS-EMR-1` reuses the built-in EC2 family policy. EMR clusters are flagged when any discovered cluster instance type falls into the current non-preferred, previous-generation family set. `CLDBRN-AWS-EMR-2` flags only active clusters whose `IsIdle` metric stays true for six consecutive 5-minute periods, which is a 30-minute idle window. @@ -122,6 +144,8 @@ Format: `CLDBRN-{PROVIDER}-{SERVICE}-{N}` `CLDBRN-AWS-LAMBDA-3` reviews only functions with configured timeouts of at least `30` seconds and flags when the timeout is at least `5x` the observed 7-day average duration. +`CLDBRN-AWS-LAMBDA-4` reviews only functions configured above `256 MB`, requires invocation history, and flags them when the observed 7-day average duration uses less than `30%` of the configured timeout. + `CLDBRN-AWS-RDS-3` reviews only `available` DB instances with a parsed create time at least 180 days old and requires active reserved-instance coverage on the same instance class, deployment mode, and normalized engine when AWS reports it. `CLDBRN-AWS-RDS-4` flags only curated non-Graviton RDS families with a clear Graviton migration path. Existing Graviton classes and unclassified families are skipped. diff --git a/packages/rules/src/aws/cloudfront/index.ts b/packages/rules/src/aws/cloudfront/index.ts index 82b7ec4..b5121a7 100644 --- a/packages/rules/src/aws/cloudfront/index.ts +++ b/packages/rules/src/aws/cloudfront/index.ts @@ -1,4 +1,5 @@ import { cloudFrontDistributionPricingClassRule } from './distribution-pricing-class.js'; +import { cloudFrontUnusedDistributionRule } from './unused-distribution.js'; // Intent: aggregate AWS CloudFront rule definitions. -export const cloudfrontRules = [cloudFrontDistributionPricingClassRule]; +export const cloudfrontRules = [cloudFrontDistributionPricingClassRule, cloudFrontUnusedDistributionRule]; diff --git a/packages/rules/src/aws/cloudfront/unused-distribution.ts b/packages/rules/src/aws/cloudfront/unused-distribution.ts new file mode 100644 index 0000000..1707087 --- /dev/null +++ b/packages/rules/src/aws/cloudfront/unused-distribution.ts @@ -0,0 +1,29 @@ +import { createFinding, createFindingMatch, createRule } from '../../shared/helpers.js'; + +const RULE_ID = 'CLDBRN-AWS-CLOUDFRONT-2'; +const RULE_SERVICE = 'cloudfront'; +const RULE_MESSAGE = 'CloudFront distributions with almost no request traffic should be reviewed for cleanup.'; + +/** Flag CloudFront distributions with very low 30-day request volume. */ +export const cloudFrontUnusedDistributionRule = createRule({ + id: RULE_ID, + name: 'CloudFront Distribution Unused', + description: 'Flag CloudFront distributions with fewer than 100 requests over the last 30 days.', + message: RULE_MESSAGE, + provider: 'aws', + service: RULE_SERVICE, + supports: ['discovery'], + discoveryDependencies: ['aws-cloudfront-distribution-request-activity'], + evaluateLive: ({ resources }) => { + const findings = resources + .get('aws-cloudfront-distribution-request-activity') + .filter( + (distribution) => distribution.totalRequestsLast30Days !== null && distribution.totalRequestsLast30Days < 100, + ) + .map((distribution) => + createFindingMatch(distribution.distributionArn, distribution.region, distribution.accountId), + ); + + return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'discovery', findings); + }, +}); diff --git a/packages/rules/src/aws/cloudwatch/index.ts b/packages/rules/src/aws/cloudwatch/index.ts index 62c9787..67ab21f 100644 --- a/packages/rules/src/aws/cloudwatch/index.ts +++ b/packages/rules/src/aws/cloudwatch/index.ts @@ -1,5 +1,10 @@ +import { cloudWatchLogGroupNoMetricFiltersRule } from './log-group-no-metric-filters.js'; import { cloudWatchLogGroupRetentionRule } from './log-group-retention.js'; import { cloudWatchUnusedLogStreamsRule } from './unused-log-streams.js'; /** Aggregate AWS CloudWatch rule definitions. */ -export const cloudwatchRules = [cloudWatchLogGroupRetentionRule, cloudWatchUnusedLogStreamsRule]; +export const cloudwatchRules = [ + cloudWatchLogGroupRetentionRule, + cloudWatchUnusedLogStreamsRule, + cloudWatchLogGroupNoMetricFiltersRule, +]; diff --git a/packages/rules/src/aws/cloudwatch/log-group-no-metric-filters.ts b/packages/rules/src/aws/cloudwatch/log-group-no-metric-filters.ts new file mode 100644 index 0000000..2cac0f4 --- /dev/null +++ b/packages/rules/src/aws/cloudwatch/log-group-no-metric-filters.ts @@ -0,0 +1,44 @@ +import { createFinding, createFindingMatch, createRule } from '../../shared/helpers.js'; + +const RULE_ID = 'CLDBRN-AWS-CLOUDWATCH-3'; +const RULE_SERVICE = 'cloudwatch'; +const RULE_MESSAGE = + 'CloudWatch log groups storing at least 1 GB should define metric filters or reduce retention aggressively.'; +const MIN_STORED_BYTES = 1_073_741_824; +const getCoverageKey = (accountId: string, region: string, logGroupName: string): string => + `${accountId}:${region}:${logGroupName}`; + +/** Flag large CloudWatch log groups that have no metric filters configured. */ +export const cloudWatchLogGroupNoMetricFiltersRule = createRule({ + id: RULE_ID, + name: 'CloudWatch Log Group No Metric Filters', + description: 'Flag CloudWatch log groups storing at least 1 GB when they define no metric filters.', + message: RULE_MESSAGE, + provider: 'aws', + service: RULE_SERVICE, + supports: ['discovery'], + discoveryDependencies: ['aws-cloudwatch-log-groups', 'aws-cloudwatch-log-metric-filter-coverage'], + evaluateLive: ({ resources }) => { + const coverageByLogGroupKey = new Map( + resources + .get('aws-cloudwatch-log-metric-filter-coverage') + .map( + (coverage) => [getCoverageKey(coverage.accountId, coverage.region, coverage.logGroupName), coverage] as const, + ), + ); + + const findings = resources + .get('aws-cloudwatch-log-groups') + .filter((logGroup) => (logGroup.storedBytes ?? 0) >= MIN_STORED_BYTES) + .filter((logGroup) => { + const coverage = coverageByLogGroupKey.get( + getCoverageKey(logGroup.accountId, logGroup.region, logGroup.logGroupName), + ); + + return coverage?.metricFilterCount === 0; + }) + .map((logGroup) => createFindingMatch(logGroup.logGroupName, logGroup.region, logGroup.accountId)); + + return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'discovery', findings); + }, +}); diff --git a/packages/rules/src/aws/costguardrails/index.ts b/packages/rules/src/aws/costguardrails/index.ts new file mode 100644 index 0000000..492749e --- /dev/null +++ b/packages/rules/src/aws/costguardrails/index.ts @@ -0,0 +1,5 @@ +import { costGuardrailMissingAnomalyDetectionRule } from './missing-anomaly-detection.js'; +import { costGuardrailMissingBudgetsRule } from './missing-budgets.js'; + +/** Aggregate AWS cost guardrail rule definitions. */ +export const costguardrailsRules = [costGuardrailMissingBudgetsRule, costGuardrailMissingAnomalyDetectionRule]; diff --git a/packages/rules/src/aws/costguardrails/missing-anomaly-detection.ts b/packages/rules/src/aws/costguardrails/missing-anomaly-detection.ts new file mode 100644 index 0000000..64f0af1 --- /dev/null +++ b/packages/rules/src/aws/costguardrails/missing-anomaly-detection.ts @@ -0,0 +1,28 @@ +import { createFinding, createFindingMatch, createRule } from '../../shared/helpers.js'; + +const RULE_ID = 'CLDBRN-AWS-COSTGUARDRAILS-2'; +const RULE_SERVICE = 'costguardrails'; +const RULE_MESSAGE = 'AWS accounts should enable Cost Anomaly Detection monitors for spend spikes.'; + +/** Flag accounts that have not configured any Cost Anomaly Detection monitors. */ +export const costGuardrailMissingAnomalyDetectionRule = createRule({ + id: RULE_ID, + name: 'Cost Anomaly Detection Missing', + description: 'Flag AWS accounts that do not have any Cost Anomaly Detection monitors configured.', + message: RULE_MESSAGE, + provider: 'aws', + service: RULE_SERVICE, + supports: ['discovery'], + discoveryDependencies: ['aws-cost-anomaly-monitors'], + evaluateLive: ({ resources }) => { + const monitorSummary = resources.get('aws-cost-anomaly-monitors')[0]; + + if (!monitorSummary || monitorSummary.monitorCount > 0) { + return null; + } + + return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'discovery', [ + createFindingMatch(monitorSummary.accountId, undefined, monitorSummary.accountId), + ]); + }, +}); diff --git a/packages/rules/src/aws/costguardrails/missing-budgets.ts b/packages/rules/src/aws/costguardrails/missing-budgets.ts new file mode 100644 index 0000000..f0cefca --- /dev/null +++ b/packages/rules/src/aws/costguardrails/missing-budgets.ts @@ -0,0 +1,28 @@ +import { createFinding, createFindingMatch, createRule } from '../../shared/helpers.js'; + +const RULE_ID = 'CLDBRN-AWS-COSTGUARDRAILS-1'; +const RULE_SERVICE = 'costguardrails'; +const RULE_MESSAGE = 'AWS accounts should define at least one AWS Budget for spend guardrails.'; + +/** Flag accounts that have not configured any AWS Budgets. */ +export const costGuardrailMissingBudgetsRule = createRule({ + id: RULE_ID, + name: 'AWS Budgets Missing', + description: 'Flag AWS accounts that do not have any AWS Budgets configured.', + message: RULE_MESSAGE, + provider: 'aws', + service: RULE_SERVICE, + supports: ['discovery'], + discoveryDependencies: ['aws-cost-guardrail-budgets'], + evaluateLive: ({ resources }) => { + const budgetSummary = resources.get('aws-cost-guardrail-budgets')[0]; + + if (!budgetSummary || budgetSummary.budgetCount > 0) { + return null; + } + + return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'discovery', [ + createFindingMatch(budgetSummary.accountId, undefined, budgetSummary.accountId), + ]); + }, +}); diff --git a/packages/rules/src/aws/dynamodb/index.ts b/packages/rules/src/aws/dynamodb/index.ts index 81b4f9b..a5e6726 100644 --- a/packages/rules/src/aws/dynamodb/index.ts +++ b/packages/rules/src/aws/dynamodb/index.ts @@ -1,5 +1,6 @@ import { dynamoDbStaleTableDataRule } from './stale-table-data.js'; import { dynamoDbTableWithoutAutoscalingRule } from './table-without-autoscaling.js'; +import { dynamoDbUnusedTableRule } from './unused-table.js'; // Intent: aggregate AWS DynamoDB rule definitions. -export const dynamodbRules = [dynamoDbStaleTableDataRule, dynamoDbTableWithoutAutoscalingRule]; +export const dynamodbRules = [dynamoDbStaleTableDataRule, dynamoDbTableWithoutAutoscalingRule, dynamoDbUnusedTableRule]; diff --git a/packages/rules/src/aws/dynamodb/unused-table.ts b/packages/rules/src/aws/dynamodb/unused-table.ts new file mode 100644 index 0000000..67d718c --- /dev/null +++ b/packages/rules/src/aws/dynamodb/unused-table.ts @@ -0,0 +1,41 @@ +import { createFinding, createFindingMatch, createRule } from '../../shared/helpers.js'; + +const RULE_ID = 'CLDBRN-AWS-DYNAMODB-3'; +const RULE_SERVICE = 'dynamodb'; +const RULE_MESSAGE = 'Provisioned DynamoDB tables should not remain unused for 30 days.'; +const getTableKey = (accountId: string, region: string, tableArn: string): string => + `${accountId}:${region}:${tableArn}`; + +/** Flag provisioned DynamoDB tables that show no consumed capacity over the last 30 days. */ +export const dynamoDbUnusedTableRule = createRule({ + id: RULE_ID, + name: 'DynamoDB Table Unused', + description: 'Flag provisioned DynamoDB tables with no consumed read or write capacity over the last 30 days.', + message: RULE_MESSAGE, + provider: 'aws', + service: RULE_SERVICE, + supports: ['discovery'], + discoveryDependencies: ['aws-dynamodb-tables', 'aws-dynamodb-table-utilization'], + evaluateLive: ({ resources }) => { + const tablesByKey = new Map( + resources + .get('aws-dynamodb-tables') + .map((table) => [getTableKey(table.accountId, table.region, table.tableArn), table] as const), + ); + + const findings = resources + .get('aws-dynamodb-table-utilization') + .filter((utilization) => { + const table = tablesByKey.get(getTableKey(utilization.accountId, utilization.region, utilization.tableArn)); + + return ( + table?.billingMode === 'PROVISIONED' && + utilization.totalConsumedReadCapacityUnitsLast30Days === 0 && + utilization.totalConsumedWriteCapacityUnitsLast30Days === 0 + ); + }) + .map((utilization) => createFindingMatch(utilization.tableArn, utilization.region, utilization.accountId)); + + return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'discovery', findings); + }, +}); diff --git a/packages/rules/src/aws/elasticache/idle-cluster.ts b/packages/rules/src/aws/elasticache/idle-cluster.ts new file mode 100644 index 0000000..9de494d --- /dev/null +++ b/packages/rules/src/aws/elasticache/idle-cluster.ts @@ -0,0 +1,40 @@ +import { createFinding, createFindingMatch, createRule } from '../../shared/helpers.js'; + +const RULE_ID = 'CLDBRN-AWS-ELASTICACHE-2'; +const RULE_SERVICE = 'elasticache'; +const RULE_MESSAGE = + 'ElastiCache clusters with almost no cache hits and active connections should be reviewed for cleanup.'; + +/** Flag ElastiCache clusters with very low hit rates and almost no active connections. */ +export const elastiCacheIdleClusterRule = createRule({ + id: RULE_ID, + name: 'ElastiCache Cluster Idle', + description: + 'Flag available ElastiCache clusters whose 14-day average cache hit rate stays below 5% and average current connections stay below 2.', + message: RULE_MESSAGE, + provider: 'aws', + service: RULE_SERVICE, + supports: ['discovery'], + discoveryDependencies: ['aws-elasticache-clusters', 'aws-elasticache-cluster-activity'], + evaluateLive: ({ resources }) => { + const clustersById = new Map( + resources.get('aws-elasticache-clusters').map((cluster) => [cluster.cacheClusterId, cluster] as const), + ); + const findings = resources.get('aws-elasticache-cluster-activity').flatMap((activity) => { + const cluster = clustersById.get(activity.cacheClusterId); + + if (!cluster || cluster.cacheClusterStatus !== 'available') { + return []; + } + + return activity.averageCacheHitRateLast14Days !== null && + activity.averageCurrentConnectionsLast14Days !== null && + activity.averageCacheHitRateLast14Days < 5 && + activity.averageCurrentConnectionsLast14Days < 2 + ? [createFindingMatch(cluster.cacheClusterId, cluster.region, cluster.accountId)] + : []; + }); + + return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'discovery', findings); + }, +}); diff --git a/packages/rules/src/aws/elasticache/index.ts b/packages/rules/src/aws/elasticache/index.ts index 2126abd..b6a9ff2 100644 --- a/packages/rules/src/aws/elasticache/index.ts +++ b/packages/rules/src/aws/elasticache/index.ts @@ -1,4 +1,5 @@ +import { elastiCacheIdleClusterRule } from './idle-cluster.js'; import { elastiCacheReservedCoverageRule } from './reserved-coverage.js'; /** Aggregate AWS ElastiCache rule definitions. */ -export const elastiCacheRules = [elastiCacheReservedCoverageRule]; +export const elastiCacheRules = [elastiCacheReservedCoverageRule, elastiCacheIdleClusterRule]; diff --git a/packages/rules/src/aws/elb/idle.ts b/packages/rules/src/aws/elb/idle.ts new file mode 100644 index 0000000..f2b1e49 --- /dev/null +++ b/packages/rules/src/aws/elb/idle.ts @@ -0,0 +1,49 @@ +import { createFinding, createFindingMatch, createRule } from '../../shared/helpers.js'; +import { hasNoRegisteredTargets } from './shared.js'; + +const RULE_ID = 'CLDBRN-AWS-ELB-5'; +const RULE_SERVICE = 'elb'; +const RULE_MESSAGE = 'Load balancers with consistently low request volume should be reviewed for cleanup.'; + +/** Flag load balancers with low 14-day request activity unless a stricter empty-target rule already covers them. */ +export const elbIdleRule = createRule({ + id: RULE_ID, + name: 'Load Balancer Idle', + description: 'Flag load balancers whose 14-day average request count stays below 10 requests per day.', + message: RULE_MESSAGE, + provider: 'aws', + service: RULE_SERVICE, + supports: ['discovery'], + discoveryDependencies: ['aws-ec2-load-balancer-request-activity', 'aws-ec2-load-balancers', 'aws-ec2-target-groups'], + evaluateLive: ({ resources }) => { + const loadBalancers = resources.get('aws-ec2-load-balancers'); + const targetGroups = resources.get('aws-ec2-target-groups'); + const loadBalancerByArn = new Map( + loadBalancers.map((loadBalancer) => [loadBalancer.loadBalancerArn, loadBalancer] as const), + ); + const findings = resources + .get('aws-ec2-load-balancer-request-activity') + .filter( + (activity) => + activity.averageRequestsPerDayLast14Days !== null && activity.averageRequestsPerDayLast14Days < 10, + ) + .flatMap((activity) => { + const loadBalancer = loadBalancerByArn.get(activity.loadBalancerArn); + + if (!loadBalancer) { + return []; + } + + const alreadyCoveredByCleanupRule = + loadBalancer.loadBalancerType === 'classic' + ? loadBalancer.instanceCount === 0 + : hasNoRegisteredTargets(loadBalancer, targetGroups); + + return alreadyCoveredByCleanupRule + ? [] + : [createFindingMatch(loadBalancer.loadBalancerArn, loadBalancer.region, loadBalancer.accountId)]; + }); + + return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'discovery', findings); + }, +}); diff --git a/packages/rules/src/aws/elb/index.ts b/packages/rules/src/aws/elb/index.ts index d4bea54..467786b 100644 --- a/packages/rules/src/aws/elb/index.ts +++ b/packages/rules/src/aws/elb/index.ts @@ -1,6 +1,7 @@ import { elbAlbWithoutTargetsRule } from './alb-without-targets.js'; import { elbClassicWithoutInstancesRule } from './classic-without-instances.js'; import { elbGatewayWithoutTargetsRule } from './gateway-without-targets.js'; +import { elbIdleRule } from './idle.js'; import { elbNetworkWithoutTargetsRule } from './network-without-targets.js'; /** Aggregate AWS ELB rule definitions. */ @@ -8,5 +9,6 @@ export const elbRules = [ elbAlbWithoutTargetsRule, elbClassicWithoutInstancesRule, elbGatewayWithoutTargetsRule, + elbIdleRule, elbNetworkWithoutTargetsRule, ]; diff --git a/packages/rules/src/aws/index.ts b/packages/rules/src/aws/index.ts index eae3389..b4d5026 100644 --- a/packages/rules/src/aws/index.ts +++ b/packages/rules/src/aws/index.ts @@ -3,6 +3,7 @@ import { cloudfrontRules } from './cloudfront/index.js'; import { cloudtrailRules } from './cloudtrail/index.js'; import { cloudwatchRules } from './cloudwatch/index.js'; import { costexplorerRules } from './costexplorer/index.js'; +import { costguardrailsRules } from './costguardrails/index.js'; import { dynamodbRules } from './dynamodb/index.js'; import { ebsRules } from './ebs/index.js'; import { ec2Rules } from './ec2/index.js'; @@ -26,6 +27,7 @@ export const awsRules = [ ...cloudfrontRules, ...cloudtrailRules, ...cloudwatchRules, + ...costguardrailsRules, ...costexplorerRules, ...dynamodbRules, ...ec2Rules, diff --git a/packages/rules/src/aws/lambda/index.ts b/packages/rules/src/aws/lambda/index.ts index 929ee1d..4583455 100644 --- a/packages/rules/src/aws/lambda/index.ts +++ b/packages/rules/src/aws/lambda/index.ts @@ -1,7 +1,12 @@ import { lambdaCostOptimalArchitectureRule } from './cost-optimal-architecture.js'; import { lambdaExcessiveTimeoutRule } from './excessive-timeout.js'; import { lambdaHighErrorRateRule } from './high-error-rate.js'; +import { lambdaMemoryOverprovisioningRule } from './memory-overprovisioning.js'; // Intent: aggregate AWS Lambda rule definitions. -// TODO(cloudburn): add memory-rightsizing and idle-function checks. -export const lambdaRules = [lambdaCostOptimalArchitectureRule, lambdaHighErrorRateRule, lambdaExcessiveTimeoutRule]; +export const lambdaRules = [ + lambdaCostOptimalArchitectureRule, + lambdaHighErrorRateRule, + lambdaExcessiveTimeoutRule, + lambdaMemoryOverprovisioningRule, +]; diff --git a/packages/rules/src/aws/lambda/memory-overprovisioning.ts b/packages/rules/src/aws/lambda/memory-overprovisioning.ts new file mode 100644 index 0000000..0d15284 --- /dev/null +++ b/packages/rules/src/aws/lambda/memory-overprovisioning.ts @@ -0,0 +1,52 @@ +import { createFinding, createFindingMatch, createRule } from '../../shared/helpers.js'; + +const RULE_ID = 'CLDBRN-AWS-LAMBDA-4'; +const RULE_SERVICE = 'lambda'; +const RULE_MESSAGE = 'Lambda functions should not keep memory far above their observed execution needs.'; +const MIN_MEMORY_REVIEW_MB = 256; +const MAX_DURATION_TO_TIMEOUT_RATIO = 0.3; + +const getFunctionKey = (accountId: string, region: string, functionName: string): string => + `${accountId}:${region}:${functionName}`; + +/** Flag Lambda functions whose configured memory stays well above observed execution needs. */ +export const lambdaMemoryOverprovisioningRule = createRule({ + id: RULE_ID, + name: 'Lambda Function Memory Overprovisioned', + description: + 'Flag Lambda functions above 256 MB whose observed 7-day average duration uses less than 30% of the configured timeout.', + message: RULE_MESSAGE, + provider: 'aws', + service: RULE_SERVICE, + supports: ['discovery'], + discoveryDependencies: ['aws-lambda-functions', 'aws-lambda-function-metrics'], + evaluateLive: ({ resources }) => { + const metricsByFunctionKey = new Map( + resources + .get('aws-lambda-function-metrics') + .map((metric) => [getFunctionKey(metric.accountId, metric.region, metric.functionName), metric] as const), + ); + + const findings = resources + .get('aws-lambda-functions') + .filter((fn) => { + if (fn.memorySizeMb <= MIN_MEMORY_REVIEW_MB) { + return false; + } + + const metric = metricsByFunctionKey.get(getFunctionKey(fn.accountId, fn.region, fn.functionName)); + + return ( + metric?.totalInvocationsLast7Days !== null && + metric?.totalInvocationsLast7Days !== undefined && + metric.totalInvocationsLast7Days > 0 && + metric.averageDurationMsLast7Days !== null && + metric.averageDurationMsLast7Days !== undefined && + metric.averageDurationMsLast7Days < fn.timeoutSeconds * 1000 * MAX_DURATION_TO_TIMEOUT_RATIO + ); + }) + .map((fn) => createFindingMatch(fn.functionName, fn.region, fn.accountId)); + + return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'discovery', findings); + }, +}); diff --git a/packages/rules/src/index.ts b/packages/rules/src/index.ts index 58a9107..0ac867f 100644 --- a/packages/rules/src/index.ts +++ b/packages/rules/src/index.ts @@ -15,20 +15,26 @@ export { export type { AwsApiGatewayStage, AwsCloudFrontDistribution, + AwsCloudFrontDistributionRequestActivity, AwsCloudTrailTrail, AwsCloudWatchLogGroup, + AwsCloudWatchLogMetricFilterCoverage, AwsCloudWatchLogStream, + AwsCostAnomalyMonitor, + AwsCostGuardrailBudget, AwsCostUsage, AwsDiscoveredResource, AwsDiscoveryCatalog, AwsDynamoDbAutoscaling, AwsDynamoDbTable, + AwsDynamoDbTableUtilization, AwsEbsSnapshot, AwsEbsVolume, AwsEc2ElasticIp, AwsEc2Instance, AwsEc2InstanceUtilization, AwsEc2LoadBalancer, + AwsEc2LoadBalancerRequestActivity, AwsEc2ReservedInstance, AwsEc2TargetGroup, AwsEc2VpcEndpointActivity, @@ -40,6 +46,7 @@ export type { AwsEcsServiceAutoscaling, AwsEksNodegroup, AwsElastiCacheCluster, + AwsElastiCacheClusterActivity, AwsElastiCacheReservedNode, AwsEmrCluster, AwsEmrClusterMetric, diff --git a/packages/rules/src/shared/metadata.ts b/packages/rules/src/shared/metadata.ts index 8da2221..3033af2 100644 --- a/packages/rules/src/shared/metadata.ts +++ b/packages/rules/src/shared/metadata.ts @@ -89,6 +89,14 @@ export type AwsCloudWatchLogStream = { accountId: string; }; +/** Discovered CloudWatch Logs metric-filter coverage keyed by log group. */ +export type AwsCloudWatchLogMetricFilterCoverage = { + logGroupName: string; + metricFilterCount: number; + region: string; + accountId: string; +}; + /** Discovered CloudFront distribution normalized for price-class review checks. */ export type AwsCloudFrontDistribution = { distributionArn: string; @@ -98,6 +106,16 @@ export type AwsCloudFrontDistribution = { accountId: string; }; +/** Discovered CloudFront distribution with 30-day request activity coverage. */ +export type AwsCloudFrontDistributionRequestActivity = { + distributionArn: string; + distributionId: string; + /** `null` means CloudWatch returned incomplete datapoints for the 30-day lookback window. */ + totalRequestsLast30Days: number | null; + region: string; + accountId: string; +}; + /** Cost Explorer service spend comparison across the last two full months. */ export type AwsCostUsage = { serviceName: string; @@ -111,6 +129,18 @@ export type AwsCostUsage = { accountId: string; }; +/** Account-scoped AWS Budget summary used by cost guardrail rules. */ +export type AwsCostGuardrailBudget = { + budgetCount: number; + accountId: string; +}; + +/** Account-scoped Cost Anomaly Detection monitor summary used by guardrail rules. */ +export type AwsCostAnomalyMonitor = { + monitorCount: number; + accountId: string; +}; + /** Discovered AWS ECR repository with lifecycle-policy state. */ export type AwsEcrRepository = { repositoryName: string; @@ -164,6 +194,17 @@ export type AwsElastiCacheCluster = { accountId: string; }; +/** Discovered ElastiCache cluster with 14-day cache-hit and connection activity coverage. */ +export type AwsElastiCacheClusterActivity = { + cacheClusterId: string; + /** `null` means CloudWatch returned incomplete datapoints or the cluster engine is unsupported in v1. */ + averageCacheHitRateLast14Days: number | null; + /** `null` means CloudWatch returned incomplete datapoints or the cluster engine is unsupported in v1. */ + averageCurrentConnectionsLast14Days: number | null; + region: string; + accountId: string; +}; + /** Discovered ElastiCache reserved node normalized for coverage checks. */ export type AwsElastiCacheReservedNode = { reservedCacheNodeId: string; @@ -215,6 +256,8 @@ export type AwsLambdaFunction = { functionName: string; /** Normalized function architectures. Missing AWS API values default to `['x86_64']`. */ architectures: string[]; + /** Configured function memory size in MB. */ + memorySizeMb: number; /** Configured function timeout in seconds. */ timeoutSeconds: number; region: string; @@ -349,6 +392,15 @@ export type AwsEc2LoadBalancer = { accountId: string; }; +/** Discovered Elastic Load Balancer with 14-day request activity coverage. */ +export type AwsEc2LoadBalancerRequestActivity = { + loadBalancerArn: string; + /** `null` means CloudWatch returned incomplete datapoints for the 14-day lookback window. */ + averageRequestsPerDayLast14Days: number | null; + region: string; + accountId: string; +}; + /** Discovered target group normalized for target registration checks. */ export type AwsEc2TargetGroup = { targetGroupArn: string; @@ -446,6 +498,18 @@ export type AwsDynamoDbAutoscaling = { accountId: string; }; +/** Discovered DynamoDB table with 30-day consumed-capacity summaries. */ +export type AwsDynamoDbTableUtilization = { + tableArn: string; + tableName: string; + /** `null` means CloudWatch returned incomplete datapoints for the 30-day read lookback window. */ + totalConsumedReadCapacityUnitsLast30Days: number | null; + /** `null` means CloudWatch returned incomplete datapoints for the 30-day write lookback window. */ + totalConsumedWriteCapacityUnitsLast30Days: number | null; + region: string; + accountId: string; +}; + /** Shared S3 lifecycle and storage-optimization analysis flags across scan modes. */ export type AwsS3BucketAnalysisFlags = { hasLifecycleSignal: boolean; @@ -544,13 +608,19 @@ export type DiscoveryDatasetKey = | 'aws-apigateway-stages' | 'aws-cloudtrail-trails' | 'aws-cloudfront-distributions' + | 'aws-cloudfront-distribution-request-activity' | 'aws-cloudwatch-log-groups' + | 'aws-cloudwatch-log-metric-filter-coverage' | 'aws-cloudwatch-log-streams' | 'aws-cost-usage' + | 'aws-cost-anomaly-monitors' + | 'aws-cost-guardrail-budgets' | 'aws-dynamodb-autoscaling' + | 'aws-dynamodb-table-utilization' | 'aws-dynamodb-tables' | 'aws-ebs-snapshots' | 'aws-ebs-volumes' + | 'aws-elasticache-cluster-activity' | 'aws-elasticache-clusters' | 'aws-elasticache-reserved-nodes' | 'aws-ecs-autoscaling' @@ -562,6 +632,7 @@ export type DiscoveryDatasetKey = | 'aws-ec2-elastic-ips' | 'aws-ec2-instances' | 'aws-ec2-instance-utilization' + | 'aws-ec2-load-balancer-request-activity' | 'aws-ec2-load-balancers' | 'aws-ec2-reserved-instances' | 'aws-ec2-target-groups' @@ -590,13 +661,19 @@ export type DiscoveryDatasetMap = { 'aws-apigateway-stages': AwsApiGatewayStage[]; 'aws-cloudtrail-trails': AwsCloudTrailTrail[]; 'aws-cloudfront-distributions': AwsCloudFrontDistribution[]; + 'aws-cloudfront-distribution-request-activity': AwsCloudFrontDistributionRequestActivity[]; 'aws-cloudwatch-log-groups': AwsCloudWatchLogGroup[]; + 'aws-cloudwatch-log-metric-filter-coverage': AwsCloudWatchLogMetricFilterCoverage[]; 'aws-cloudwatch-log-streams': AwsCloudWatchLogStream[]; 'aws-cost-usage': AwsCostUsage[]; + 'aws-cost-anomaly-monitors': AwsCostAnomalyMonitor[]; + 'aws-cost-guardrail-budgets': AwsCostGuardrailBudget[]; 'aws-dynamodb-autoscaling': AwsDynamoDbAutoscaling[]; + 'aws-dynamodb-table-utilization': AwsDynamoDbTableUtilization[]; 'aws-dynamodb-tables': AwsDynamoDbTable[]; 'aws-ebs-snapshots': AwsEbsSnapshot[]; 'aws-ebs-volumes': AwsEbsVolume[]; + 'aws-elasticache-cluster-activity': AwsElastiCacheClusterActivity[]; 'aws-elasticache-clusters': AwsElastiCacheCluster[]; 'aws-elasticache-reserved-nodes': AwsElastiCacheReservedNode[]; 'aws-ecs-autoscaling': AwsEcsServiceAutoscaling[]; @@ -608,6 +685,7 @@ export type DiscoveryDatasetMap = { 'aws-ec2-elastic-ips': AwsEc2ElasticIp[]; 'aws-ec2-instances': AwsEc2Instance[]; 'aws-ec2-instance-utilization': AwsEc2InstanceUtilization[]; + 'aws-ec2-load-balancer-request-activity': AwsEc2LoadBalancerRequestActivity[]; 'aws-ec2-load-balancers': AwsEc2LoadBalancer[]; 'aws-ec2-reserved-instances': AwsEc2ReservedInstance[]; 'aws-ec2-target-groups': AwsEc2TargetGroup[]; diff --git a/packages/rules/test/cloudfront-unused-distribution.test.ts b/packages/rules/test/cloudfront-unused-distribution.test.ts new file mode 100644 index 0000000..5381c72 --- /dev/null +++ b/packages/rules/test/cloudfront-unused-distribution.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { cloudFrontUnusedDistributionRule } from '../src/aws/cloudfront/unused-distribution.js'; +import type { AwsCloudFrontDistributionRequestActivity } from '../src/index.js'; +import { LiveResourceBag } from '../src/index.js'; + +const createActivity = ( + overrides: Partial = {}, +): AwsCloudFrontDistributionRequestActivity => ({ + accountId: '123456789012', + distributionArn: 'arn:aws:cloudfront::123456789012:distribution/E1234567890ABC', + distributionId: 'E1234567890ABC', + region: 'global', + totalRequestsLast30Days: 99, + ...overrides, +}); + +describe('cloudFrontUnusedDistributionRule', () => { + it('flags distributions with fewer than 100 requests over the last 30 days', () => { + const finding = cloudFrontUnusedDistributionRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-cloudfront-distribution-request-activity': [createActivity()], + }), + }); + + expect(finding?.findings).toEqual([ + { + accountId: '123456789012', + region: 'global', + resourceId: 'arn:aws:cloudfront::123456789012:distribution/E1234567890ABC', + }, + ]); + }); + + it('skips distributions with incomplete metric coverage', () => { + const finding = cloudFrontUnusedDistributionRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-cloudfront-distribution-request-activity': [createActivity({ totalRequestsLast30Days: null })], + }), + }); + + expect(finding).toBeNull(); + }); + + it('skips distributions with at least 100 requests over the last 30 days', () => { + const finding = cloudFrontUnusedDistributionRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-cloudfront-distribution-request-activity': [createActivity({ totalRequestsLast30Days: 100 })], + }), + }); + + expect(finding).toBeNull(); + }); +}); diff --git a/packages/rules/test/cloudwatch-log-group-no-metric-filters.test.ts b/packages/rules/test/cloudwatch-log-group-no-metric-filters.test.ts new file mode 100644 index 0000000..2614041 --- /dev/null +++ b/packages/rules/test/cloudwatch-log-group-no-metric-filters.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; +import { cloudWatchLogGroupNoMetricFiltersRule } from '../src/aws/cloudwatch/log-group-no-metric-filters.js'; +import type { AwsCloudWatchLogGroup, AwsCloudWatchLogMetricFilterCoverage } from '../src/index.js'; +import { LiveResourceBag } from '../src/index.js'; + +const createLogGroup = (overrides: Partial = {}): AwsCloudWatchLogGroup => ({ + accountId: '123456789012', + logGroupArn: 'arn:aws:logs:us-east-1:123456789012:log-group:/aws/lambda/app', + logGroupName: '/aws/lambda/app', + region: 'us-east-1', + storedBytes: 2_147_483_648, + ...overrides, +}); + +const createMetricFilterCoverage = ( + overrides: Partial = {}, +): AwsCloudWatchLogMetricFilterCoverage => ({ + accountId: '123456789012', + logGroupName: '/aws/lambda/app', + metricFilterCount: 0, + region: 'us-east-1', + ...overrides, +}); + +describe('cloudWatchLogGroupNoMetricFiltersRule', () => { + it('flags 1 GB+ log groups with zero metric filters', () => { + const finding = cloudWatchLogGroupNoMetricFiltersRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-cloudwatch-log-groups': [createLogGroup()], + 'aws-cloudwatch-log-metric-filter-coverage': [createMetricFilterCoverage()], + }), + }); + + expect(finding).toEqual({ + ruleId: 'CLDBRN-AWS-CLOUDWATCH-3', + service: 'cloudwatch', + source: 'discovery', + message: + 'CloudWatch log groups storing at least 1 GB should define metric filters or reduce retention aggressively.', + findings: [ + { + accountId: '123456789012', + region: 'us-east-1', + resourceId: '/aws/lambda/app', + }, + ], + }); + }); + + it('skips smaller log groups', () => { + const finding = cloudWatchLogGroupNoMetricFiltersRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-cloudwatch-log-groups': [createLogGroup({ storedBytes: 1_073_741_823 })], + 'aws-cloudwatch-log-metric-filter-coverage': [createMetricFilterCoverage()], + }), + }); + + expect(finding).toBeNull(); + }); + + it('skips log groups with metric filters', () => { + const finding = cloudWatchLogGroupNoMetricFiltersRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-cloudwatch-log-groups': [createLogGroup()], + 'aws-cloudwatch-log-metric-filter-coverage': [createMetricFilterCoverage({ metricFilterCount: 2 })], + }), + }); + + expect(finding).toBeNull(); + }); +}); diff --git a/packages/rules/test/costguardrails-missing-anomaly-detection.test.ts b/packages/rules/test/costguardrails-missing-anomaly-detection.test.ts new file mode 100644 index 0000000..128f1d1 --- /dev/null +++ b/packages/rules/test/costguardrails-missing-anomaly-detection.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { costGuardrailMissingAnomalyDetectionRule } from '../src/aws/costguardrails/missing-anomaly-detection.js'; +import type { AwsCostAnomalyMonitor } from '../src/index.js'; +import { LiveResourceBag } from '../src/index.js'; + +const createMonitor = (overrides: Partial = {}): AwsCostAnomalyMonitor => ({ + accountId: '123456789012', + monitorCount: 1, + ...overrides, +}); + +describe('costGuardrailMissingAnomalyDetectionRule', () => { + it('emits an account-level finding when no anomaly monitors exist', () => { + const finding = costGuardrailMissingAnomalyDetectionRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-cost-anomaly-monitors': [createMonitor({ monitorCount: 0 })], + }), + }); + + expect(finding).toEqual({ + ruleId: 'CLDBRN-AWS-COSTGUARDRAILS-2', + service: 'costguardrails', + source: 'discovery', + message: 'AWS accounts should enable Cost Anomaly Detection monitors for spend spikes.', + findings: [ + { + accountId: '123456789012', + resourceId: '123456789012', + }, + ], + }); + }); + + it('skips accounts that already have anomaly monitors', () => { + const finding = costGuardrailMissingAnomalyDetectionRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-cost-anomaly-monitors': [createMonitor()], + }), + }); + + expect(finding).toBeNull(); + }); +}); diff --git a/packages/rules/test/costguardrails-missing-budgets.test.ts b/packages/rules/test/costguardrails-missing-budgets.test.ts new file mode 100644 index 0000000..aa184d6 --- /dev/null +++ b/packages/rules/test/costguardrails-missing-budgets.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { costGuardrailMissingBudgetsRule } from '../src/aws/costguardrails/missing-budgets.js'; +import type { AwsCostGuardrailBudget } from '../src/index.js'; +import { LiveResourceBag } from '../src/index.js'; + +const createBudget = (overrides: Partial = {}): AwsCostGuardrailBudget => ({ + accountId: '123456789012', + budgetCount: 1, + ...overrides, +}); + +describe('costGuardrailMissingBudgetsRule', () => { + it('emits an account-level finding when no budgets exist', () => { + const finding = costGuardrailMissingBudgetsRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-cost-guardrail-budgets': [createBudget({ budgetCount: 0 })], + }), + }); + + expect(finding).toEqual({ + ruleId: 'CLDBRN-AWS-COSTGUARDRAILS-1', + service: 'costguardrails', + source: 'discovery', + message: 'AWS accounts should define at least one AWS Budget for spend guardrails.', + findings: [ + { + accountId: '123456789012', + resourceId: '123456789012', + }, + ], + }); + }); + + it('skips accounts that already have budgets', () => { + const finding = costGuardrailMissingBudgetsRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-cost-guardrail-budgets': [createBudget()], + }), + }); + + expect(finding).toBeNull(); + }); +}); diff --git a/packages/rules/test/dynamodb-unused-table.test.ts b/packages/rules/test/dynamodb-unused-table.test.ts new file mode 100644 index 0000000..32af140 --- /dev/null +++ b/packages/rules/test/dynamodb-unused-table.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; +import { dynamoDbUnusedTableRule } from '../src/aws/dynamodb/unused-table.js'; +import type { AwsDynamoDbTable, AwsDynamoDbTableUtilization } from '../src/index.js'; +import { LiveResourceBag } from '../src/index.js'; + +const createTable = (overrides: Partial = {}): AwsDynamoDbTable => ({ + accountId: '123456789012', + billingMode: 'PROVISIONED', + region: 'us-east-1', + tableArn: 'arn:aws:dynamodb:us-east-1:123456789012:table/orders', + tableName: 'orders', + tableStatus: 'ACTIVE', + ...overrides, +}); + +const createUtilization = (overrides: Partial = {}): AwsDynamoDbTableUtilization => ({ + accountId: '123456789012', + region: 'us-east-1', + tableArn: 'arn:aws:dynamodb:us-east-1:123456789012:table/orders', + tableName: 'orders', + totalConsumedReadCapacityUnitsLast30Days: 0, + totalConsumedWriteCapacityUnitsLast30Days: 0, + ...overrides, +}); + +describe('dynamoDbUnusedTableRule', () => { + it('flags provisioned tables with zero consumed read and write units over 30 days', () => { + const finding = dynamoDbUnusedTableRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-dynamodb-table-utilization': [createUtilization()], + 'aws-dynamodb-tables': [createTable()], + }), + }); + + expect(finding).toEqual({ + ruleId: 'CLDBRN-AWS-DYNAMODB-3', + service: 'dynamodb', + source: 'discovery', + message: 'Provisioned DynamoDB tables should not remain unused for 30 days.', + findings: [ + { + accountId: '123456789012', + region: 'us-east-1', + resourceId: 'arn:aws:dynamodb:us-east-1:123456789012:table/orders', + }, + ], + }); + }); + + it('skips on-demand tables and active provisioned tables', () => { + const finding = dynamoDbUnusedTableRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-dynamodb-table-utilization': [ + createUtilization({ totalConsumedReadCapacityUnitsLast30Days: 10 }), + createUtilization({ + tableArn: 'arn:aws:dynamodb:us-east-1:123456789012:table/audit', + tableName: 'audit', + }), + ], + 'aws-dynamodb-tables': [ + createTable(), + createTable({ + billingMode: 'PAY_PER_REQUEST', + tableArn: 'arn:aws:dynamodb:us-east-1:123456789012:table/audit', + tableName: 'audit', + }), + ], + }), + }); + + expect(finding).toBeNull(); + }); + + it('skips tables without complete metric coverage', () => { + const finding = dynamoDbUnusedTableRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-dynamodb-table-utilization': [ + createUtilization({ + totalConsumedReadCapacityUnitsLast30Days: null, + totalConsumedWriteCapacityUnitsLast30Days: null, + }), + ], + 'aws-dynamodb-tables': [createTable()], + }), + }); + + expect(finding).toBeNull(); + }); +}); diff --git a/packages/rules/test/elasticache-idle-cluster.test.ts b/packages/rules/test/elasticache-idle-cluster.test.ts new file mode 100644 index 0000000..6aacbf0 --- /dev/null +++ b/packages/rules/test/elasticache-idle-cluster.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; +import { elastiCacheIdleClusterRule } from '../src/aws/elasticache/idle-cluster.js'; +import type { AwsElastiCacheCluster, AwsElastiCacheClusterActivity } from '../src/index.js'; +import { LiveResourceBag } from '../src/index.js'; + +const createCluster = (overrides: Partial = {}): AwsElastiCacheCluster => ({ + accountId: '123456789012', + cacheClusterCreateTime: '2025-01-01T00:00:00.000Z', + cacheClusterId: 'cache-prod', + cacheClusterStatus: 'available', + cacheNodeType: 'cache.r6g.large', + engine: 'redis', + numCacheNodes: 2, + region: 'us-east-1', + ...overrides, +}); + +const createActivity = (overrides: Partial = {}): AwsElastiCacheClusterActivity => ({ + accountId: '123456789012', + averageCacheHitRateLast14Days: 4.9, + averageCurrentConnectionsLast14Days: 1.9, + cacheClusterId: 'cache-prod', + region: 'us-east-1', + ...overrides, +}); + +describe('elastiCacheIdleClusterRule', () => { + it('flags available clusters with low hit rates and fewer than 2 average connections', () => { + const finding = elastiCacheIdleClusterRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-elasticache-cluster-activity': [createActivity()], + 'aws-elasticache-clusters': [createCluster()], + }), + }); + + expect(finding?.findings).toEqual([ + { + accountId: '123456789012', + region: 'us-east-1', + resourceId: 'cache-prod', + }, + ]); + }); + + it('skips clusters with incomplete metric coverage', () => { + const finding = elastiCacheIdleClusterRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-elasticache-cluster-activity': [createActivity({ averageCacheHitRateLast14Days: null })], + 'aws-elasticache-clusters': [createCluster()], + }), + }); + + expect(finding).toBeNull(); + }); + + it('skips non-available clusters', () => { + const finding = elastiCacheIdleClusterRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-elasticache-cluster-activity': [createActivity()], + 'aws-elasticache-clusters': [createCluster({ cacheClusterStatus: 'modifying' })], + }), + }); + + expect(finding).toBeNull(); + }); + + it('skips clusters at or above the hit-rate or connection thresholds', () => { + const finding = elastiCacheIdleClusterRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-elasticache-cluster-activity': [ + createActivity({ averageCacheHitRateLast14Days: 5, averageCurrentConnectionsLast14Days: 1 }), + createActivity({ + averageCacheHitRateLast14Days: 4, + averageCurrentConnectionsLast14Days: 2, + cacheClusterId: 'cache-prod-2', + }), + ], + 'aws-elasticache-clusters': [createCluster(), createCluster({ cacheClusterId: 'cache-prod-2' })], + }), + }); + + expect(finding).toBeNull(); + }); +}); diff --git a/packages/rules/test/elb-idle.test.ts b/packages/rules/test/elb-idle.test.ts new file mode 100644 index 0000000..d5d6279 --- /dev/null +++ b/packages/rules/test/elb-idle.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; +import { elbIdleRule } from '../src/aws/elb/idle.js'; +import type { AwsEc2LoadBalancer, AwsEc2LoadBalancerRequestActivity, AwsEc2TargetGroup } from '../src/index.js'; +import { LiveResourceBag } from '../src/index.js'; + +const createLoadBalancer = (overrides: Partial = {}): AwsEc2LoadBalancer => ({ + accountId: '123456789012', + attachedTargetGroupArns: ['arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/alb/123'], + instanceCount: 0, + loadBalancerArn: 'arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/alb/123', + loadBalancerName: 'alb', + loadBalancerType: 'application', + region: 'us-east-1', + ...overrides, +}); + +const createTargetGroup = (overrides: Partial = {}): AwsEc2TargetGroup => ({ + accountId: '123456789012', + loadBalancerArns: ['arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/alb/123'], + region: 'us-east-1', + registeredTargetCount: 1, + targetGroupArn: 'arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/alb/123', + ...overrides, +}); + +const createActivity = ( + overrides: Partial = {}, +): AwsEc2LoadBalancerRequestActivity => ({ + accountId: '123456789012', + averageRequestsPerDayLast14Days: 9, + loadBalancerArn: 'arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/alb/123', + region: 'us-east-1', + ...overrides, +}); + +describe('elbIdleRule', () => { + it('flags load balancers averaging fewer than 10 requests per day over 14 days', () => { + const finding = elbIdleRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-ec2-load-balancer-request-activity': [createActivity()], + 'aws-ec2-load-balancers': [createLoadBalancer()], + 'aws-ec2-target-groups': [createTargetGroup()], + }), + }); + + expect(finding?.findings).toEqual([ + { + accountId: '123456789012', + region: 'us-east-1', + resourceId: 'arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/alb/123', + }, + ]); + }); + + it('skips load balancers with incomplete metric coverage', () => { + const finding = elbIdleRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-ec2-load-balancer-request-activity': [createActivity({ averageRequestsPerDayLast14Days: null })], + 'aws-ec2-load-balancers': [createLoadBalancer()], + 'aws-ec2-target-groups': [createTargetGroup()], + }), + }); + + expect(finding).toBeNull(); + }); + + it('skips load balancers already caught by empty-target cleanup rules', () => { + const finding = elbIdleRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-ec2-load-balancer-request-activity': [createActivity()], + 'aws-ec2-load-balancers': [createLoadBalancer()], + 'aws-ec2-target-groups': [createTargetGroup({ registeredTargetCount: 0 })], + }), + }); + + expect(finding).toBeNull(); + }); + + it('skips load balancers with 10 or more average daily requests', () => { + const finding = elbIdleRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-ec2-load-balancer-request-activity': [createActivity({ averageRequestsPerDayLast14Days: 10 })], + 'aws-ec2-load-balancers': [createLoadBalancer()], + 'aws-ec2-target-groups': [createTargetGroup()], + }), + }); + + expect(finding).toBeNull(); + }); +}); diff --git a/packages/rules/test/exports.test.ts b/packages/rules/test/exports.test.ts index d686d2e..bdf23dc 100644 --- a/packages/rules/test/exports.test.ts +++ b/packages/rules/test/exports.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import type { AwsApiGatewayStage, AwsCloudFrontDistribution, + AwsCloudFrontDistributionRequestActivity, AwsCloudTrailTrail, AwsCloudWatchLogGroup, AwsCostUsage, @@ -11,12 +12,14 @@ import type { AwsEbsVolume, AwsEc2Instance, AwsEc2LoadBalancer, + AwsEc2LoadBalancerRequestActivity, AwsEc2ReservedInstance, AwsEc2TargetGroup, AwsEcsClusterMetric, AwsEcsService, AwsEksNodegroup, AwsElastiCacheCluster, + AwsElastiCacheClusterActivity, AwsElastiCacheReservedNode, AwsEmrCluster, AwsEmrClusterMetric, @@ -52,13 +55,18 @@ describe('rule exports', () => { expect.arrayContaining([ 'CLDBRN-AWS-APIGATEWAY-1', 'CLDBRN-AWS-CLOUDFRONT-1', + 'CLDBRN-AWS-CLOUDFRONT-2', 'CLDBRN-AWS-CLOUDTRAIL-1', 'CLDBRN-AWS-CLOUDTRAIL-2', 'CLDBRN-AWS-CLOUDWATCH-1', 'CLDBRN-AWS-CLOUDWATCH-2', + 'CLDBRN-AWS-CLOUDWATCH-3', + 'CLDBRN-AWS-COSTGUARDRAILS-1', + 'CLDBRN-AWS-COSTGUARDRAILS-2', 'CLDBRN-AWS-COSTEXPLORER-1', 'CLDBRN-AWS-DYNAMODB-1', 'CLDBRN-AWS-DYNAMODB-2', + 'CLDBRN-AWS-DYNAMODB-3', 'CLDBRN-AWS-EC2-2', 'CLDBRN-AWS-EC2-3', 'CLDBRN-AWS-EC2-4', @@ -79,14 +87,17 @@ describe('rule exports', () => { 'CLDBRN-AWS-ECR-1', 'CLDBRN-AWS-EKS-1', 'CLDBRN-AWS-ELASTICACHE-1', + 'CLDBRN-AWS-ELASTICACHE-2', 'CLDBRN-AWS-ELB-1', 'CLDBRN-AWS-ELB-2', 'CLDBRN-AWS-ELB-3', 'CLDBRN-AWS-ELB-4', + 'CLDBRN-AWS-ELB-5', 'CLDBRN-AWS-EMR-1', 'CLDBRN-AWS-EMR-2', 'CLDBRN-AWS-LAMBDA-2', 'CLDBRN-AWS-LAMBDA-3', + 'CLDBRN-AWS-LAMBDA-4', 'CLDBRN-AWS-RDS-2', 'CLDBRN-AWS-RDS-3', 'CLDBRN-AWS-RDS-4', @@ -219,6 +230,19 @@ describe('rule exports', () => { registeredTargetCount: 0, targetGroupArn: 'arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/app/123', }; + const cloudFrontRequestActivity: AwsCloudFrontDistributionRequestActivity = { + accountId: '123456789012', + distributionArn: cloudFrontDistribution.distributionArn, + distributionId: cloudFrontDistribution.distributionId, + region: 'global', + totalRequestsLast30Days: 42, + }; + const loadBalancerRequestActivity: AwsEc2LoadBalancerRequestActivity = { + accountId: '123456789012', + averageRequestsPerDayLast14Days: 7, + loadBalancerArn: loadBalancer.loadBalancerArn, + region: 'us-east-1', + }; const cacheCluster: AwsElastiCacheCluster = { accountId: '123456789012', cacheClusterCreateTime: '2025-01-01T00:00:00.000Z', @@ -229,6 +253,13 @@ describe('rule exports', () => { numCacheNodes: 2, region: 'us-east-1', }; + const cacheClusterActivity: AwsElastiCacheClusterActivity = { + accountId: '123456789012', + averageCacheHitRateLast14Days: 4.5, + averageCurrentConnectionsLast14Days: 1.5, + cacheClusterId: cacheCluster.cacheClusterId, + region: 'us-east-1', + }; const reservedCacheNode: AwsElastiCacheReservedNode = { accountId: '123456789012', cacheNodeCount: 2, @@ -372,6 +403,7 @@ describe('rule exports', () => { const apiGatewayDatasetKey: DiscoveryDatasetKey = 'aws-apigateway-stages'; const cloudFrontDatasetKey: DiscoveryDatasetKey = 'aws-cloudfront-distributions'; + const cloudFrontRequestActivityDatasetKey: DiscoveryDatasetKey = 'aws-cloudfront-distribution-request-activity'; const datasetKey: DiscoveryDatasetKey = 'aws-rds-instances'; const cloudWatchDatasetKey: DiscoveryDatasetKey = 'aws-cloudwatch-log-groups'; const cloudWatchLogStreamDatasetKey: DiscoveryDatasetKey = 'aws-cloudwatch-log-streams'; @@ -379,9 +411,11 @@ describe('rule exports', () => { const dynamoDbAutoscalingDatasetKey: DiscoveryDatasetKey = 'aws-dynamodb-autoscaling'; const dynamoDbTableDatasetKey: DiscoveryDatasetKey = 'aws-dynamodb-tables'; const ecsAutoscalingDatasetKey: DiscoveryDatasetKey = 'aws-ecs-autoscaling'; + const elastiCacheActivityDatasetKey: DiscoveryDatasetKey = 'aws-elasticache-cluster-activity'; const elastiCacheDatasetKey: DiscoveryDatasetKey = 'aws-elasticache-clusters'; const elastiCacheReservedDatasetKey: DiscoveryDatasetKey = 'aws-elasticache-reserved-nodes'; const loadBalancerDatasetKey: DiscoveryDatasetKey = 'aws-ec2-load-balancers'; + const loadBalancerRequestActivityDatasetKey: DiscoveryDatasetKey = 'aws-ec2-load-balancer-request-activity'; const emrDatasetKey: DiscoveryDatasetKey = 'aws-emr-clusters'; const emrMetricDatasetKey: DiscoveryDatasetKey = 'aws-emr-cluster-metrics'; const reservedInstanceDatasetKey: DiscoveryDatasetKey = 'aws-ec2-reserved-instances'; @@ -397,6 +431,7 @@ describe('rule exports', () => { expect(apiGatewayDatasetKey).toBe('aws-apigateway-stages'); expect(cloudFrontDatasetKey).toBe('aws-cloudfront-distributions'); + expect(cloudFrontRequestActivityDatasetKey).toBe('aws-cloudfront-distribution-request-activity'); expect(datasetKey).toBe('aws-rds-instances'); expect(cloudWatchDatasetKey).toBe('aws-cloudwatch-log-groups'); expect(cloudWatchLogStreamDatasetKey).toBe('aws-cloudwatch-log-streams'); @@ -404,9 +439,11 @@ describe('rule exports', () => { expect(dynamoDbAutoscalingDatasetKey).toBe('aws-dynamodb-autoscaling'); expect(dynamoDbTableDatasetKey).toBe('aws-dynamodb-tables'); expect(ecsAutoscalingDatasetKey).toBe('aws-ecs-autoscaling'); + expect(elastiCacheActivityDatasetKey).toBe('aws-elasticache-cluster-activity'); expect(elastiCacheDatasetKey).toBe('aws-elasticache-clusters'); expect(elastiCacheReservedDatasetKey).toBe('aws-elasticache-reserved-nodes'); expect(loadBalancerDatasetKey).toBe('aws-ec2-load-balancers'); + expect(loadBalancerRequestActivityDatasetKey).toBe('aws-ec2-load-balancer-request-activity'); expect(emrDatasetKey).toBe('aws-emr-clusters'); expect(emrMetricDatasetKey).toBe('aws-emr-cluster-metrics'); expect(reservedInstanceDatasetKey).toBe('aws-ec2-reserved-instances'); @@ -422,11 +459,14 @@ describe('rule exports', () => { expect(dynamoDbTable.tableName).toBe('orders'); expect(dynamoDbAutoscaling.hasReadTarget).toBe(true); expect(targetGroupDatasetKey).toBe('aws-ec2-target-groups'); + expect(cloudFrontRequestActivity.totalRequestsLast30Days).toBe(42); + expect(loadBalancerRequestActivity.averageRequestsPerDayLast14Days).toBe(7); expect(route53Zone.zoneName).toBe('example.com.'); expect(route53Record.ttl).toBe(300); expect(route53HealthCheck.healthCheckId).toBe('abcd1234'); expect(secret.secretName).toBe('db-password'); expect(cacheCluster.cacheClusterStatus).toBe('available'); + expect(cacheClusterActivity.averageCacheHitRateLast14Days).toBe(4.5); expect(reservedCacheNode.state).toBe('active'); expect(ecsClusterMetric.averageCpuUtilizationLast14Days).toBe(4.2); expect(ecsService.schedulingStrategy).toBe('REPLICA'); diff --git a/packages/rules/test/lambda-memory-overprovisioning.test.ts b/packages/rules/test/lambda-memory-overprovisioning.test.ts new file mode 100644 index 0000000..c1f672a --- /dev/null +++ b/packages/rules/test/lambda-memory-overprovisioning.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; +import { lambdaMemoryOverprovisioningRule } from '../src/aws/lambda/memory-overprovisioning.js'; +import type { AwsLambdaFunction, AwsLambdaFunctionMetric } from '../src/index.js'; +import { LiveResourceBag } from '../src/index.js'; + +const createLambdaFunction = (overrides: Partial = {}): AwsLambdaFunction => ({ + accountId: '123456789012', + architectures: ['x86_64'], + functionName: 'my-function', + memorySizeMb: 512, + region: 'us-east-1', + timeoutSeconds: 60, + ...overrides, +}); + +const createLambdaFunctionMetric = (overrides: Partial = {}): AwsLambdaFunctionMetric => ({ + accountId: '123456789012', + averageDurationMsLast7Days: 10_000, + functionName: 'my-function', + region: 'us-east-1', + totalErrorsLast7Days: 0, + totalInvocationsLast7Days: 100, + ...overrides, +}); + +describe('lambdaMemoryOverprovisioningRule', () => { + it('flags functions with memory above 256 MB whose average duration uses less than 30% of timeout', () => { + const finding = lambdaMemoryOverprovisioningRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-lambda-functions': [createLambdaFunction()], + 'aws-lambda-function-metrics': [createLambdaFunctionMetric()], + }), + }); + + expect(finding).toEqual({ + ruleId: 'CLDBRN-AWS-LAMBDA-4', + service: 'lambda', + source: 'discovery', + message: 'Lambda functions should not keep memory far above their observed execution needs.', + findings: [ + { + accountId: '123456789012', + region: 'us-east-1', + resourceId: 'my-function', + }, + ], + }); + }); + + it('skips functions at or below 256 MB', () => { + const finding = lambdaMemoryOverprovisioningRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-lambda-functions': [createLambdaFunction({ memorySizeMb: 256 })], + 'aws-lambda-function-metrics': [createLambdaFunctionMetric()], + }), + }); + + expect(finding).toBeNull(); + }); + + it('skips functions whose average duration uses 30% or more of timeout', () => { + const finding = lambdaMemoryOverprovisioningRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-lambda-functions': [createLambdaFunction()], + 'aws-lambda-function-metrics': [createLambdaFunctionMetric({ averageDurationMsLast7Days: 18_000 })], + }), + }); + + expect(finding).toBeNull(); + }); + + it('skips functions without invocation history', () => { + const finding = lambdaMemoryOverprovisioningRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-lambda-functions': [createLambdaFunction()], + 'aws-lambda-function-metrics': [createLambdaFunctionMetric({ totalInvocationsLast7Days: null })], + }), + }); + + expect(finding).toBeNull(); + }); +}); diff --git a/packages/rules/test/rule-metadata.test.ts b/packages/rules/test/rule-metadata.test.ts index 6725329..a0dc59b 100644 --- a/packages/rules/test/rule-metadata.test.ts +++ b/packages/rules/test/rule-metadata.test.ts @@ -133,6 +133,23 @@ describe('rule metadata', () => { }); }); + it('defines the expected CloudWatch no-metric-filters rule metadata', () => { + const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-CLOUDWATCH-3'); + + expect(rule).toBeDefined(); + expect(rule).toMatchObject({ + id: 'CLDBRN-AWS-CLOUDWATCH-3', + name: 'CloudWatch Log Group No Metric Filters', + description: 'Flag CloudWatch log groups storing at least 1 GB when they define no metric filters.', + message: + 'CloudWatch log groups storing at least 1 GB should define metric filters or reduce retention aggressively.', + provider: 'aws', + service: 'cloudwatch', + supports: ['discovery'], + discoveryDependencies: ['aws-cloudwatch-log-groups', 'aws-cloudwatch-log-metric-filter-coverage'], + }); + }); + it('defines the expected S3 lifecycle rule metadata', () => { const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-S3-1'); @@ -234,6 +251,23 @@ describe('rule metadata', () => { }); }); + it('defines the expected ElastiCache idle-cluster rule metadata', () => { + const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-ELASTICACHE-2'); + + expect(rule).toBeDefined(); + expect(rule).toMatchObject({ + id: 'CLDBRN-AWS-ELASTICACHE-2', + name: 'ElastiCache Cluster Idle', + description: + 'Flag available ElastiCache clusters whose 14-day average cache hit rate stays below 5% and average current connections stay below 2.', + message: 'ElastiCache clusters with almost no cache hits and active connections should be reviewed for cleanup.', + provider: 'aws', + service: 'elasticache', + supports: ['discovery'], + discoveryDependencies: ['aws-elasticache-clusters', 'aws-elasticache-cluster-activity'], + }); + }); + it('defines the expected EBS current-generation rule metadata', () => { const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-EBS-1'); @@ -625,6 +659,26 @@ describe('rule metadata', () => { }); }); + it('defines the expected ELB idle rule metadata', () => { + const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-ELB-5'); + + expect(rule).toBeDefined(); + expect(rule).toMatchObject({ + id: 'CLDBRN-AWS-ELB-5', + name: 'Load Balancer Idle', + description: 'Flag load balancers whose 14-day average request count stays below 10 requests per day.', + message: 'Load balancers with consistently low request volume should be reviewed for cleanup.', + provider: 'aws', + service: 'elb', + supports: ['discovery'], + discoveryDependencies: [ + 'aws-ec2-load-balancer-request-activity', + 'aws-ec2-load-balancers', + 'aws-ec2-target-groups', + ], + }); + }); + it('defines the expected Lambda high-error-rate rule metadata', () => { const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-LAMBDA-2'); @@ -658,6 +712,23 @@ describe('rule metadata', () => { }); }); + it('defines the expected Lambda memory-overprovisioning rule metadata', () => { + const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-LAMBDA-4'); + + expect(rule).toBeDefined(); + expect(rule).toMatchObject({ + id: 'CLDBRN-AWS-LAMBDA-4', + name: 'Lambda Function Memory Overprovisioned', + description: + 'Flag Lambda functions above 256 MB whose observed 7-day average duration uses less than 30% of the configured timeout.', + message: 'Lambda functions should not keep memory far above their observed execution needs.', + provider: 'aws', + service: 'lambda', + supports: ['discovery'], + discoveryDependencies: ['aws-lambda-functions', 'aws-lambda-function-metrics'], + }); + }); + it('defines the expected RDS idle-instance rule metadata', () => { const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-RDS-2'); @@ -836,6 +907,22 @@ describe('rule metadata', () => { }); }); + it('defines the expected CloudFront unused-distribution rule metadata', () => { + const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-CLOUDFRONT-2'); + + expect(rule).toBeDefined(); + expect(rule).toMatchObject({ + id: 'CLDBRN-AWS-CLOUDFRONT-2', + name: 'CloudFront Distribution Unused', + description: 'Flag CloudFront distributions with fewer than 100 requests over the last 30 days.', + message: 'CloudFront distributions with almost no request traffic should be reviewed for cleanup.', + provider: 'aws', + service: 'cloudfront', + supports: ['discovery'], + discoveryDependencies: ['aws-cloudfront-distribution-request-activity'], + }); + }); + it('defines the expected Cost Explorer full-month-cost-changes rule metadata', () => { const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-COSTEXPLORER-1'); @@ -853,6 +940,38 @@ describe('rule metadata', () => { }); }); + it('defines the expected cost guardrail missing-budgets rule metadata', () => { + const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-COSTGUARDRAILS-1'); + + expect(rule).toBeDefined(); + expect(rule).toMatchObject({ + id: 'CLDBRN-AWS-COSTGUARDRAILS-1', + name: 'AWS Budgets Missing', + description: 'Flag AWS accounts that do not have any AWS Budgets configured.', + message: 'AWS accounts should define at least one AWS Budget for spend guardrails.', + provider: 'aws', + service: 'costguardrails', + supports: ['discovery'], + discoveryDependencies: ['aws-cost-guardrail-budgets'], + }); + }); + + it('defines the expected cost guardrail missing-anomaly-detection rule metadata', () => { + const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-COSTGUARDRAILS-2'); + + expect(rule).toBeDefined(); + expect(rule).toMatchObject({ + id: 'CLDBRN-AWS-COSTGUARDRAILS-2', + name: 'Cost Anomaly Detection Missing', + description: 'Flag AWS accounts that do not have any Cost Anomaly Detection monitors configured.', + message: 'AWS accounts should enable Cost Anomaly Detection monitors for spend spikes.', + provider: 'aws', + service: 'costguardrails', + supports: ['discovery'], + discoveryDependencies: ['aws-cost-anomaly-monitors'], + }); + }); + it('defines the expected DynamoDB stale-data rule metadata', () => { const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-DYNAMODB-1'); @@ -885,6 +1004,22 @@ describe('rule metadata', () => { }); }); + it('defines the expected DynamoDB unused-table rule metadata', () => { + const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-DYNAMODB-3'); + + expect(rule).toBeDefined(); + expect(rule).toMatchObject({ + id: 'CLDBRN-AWS-DYNAMODB-3', + name: 'DynamoDB Table Unused', + description: 'Flag provisioned DynamoDB tables with no consumed read or write capacity over the last 30 days.', + message: 'Provisioned DynamoDB tables should not remain unused for 30 days.', + provider: 'aws', + service: 'dynamodb', + supports: ['discovery'], + discoveryDependencies: ['aws-dynamodb-tables', 'aws-dynamodb-table-utilization'], + }); + }); + it('defines the expected Route 53 higher-ttl rule metadata', () => { const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-ROUTE53-1'); diff --git a/packages/sdk/package.json b/packages/sdk/package.json index b368cad..d3d49e0 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -45,6 +45,7 @@ "dependencies": { "@aws-sdk/client-api-gateway": "^3.1015.0", "@aws-sdk/client-application-auto-scaling": "^3.1009.0", + "@aws-sdk/client-budgets": "^3.1015.0", "@aws-sdk/client-cloudfront": "^3.1015.0", "@aws-sdk/client-cloudtrail": "^3.1003.0", "@aws-sdk/client-cloudwatch": "^3.1003.0", diff --git a/packages/sdk/src/providers/aws/client.ts b/packages/sdk/src/providers/aws/client.ts index 08f2a26..6ea5adf 100644 --- a/packages/sdk/src/providers/aws/client.ts +++ b/packages/sdk/src/providers/aws/client.ts @@ -1,5 +1,6 @@ import { APIGatewayClient } from '@aws-sdk/client-api-gateway'; import { ApplicationAutoScalingClient } from '@aws-sdk/client-application-auto-scaling'; +import { BudgetsClient } from '@aws-sdk/client-budgets'; import { CloudFrontClient } from '@aws-sdk/client-cloudfront'; import { CloudTrailClient } from '@aws-sdk/client-cloudtrail'; import { CloudWatchClient } from '@aws-sdk/client-cloudwatch'; @@ -84,6 +85,12 @@ export const createApiGatewayClient = (config: AwsClientConfig): APIGatewayClien region: config.region, }); +/** Creates an AWS Budgets client against the global billing control plane. */ +export const createBudgetsClient = (): BudgetsClient => + new BudgetsClient({ + region: AWS_GLOBAL_CONTROL_REGION, + }); + /** Creates an AWS ElastiCache client for a specific region. */ export const createElastiCacheClient = (config: AwsClientConfig): ElastiCacheClient => new ElastiCacheClient({ diff --git a/packages/sdk/src/providers/aws/discovery-registry.ts b/packages/sdk/src/providers/aws/discovery-registry.ts index 75520c3..4f91b22 100644 --- a/packages/sdk/src/providers/aws/discovery-registry.ts +++ b/packages/sdk/src/providers/aws/discovery-registry.ts @@ -1,11 +1,23 @@ import type { AwsDiscoveredResource, DiscoveryDatasetKey, DiscoveryDatasetMap } from '@cloudburn/rules'; import type { ScanDiagnostic } from '../../types.js'; import { hydrateAwsApiGatewayStages } from './resources/apigateway.js'; -import { hydrateAwsCloudFrontDistributions } from './resources/cloudfront.js'; +import { + hydrateAwsCloudFrontDistributionRequestActivity, + hydrateAwsCloudFrontDistributions, +} from './resources/cloudfront.js'; import { hydrateAwsCloudTrailTrails } from './resources/cloudtrail.js'; -import { hydrateAwsCloudWatchLogGroups, hydrateAwsCloudWatchLogStreams } from './resources/cloudwatch-logs.js'; +import { + hydrateAwsCloudWatchLogGroups, + hydrateAwsCloudWatchLogMetricFilterCoverage, + hydrateAwsCloudWatchLogStreams, +} from './resources/cloudwatch-logs.js'; import { hydrateAwsCostUsage } from './resources/cost-explorer.js'; -import { hydrateAwsDynamoDbAutoscaling, hydrateAwsDynamoDbTables } from './resources/dynamodb.js'; +import { hydrateAwsCostAnomalyMonitors, hydrateAwsCostGuardrailBudgets } from './resources/cost-guardrails.js'; +import { + hydrateAwsDynamoDbAutoscaling, + hydrateAwsDynamoDbTables, + hydrateAwsDynamoDbTableUtilization, +} from './resources/dynamodb.js'; import { hydrateAwsEbsSnapshots, hydrateAwsEbsVolumes } from './resources/ebs.js'; import { hydrateAwsEc2Instances } from './resources/ec2.js'; import { hydrateAwsEc2ElasticIps } from './resources/ec2-elastic-ips.js'; @@ -16,8 +28,16 @@ import { hydrateAwsEcsClusters, hydrateAwsEcsContainerInstances, hydrateAwsEcsSe import { hydrateAwsEcsAutoscaling } from './resources/ecs-autoscaling.js'; import { hydrateAwsEcsClusterMetrics } from './resources/ecs-cluster-metrics.js'; import { hydrateAwsEksNodegroups } from './resources/eks.js'; -import { hydrateAwsElastiCacheClusters, hydrateAwsElastiCacheReservedNodes } from './resources/elasticache.js'; -import { hydrateAwsEc2LoadBalancers, hydrateAwsEc2TargetGroups } from './resources/elbv2.js'; +import { + hydrateAwsElastiCacheClusterActivity, + hydrateAwsElastiCacheClusters, + hydrateAwsElastiCacheReservedNodes, +} from './resources/elasticache.js'; +import { + hydrateAwsEc2LoadBalancerRequestActivity, + hydrateAwsEc2LoadBalancers, + hydrateAwsEc2TargetGroups, +} from './resources/elbv2.js'; import { hydrateAwsEmrClusterMetrics, hydrateAwsEmrClusters } from './resources/emr.js'; import { hydrateAwsLambdaFunctionMetrics, hydrateAwsLambdaFunctions } from './resources/lambda.js'; import { hydrateAwsRdsInstances, hydrateAwsRdsReservedInstances, hydrateAwsRdsSnapshots } from './resources/rds.js'; @@ -54,6 +74,7 @@ export type AwsDiscoveryDatasetDefinition> @@ -111,3 +119,51 @@ export const hydrateAwsCloudFrontDistributions = async ( return distributions.sort((left, right) => left.distributionArn.localeCompare(right.distributionArn)); }; + +/** + * Hydrates discovered CloudFront distributions with 30-day request totals. + * + * @param resources - Optional catalog resources filtered to CloudFront distributions. + * @returns Request activity coverage for CloudFront distributions. + */ +export const hydrateAwsCloudFrontDistributionRequestActivity = async ( + resources: AwsDiscoveredResource[], +): Promise => { + const distributions = await hydrateAwsCloudFrontDistributions(resources); + + if (distributions.length === 0) { + return []; + } + + const metricData = await fetchCloudWatchSignals({ + endTime: new Date(), + queries: distributions.map((distribution, index) => ({ + dimensions: [ + { Name: 'DistributionId', Value: distribution.distributionId }, + { Name: 'Region', Value: 'Global' }, + ], + id: `distribution${index}`, + metricName: 'Requests', + namespace: 'AWS/CloudFront', + period: DAILY_PERIOD_IN_SECONDS, + stat: 'Sum' as const, + })), + region: CLOUDFRONT_CONTROL_REGION, + startTime: new Date(Date.now() - THIRTY_DAYS_IN_SECONDS * 1000), + }); + + return distributions.map((distribution, index) => { + const requestPoints = metricData.get(`distribution${index}`) ?? []; + + return { + accountId: distribution.accountId, + distributionArn: distribution.distributionArn, + distributionId: distribution.distributionId, + region: distribution.region, + totalRequestsLast30Days: + requestPoints.length >= REQUIRED_CLOUDFRONT_DAILY_POINTS + ? requestPoints.reduce((sum, point) => sum + point.value, 0) + : null, + } satisfies AwsCloudFrontDistributionRequestActivity; + }); +}; diff --git a/packages/sdk/src/providers/aws/resources/cloudwatch-logs.ts b/packages/sdk/src/providers/aws/resources/cloudwatch-logs.ts index 8f3162f..159cbd5 100644 --- a/packages/sdk/src/providers/aws/resources/cloudwatch-logs.ts +++ b/packages/sdk/src/providers/aws/resources/cloudwatch-logs.ts @@ -1,5 +1,14 @@ -import { DescribeLogGroupsCommand, DescribeLogStreamsCommand } from '@aws-sdk/client-cloudwatch-logs'; -import type { AwsCloudWatchLogGroup, AwsCloudWatchLogStream, AwsDiscoveredResource } from '@cloudburn/rules'; +import { + DescribeLogGroupsCommand, + DescribeLogStreamsCommand, + DescribeMetricFiltersCommand, +} from '@aws-sdk/client-cloudwatch-logs'; +import type { + AwsCloudWatchLogGroup, + AwsCloudWatchLogMetricFilterCoverage, + AwsCloudWatchLogStream, + AwsDiscoveredResource, +} from '@cloudburn/rules'; import { createCloudWatchLogsClient } from '../client.js'; import { withAwsServiceErrorContext } from './utils.js'; @@ -184,3 +193,76 @@ export const hydrateAwsCloudWatchLogStreams = async ( return hydratedPages.flat().sort((left, right) => left.arn.localeCompare(right.arn)); }; + +/** + * Hydrates discovered CloudWatch log groups with their metric-filter counts. + * + * @param resources - Catalog resources filtered to CloudWatch Logs log groups. + * @returns Metric-filter coverage summaries keyed by log group. + */ +export const hydrateAwsCloudWatchLogMetricFilterCoverage = async ( + resources: AwsDiscoveredResource[], +): Promise => { + const resourcesByRegion = new Map(); + + for (const resource of resources) { + const logGroupName = extractLogGroupName(resource.arn); + + if (!logGroupName) { + continue; + } + + const regionResources = resourcesByRegion.get(resource.region) ?? []; + regionResources.push(resource); + resourcesByRegion.set(resource.region, regionResources); + } + + const hydratedPages = await Promise.all( + [...resourcesByRegion.entries()].map(async ([region, regionResources]) => { + const client = createCloudWatchLogsClient({ region }); + const desiredLogGroups = new Map( + regionResources.flatMap((resource) => { + const logGroupName = extractLogGroupName(resource.arn); + + return logGroupName ? [[logGroupName, resource.accountId] as const] : []; + }), + ); + + const coverage = await Promise.all( + [...desiredLogGroups.entries()].map(async ([logGroupName, accountId]) => { + let nextToken: string | undefined; + let metricFilterCount = 0; + + do { + const response = await withAwsServiceErrorContext( + 'Amazon CloudWatch Logs', + 'DescribeMetricFilters', + region, + () => + client.send( + new DescribeMetricFiltersCommand({ + logGroupName, + nextToken, + }), + ), + ); + + metricFilterCount += (response.metricFilters ?? []).length; + nextToken = response.nextToken; + } while (nextToken); + + return { + accountId, + logGroupName, + metricFilterCount, + region, + } satisfies AwsCloudWatchLogMetricFilterCoverage; + }), + ); + + return coverage; + }), + ); + + return hydratedPages.flat().sort((left, right) => left.logGroupName.localeCompare(right.logGroupName)); +}; diff --git a/packages/sdk/src/providers/aws/resources/cost-guardrails.ts b/packages/sdk/src/providers/aws/resources/cost-guardrails.ts new file mode 100644 index 0000000..f9f75ef --- /dev/null +++ b/packages/sdk/src/providers/aws/resources/cost-guardrails.ts @@ -0,0 +1,85 @@ +import { DescribeBudgetsCommand } from '@aws-sdk/client-budgets'; +import { GetAnomalyMonitorsCommand } from '@aws-sdk/client-cost-explorer'; +import type { AwsCostAnomalyMonitor, AwsCostGuardrailBudget, AwsDiscoveredResource } from '@cloudburn/rules'; +import { createBudgetsClient, createCostExplorerClient, resolveAwsAccountId } from '../client.js'; +import { withAwsServiceErrorContext } from './utils.js'; + +const COST_CONTROL_REGION = 'us-east-1'; +const PAGE_SIZE = 100; + +/** + * Hydrates account-scoped AWS Budgets summaries. + * + * @param _resources - Unused because budgets are account-scoped. + * @returns Budget summaries for the current account. + */ +export const hydrateAwsCostGuardrailBudgets = async ( + _resources: AwsDiscoveredResource[], +): Promise => { + const accountId = await resolveAwsAccountId(); + const client = createBudgetsClient(); + let budgetCount = 0; + let nextToken: string | undefined; + + do { + const response = await withAwsServiceErrorContext('AWS Budgets', 'DescribeBudgets', COST_CONTROL_REGION, () => + client.send( + new DescribeBudgetsCommand({ + AccountId: accountId, + MaxResults: PAGE_SIZE, + NextToken: nextToken, + }), + ), + ); + + budgetCount += (response.Budgets ?? []).filter((budget) => budget.BudgetName).length; + nextToken = response.NextToken; + } while (nextToken); + + return [ + { + accountId, + budgetCount, + } satisfies AwsCostGuardrailBudget, + ]; +}; + +/** + * Hydrates account-scoped Cost Anomaly Detection monitors. + * + * @param _resources - Unused because anomaly monitors are account-scoped. + * @returns Cost anomaly monitor summaries for the current account. + */ +export const hydrateAwsCostAnomalyMonitors = async ( + _resources: AwsDiscoveredResource[], +): Promise => { + const accountId = await resolveAwsAccountId(); + const client = createCostExplorerClient(); + let monitorCount = 0; + let nextPageToken: string | undefined; + + do { + const response = await withAwsServiceErrorContext( + 'AWS Cost Explorer', + 'GetAnomalyMonitors', + COST_CONTROL_REGION, + () => + client.send( + new GetAnomalyMonitorsCommand({ + MaxResults: PAGE_SIZE, + NextPageToken: nextPageToken, + }), + ), + ); + + monitorCount += (response.AnomalyMonitors ?? []).filter((monitor) => monitor.MonitorArn).length; + nextPageToken = response.NextPageToken; + } while (nextPageToken); + + return [ + { + accountId, + monitorCount, + } satisfies AwsCostAnomalyMonitor, + ]; +}; diff --git a/packages/sdk/src/providers/aws/resources/dynamodb.ts b/packages/sdk/src/providers/aws/resources/dynamodb.ts index bb32bc0..9f290aa 100644 --- a/packages/sdk/src/providers/aws/resources/dynamodb.ts +++ b/packages/sdk/src/providers/aws/resources/dynamodb.ts @@ -1,11 +1,20 @@ import { DescribeScalableTargetsCommand } from '@aws-sdk/client-application-auto-scaling'; import { DescribeTableCommand } from '@aws-sdk/client-dynamodb'; -import type { AwsDiscoveredResource, AwsDynamoDbAutoscaling, AwsDynamoDbTable } from '@cloudburn/rules'; +import type { + AwsDiscoveredResource, + AwsDynamoDbAutoscaling, + AwsDynamoDbTable, + AwsDynamoDbTableUtilization, +} from '@cloudburn/rules'; import { createApplicationAutoScalingClient, createDynamoDbClient } from '../client.js'; +import { fetchCloudWatchSignals } from './cloudwatch.js'; import { chunkItems, extractTerminalArnResourceIdentifier, withAwsServiceErrorContext } from './utils.js'; const DYNAMODB_TABLE_CONCURRENCY = 10; const APPLICATION_AUTO_SCALING_BATCH_SIZE = 50; +const THIRTY_DAYS_IN_SECONDS = 30 * 24 * 60 * 60; +const DAILY_PERIOD_IN_SECONDS = 24 * 60 * 60; +const REQUIRED_DYNAMODB_DAILY_POINTS = THIRTY_DAYS_IN_SECONDS / DAILY_PERIOD_IN_SECONDS; type ParsedDynamoDbTable = { tableArn: string; @@ -174,3 +183,72 @@ export const hydrateAwsDynamoDbAutoscaling = async ( return hydratedPages.flat().sort((left, right) => left.tableArn.localeCompare(right.tableArn)); }; + +/** + * Hydrates discovered DynamoDB tables with 30-day consumed read/write capacity summaries. + * + * @param resources - Catalog resources filtered to DynamoDB tables. + * @returns Table utilization summaries for rule evaluation. + */ +export const hydrateAwsDynamoDbTableUtilization = async ( + resources: AwsDiscoveredResource[], +): Promise => { + const tables = await hydrateAwsDynamoDbTables(resources); + const tablesByRegion = new Map(); + + for (const table of tables) { + const regionTables = tablesByRegion.get(table.region) ?? []; + regionTables.push(table); + tablesByRegion.set(table.region, regionTables); + } + + const hydratedPages = await Promise.all( + [...tablesByRegion.entries()].map(async ([region, regionTables]) => { + const metricData = await fetchCloudWatchSignals({ + endTime: new Date(), + queries: regionTables.flatMap((table, index) => [ + { + dimensions: [{ Name: 'TableName', Value: table.tableName }], + id: `read${index}`, + metricName: 'ConsumedReadCapacityUnits', + namespace: 'AWS/DynamoDB', + period: DAILY_PERIOD_IN_SECONDS, + stat: 'Sum' as const, + }, + { + dimensions: [{ Name: 'TableName', Value: table.tableName }], + id: `write${index}`, + metricName: 'ConsumedWriteCapacityUnits', + namespace: 'AWS/DynamoDB', + period: DAILY_PERIOD_IN_SECONDS, + stat: 'Sum' as const, + }, + ]), + region, + startTime: new Date(Date.now() - THIRTY_DAYS_IN_SECONDS * 1000), + }); + + return regionTables.map((table, index) => { + const readPoints = metricData.get(`read${index}`) ?? []; + const writePoints = metricData.get(`write${index}`) ?? []; + + return { + accountId: table.accountId, + region, + tableArn: table.tableArn, + tableName: table.tableName, + totalConsumedReadCapacityUnitsLast30Days: + readPoints.length >= REQUIRED_DYNAMODB_DAILY_POINTS + ? readPoints.reduce((sum, point) => sum + point.value, 0) + : null, + totalConsumedWriteCapacityUnitsLast30Days: + writePoints.length >= REQUIRED_DYNAMODB_DAILY_POINTS + ? writePoints.reduce((sum, point) => sum + point.value, 0) + : null, + } satisfies AwsDynamoDbTableUtilization; + }); + }), + ); + + return hydratedPages.flat().sort((left, right) => left.tableArn.localeCompare(right.tableArn)); +}; diff --git a/packages/sdk/src/providers/aws/resources/elasticache.ts b/packages/sdk/src/providers/aws/resources/elasticache.ts index 7797bcf..5c8e63c 100644 --- a/packages/sdk/src/providers/aws/resources/elasticache.ts +++ b/packages/sdk/src/providers/aws/resources/elasticache.ts @@ -1,15 +1,26 @@ import { DescribeCacheClustersCommand, DescribeReservedCacheNodesCommand } from '@aws-sdk/client-elasticache'; -import type { AwsDiscoveredResource, AwsElastiCacheCluster, AwsElastiCacheReservedNode } from '@cloudburn/rules'; +import type { + AwsDiscoveredResource, + AwsElastiCacheCluster, + AwsElastiCacheClusterActivity, + AwsElastiCacheReservedNode, +} from '@cloudburn/rules'; import { createElastiCacheClient } from '../client.js'; +import { fetchCloudWatchSignals } from './cloudwatch.js'; import { extractTerminalResourceIdentifier, withAwsServiceErrorContext } from './utils.js'; const ELASTICACHE_PAGE_SIZE = 100; +const FOURTEEN_DAYS_IN_SECONDS = 14 * 24 * 60 * 60; +const DAILY_PERIOD_IN_SECONDS = 24 * 60 * 60; +const REQUIRED_ELASTICACHE_DAILY_POINTS = FOURTEEN_DAYS_IN_SECONDS / DAILY_PERIOD_IN_SECONDS; const sortByIdentifier = (items: T[], getIdentifier: (item: T) => string): T[] => items.sort( (left, right) => left.region.localeCompare(right.region) || getIdentifier(left).localeCompare(getIdentifier(right)), ); +const isSupportedElastiCacheActivityEngine = (engine: string): boolean => ['redis', 'valkey'].includes(engine); + /** * Hydrates discovered ElastiCache clusters with their normalized node metadata. * @@ -157,3 +168,109 @@ export const hydrateAwsElastiCacheReservedNodes = async ( return sortByIdentifier(hydratedPages.flat(), (reservedNode) => reservedNode.reservedCacheNodeId); }; + +/** + * Hydrates discovered ElastiCache clusters with 14-day cache hit-rate and connection activity. + * + * v1 supports Redis and Valkey clusters only. Unsupported engines return `null` activity fields. + * + * @param resources - Catalog resources filtered to ElastiCache cluster resource types. + * @returns Activity coverage for ElastiCache cluster evaluation. + */ +export const hydrateAwsElastiCacheClusterActivity = async ( + resources: AwsDiscoveredResource[], +): Promise => { + const clusters = await hydrateAwsElastiCacheClusters(resources); + const clustersByRegion = new Map(); + + for (const cluster of clusters) { + const regionClusters = clustersByRegion.get(cluster.region) ?? []; + regionClusters.push(cluster); + clustersByRegion.set(cluster.region, regionClusters); + } + + const hydratedPages = await Promise.all( + [...clustersByRegion.entries()].map(async ([region, regionClusters]) => { + const supportedClusters = regionClusters.filter((cluster) => + isSupportedElastiCacheActivityEngine(cluster.engine), + ); + const metricData = + supportedClusters.length > 0 + ? await fetchCloudWatchSignals({ + endTime: new Date(), + queries: supportedClusters.flatMap((cluster, index) => [ + { + dimensions: [{ Name: 'CacheClusterId', Value: cluster.cacheClusterId }], + id: `hits${index}`, + metricName: 'CacheHits', + namespace: 'AWS/ElastiCache', + period: DAILY_PERIOD_IN_SECONDS, + stat: 'Sum' as const, + }, + { + dimensions: [{ Name: 'CacheClusterId', Value: cluster.cacheClusterId }], + id: `misses${index}`, + metricName: 'CacheMisses', + namespace: 'AWS/ElastiCache', + period: DAILY_PERIOD_IN_SECONDS, + stat: 'Sum' as const, + }, + { + dimensions: [{ Name: 'CacheClusterId', Value: cluster.cacheClusterId }], + id: `connections${index}`, + metricName: 'CurrConnections', + namespace: 'AWS/ElastiCache', + period: DAILY_PERIOD_IN_SECONDS, + stat: 'Average' as const, + }, + ]), + region, + startTime: new Date(Date.now() - FOURTEEN_DAYS_IN_SECONDS * 1000), + }) + : new Map(); + + const supportedIndexByClusterId = new Map( + supportedClusters.map((cluster, index) => [cluster.cacheClusterId, index] as const), + ); + + return regionClusters.map((cluster) => { + const supportedIndex = supportedIndexByClusterId.get(cluster.cacheClusterId); + + if (supportedIndex === undefined) { + return { + accountId: cluster.accountId, + averageCacheHitRateLast14Days: null, + averageCurrentConnectionsLast14Days: null, + cacheClusterId: cluster.cacheClusterId, + region: cluster.region, + } satisfies AwsElastiCacheClusterActivity; + } + + const hitPoints = metricData.get(`hits${supportedIndex}`) ?? []; + const missPoints = metricData.get(`misses${supportedIndex}`) ?? []; + const connectionPoints = metricData.get(`connections${supportedIndex}`) ?? []; + const hasCompleteCoverage = + hitPoints.length >= REQUIRED_ELASTICACHE_DAILY_POINTS && + missPoints.length >= REQUIRED_ELASTICACHE_DAILY_POINTS && + connectionPoints.length >= REQUIRED_ELASTICACHE_DAILY_POINTS; + const totalHits = hitPoints.reduce((sum: number, point: { value: number }) => sum + point.value, 0); + const totalMisses = missPoints.reduce((sum: number, point: { value: number }) => sum + point.value, 0); + const totalLookups = totalHits + totalMisses; + + return { + accountId: cluster.accountId, + averageCacheHitRateLast14Days: + hasCompleteCoverage && totalLookups > 0 ? (totalHits / totalLookups) * 100 : hasCompleteCoverage ? 0 : null, + averageCurrentConnectionsLast14Days: hasCompleteCoverage + ? connectionPoints.reduce((sum: number, point: { value: number }) => sum + point.value, 0) / + connectionPoints.length + : null, + cacheClusterId: cluster.cacheClusterId, + region: cluster.region, + } satisfies AwsElastiCacheClusterActivity; + }); + }), + ); + + return sortByIdentifier(hydratedPages.flat(), (cluster) => cluster.cacheClusterId); +}; diff --git a/packages/sdk/src/providers/aws/resources/elbv2.ts b/packages/sdk/src/providers/aws/resources/elbv2.ts index 398a991..7ac6c84 100644 --- a/packages/sdk/src/providers/aws/resources/elbv2.ts +++ b/packages/sdk/src/providers/aws/resources/elbv2.ts @@ -4,8 +4,14 @@ import { DescribeTargetGroupsCommand, DescribeTargetHealthCommand, } from '@aws-sdk/client-elastic-load-balancing-v2'; -import type { AwsDiscoveredResource, AwsEc2LoadBalancer, AwsEc2TargetGroup } from '@cloudburn/rules'; +import type { + AwsDiscoveredResource, + AwsEc2LoadBalancer, + AwsEc2LoadBalancerRequestActivity, + AwsEc2TargetGroup, +} from '@cloudburn/rules'; import { createElasticLoadBalancingClient, createElasticLoadBalancingV2Client } from '../client.js'; +import { fetchCloudWatchSignals } from './cloudwatch.js'; import { chunkItems, withAwsServiceErrorContext } from './utils.js'; const CLASSIC_LOAD_BALANCER_ARN_PREFIX = 'loadbalancer/'; @@ -13,6 +19,9 @@ const TARGET_GROUP_ARN_PREFIX = 'targetgroup/'; const CLASSIC_LOAD_BALANCER_BATCH_SIZE = 20; const V2_LOAD_BALANCER_BATCH_SIZE = 20; const TARGET_GROUP_BATCH_SIZE = 20; +const FOURTEEN_DAYS_IN_SECONDS = 14 * 24 * 60 * 60; +const DAILY_PERIOD_IN_SECONDS = 24 * 60 * 60; +const REQUIRED_ELB_DAILY_POINTS = FOURTEEN_DAYS_IN_SECONDS / DAILY_PERIOD_IN_SECONDS; const inferClassicLoadBalancerName = (resource: AwsDiscoveredResource): string | null => { if (resource.name) { @@ -66,6 +75,16 @@ const isTargetGroupMissingError = (error: unknown): boolean => error.name === 'TargetGroupNotFoundException' || error.message.includes('TargetGroupNotFound')); +const extractLoadBalancerMetricDimensionValue = (loadBalancerArn: string): string | null => { + const resourceSegment = loadBalancerArn.split(':')[5]; + + if (!resourceSegment?.startsWith(CLASSIC_LOAD_BALANCER_ARN_PREFIX)) { + return null; + } + + return resourceSegment.slice(CLASSIC_LOAD_BALANCER_ARN_PREFIX.length) || null; +}; + const describeClassicLoadBalancersSafely = async (options: { client: ReturnType; classicResources: Array<{ accountId: string; arn: string; name: string }>; @@ -386,6 +405,79 @@ export const hydrateAwsEc2LoadBalancers = async (resources: AwsDiscoveredResourc return hydratedPages.flat().sort((left, right) => left.loadBalancerArn.localeCompare(right.loadBalancerArn)); }; +/** + * Hydrates discovered load balancers with 14-day request-activity coverage. + * + * @param resources - Catalog resources filtered to ELB resource types. + * @returns Request activity summaries for load balancers. + */ +export const hydrateAwsEc2LoadBalancerRequestActivity = async ( + resources: AwsDiscoveredResource[], +): Promise => { + const loadBalancers = await hydrateAwsEc2LoadBalancers(resources); + const loadBalancersByRegion = new Map(); + + for (const loadBalancer of loadBalancers) { + const regionLoadBalancers = loadBalancersByRegion.get(loadBalancer.region) ?? []; + regionLoadBalancers.push(loadBalancer); + loadBalancersByRegion.set(loadBalancer.region, regionLoadBalancers); + } + + const hydratedPages = await Promise.all( + [...loadBalancersByRegion.entries()].map(async ([region, regionLoadBalancers]) => { + const metricData = await fetchCloudWatchSignals({ + endTime: new Date(), + queries: regionLoadBalancers.flatMap((loadBalancer, index) => { + const dimensionValue = extractLoadBalancerMetricDimensionValue(loadBalancer.loadBalancerArn); + + if (!dimensionValue) { + return []; + } + + return [ + { + dimensions: [ + { + Name: loadBalancer.loadBalancerType === 'classic' ? 'LoadBalancerName' : 'LoadBalancer', + Value: dimensionValue, + }, + ], + id: `lb${index}`, + metricName: 'RequestCount', + namespace: + loadBalancer.loadBalancerType === 'classic' + ? 'AWS/ELB' + : loadBalancer.loadBalancerType === 'application' + ? 'AWS/ApplicationELB' + : 'AWS/NetworkELB', + period: DAILY_PERIOD_IN_SECONDS, + stat: 'Sum' as const, + }, + ]; + }), + region, + startTime: new Date(Date.now() - FOURTEEN_DAYS_IN_SECONDS * 1000), + }); + + return regionLoadBalancers.map((loadBalancer, index) => { + const requestPoints = metricData.get(`lb${index}`) ?? []; + + return { + accountId: loadBalancer.accountId, + averageRequestsPerDayLast14Days: + requestPoints.length >= REQUIRED_ELB_DAILY_POINTS + ? requestPoints.reduce((sum, point) => sum + point.value, 0) / requestPoints.length + : null, + loadBalancerArn: loadBalancer.loadBalancerArn, + region: loadBalancer.region, + } satisfies AwsEc2LoadBalancerRequestActivity; + }); + }), + ); + + return hydratedPages.flat().sort((left, right) => left.loadBalancerArn.localeCompare(right.loadBalancerArn)); +}; + /** * Hydrates discovered target groups with their attached load balancers and target counts. * diff --git a/packages/sdk/src/providers/aws/resources/lambda.ts b/packages/sdk/src/providers/aws/resources/lambda.ts index e9fe431..8621655 100644 --- a/packages/sdk/src/providers/aws/resources/lambda.ts +++ b/packages/sdk/src/providers/aws/resources/lambda.ts @@ -5,6 +5,7 @@ import { fetchCloudWatchSignals } from './cloudwatch.js'; import { chunkItems, withAwsServiceErrorContext } from './utils.js'; const DEFAULT_LAMBDA_ARCHITECTURES = ['x86_64']; +const DEFAULT_LAMBDA_MEMORY_MB = 128; const DEFAULT_LAMBDA_TIMEOUT_SECONDS = 3; const LAMBDA_CONFIGURATION_CONCURRENCY = 5; const SEVEN_DAYS_IN_SECONDS = 7 * 24 * 60 * 60; @@ -70,6 +71,7 @@ export const hydrateAwsLambdaFunctions = async (resources: AwsDiscoveredResource accountId: resource.accountId, architectures: response.Architectures?.map(String) ?? [...DEFAULT_LAMBDA_ARCHITECTURES], functionName, + memorySizeMb: response.MemorySize ?? DEFAULT_LAMBDA_MEMORY_MB, region, timeoutSeconds: response.Timeout ?? DEFAULT_LAMBDA_TIMEOUT_SECONDS, } satisfies AwsLambdaFunction; diff --git a/packages/sdk/test/exports.test.ts b/packages/sdk/test/exports.test.ts index a591e9b..a6c5307 100644 --- a/packages/sdk/test/exports.test.ts +++ b/packages/sdk/test/exports.test.ts @@ -66,6 +66,13 @@ describe('sdk exports', () => { service: 'cloudfront', supports: ['discovery'], }, + { + description: 'Flag CloudFront distributions with fewer than 100 requests over the last 30 days.', + id: 'CLDBRN-AWS-CLOUDFRONT-2', + provider: 'aws', + service: 'cloudfront', + supports: ['discovery'], + }, { description: 'Flag redundant multi-region CloudTrail trails when more than one trail covers the same account.', id: 'CLDBRN-AWS-CLOUDTRAIL-1', @@ -95,6 +102,13 @@ describe('sdk exports', () => { service: 'cloudwatch', supports: ['discovery'], }, + { + description: 'Flag CloudWatch log groups storing at least 1 GB when they define no metric filters.', + id: 'CLDBRN-AWS-CLOUDWATCH-3', + provider: 'aws', + service: 'cloudwatch', + supports: ['discovery'], + }, { description: 'Flag services with significant cost increases between the last two full months.', id: 'CLDBRN-AWS-COSTEXPLORER-1', @@ -102,6 +116,20 @@ describe('sdk exports', () => { service: 'costexplorer', supports: ['discovery'], }, + { + description: 'Flag AWS accounts that do not have any AWS Budgets configured.', + id: 'CLDBRN-AWS-COSTGUARDRAILS-1', + provider: 'aws', + service: 'costguardrails', + supports: ['discovery'], + }, + { + description: 'Flag AWS accounts that do not have any Cost Anomaly Detection monitors configured.', + id: 'CLDBRN-AWS-COSTGUARDRAILS-2', + provider: 'aws', + service: 'costguardrails', + supports: ['discovery'], + }, { description: 'Flag DynamoDB tables with no data changes exceeding a threshold (default 90 days).', id: 'CLDBRN-AWS-DYNAMODB-1', @@ -116,6 +144,13 @@ describe('sdk exports', () => { service: 'dynamodb', supports: ['discovery'], }, + { + description: 'Flag provisioned DynamoDB tables with no consumed read or write capacity over the last 30 days.', + id: 'CLDBRN-AWS-DYNAMODB-3', + provider: 'aws', + service: 'dynamodb', + supports: ['discovery'], + }, { description: 'Flag EBS volumes using previous-generation storage types when a current-generation replacement exists.', @@ -276,6 +311,14 @@ describe('sdk exports', () => { service: 'elasticache', supports: ['discovery'], }, + { + description: + 'Flag available ElastiCache clusters whose 14-day average cache hit rate stays below 5% and average current connections stay below 2.', + id: 'CLDBRN-AWS-ELASTICACHE-2', + provider: 'aws', + service: 'elasticache', + supports: ['discovery'], + }, { description: 'Flag Application Load Balancers that have no attached target groups or no registered targets.', id: 'CLDBRN-AWS-ELB-1', @@ -304,6 +347,13 @@ describe('sdk exports', () => { service: 'elb', supports: ['discovery'], }, + { + description: 'Flag load balancers whose 14-day average request count stays below 10 requests per day.', + id: 'CLDBRN-AWS-ELB-5', + provider: 'aws', + service: 'elb', + supports: ['discovery'], + }, { description: 'Flag EMR clusters that still use previous-generation EC2 instance types.', id: 'CLDBRN-AWS-EMR-1', @@ -340,6 +390,14 @@ describe('sdk exports', () => { service: 'lambda', supports: ['discovery'], }, + { + description: + 'Flag Lambda functions above 256 MB whose observed 7-day average duration uses less than 30% of the configured timeout.', + id: 'CLDBRN-AWS-LAMBDA-4', + provider: 'aws', + service: 'lambda', + supports: ['discovery'], + }, { description: 'Flag RDS DB instances that do not use curated preferred instance classes.', id: 'CLDBRN-AWS-RDS-1', diff --git a/packages/sdk/test/providers/aws-cloudfront-resource.test.ts b/packages/sdk/test/providers/aws-cloudfront-resource.test.ts index 3c9b56a..3be44a5 100644 --- a/packages/sdk/test/providers/aws-cloudfront-resource.test.ts +++ b/packages/sdk/test/providers/aws-cloudfront-resource.test.ts @@ -1,15 +1,24 @@ import type { GetDistributionCommand, ListDistributionsCommand } from '@aws-sdk/client-cloudfront'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createCloudFrontClient, resolveAwsAccountId } from '../../src/providers/aws/client.js'; -import { hydrateAwsCloudFrontDistributions } from '../../src/providers/aws/resources/cloudfront.js'; +import { + hydrateAwsCloudFrontDistributionRequestActivity, + hydrateAwsCloudFrontDistributions, +} from '../../src/providers/aws/resources/cloudfront.js'; +import { fetchCloudWatchSignals } from '../../src/providers/aws/resources/cloudwatch.js'; vi.mock('../../src/providers/aws/client.js', () => ({ createCloudFrontClient: vi.fn(), resolveAwsAccountId: vi.fn(), })); +vi.mock('../../src/providers/aws/resources/cloudwatch.js', () => ({ + fetchCloudWatchSignals: vi.fn(), +})); + const mockedCreateCloudFrontClient = vi.mocked(createCloudFrontClient); const mockedResolveAwsAccountId = vi.mocked(resolveAwsAccountId); +const mockedFetchCloudWatchSignals = vi.mocked(fetchCloudWatchSignals); describe('hydrateAwsCloudFrontDistributions', () => { beforeEach(() => { @@ -54,4 +63,99 @@ describe('hydrateAwsCloudFrontDistributions', () => { }, ]); }); + + it('hydrates 30-day CloudFront request activity from CloudWatch metrics', async () => { + mockedCreateCloudFrontClient.mockReturnValue({ + send: vi.fn(async (command: ListDistributionsCommand | GetDistributionCommand) => { + if (command.constructor.name === 'ListDistributionsCommand') { + return { + DistributionList: { + Items: [ + { + ARN: 'arn:aws:cloudfront::123456789012:distribution/E1234567890ABC', + Id: 'E1234567890ABC', + }, + ], + }, + }; + } + + return { + Distribution: { + DistributionConfig: { + PriceClass: 'PriceClass_100', + }, + }, + }; + }), + } as never); + mockedResolveAwsAccountId.mockResolvedValue('123456789012'); + mockedFetchCloudWatchSignals.mockResolvedValue( + new Map([ + [ + 'distribution0', + Array.from({ length: 30 }, (_, index) => ({ + timestamp: `2026-02-${String(index + 1).padStart(2, '0')}T00:00:00.000Z`, + value: 3, + })), + ], + ]), + ); + + await expect(hydrateAwsCloudFrontDistributionRequestActivity([])).resolves.toEqual([ + { + accountId: '123456789012', + distributionArn: 'arn:aws:cloudfront::123456789012:distribution/E1234567890ABC', + distributionId: 'E1234567890ABC', + region: 'global', + totalRequestsLast30Days: 90, + }, + ]); + }); + + it('preserves incomplete CloudFront request coverage as null totals', async () => { + mockedCreateCloudFrontClient.mockReturnValue({ + send: vi.fn(async (_command: GetDistributionCommand) => ({ + Distribution: { + DistributionConfig: { + PriceClass: 'PriceClass_100', + }, + }, + })), + } as never); + mockedFetchCloudWatchSignals.mockResolvedValue( + new Map([ + [ + 'distribution0', + [ + { + timestamp: '2026-02-01T00:00:00.000Z', + value: 3, + }, + ], + ], + ]), + ); + + await expect( + hydrateAwsCloudFrontDistributionRequestActivity([ + { + accountId: '123456789012', + arn: 'arn:aws:cloudfront::123456789012:distribution/E1234567890ABC', + properties: [], + region: 'global', + resourceType: 'cloudfront:distribution', + service: 'cloudfront', + }, + ]), + ).resolves.toEqual([ + { + accountId: '123456789012', + distributionArn: 'arn:aws:cloudfront::123456789012:distribution/E1234567890ABC', + distributionId: 'E1234567890ABC', + region: 'global', + totalRequestsLast30Days: null, + }, + ]); + }); }); diff --git a/packages/sdk/test/providers/aws-cloudwatch-logs-resource.test.ts b/packages/sdk/test/providers/aws-cloudwatch-logs-resource.test.ts index f1ebcf7..b374c47 100644 --- a/packages/sdk/test/providers/aws-cloudwatch-logs-resource.test.ts +++ b/packages/sdk/test/providers/aws-cloudwatch-logs-resource.test.ts @@ -1,8 +1,13 @@ -import type { DescribeLogGroupsCommand, DescribeLogStreamsCommand } from '@aws-sdk/client-cloudwatch-logs'; +import type { + DescribeLogGroupsCommand, + DescribeLogStreamsCommand, + DescribeMetricFiltersCommand, +} from '@aws-sdk/client-cloudwatch-logs'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createCloudWatchLogsClient } from '../../src/providers/aws/client.js'; import { hydrateAwsCloudWatchLogGroups, + hydrateAwsCloudWatchLogMetricFilterCoverage, hydrateAwsCloudWatchLogStreams, } from '../../src/providers/aws/resources/cloudwatch-logs.js'; @@ -331,3 +336,84 @@ describe('hydrateAwsCloudWatchLogStreams', () => { ); }); }); + +describe('hydrateAwsCloudWatchLogMetricFilterCoverage', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('hydrates discovered log groups with metric filter counts', async () => { + mockedCreateCloudWatchLogsClient.mockReturnValue({ + send: vi.fn(async (command: DescribeMetricFiltersCommand) => { + const input = command.input as { filterNamePrefix?: string; logGroupName?: string; nextToken?: string }; + + expect(input.filterNamePrefix).toBeUndefined(); + + if (input.nextToken === undefined) { + return { + metricFilters: [{ filterName: 'errors' }], + nextToken: 'page-2', + }; + } + + return { + metricFilters: [{ filterName: 'warnings' }], + }; + }), + } as never); + + await expect( + hydrateAwsCloudWatchLogMetricFilterCoverage([ + { + accountId: '123456789012', + arn: 'arn:aws:logs:us-east-1:123456789012:log-group:/aws/lambda/app', + properties: [], + region: 'us-east-1', + resourceType: 'logs:log-group', + service: 'logs', + }, + ]), + ).resolves.toEqual([ + { + accountId: '123456789012', + logGroupName: '/aws/lambda/app', + metricFilterCount: 2, + region: 'us-east-1', + }, + ]); + }); + + it('preserves CloudWatch Logs error identity when metric-filter hydration is access denied', async () => { + mockedCreateCloudWatchLogsClient.mockReturnValue({ + send: vi.fn().mockRejectedValue( + Object.assign(new Error('User is not authorized to perform: logs:DescribeMetricFilters'), { + name: 'AccessDeniedException', + code: 'AccessDeniedException', + $metadata: { + httpStatusCode: 403, + requestId: 'request-metric-filters', + }, + }), + ), + } as never); + + const error = await hydrateAwsCloudWatchLogMetricFilterCoverage([ + { + accountId: '123456789012', + arn: 'arn:aws:logs:eu-central-1:123456789012:log-group:/aws/lambda/app', + properties: [], + region: 'eu-central-1', + resourceType: 'logs:log-group', + service: 'logs', + }, + ]).catch((err) => err); + + expect(error).toMatchObject({ + code: 'AccessDeniedException', + name: 'AccessDeniedException', + }); + expect((error as Error).message).toBe( + 'Amazon CloudWatch Logs DescribeMetricFilters failed in eu-central-1 with AccessDeniedException: User is not authorized to perform: logs:DescribeMetricFilters Request ID: request-metric-filters.', + ); + }); +}); diff --git a/packages/sdk/test/providers/aws-cost-guardrails-resource.test.ts b/packages/sdk/test/providers/aws-cost-guardrails-resource.test.ts new file mode 100644 index 0000000..04a0a73 --- /dev/null +++ b/packages/sdk/test/providers/aws-cost-guardrails-resource.test.ts @@ -0,0 +1,80 @@ +import type { DescribeBudgetsCommand } from '@aws-sdk/client-budgets'; +import type { GetAnomalyMonitorsCommand } from '@aws-sdk/client-cost-explorer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createBudgetsClient, createCostExplorerClient, resolveAwsAccountId } from '../../src/providers/aws/client.js'; +import { + hydrateAwsCostAnomalyMonitors, + hydrateAwsCostGuardrailBudgets, +} from '../../src/providers/aws/resources/cost-guardrails.js'; + +vi.mock('../../src/providers/aws/client.js', () => ({ + createBudgetsClient: vi.fn(), + createCostExplorerClient: vi.fn(), + resolveAwsAccountId: vi.fn(), +})); + +const mockedCreateBudgetsClient = vi.mocked(createBudgetsClient); +const mockedCreateCostExplorerClient = vi.mocked(createCostExplorerClient); +const mockedResolveAwsAccountId = vi.mocked(resolveAwsAccountId); + +describe('Cost guardrail discovery resources', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('hydrates AWS Budgets for the current account', async () => { + mockedResolveAwsAccountId.mockResolvedValue('123456789012'); + mockedCreateBudgetsClient.mockReturnValue({ + send: vi.fn(async (command: DescribeBudgetsCommand) => { + expect(command.input).toEqual({ + AccountId: '123456789012', + MaxResults: 100, + NextToken: undefined, + }); + + return { + Budgets: [ + { + BudgetName: 'monthly-spend', + }, + ], + }; + }), + } as never); + + await expect(hydrateAwsCostGuardrailBudgets([])).resolves.toEqual([ + { + accountId: '123456789012', + budgetCount: 1, + }, + ]); + }); + + it('hydrates Cost Anomaly Detection monitors for the current account', async () => { + mockedResolveAwsAccountId.mockResolvedValue('123456789012'); + mockedCreateCostExplorerClient.mockReturnValue({ + send: vi.fn(async (command: GetAnomalyMonitorsCommand) => { + expect(command.input).toEqual({ + MaxResults: 100, + NextPageToken: undefined, + }); + + return { + AnomalyMonitors: [ + { + MonitorArn: 'arn:aws:ce::123456789012:anomalymonitor/1234abcd', + MonitorName: 'account-monitor', + }, + ], + }; + }), + } as never); + + await expect(hydrateAwsCostAnomalyMonitors([])).resolves.toEqual([ + { + accountId: '123456789012', + monitorCount: 1, + }, + ]); + }); +}); diff --git a/packages/sdk/test/providers/aws-discovery.test.ts b/packages/sdk/test/providers/aws-discovery.test.ts index 1d1395b..61ff110 100644 --- a/packages/sdk/test/providers/aws-discovery.test.ts +++ b/packages/sdk/test/providers/aws-discovery.test.ts @@ -20,14 +20,26 @@ import { waitForAwsResourceExplorerSetup, } from '../../src/providers/aws/resource-explorer.js'; import { hydrateAwsApiGatewayStages } from '../../src/providers/aws/resources/apigateway.js'; -import { hydrateAwsCloudFrontDistributions } from '../../src/providers/aws/resources/cloudfront.js'; +import { + hydrateAwsCloudFrontDistributionRequestActivity, + hydrateAwsCloudFrontDistributions, +} from '../../src/providers/aws/resources/cloudfront.js'; import { hydrateAwsCloudTrailTrails } from '../../src/providers/aws/resources/cloudtrail.js'; import { hydrateAwsCloudWatchLogGroups, + hydrateAwsCloudWatchLogMetricFilterCoverage, hydrateAwsCloudWatchLogStreams, } from '../../src/providers/aws/resources/cloudwatch-logs.js'; import { hydrateAwsCostUsage } from '../../src/providers/aws/resources/cost-explorer.js'; -import { hydrateAwsDynamoDbAutoscaling, hydrateAwsDynamoDbTables } from '../../src/providers/aws/resources/dynamodb.js'; +import { + hydrateAwsCostAnomalyMonitors, + hydrateAwsCostGuardrailBudgets, +} from '../../src/providers/aws/resources/cost-guardrails.js'; +import { + hydrateAwsDynamoDbAutoscaling, + hydrateAwsDynamoDbTables, + hydrateAwsDynamoDbTableUtilization, +} from '../../src/providers/aws/resources/dynamodb.js'; import { hydrateAwsEbsSnapshots, hydrateAwsEbsVolumes } from '../../src/providers/aws/resources/ebs.js'; import { hydrateAwsEc2Instances } from '../../src/providers/aws/resources/ec2.js'; import { hydrateAwsEc2ReservedInstances } from '../../src/providers/aws/resources/ec2-reserved-instances.js'; @@ -42,10 +54,15 @@ import { hydrateAwsEcsAutoscaling } from '../../src/providers/aws/resources/ecs- import { hydrateAwsEcsClusterMetrics } from '../../src/providers/aws/resources/ecs-cluster-metrics.js'; import { hydrateAwsEksNodegroups } from '../../src/providers/aws/resources/eks.js'; import { + hydrateAwsElastiCacheClusterActivity, hydrateAwsElastiCacheClusters, hydrateAwsElastiCacheReservedNodes, } from '../../src/providers/aws/resources/elasticache.js'; -import { hydrateAwsEc2LoadBalancers, hydrateAwsEc2TargetGroups } from '../../src/providers/aws/resources/elbv2.js'; +import { + hydrateAwsEc2LoadBalancerRequestActivity, + hydrateAwsEc2LoadBalancers, + hydrateAwsEc2TargetGroups, +} from '../../src/providers/aws/resources/elbv2.js'; import { hydrateAwsEmrClusterMetrics, hydrateAwsEmrClusters } from '../../src/providers/aws/resources/emr.js'; import { hydrateAwsLambdaFunctionMetrics, @@ -100,6 +117,7 @@ vi.mock('../../src/providers/aws/resources/ebs.js', () => ({ })); vi.mock('../../src/providers/aws/resources/elasticache.js', () => ({ + hydrateAwsElastiCacheClusterActivity: vi.fn(), hydrateAwsElastiCacheClusters: vi.fn(), hydrateAwsElastiCacheReservedNodes: vi.fn(), })); @@ -127,11 +145,13 @@ vi.mock('../../src/providers/aws/resources/apigateway.js', () => ({ })); vi.mock('../../src/providers/aws/resources/cloudfront.js', () => ({ + hydrateAwsCloudFrontDistributionRequestActivity: vi.fn(), hydrateAwsCloudFrontDistributions: vi.fn(), })); vi.mock('../../src/providers/aws/resources/cloudwatch-logs.js', () => ({ hydrateAwsCloudWatchLogGroups: vi.fn(), + hydrateAwsCloudWatchLogMetricFilterCoverage: vi.fn(), hydrateAwsCloudWatchLogStreams: vi.fn(), })); @@ -139,8 +159,14 @@ vi.mock('../../src/providers/aws/resources/cost-explorer.js', () => ({ hydrateAwsCostUsage: vi.fn(), })); +vi.mock('../../src/providers/aws/resources/cost-guardrails.js', () => ({ + hydrateAwsCostAnomalyMonitors: vi.fn(), + hydrateAwsCostGuardrailBudgets: vi.fn(), +})); + vi.mock('../../src/providers/aws/resources/dynamodb.js', () => ({ hydrateAwsDynamoDbAutoscaling: vi.fn(), + hydrateAwsDynamoDbTableUtilization: vi.fn(), hydrateAwsDynamoDbTables: vi.fn(), })); @@ -175,6 +201,7 @@ vi.mock('../../src/providers/aws/resources/lambda.js', () => ({ })); vi.mock('../../src/providers/aws/resources/elbv2.js', () => ({ + hydrateAwsEc2LoadBalancerRequestActivity: vi.fn(), hydrateAwsEc2LoadBalancers: vi.fn(), hydrateAwsEc2TargetGroups: vi.fn(), })); @@ -222,14 +249,22 @@ const mockedWaitForAwsResourceExplorerIndex = vi.mocked(waitForAwsResourceExplor const mockedWaitForAwsResourceExplorerSetup = vi.mocked(waitForAwsResourceExplorerSetup); const mockedHydrateAwsApiGatewayStages = vi.mocked(hydrateAwsApiGatewayStages); const mockedHydrateAwsCloudFrontDistributions = vi.mocked(hydrateAwsCloudFrontDistributions); +const _mockedHydrateAwsCloudFrontDistributionRequestActivity = vi.mocked( + hydrateAwsCloudFrontDistributionRequestActivity, +); const mockedHydrateAwsCloudTrailTrails = vi.mocked(hydrateAwsCloudTrailTrails); const mockedHydrateAwsCloudWatchLogGroups = vi.mocked(hydrateAwsCloudWatchLogGroups); +const mockedHydrateAwsCloudWatchLogMetricFilterCoverage = vi.mocked(hydrateAwsCloudWatchLogMetricFilterCoverage); const mockedHydrateAwsCloudWatchLogStreams = vi.mocked(hydrateAwsCloudWatchLogStreams); const mockedHydrateAwsCostUsage = vi.mocked(hydrateAwsCostUsage); +const mockedHydrateAwsCostAnomalyMonitors = vi.mocked(hydrateAwsCostAnomalyMonitors); +const mockedHydrateAwsCostGuardrailBudgets = vi.mocked(hydrateAwsCostGuardrailBudgets); const mockedHydrateAwsDynamoDbAutoscaling = vi.mocked(hydrateAwsDynamoDbAutoscaling); +const mockedHydrateAwsDynamoDbTableUtilization = vi.mocked(hydrateAwsDynamoDbTableUtilization); const mockedHydrateAwsDynamoDbTables = vi.mocked(hydrateAwsDynamoDbTables); const mockedHydrateAwsEbsSnapshots = vi.mocked(hydrateAwsEbsSnapshots); const mockedHydrateAwsEbsVolumes = vi.mocked(hydrateAwsEbsVolumes); +const _mockedHydrateAwsElastiCacheClusterActivity = vi.mocked(hydrateAwsElastiCacheClusterActivity); const mockedHydrateAwsElastiCacheClusters = vi.mocked(hydrateAwsElastiCacheClusters); const mockedHydrateAwsElastiCacheReservedNodes = vi.mocked(hydrateAwsElastiCacheReservedNodes); const mockedHydrateAwsEcsAutoscaling = vi.mocked(hydrateAwsEcsAutoscaling); @@ -244,6 +279,7 @@ const mockedHydrateAwsEc2Instances = vi.mocked(hydrateAwsEc2Instances); const mockedHydrateAwsEc2InstanceUtilization = vi.mocked(hydrateAwsEc2InstanceUtilization); const mockedHydrateAwsEc2ReservedInstances = vi.mocked(hydrateAwsEc2ReservedInstances); const mockedHydrateAwsEc2LoadBalancers = vi.mocked(hydrateAwsEc2LoadBalancers); +const _mockedHydrateAwsEc2LoadBalancerRequestActivity = vi.mocked(hydrateAwsEc2LoadBalancerRequestActivity); const mockedHydrateAwsEc2TargetGroups = vi.mocked(hydrateAwsEc2TargetGroups); const mockedHydrateAwsEksNodegroups = vi.mocked(hydrateAwsEksNodegroups); const mockedHydrateAwsLambdaFunctionMetrics = vi.mocked(hydrateAwsLambdaFunctionMetrics); @@ -501,6 +537,7 @@ describe('discoverAwsResources', () => { accountId: '123456789012', architectures: ['x86_64'], functionName: 'my-func', + memorySizeMb: 512, region: 'us-east-1', timeoutSeconds: 60, }, @@ -593,6 +630,7 @@ describe('discoverAwsResources', () => { accountId: '123456789012', architectures: ['x86_64'], functionName: 'my-func', + memorySizeMb: 512, region: 'us-east-1', timeoutSeconds: 60, }, @@ -720,6 +758,16 @@ describe('discoverAwsResources', () => { tableName: 'orders', }, ]); + mockedHydrateAwsDynamoDbTableUtilization.mockResolvedValue([ + { + accountId: '123456789012', + region: 'us-east-1', + tableArn: 'arn:aws:dynamodb:us-east-1:123456789012:table/orders', + tableName: 'orders', + totalConsumedReadCapacityUnitsLast30Days: 0, + totalConsumedWriteCapacityUnitsLast30Days: 0, + }, + ]); mockedHydrateAwsRoute53Zones.mockResolvedValue([ { accountId: '123456789012', @@ -778,7 +826,7 @@ describe('discoverAwsResources', () => { createRule({ id: 'CLDBRN-AWS-TEST-4', service: 'dynamodb', - discoveryDependencies: ['aws-dynamodb-tables', 'aws-dynamodb-autoscaling'], + discoveryDependencies: ['aws-dynamodb-tables', 'aws-dynamodb-autoscaling', 'aws-dynamodb-table-utilization'], }), createRule({ id: 'CLDBRN-AWS-TEST-5', @@ -807,6 +855,7 @@ describe('discoverAwsResources', () => { expect(mockedHydrateAwsCostUsage).toHaveBeenCalledWith([]); expect(mockedHydrateAwsDynamoDbTables).toHaveBeenCalledWith([extendedCatalog.resources[2]]); expect(mockedHydrateAwsDynamoDbAutoscaling).toHaveBeenCalledWith([extendedCatalog.resources[2]]); + expect(mockedHydrateAwsDynamoDbTableUtilization).toHaveBeenCalledWith([extendedCatalog.resources[2]]); expect(mockedHydrateAwsRoute53Zones).toHaveBeenCalledWith([extendedCatalog.resources[3]]); expect(mockedHydrateAwsRoute53Records).toHaveBeenCalledWith([extendedCatalog.resources[3]]); expect(mockedHydrateAwsRoute53HealthChecks).toHaveBeenCalledWith([extendedCatalog.resources[4]]); @@ -828,6 +877,18 @@ describe('discoverAwsResources', () => { serviceSlug: 'amazon-route-53', }, ]); + mockedHydrateAwsCostGuardrailBudgets.mockResolvedValue([ + { + accountId: '123456789012', + budgetCount: 0, + }, + ]); + mockedHydrateAwsCostAnomalyMonitors.mockResolvedValue([ + { + accountId: '123456789012', + monitorCount: 0, + }, + ]); const result = await discoverAwsResources( [ @@ -835,12 +896,24 @@ describe('discoverAwsResources', () => { service: 'costexplorer', discoveryDependencies: ['aws-cost-usage'], }), + createRule({ + id: 'CLDBRN-AWS-TEST-BUDGETS', + service: 'costguardrails', + discoveryDependencies: ['aws-cost-guardrail-budgets'], + }), + createRule({ + id: 'CLDBRN-AWS-TEST-ANOMALY', + service: 'costguardrails', + discoveryDependencies: ['aws-cost-anomaly-monitors'], + }), ], { mode: 'region', region: 'eu-west-1' }, ); expect(mockedBuildAwsDiscoveryCatalog).not.toHaveBeenCalled(); expect(mockedHydrateAwsCostUsage).toHaveBeenCalledWith([]); + expect(mockedHydrateAwsCostGuardrailBudgets).toHaveBeenCalledWith([]); + expect(mockedHydrateAwsCostAnomalyMonitors).toHaveBeenCalledWith([]); expect(result.catalog).toEqual({ indexType: 'LOCAL', resources: [], @@ -857,6 +930,18 @@ describe('discoverAwsResources', () => { serviceSlug: 'amazon-route-53', }, ]); + expect(result.resources.get('aws-cost-guardrail-budgets')).toEqual([ + { + accountId: '123456789012', + budgetCount: 0, + }, + ]); + expect(result.resources.get('aws-cost-anomaly-monitors')).toEqual([ + { + accountId: '123456789012', + monitorCount: 0, + }, + ]); }); it('hydrates CloudTrail trails when an active rule requires the CloudTrail dataset', async () => { @@ -1352,6 +1437,45 @@ describe('discoverAwsResources', () => { ]); }); + it('hydrates CloudWatch log metric-filter coverage from log-group catalog resources', async () => { + mockedBuildAwsDiscoveryCatalog.mockResolvedValue({ + indexType: 'LOCAL', + resources: [catalog.resources[7]], + searchRegion: 'us-east-1', + }); + mockedHydrateAwsCloudWatchLogMetricFilterCoverage.mockResolvedValue([ + { + accountId: '123456789012', + logGroupName: '/aws/lambda/app', + metricFilterCount: 0, + region: 'us-east-1', + }, + ]); + + const result = await discoverAwsResources( + [ + createRule({ + discoveryDependencies: ['aws-cloudwatch-log-metric-filter-coverage'], + service: 'cloudwatch', + }), + ], + { mode: 'region', region: 'us-east-1' }, + ); + + expect(mockedBuildAwsDiscoveryCatalog).toHaveBeenCalledWith({ mode: 'region', region: 'us-east-1' }, [ + 'logs:log-group', + ]); + expect(mockedHydrateAwsCloudWatchLogMetricFilterCoverage).toHaveBeenCalledWith([catalog.resources[7]]); + expect(result.resources.get('aws-cloudwatch-log-metric-filter-coverage')).toEqual([ + { + accountId: '123456789012', + logGroupName: '/aws/lambda/app', + metricFilterCount: 0, + region: 'us-east-1', + }, + ]); + }); + it('loads only the S3 hydrator when active rules require only S3 bucket analyses', async () => { mockedBuildAwsDiscoveryCatalog.mockResolvedValue(catalog); mockedHydrateAwsS3BucketAnalyses.mockResolvedValue([ diff --git a/packages/sdk/test/providers/aws-dynamodb-resource.test.ts b/packages/sdk/test/providers/aws-dynamodb-resource.test.ts index 71d42e0..70c50d7 100644 --- a/packages/sdk/test/providers/aws-dynamodb-resource.test.ts +++ b/packages/sdk/test/providers/aws-dynamodb-resource.test.ts @@ -2,15 +2,25 @@ import type { DescribeScalableTargetsCommand } from '@aws-sdk/client-application import type { DescribeTableCommand } from '@aws-sdk/client-dynamodb'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createApplicationAutoScalingClient, createDynamoDbClient } from '../../src/providers/aws/client.js'; -import { hydrateAwsDynamoDbAutoscaling, hydrateAwsDynamoDbTables } from '../../src/providers/aws/resources/dynamodb.js'; +import { fetchCloudWatchSignals } from '../../src/providers/aws/resources/cloudwatch.js'; +import { + hydrateAwsDynamoDbAutoscaling, + hydrateAwsDynamoDbTables, + hydrateAwsDynamoDbTableUtilization, +} from '../../src/providers/aws/resources/dynamodb.js'; vi.mock('../../src/providers/aws/client.js', () => ({ createApplicationAutoScalingClient: vi.fn(), createDynamoDbClient: vi.fn(), })); +vi.mock('../../src/providers/aws/resources/cloudwatch.js', () => ({ + fetchCloudWatchSignals: vi.fn(), +})); + const mockedCreateApplicationAutoScalingClient = vi.mocked(createApplicationAutoScalingClient); const mockedCreateDynamoDbClient = vi.mocked(createDynamoDbClient); +const mockedFetchCloudWatchSignals = vi.mocked(fetchCloudWatchSignals); describe('DynamoDB discovery resources', () => { beforeEach(() => { @@ -103,4 +113,118 @@ describe('DynamoDB discovery resources', () => { }, ]); }); + + it('hydrates 30-day DynamoDB table utilization from CloudWatch consumed capacity metrics', async () => { + mockedCreateDynamoDbClient.mockReturnValue({ + send: vi.fn(async (_command: DescribeTableCommand) => ({ + Table: { + BillingModeSummary: { + BillingMode: 'PROVISIONED', + }, + TableArn: 'arn:aws:dynamodb:us-east-1:123456789012:table/orders', + TableName: 'orders', + TableStatus: 'ACTIVE', + }, + })), + } as never); + mockedFetchCloudWatchSignals.mockResolvedValue( + new Map([ + [ + 'read0', + Array.from({ length: 30 }, (_, index) => ({ + timestamp: `2026-02-${String(index + 1).padStart(2, '0')}T00:00:00.000Z`, + value: 0, + })), + ], + [ + 'write0', + Array.from({ length: 30 }, (_, index) => ({ + timestamp: `2026-02-${String(index + 1).padStart(2, '0')}T00:00:00.000Z`, + value: 0, + })), + ], + ]), + ); + + await expect( + hydrateAwsDynamoDbTableUtilization([ + { + accountId: '123456789012', + arn: 'arn:aws:dynamodb:us-east-1:123456789012:table/orders', + properties: [], + region: 'us-east-1', + resourceType: 'dynamodb:table', + service: 'dynamodb', + }, + ]), + ).resolves.toEqual([ + { + accountId: '123456789012', + region: 'us-east-1', + tableArn: 'arn:aws:dynamodb:us-east-1:123456789012:table/orders', + tableName: 'orders', + totalConsumedReadCapacityUnitsLast30Days: 0, + totalConsumedWriteCapacityUnitsLast30Days: 0, + }, + ]); + }); + + it('preserves incomplete DynamoDB utilization coverage as null totals', async () => { + mockedCreateDynamoDbClient.mockReturnValue({ + send: vi.fn(async (_command: DescribeTableCommand) => ({ + Table: { + BillingModeSummary: { + BillingMode: 'PROVISIONED', + }, + TableArn: 'arn:aws:dynamodb:us-east-1:123456789012:table/orders', + TableName: 'orders', + TableStatus: 'ACTIVE', + }, + })), + } as never); + mockedFetchCloudWatchSignals.mockResolvedValue( + new Map([ + [ + 'read0', + [ + { + timestamp: '2026-02-01T00:00:00.000Z', + value: 0, + }, + ], + ], + [ + 'write0', + [ + { + timestamp: '2026-02-01T00:00:00.000Z', + value: 0, + }, + ], + ], + ]), + ); + + await expect( + hydrateAwsDynamoDbTableUtilization([ + { + accountId: '123456789012', + arn: 'arn:aws:dynamodb:us-east-1:123456789012:table/orders', + properties: [], + region: 'us-east-1', + resourceType: 'dynamodb:table', + service: 'dynamodb', + }, + ]), + ).resolves.toEqual([ + { + accountId: '123456789012', + region: 'us-east-1', + tableArn: 'arn:aws:dynamodb:us-east-1:123456789012:table/orders', + tableName: 'orders', + totalConsumedReadCapacityUnitsLast30Days: null, + totalConsumedWriteCapacityUnitsLast30Days: null, + }, + ]); + }); }); diff --git a/packages/sdk/test/providers/aws-elasticache-resource.test.ts b/packages/sdk/test/providers/aws-elasticache-resource.test.ts index 7bc1150..f9af7f6 100644 --- a/packages/sdk/test/providers/aws-elasticache-resource.test.ts +++ b/packages/sdk/test/providers/aws-elasticache-resource.test.ts @@ -1,7 +1,9 @@ import type { DescribeCacheClustersCommand, DescribeReservedCacheNodesCommand } from '@aws-sdk/client-elasticache'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createElastiCacheClient } from '../../src/providers/aws/client.js'; +import { fetchCloudWatchSignals } from '../../src/providers/aws/resources/cloudwatch.js'; import { + hydrateAwsElastiCacheClusterActivity, hydrateAwsElastiCacheClusters, hydrateAwsElastiCacheReservedNodes, } from '../../src/providers/aws/resources/elasticache.js'; @@ -10,7 +12,12 @@ vi.mock('../../src/providers/aws/client.js', () => ({ createElastiCacheClient: vi.fn(), })); +vi.mock('../../src/providers/aws/resources/cloudwatch.js', () => ({ + fetchCloudWatchSignals: vi.fn(), +})); + const mockedCreateElastiCacheClient = vi.mocked(createElastiCacheClient); +const mockedFetchCloudWatchSignals = vi.mocked(fetchCloudWatchSignals); describe('ElastiCache discovery resources', () => { beforeEach(() => { @@ -98,4 +105,135 @@ describe('ElastiCache discovery resources', () => { }, ]); }); + + it('hydrates 14-day ElastiCache activity for Redis clusters', async () => { + mockedCreateElastiCacheClient.mockReturnValue({ + send: vi.fn(async (_command: DescribeCacheClustersCommand) => ({ + CacheClusters: [ + { + CacheClusterCreateTime: new Date('2025-01-01T00:00:00.000Z'), + CacheClusterId: 'cache-prod', + CacheClusterStatus: 'available', + CacheNodeType: 'cache.r6g.large', + Engine: 'redis', + NumCacheNodes: 2, + }, + ], + })), + } as never); + mockedFetchCloudWatchSignals.mockResolvedValue( + new Map([ + [ + 'hits0', + Array.from({ length: 14 }, (_, index) => ({ + timestamp: `2026-03-${String(index + 1).padStart(2, '0')}T00:00:00.000Z`, + value: 1, + })), + ], + [ + 'misses0', + Array.from({ length: 14 }, (_, index) => ({ + timestamp: `2026-03-${String(index + 1).padStart(2, '0')}T00:00:00.000Z`, + value: 19, + })), + ], + [ + 'connections0', + Array.from({ length: 14 }, (_, index) => ({ + timestamp: `2026-03-${String(index + 1).padStart(2, '0')}T00:00:00.000Z`, + value: 1, + })), + ], + ]), + ); + + await expect( + hydrateAwsElastiCacheClusterActivity([ + { + accountId: '123456789012', + arn: 'arn:aws:elasticache:us-east-1:123456789012:cluster:cache-prod', + properties: [], + region: 'us-east-1', + resourceType: 'elasticache:cluster', + service: 'elasticache', + }, + ]), + ).resolves.toEqual([ + { + accountId: '123456789012', + averageCacheHitRateLast14Days: 5, + averageCurrentConnectionsLast14Days: 1, + cacheClusterId: 'cache-prod', + region: 'us-east-1', + }, + ]); + }); + + it('preserves incomplete ElastiCache metric coverage as null activity fields', async () => { + mockedCreateElastiCacheClient.mockReturnValue({ + send: vi.fn(async (_command: DescribeCacheClustersCommand) => ({ + CacheClusters: [ + { + CacheClusterId: 'cache-prod', + CacheClusterStatus: 'available', + CacheNodeType: 'cache.r6g.large', + Engine: 'redis', + NumCacheNodes: 2, + }, + ], + })), + } as never); + mockedFetchCloudWatchSignals.mockResolvedValue( + new Map([ + [ + 'hits0', + [ + { + timestamp: '2026-03-01T00:00:00.000Z', + value: 1, + }, + ], + ], + [ + 'misses0', + [ + { + timestamp: '2026-03-01T00:00:00.000Z', + value: 19, + }, + ], + ], + [ + 'connections0', + [ + { + timestamp: '2026-03-01T00:00:00.000Z', + value: 1, + }, + ], + ], + ]), + ); + + await expect( + hydrateAwsElastiCacheClusterActivity([ + { + accountId: '123456789012', + arn: 'arn:aws:elasticache:us-east-1:123456789012:cluster:cache-prod', + properties: [], + region: 'us-east-1', + resourceType: 'elasticache:cluster', + service: 'elasticache', + }, + ]), + ).resolves.toEqual([ + { + accountId: '123456789012', + averageCacheHitRateLast14Days: null, + averageCurrentConnectionsLast14Days: null, + cacheClusterId: 'cache-prod', + region: 'us-east-1', + }, + ]); + }); }); diff --git a/packages/sdk/test/providers/aws-elb-resource.test.ts b/packages/sdk/test/providers/aws-elb-resource.test.ts index 39c62c4..56cd553 100644 --- a/packages/sdk/test/providers/aws-elb-resource.test.ts +++ b/packages/sdk/test/providers/aws-elb-resource.test.ts @@ -9,15 +9,25 @@ import { createElasticLoadBalancingClient, createElasticLoadBalancingV2Client, } from '../../src/providers/aws/client.js'; -import { hydrateAwsEc2LoadBalancers, hydrateAwsEc2TargetGroups } from '../../src/providers/aws/resources/elbv2.js'; +import { fetchCloudWatchSignals } from '../../src/providers/aws/resources/cloudwatch.js'; +import { + hydrateAwsEc2LoadBalancerRequestActivity, + hydrateAwsEc2LoadBalancers, + hydrateAwsEc2TargetGroups, +} from '../../src/providers/aws/resources/elbv2.js'; vi.mock('../../src/providers/aws/client.js', () => ({ createElasticLoadBalancingClient: vi.fn(), createElasticLoadBalancingV2Client: vi.fn(), })); +vi.mock('../../src/providers/aws/resources/cloudwatch.js', () => ({ + fetchCloudWatchSignals: vi.fn(), +})); + const mockedCreateElasticLoadBalancingClient = vi.mocked(createElasticLoadBalancingClient); const mockedCreateElasticLoadBalancingV2Client = vi.mocked(createElasticLoadBalancingV2Client); +const mockedFetchCloudWatchSignals = vi.mocked(fetchCloudWatchSignals); describe('hydrateAwsEc2LoadBalancers', () => { beforeEach(() => { @@ -256,6 +266,153 @@ describe('hydrateAwsEc2LoadBalancers', () => { }, ]); }); + + it('hydrates 14-day request activity for classic and v2 load balancers', async () => { + mockedCreateElasticLoadBalancingClient.mockImplementation(({ region }) => { + const send = vi.fn(async (_command: DescribeClassicLoadBalancersCommand) => ({ + LoadBalancerDescriptions: [ + { + Instances: [{ InstanceId: 'i-123' }], + LoadBalancerName: 'classic-lb', + }, + ], + })); + + return { send, region } as never; + }); + mockedCreateElasticLoadBalancingV2Client.mockImplementation(({ region }) => { + const send = vi.fn( + async (command: DescribeLoadBalancersV2Command | DescribeTargetGroupsCommand | DescribeTargetHealthCommand) => { + const input = command.input as { LoadBalancerArn?: string; LoadBalancerArns?: string[] }; + + if ('LoadBalancerArn' in input) { + return { TargetGroups: [] }; + } + + return { + LoadBalancers: (input.LoadBalancerArns ?? []).map((loadBalancerArn) => ({ + LoadBalancerArn: loadBalancerArn, + LoadBalancerName: 'alb', + Type: 'application', + })), + }; + }, + ); + + return { send, region } as never; + }); + mockedFetchCloudWatchSignals.mockResolvedValue( + new Map([ + [ + 'lb0', + Array.from({ length: 14 }, (_, index) => ({ + timestamp: `2026-03-${String(index + 1).padStart(2, '0')}T00:00:00.000Z`, + value: 5, + })), + ], + [ + 'lb1', + Array.from({ length: 14 }, (_, index) => ({ + timestamp: `2026-03-${String(index + 1).padStart(2, '0')}T00:00:00.000Z`, + value: 14, + })), + ], + ]), + ); + + await expect( + hydrateAwsEc2LoadBalancerRequestActivity([ + { + accountId: '123456789012', + arn: 'arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/classic-lb', + properties: [], + region: 'us-east-1', + resourceType: 'elasticloadbalancing:loadbalancer', + service: 'elasticloadbalancing', + }, + { + accountId: '123456789012', + arn: 'arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/alb/123', + properties: [], + region: 'us-east-1', + resourceType: 'elasticloadbalancing:loadbalancer/app', + service: 'elasticloadbalancing', + }, + ]), + ).resolves.toEqual([ + { + accountId: '123456789012', + averageRequestsPerDayLast14Days: 5, + loadBalancerArn: 'arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/alb/123', + region: 'us-east-1', + }, + { + accountId: '123456789012', + averageRequestsPerDayLast14Days: 14, + loadBalancerArn: 'arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/classic-lb', + region: 'us-east-1', + }, + ]); + }); + + it('preserves incomplete ELB request coverage as null averages', async () => { + mockedCreateElasticLoadBalancingV2Client.mockImplementation(({ region }) => { + const send = vi.fn( + async (command: DescribeLoadBalancersV2Command | DescribeTargetGroupsCommand | DescribeTargetHealthCommand) => { + const input = command.input as { LoadBalancerArn?: string; LoadBalancerArns?: string[] }; + + if ('LoadBalancerArn' in input) { + return { TargetGroups: [] }; + } + + return { + LoadBalancers: [ + { + LoadBalancerArn: input.LoadBalancerArns?.[0], + LoadBalancerName: 'alb', + Type: 'application', + }, + ], + }; + }, + ); + + return { send, region } as never; + }); + mockedFetchCloudWatchSignals.mockResolvedValue( + new Map([ + [ + 'lb0', + [ + { + timestamp: '2026-03-01T00:00:00.000Z', + value: 5, + }, + ], + ], + ]), + ); + + await expect( + hydrateAwsEc2LoadBalancerRequestActivity([ + { + accountId: '123456789012', + arn: 'arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/alb/123', + properties: [], + region: 'us-east-1', + resourceType: 'elasticloadbalancing:loadbalancer/app', + service: 'elasticloadbalancing', + }, + ]), + ).resolves.toEqual([ + { + accountId: '123456789012', + averageRequestsPerDayLast14Days: null, + loadBalancerArn: 'arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/alb/123', + region: 'us-east-1', + }, + ]); + }); }); describe('hydrateAwsEc2TargetGroups', () => { diff --git a/packages/sdk/test/providers/aws-lambda-resource.test.ts b/packages/sdk/test/providers/aws-lambda-resource.test.ts index 98df9bb..bae5db0 100644 --- a/packages/sdk/test/providers/aws-lambda-resource.test.ts +++ b/packages/sdk/test/providers/aws-lambda-resource.test.ts @@ -67,6 +67,7 @@ describe('hydrateAwsLambdaFunctions', () => { accountId: '123456789012', architectures: ['x86_64'], functionName: 'first-function', + memorySizeMb: 128, region: 'us-east-1', timeoutSeconds: 3, }, @@ -74,6 +75,7 @@ describe('hydrateAwsLambdaFunctions', () => { accountId: '123456789012', architectures: ['arm64'], functionName: 'second-function', + memorySizeMb: 128, region: 'us-east-1', timeoutSeconds: 3, }, @@ -127,6 +129,7 @@ describe('hydrateAwsLambdaFunctions', () => { accountId: '123456789012', architectures: ['arm64'], functionName: 'first-function', + memorySizeMb: 128, region: 'us-east-1', timeoutSeconds: 3, }, @@ -134,6 +137,7 @@ describe('hydrateAwsLambdaFunctions', () => { accountId: '123456789012', architectures: ['x86_64'], functionName: 'second-function', + memorySizeMb: 128, region: 'us-east-1', timeoutSeconds: 3, }, @@ -223,6 +227,7 @@ describe('hydrateAwsLambdaFunctions', () => { accountId: '123456789012', architectures: ['arm64'], functionName: 'retry-function', + memorySizeMb: 128, region: 'eu-central-1', timeoutSeconds: 15, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f2c4d2..f267a84 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,6 +105,9 @@ importers: '@aws-sdk/client-application-auto-scaling': specifier: ^3.1009.0 version: 3.1009.0 + '@aws-sdk/client-budgets': + specifier: ^3.1015.0 + version: 3.1016.0 '@aws-sdk/client-cloudfront': specifier: ^3.1015.0 version: 3.1015.0 @@ -233,6 +236,10 @@ packages: resolution: {integrity: sha512-pdBTQO2FPzhFV5kD/4pslI1FuPLMWxeLg1mUYLmbgsggVB5ws4dnLU2FKhsbQw8xXMAk6m4pDGvGn+wnC6YBnw==} engines: {node: '>=20.0.0'} + '@aws-sdk/client-budgets@3.1016.0': + resolution: {integrity: sha512-mUcdTAZ/1YcedAbXC1IhOB+Dq4H05NXVuIYa+Ii//3XsT8NCBrm39QkAV4Ogz8ycS771LWcKSGwl+ejFXNHMdA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/client-cloudfront@3.1015.0': resolution: {integrity: sha512-R1usvnjjGk2Qj/HlAScYI/R0A1+RHLIrARyALyZgP4anb+hBgsGkyk/VpK71xFyE520DK7Qy34T52xj7/qyPsg==} engines: {node: '>=20.0.0'} @@ -2667,6 +2674,50 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/client-budgets@3.1016.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.24 + '@aws-sdk/credential-provider-node': 3.972.25 + '@aws-sdk/middleware-host-header': 3.972.8 + '@aws-sdk/middleware-logger': 3.972.8 + '@aws-sdk/middleware-recursion-detection': 3.972.8 + '@aws-sdk/middleware-user-agent': 3.972.25 + '@aws-sdk/region-config-resolver': 3.972.9 + '@aws-sdk/types': 3.973.6 + '@aws-sdk/util-endpoints': 3.996.5 + '@aws-sdk/util-user-agent-browser': 3.972.8 + '@aws-sdk/util-user-agent-node': 3.973.11 + '@smithy/config-resolver': 4.4.13 + '@smithy/core': 3.23.12 + '@smithy/fetch-http-handler': 5.3.15 + '@smithy/hash-node': 4.2.12 + '@smithy/invalid-dependency': 4.2.12 + '@smithy/middleware-content-length': 4.2.12 + '@smithy/middleware-endpoint': 4.4.27 + '@smithy/middleware-retry': 4.4.44 + '@smithy/middleware-serde': 4.2.15 + '@smithy/middleware-stack': 4.2.12 + '@smithy/node-config-provider': 4.3.12 + '@smithy/node-http-handler': 4.5.0 + '@smithy/protocol-http': 5.3.12 + '@smithy/smithy-client': 4.12.7 + '@smithy/types': 4.13.1 + '@smithy/url-parser': 4.2.12 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.43 + '@smithy/util-defaults-mode-node': 4.2.47 + '@smithy/util-endpoints': 3.3.3 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-retry': 4.2.12 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/client-cloudfront@3.1015.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0