Skip to content

Commit e1d241f

Browse files
authored
feat(rules): add stopped RDS instance rule (#60)
* feat(rules): add stopped RDS instance rule * feat(rules): add recently expired EC2 RI rule
1 parent 67c3b85 commit e1d241f

10 files changed

Lines changed: 292 additions & 0 deletions

.changeset/witty-clouds-repeat.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cloudburn/rules": minor
3+
---
4+
5+
Add discovery rules that flag stopped Amazon RDS DB instances and recently expired EC2 reserved instances for review.

docs/reference/rule-ids.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ Format: `CLDBRN-{PROVIDER}-{SERVICE}-{N}`
4242
| `CLDBRN-AWS-EC2-9` | Flags only instances with a parsed launch timestamp at least 180 days old. | ec2 | discovery |
4343
| `CLDBRN-AWS-EC2-10` | Flags IaC-defined instances only when detailed monitoring is explicitly enabled. | ec2 | iac |
4444
| `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 |
45+
| `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 |
4546
| `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 |
4647
| `CLDBRN-AWS-ECS-2` | Flags only ECS clusters with a complete 14-day `AWS/ECS` CPU history and an average below `10%`. | ecs | discovery |
4748
| `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 |
@@ -75,6 +76,7 @@ Format: `CLDBRN-{PROVIDER}-{SERVICE}-{N}`
7576
| `CLDBRN-AWS-RDS-6` | Flags only RDS MySQL `5.7.x` and PostgreSQL `11.x` DB instances for extended-support review. | rds | discovery, iac |
7677
| `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 |
7778
| `CLDBRN-AWS-RDS-8` | Flags only DB instances with Performance Insights enabled and a retention period above the included 7-day baseline. | rds | iac |
79+
| `CLDBRN-AWS-RDS-9` | Flags only RDS DB instances whose discovered `dbInstanceStatus` is `stopped`, surfacing them for cleanup review. | rds | discovery |
7880
| `CLDBRN-AWS-REDSHIFT-1` | Reviews only `available` clusters and treats a 14-day average `CPUUtilization` of 10% or lower as low utilization. | redshift | discovery |
7981
| `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 |
8082
| `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 |

packages/rules/src/aws/ec2/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ec2LongRunningInstanceRule } from './long-running-instance.js';
77
import { ec2LowUtilizationRule } from './low-utilization.js';
88
import { ec2PreferredInstanceTypeRule } from './preferred-instance-types.js';
99
import { ec2ReservedInstanceExpiringRule } from './reserved-instance-expiring.js';
10+
import { ec2ReservedInstanceRecentlyExpiredRule } from './reserved-instance-recently-expired.js';
1011
import { ec2S3InterfaceEndpointRule } from './s3-interface-endpoint.js';
1112
import { ec2UnassociatedElasticIpRule } from './unassociated-elastic-ip.js';
1213

@@ -23,4 +24,5 @@ export const ec2Rules = [
2324
ec2LongRunningInstanceRule,
2425
ec2DetailedMonitoringEnabledRule,
2526
ec2IdleNatGatewayRule,
27+
ec2ReservedInstanceRecentlyExpiredRule,
2628
];
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { createFinding, createFindingMatch, createRule } from '../../shared/helpers.js';
2+
3+
const RULE_ID = 'CLDBRN-AWS-EC2-12';
4+
const RULE_SERVICE = 'ec2';
5+
const RULE_MESSAGE = 'EC2 reserved instances that expired within the last 30 days should be reviewed.';
6+
const DAY_MS = 24 * 60 * 60 * 1000;
7+
const RESERVED_INSTANCE_RECENTLY_EXPIRED_WINDOW_DAYS = 30;
8+
9+
/** Flag EC2 reserved instances whose end date falls within the previous 30 days. */
10+
export const ec2ReservedInstanceRecentlyExpiredRule = createRule({
11+
id: RULE_ID,
12+
name: 'EC2 Reserved Instance Recently Expired',
13+
description: 'Flag EC2 reserved instances whose end date fell within the last 30 days.',
14+
message: RULE_MESSAGE,
15+
provider: 'aws',
16+
service: RULE_SERVICE,
17+
supports: ['discovery'],
18+
discoveryDependencies: ['aws-ec2-reserved-instances'],
19+
evaluateLive: ({ resources }) => {
20+
const now = Date.now();
21+
const cutoff = now - RESERVED_INSTANCE_RECENTLY_EXPIRED_WINDOW_DAYS * DAY_MS;
22+
23+
const findings = resources
24+
.get('aws-ec2-reserved-instances')
25+
.filter((instance) => {
26+
const endTime = instance.endTime ? Date.parse(instance.endTime) : Number.NaN;
27+
28+
if (Number.isNaN(endTime)) {
29+
return false;
30+
}
31+
32+
return endTime < now && endTime >= cutoff;
33+
})
34+
.map((instance) => createFindingMatch(instance.reservedInstancesId, instance.region, instance.accountId));
35+
36+
return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'discovery', findings);
37+
},
38+
});

packages/rules/src/aws/rds/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { rdsLowCpuUtilizationRule } from './low-cpu-utilization.js';
44
import { rdsPerformanceInsightsExtendedRetentionRule } from './performance-insights-extended-retention.js';
55
import { rdsPreferredInstanceClassRule } from './preferred-instance-classes.js';
66
import { rdsReservedCoverageRule } from './reserved-coverage.js';
7+
import { rdsStoppedInstanceRule } from './stopped-instance.js';
78
import { rdsUnsupportedEngineVersionRule } from './unsupported-engine-version.js';
89
import { rdsUnusedSnapshotsRule } from './unused-snapshots.js';
910

@@ -18,4 +19,5 @@ export const rdsRules = [
1819
rdsUnsupportedEngineVersionRule,
1920
rdsUnusedSnapshotsRule,
2021
rdsPerformanceInsightsExtendedRetentionRule,
22+
rdsStoppedInstanceRule,
2123
];
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { createFinding, createFindingMatch, createRule } from '../../shared/helpers.js';
2+
3+
const RULE_ID = 'CLDBRN-AWS-RDS-9';
4+
const RULE_SERVICE = 'rds';
5+
const RULE_MESSAGE = 'Stopped RDS DB instances should be reviewed for cleanup.';
6+
7+
/** Flag RDS DB instances that remain in the stopped state. */
8+
export const rdsStoppedInstanceRule = createRule({
9+
id: RULE_ID,
10+
name: 'RDS DB Instance Stopped',
11+
description: 'Flag RDS DB instances that are currently in the stopped state for cleanup review.',
12+
message: RULE_MESSAGE,
13+
provider: 'aws',
14+
service: RULE_SERVICE,
15+
supports: ['discovery'],
16+
discoveryDependencies: ['aws-rds-instances'],
17+
evaluateLive: ({ resources }) => {
18+
const findings = resources
19+
.get('aws-rds-instances')
20+
.filter((instance) => instance.dbInstanceStatus === 'stopped')
21+
.map((instance) => createFindingMatch(instance.dbInstanceIdentifier, instance.region, instance.accountId));
22+
23+
return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'discovery', findings);
24+
},
25+
});
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { ec2ReservedInstanceRecentlyExpiredRule } from '../src/aws/ec2/reserved-instance-recently-expired.js';
3+
import type { AwsEc2ReservedInstance } from '../src/index.js';
4+
import { LiveResourceBag } from '../src/index.js';
5+
6+
const createReservedInstance = (overrides: Partial<AwsEc2ReservedInstance> = {}): AwsEc2ReservedInstance => ({
7+
accountId: '123456789012',
8+
endTime: '2025-12-15T00:00:00.000Z',
9+
instanceType: 'm6i.large',
10+
region: 'us-east-1',
11+
reservedInstancesId: 'abcd1234-ef56-7890-abcd-1234567890ab',
12+
state: 'retired',
13+
...overrides,
14+
});
15+
16+
describe('ec2ReservedInstanceRecentlyExpiredRule', () => {
17+
beforeEach(() => {
18+
vi.useFakeTimers();
19+
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
20+
});
21+
22+
afterEach(() => {
23+
vi.useRealTimers();
24+
});
25+
26+
it('flags reserved instances that expired within the last 30 days', () => {
27+
const finding = ec2ReservedInstanceRecentlyExpiredRule.evaluateLive?.({
28+
catalog: {
29+
indexType: 'LOCAL',
30+
resources: [],
31+
searchRegion: 'us-east-1',
32+
},
33+
resources: new LiveResourceBag({
34+
'aws-ec2-reserved-instances': [createReservedInstance()],
35+
}),
36+
});
37+
38+
expect(finding).toEqual({
39+
ruleId: 'CLDBRN-AWS-EC2-12',
40+
service: 'ec2',
41+
source: 'discovery',
42+
message: 'EC2 reserved instances that expired within the last 30 days should be reviewed.',
43+
findings: [
44+
{
45+
accountId: '123456789012',
46+
region: 'us-east-1',
47+
resourceId: 'abcd1234-ef56-7890-abcd-1234567890ab',
48+
},
49+
],
50+
});
51+
});
52+
53+
it('skips reserved instances that expired outside the review window', () => {
54+
const finding = ec2ReservedInstanceRecentlyExpiredRule.evaluateLive?.({
55+
catalog: {
56+
indexType: 'LOCAL',
57+
resources: [],
58+
searchRegion: 'us-east-1',
59+
},
60+
resources: new LiveResourceBag({
61+
'aws-ec2-reserved-instances': [
62+
createReservedInstance({
63+
endTime: '2025-11-15T00:00:00.000Z',
64+
reservedInstancesId: 'older-expired',
65+
}),
66+
],
67+
}),
68+
});
69+
70+
expect(finding).toBeNull();
71+
});
72+
73+
it('skips reserved instances that have not expired yet', () => {
74+
const finding = ec2ReservedInstanceRecentlyExpiredRule.evaluateLive?.({
75+
catalog: {
76+
indexType: 'LOCAL',
77+
resources: [],
78+
searchRegion: 'us-east-1',
79+
},
80+
resources: new LiveResourceBag({
81+
'aws-ec2-reserved-instances': [
82+
createReservedInstance({
83+
endTime: '2026-01-15T00:00:00.000Z',
84+
reservedInstancesId: 'future-expiry',
85+
state: 'active',
86+
}),
87+
],
88+
}),
89+
});
90+
91+
expect(finding).toBeNull();
92+
});
93+
});

packages/rules/test/exports.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ describe('rule exports', () => {
8080
'CLDBRN-AWS-EC2-9',
8181
'CLDBRN-AWS-EC2-10',
8282
'CLDBRN-AWS-EC2-11',
83+
'CLDBRN-AWS-EC2-12',
8384
'CLDBRN-AWS-ECS-1',
8485
'CLDBRN-AWS-ECS-2',
8586
'CLDBRN-AWS-ECS-3',
@@ -115,6 +116,7 @@ describe('rule exports', () => {
115116
'CLDBRN-AWS-RDS-6',
116117
'CLDBRN-AWS-RDS-7',
117118
'CLDBRN-AWS-RDS-8',
119+
'CLDBRN-AWS-RDS-9',
118120
'CLDBRN-AWS-REDSHIFT-1',
119121
'CLDBRN-AWS-REDSHIFT-2',
120122
'CLDBRN-AWS-REDSHIFT-3',
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { rdsStoppedInstanceRule } from '../src/aws/rds/stopped-instance.js';
3+
import type { AwsRdsInstance } from '../src/index.js';
4+
import { LiveResourceBag } from '../src/index.js';
5+
6+
const createInstance = (overrides: Partial<AwsRdsInstance> = {}): AwsRdsInstance => ({
7+
accountId: '123456789012',
8+
dbInstanceIdentifier: 'stopped-db',
9+
dbInstanceStatus: 'stopped',
10+
engine: 'postgres',
11+
engineVersion: '16.2',
12+
instanceClass: 'db.m7g.large',
13+
instanceCreateTime: '2025-01-01T00:00:00.000Z',
14+
multiAz: false,
15+
region: 'us-east-1',
16+
...overrides,
17+
});
18+
19+
describe('rdsStoppedInstanceRule', () => {
20+
it('flags stopped DB instances in discovery mode', () => {
21+
const finding = rdsStoppedInstanceRule.evaluateLive?.({
22+
catalog: {
23+
indexType: 'LOCAL',
24+
resources: [],
25+
searchRegion: 'us-east-1',
26+
},
27+
resources: new LiveResourceBag({
28+
'aws-rds-instances': [createInstance()],
29+
}),
30+
});
31+
32+
expect(finding).toEqual({
33+
ruleId: 'CLDBRN-AWS-RDS-9',
34+
service: 'rds',
35+
source: 'discovery',
36+
message: 'Stopped RDS DB instances should be reviewed for cleanup.',
37+
findings: [
38+
{
39+
accountId: '123456789012',
40+
region: 'us-east-1',
41+
resourceId: 'stopped-db',
42+
},
43+
],
44+
});
45+
});
46+
47+
it('skips DB instances that are not stopped', () => {
48+
const finding = rdsStoppedInstanceRule.evaluateLive?.({
49+
catalog: {
50+
indexType: 'LOCAL',
51+
resources: [],
52+
searchRegion: 'us-east-1',
53+
},
54+
resources: new LiveResourceBag({
55+
'aws-rds-instances': [createInstance({ dbInstanceIdentifier: 'running-db', dbInstanceStatus: 'available' })],
56+
}),
57+
});
58+
59+
expect(finding).toBeNull();
60+
});
61+
62+
it('returns only stopped DB instances from mixed discovery results', () => {
63+
const finding = rdsStoppedInstanceRule.evaluateLive?.({
64+
catalog: {
65+
indexType: 'LOCAL',
66+
resources: [],
67+
searchRegion: 'us-east-1',
68+
},
69+
resources: new LiveResourceBag({
70+
'aws-rds-instances': [
71+
createInstance(),
72+
createInstance({ dbInstanceIdentifier: 'running-db', dbInstanceStatus: 'available' }),
73+
createInstance({ dbInstanceIdentifier: 'stopped-db-2', region: 'eu-west-1' }),
74+
],
75+
}),
76+
});
77+
78+
expect(finding?.findings).toEqual([
79+
{
80+
accountId: '123456789012',
81+
region: 'us-east-1',
82+
resourceId: 'stopped-db',
83+
},
84+
{
85+
accountId: '123456789012',
86+
region: 'eu-west-1',
87+
resourceId: 'stopped-db-2',
88+
},
89+
]);
90+
});
91+
});

packages/rules/test/rule-metadata.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -736,6 +736,22 @@ describe('rule metadata', () => {
736736
});
737737
});
738738

739+
it('defines the expected EC2 reserved-instance-recently-expired rule metadata', () => {
740+
const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-EC2-12');
741+
742+
expect(rule).toBeDefined();
743+
expect(rule).toMatchObject({
744+
id: 'CLDBRN-AWS-EC2-12',
745+
name: 'EC2 Reserved Instance Recently Expired',
746+
description: 'Flag EC2 reserved instances whose end date fell within the last 30 days.',
747+
message: 'EC2 reserved instances that expired within the last 30 days should be reviewed.',
748+
provider: 'aws',
749+
service: 'ec2',
750+
supports: ['discovery'],
751+
discoveryDependencies: ['aws-ec2-reserved-instances'],
752+
});
753+
});
754+
739755
it('defines the expected ELB ALB-without-targets rule metadata', () => {
740756
const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-ELB-1');
741757

@@ -1002,6 +1018,22 @@ describe('rule metadata', () => {
10021018
});
10031019
});
10041020

1021+
it('defines the expected RDS stopped-instance rule metadata', () => {
1022+
const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-RDS-9');
1023+
1024+
expect(rule).toBeDefined();
1025+
expect(rule).toMatchObject({
1026+
id: 'CLDBRN-AWS-RDS-9',
1027+
name: 'RDS DB Instance Stopped',
1028+
description: 'Flag RDS DB instances that are currently in the stopped state for cleanup review.',
1029+
message: 'Stopped RDS DB instances should be reviewed for cleanup.',
1030+
provider: 'aws',
1031+
service: 'rds',
1032+
supports: ['discovery'],
1033+
discoveryDependencies: ['aws-rds-instances'],
1034+
});
1035+
});
1036+
10051037
it('defines the expected Redshift low-cpu rule metadata', () => {
10061038
const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-REDSHIFT-1');
10071039

0 commit comments

Comments
 (0)