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/witty-clouds-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudburn/rules": minor
---

Add discovery rules that flag stopped Amazon RDS DB instances and recently expired EC2 reserved instances for review.
2 changes: 2 additions & 0 deletions docs/reference/rule-ids.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Format: `CLDBRN-{PROVIDER}-{SERVICE}-{N}`
| `CLDBRN-AWS-EC2-9` | Flags only instances with a parsed launch timestamp at least 180 days old. | ec2 | discovery |
| `CLDBRN-AWS-EC2-10` | Flags IaC-defined instances only when detailed monitoring is explicitly enabled. | ec2 | iac |
| `CLDBRN-AWS-EC2-11` | Flags only NAT gateways in the `available` state and requires complete 7-day `BytesInFromDestination` and `BytesOutToDestination` coverage, with both totals equal to `0`. | ec2 | discovery |
| `CLDBRN-AWS-EC2-12` | Flags only EC2 reserved instances whose `endTime` fell within the last 30 days, surfacing them for renewal follow-up review. | ec2 | discovery |
| `CLDBRN-AWS-ECS-1` | Flags only EC2-backed container instances whose instance families have a curated Graviton-equivalent path. Fargate and unclassified backing instances are skipped. | ecs | discovery |
| `CLDBRN-AWS-ECS-2` | Flags only ECS clusters with a complete 14-day `AWS/ECS` CPU history and an average below `10%`. | ecs | discovery |
| `CLDBRN-AWS-ECS-3` | Flags only active `REPLICA` ECS services and requires both a scalable target and at least one scaling policy. | ecs | discovery, iac |
Expand Down Expand Up @@ -75,6 +76,7 @@ Format: `CLDBRN-{PROVIDER}-{SERVICE}-{N}`
| `CLDBRN-AWS-RDS-6` | Flags only RDS MySQL `5.7.x` and PostgreSQL `11.x` DB instances for extended-support review. | rds | discovery, iac |
| `CLDBRN-AWS-RDS-7` | Flags only snapshots whose source DB instance no longer exists and whose parsed create time is at least `30` days old. | rds | discovery |
| `CLDBRN-AWS-RDS-8` | Flags only DB instances with Performance Insights enabled and a retention period above the included 7-day baseline. | rds | iac |
| `CLDBRN-AWS-RDS-9` | Flags only RDS DB instances whose discovered `dbInstanceStatus` is `stopped`, surfacing them for cleanup review. | rds | discovery |
| `CLDBRN-AWS-REDSHIFT-1` | Reviews only `available` clusters and treats a 14-day average `CPUUtilization` of 10% or lower as low utilization. | redshift | discovery |
| `CLDBRN-AWS-REDSHIFT-2` | Reviews only `available` clusters with a parsed create time at least 180 days old and requires active reserved-node coverage for the same node type. | redshift | discovery |
| `CLDBRN-AWS-REDSHIFT-3` | Flags only `available`, VPC-backed clusters with automated snapshots enabled, no HSM, and no Multi-AZ deployment when either the pause or resume schedule is missing. | redshift | discovery, iac |
Expand Down
2 changes: 2 additions & 0 deletions packages/rules/src/aws/ec2/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ec2LongRunningInstanceRule } from './long-running-instance.js';
import { ec2LowUtilizationRule } from './low-utilization.js';
import { ec2PreferredInstanceTypeRule } from './preferred-instance-types.js';
import { ec2ReservedInstanceExpiringRule } from './reserved-instance-expiring.js';
import { ec2ReservedInstanceRecentlyExpiredRule } from './reserved-instance-recently-expired.js';
import { ec2S3InterfaceEndpointRule } from './s3-interface-endpoint.js';
import { ec2UnassociatedElasticIpRule } from './unassociated-elastic-ip.js';

Expand All @@ -23,4 +24,5 @@ export const ec2Rules = [
ec2LongRunningInstanceRule,
ec2DetailedMonitoringEnabledRule,
ec2IdleNatGatewayRule,
ec2ReservedInstanceRecentlyExpiredRule,
];
38 changes: 38 additions & 0 deletions packages/rules/src/aws/ec2/reserved-instance-recently-expired.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { createFinding, createFindingMatch, createRule } from '../../shared/helpers.js';

const RULE_ID = 'CLDBRN-AWS-EC2-12';
const RULE_SERVICE = 'ec2';
const RULE_MESSAGE = 'EC2 reserved instances that expired within the last 30 days should be reviewed.';
const DAY_MS = 24 * 60 * 60 * 1000;
const RESERVED_INSTANCE_RECENTLY_EXPIRED_WINDOW_DAYS = 30;

/** Flag EC2 reserved instances whose end date falls within the previous 30 days. */
export const ec2ReservedInstanceRecentlyExpiredRule = createRule({
id: RULE_ID,
name: 'EC2 Reserved Instance Recently Expired',
description: 'Flag EC2 reserved instances whose end date fell within the last 30 days.',
message: RULE_MESSAGE,
provider: 'aws',
service: RULE_SERVICE,
supports: ['discovery'],
discoveryDependencies: ['aws-ec2-reserved-instances'],
evaluateLive: ({ resources }) => {
const now = Date.now();
const cutoff = now - RESERVED_INSTANCE_RECENTLY_EXPIRED_WINDOW_DAYS * DAY_MS;

const findings = resources
.get('aws-ec2-reserved-instances')
.filter((instance) => {
const endTime = instance.endTime ? Date.parse(instance.endTime) : Number.NaN;

if (Number.isNaN(endTime)) {
return false;
}

return endTime < now && endTime >= cutoff;
})
.map((instance) => createFindingMatch(instance.reservedInstancesId, instance.region, instance.accountId));

return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'discovery', findings);
},
});
2 changes: 2 additions & 0 deletions packages/rules/src/aws/rds/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { rdsLowCpuUtilizationRule } from './low-cpu-utilization.js';
import { rdsPerformanceInsightsExtendedRetentionRule } from './performance-insights-extended-retention.js';
import { rdsPreferredInstanceClassRule } from './preferred-instance-classes.js';
import { rdsReservedCoverageRule } from './reserved-coverage.js';
import { rdsStoppedInstanceRule } from './stopped-instance.js';
import { rdsUnsupportedEngineVersionRule } from './unsupported-engine-version.js';
import { rdsUnusedSnapshotsRule } from './unused-snapshots.js';

Expand All @@ -18,4 +19,5 @@ export const rdsRules = [
rdsUnsupportedEngineVersionRule,
rdsUnusedSnapshotsRule,
rdsPerformanceInsightsExtendedRetentionRule,
rdsStoppedInstanceRule,
];
25 changes: 25 additions & 0 deletions packages/rules/src/aws/rds/stopped-instance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { createFinding, createFindingMatch, createRule } from '../../shared/helpers.js';

const RULE_ID = 'CLDBRN-AWS-RDS-9';
const RULE_SERVICE = 'rds';
const RULE_MESSAGE = 'Stopped RDS DB instances should be reviewed for cleanup.';

/** Flag RDS DB instances that remain in the stopped state. */
export const rdsStoppedInstanceRule = createRule({
id: RULE_ID,
name: 'RDS DB Instance Stopped',
description: 'Flag RDS DB instances that are currently in the stopped state for cleanup review.',
message: RULE_MESSAGE,
provider: 'aws',
service: RULE_SERVICE,
supports: ['discovery'],
discoveryDependencies: ['aws-rds-instances'],
evaluateLive: ({ resources }) => {
const findings = resources
.get('aws-rds-instances')
.filter((instance) => instance.dbInstanceStatus === 'stopped')
.map((instance) => createFindingMatch(instance.dbInstanceIdentifier, instance.region, instance.accountId));

return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'discovery', findings);
},
});
93 changes: 93 additions & 0 deletions packages/rules/test/ec2-reserved-instance-recently-expired.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ec2ReservedInstanceRecentlyExpiredRule } from '../src/aws/ec2/reserved-instance-recently-expired.js';
import type { AwsEc2ReservedInstance } from '../src/index.js';
import { LiveResourceBag } from '../src/index.js';

const createReservedInstance = (overrides: Partial<AwsEc2ReservedInstance> = {}): AwsEc2ReservedInstance => ({
accountId: '123456789012',
endTime: '2025-12-15T00:00:00.000Z',
instanceType: 'm6i.large',
region: 'us-east-1',
reservedInstancesId: 'abcd1234-ef56-7890-abcd-1234567890ab',
state: 'retired',
...overrides,
});

describe('ec2ReservedInstanceRecentlyExpiredRule', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
});

afterEach(() => {
vi.useRealTimers();
});

it('flags reserved instances that expired within the last 30 days', () => {
const finding = ec2ReservedInstanceRecentlyExpiredRule.evaluateLive?.({
catalog: {
indexType: 'LOCAL',
resources: [],
searchRegion: 'us-east-1',
},
resources: new LiveResourceBag({
'aws-ec2-reserved-instances': [createReservedInstance()],
}),
});

expect(finding).toEqual({
ruleId: 'CLDBRN-AWS-EC2-12',
service: 'ec2',
source: 'discovery',
message: 'EC2 reserved instances that expired within the last 30 days should be reviewed.',
findings: [
{
accountId: '123456789012',
region: 'us-east-1',
resourceId: 'abcd1234-ef56-7890-abcd-1234567890ab',
},
],
});
});

it('skips reserved instances that expired outside the review window', () => {
const finding = ec2ReservedInstanceRecentlyExpiredRule.evaluateLive?.({
catalog: {
indexType: 'LOCAL',
resources: [],
searchRegion: 'us-east-1',
},
resources: new LiveResourceBag({
'aws-ec2-reserved-instances': [
createReservedInstance({
endTime: '2025-11-15T00:00:00.000Z',
reservedInstancesId: 'older-expired',
}),
],
}),
});

expect(finding).toBeNull();
});

it('skips reserved instances that have not expired yet', () => {
const finding = ec2ReservedInstanceRecentlyExpiredRule.evaluateLive?.({
catalog: {
indexType: 'LOCAL',
resources: [],
searchRegion: 'us-east-1',
},
resources: new LiveResourceBag({
'aws-ec2-reserved-instances': [
createReservedInstance({
endTime: '2026-01-15T00:00:00.000Z',
reservedInstancesId: 'future-expiry',
state: 'active',
}),
],
}),
});

expect(finding).toBeNull();
});
});
2 changes: 2 additions & 0 deletions packages/rules/test/exports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ describe('rule exports', () => {
'CLDBRN-AWS-EC2-9',
'CLDBRN-AWS-EC2-10',
'CLDBRN-AWS-EC2-11',
'CLDBRN-AWS-EC2-12',
'CLDBRN-AWS-ECS-1',
'CLDBRN-AWS-ECS-2',
'CLDBRN-AWS-ECS-3',
Expand Down Expand Up @@ -115,6 +116,7 @@ describe('rule exports', () => {
'CLDBRN-AWS-RDS-6',
'CLDBRN-AWS-RDS-7',
'CLDBRN-AWS-RDS-8',
'CLDBRN-AWS-RDS-9',
'CLDBRN-AWS-REDSHIFT-1',
'CLDBRN-AWS-REDSHIFT-2',
'CLDBRN-AWS-REDSHIFT-3',
Expand Down
91 changes: 91 additions & 0 deletions packages/rules/test/rds-stopped-instance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest';
import { rdsStoppedInstanceRule } from '../src/aws/rds/stopped-instance.js';
import type { AwsRdsInstance } from '../src/index.js';
import { LiveResourceBag } from '../src/index.js';

const createInstance = (overrides: Partial<AwsRdsInstance> = {}): AwsRdsInstance => ({
accountId: '123456789012',
dbInstanceIdentifier: 'stopped-db',
dbInstanceStatus: 'stopped',
engine: 'postgres',
engineVersion: '16.2',
instanceClass: 'db.m7g.large',
instanceCreateTime: '2025-01-01T00:00:00.000Z',
multiAz: false,
region: 'us-east-1',
...overrides,
});

describe('rdsStoppedInstanceRule', () => {
it('flags stopped DB instances in discovery mode', () => {
const finding = rdsStoppedInstanceRule.evaluateLive?.({
catalog: {
indexType: 'LOCAL',
resources: [],
searchRegion: 'us-east-1',
},
resources: new LiveResourceBag({
'aws-rds-instances': [createInstance()],
}),
});

expect(finding).toEqual({
ruleId: 'CLDBRN-AWS-RDS-9',
service: 'rds',
source: 'discovery',
message: 'Stopped RDS DB instances should be reviewed for cleanup.',
findings: [
{
accountId: '123456789012',
region: 'us-east-1',
resourceId: 'stopped-db',
},
],
});
});

it('skips DB instances that are not stopped', () => {
const finding = rdsStoppedInstanceRule.evaluateLive?.({
catalog: {
indexType: 'LOCAL',
resources: [],
searchRegion: 'us-east-1',
},
resources: new LiveResourceBag({
'aws-rds-instances': [createInstance({ dbInstanceIdentifier: 'running-db', dbInstanceStatus: 'available' })],
}),
});

expect(finding).toBeNull();
});

it('returns only stopped DB instances from mixed discovery results', () => {
const finding = rdsStoppedInstanceRule.evaluateLive?.({
catalog: {
indexType: 'LOCAL',
resources: [],
searchRegion: 'us-east-1',
},
resources: new LiveResourceBag({
'aws-rds-instances': [
createInstance(),
createInstance({ dbInstanceIdentifier: 'running-db', dbInstanceStatus: 'available' }),
createInstance({ dbInstanceIdentifier: 'stopped-db-2', region: 'eu-west-1' }),
],
}),
});

expect(finding?.findings).toEqual([
{
accountId: '123456789012',
region: 'us-east-1',
resourceId: 'stopped-db',
},
{
accountId: '123456789012',
region: 'eu-west-1',
resourceId: 'stopped-db-2',
},
]);
});
});
32 changes: 32 additions & 0 deletions packages/rules/test/rule-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -736,6 +736,22 @@ describe('rule metadata', () => {
});
});

it('defines the expected EC2 reserved-instance-recently-expired rule metadata', () => {
const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-EC2-12');

expect(rule).toBeDefined();
expect(rule).toMatchObject({
id: 'CLDBRN-AWS-EC2-12',
name: 'EC2 Reserved Instance Recently Expired',
description: 'Flag EC2 reserved instances whose end date fell within the last 30 days.',
message: 'EC2 reserved instances that expired within the last 30 days should be reviewed.',
provider: 'aws',
service: 'ec2',
supports: ['discovery'],
discoveryDependencies: ['aws-ec2-reserved-instances'],
});
});

it('defines the expected ELB ALB-without-targets rule metadata', () => {
const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-ELB-1');

Expand Down Expand Up @@ -1002,6 +1018,22 @@ describe('rule metadata', () => {
});
});

it('defines the expected RDS stopped-instance rule metadata', () => {
const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-RDS-9');

expect(rule).toBeDefined();
expect(rule).toMatchObject({
id: 'CLDBRN-AWS-RDS-9',
name: 'RDS DB Instance Stopped',
description: 'Flag RDS DB instances that are currently in the stopped state for cleanup review.',
message: 'Stopped RDS DB instances should be reviewed for cleanup.',
provider: 'aws',
service: 'rds',
supports: ['discovery'],
discoveryDependencies: ['aws-rds-instances'],
});
});

it('defines the expected Redshift low-cpu rule metadata', () => {
const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-REDSHIFT-1');

Expand Down
Loading