Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/steady-flames-bake.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/tidy-apples-jump.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 24 additions & 0 deletions docs/reference/rule-ids.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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 |
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion packages/rules/src/aws/cloudfront/index.ts
Original file line number Diff line number Diff line change
@@ -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];
29 changes: 29 additions & 0 deletions packages/rules/src/aws/cloudfront/unused-distribution.ts
Original file line number Diff line number Diff line change
@@ -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);
},
});
7 changes: 6 additions & 1 deletion packages/rules/src/aws/cloudwatch/index.ts
Original file line number Diff line number Diff line change
@@ -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,
];
44 changes: 44 additions & 0 deletions packages/rules/src/aws/cloudwatch/log-group-no-metric-filters.ts
Original file line number Diff line number Diff line change
@@ -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);
},
});
5 changes: 5 additions & 0 deletions packages/rules/src/aws/costguardrails/index.ts
Original file line number Diff line number Diff line change
@@ -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];
28 changes: 28 additions & 0 deletions packages/rules/src/aws/costguardrails/missing-anomaly-detection.ts
Original file line number Diff line number Diff line change
@@ -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),
]);
},
});
28 changes: 28 additions & 0 deletions packages/rules/src/aws/costguardrails/missing-budgets.ts
Original file line number Diff line number Diff line change
@@ -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),
]);
},
});
3 changes: 2 additions & 1 deletion packages/rules/src/aws/dynamodb/index.ts
Original file line number Diff line number Diff line change
@@ -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];
41 changes: 41 additions & 0 deletions packages/rules/src/aws/dynamodb/unused-table.ts
Original file line number Diff line number Diff line change
@@ -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);
},
});
40 changes: 40 additions & 0 deletions packages/rules/src/aws/elasticache/idle-cluster.ts
Original file line number Diff line number Diff line change
@@ -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);
},
});
3 changes: 2 additions & 1 deletion packages/rules/src/aws/elasticache/index.ts
Original file line number Diff line number Diff line change
@@ -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];
Loading