From a8faec07561436ee98d0823f82581eef3e22738b Mon Sep 17 00:00:00 2001 From: Axon Stone Date: Thu, 2 Apr 2026 11:52:51 +0200 Subject: [PATCH 1/2] feat(rules): add stopped RDS instance rule --- .changeset/witty-clouds-repeat.md | 5 + docs/reference/rule-ids.md | 1 + packages/rules/src/aws/rds/index.ts | 2 + .../rules/src/aws/rds/stopped-instance.ts | 25 +++++ packages/rules/test/exports.test.ts | 1 + .../rules/test/rds-stopped-instance.test.ts | 91 +++++++++++++++++++ packages/rules/test/rule-metadata.test.ts | 16 ++++ 7 files changed, 141 insertions(+) create mode 100644 .changeset/witty-clouds-repeat.md create mode 100644 packages/rules/src/aws/rds/stopped-instance.ts create mode 100644 packages/rules/test/rds-stopped-instance.test.ts diff --git a/.changeset/witty-clouds-repeat.md b/.changeset/witty-clouds-repeat.md new file mode 100644 index 0000000..5fd4a72 --- /dev/null +++ b/.changeset/witty-clouds-repeat.md @@ -0,0 +1,5 @@ +--- +"@cloudburn/rules": minor +--- + +Add a discovery rule that flags stopped Amazon RDS DB instances for cleanup review. diff --git a/docs/reference/rule-ids.md b/docs/reference/rule-ids.md index d80e878..c28cfb2 100644 --- a/docs/reference/rule-ids.md +++ b/docs/reference/rule-ids.md @@ -75,6 +75,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 | diff --git a/packages/rules/src/aws/rds/index.ts b/packages/rules/src/aws/rds/index.ts index c643340..743e198 100644 --- a/packages/rules/src/aws/rds/index.ts +++ b/packages/rules/src/aws/rds/index.ts @@ -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'; @@ -18,4 +19,5 @@ export const rdsRules = [ rdsUnsupportedEngineVersionRule, rdsUnusedSnapshotsRule, rdsPerformanceInsightsExtendedRetentionRule, + rdsStoppedInstanceRule, ]; diff --git a/packages/rules/src/aws/rds/stopped-instance.ts b/packages/rules/src/aws/rds/stopped-instance.ts new file mode 100644 index 0000000..9b4041c --- /dev/null +++ b/packages/rules/src/aws/rds/stopped-instance.ts @@ -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); + }, +}); diff --git a/packages/rules/test/exports.test.ts b/packages/rules/test/exports.test.ts index 38cd928..487edc1 100644 --- a/packages/rules/test/exports.test.ts +++ b/packages/rules/test/exports.test.ts @@ -115,6 +115,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', diff --git a/packages/rules/test/rds-stopped-instance.test.ts b/packages/rules/test/rds-stopped-instance.test.ts new file mode 100644 index 0000000..119a321 --- /dev/null +++ b/packages/rules/test/rds-stopped-instance.test.ts @@ -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 => ({ + 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', + }, + ]); + }); +}); diff --git a/packages/rules/test/rule-metadata.test.ts b/packages/rules/test/rule-metadata.test.ts index 25df261..6c00c3b 100644 --- a/packages/rules/test/rule-metadata.test.ts +++ b/packages/rules/test/rule-metadata.test.ts @@ -1002,6 +1002,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'); From 77ec106121c26d5cc638d76ad21a0e5bfc36956e Mon Sep 17 00:00:00 2001 From: Axon Stone Date: Thu, 2 Apr 2026 12:13:27 +0200 Subject: [PATCH 2/2] feat(rules): add recently expired EC2 RI rule --- .changeset/witty-clouds-repeat.md | 2 +- docs/reference/rule-ids.md | 1 + packages/rules/src/aws/ec2/index.ts | 2 + .../ec2/reserved-instance-recently-expired.ts | 38 ++++++++ ...reserved-instance-recently-expired.test.ts | 93 +++++++++++++++++++ packages/rules/test/exports.test.ts | 1 + packages/rules/test/rule-metadata.test.ts | 16 ++++ 7 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 packages/rules/src/aws/ec2/reserved-instance-recently-expired.ts create mode 100644 packages/rules/test/ec2-reserved-instance-recently-expired.test.ts diff --git a/.changeset/witty-clouds-repeat.md b/.changeset/witty-clouds-repeat.md index 5fd4a72..c0f2c55 100644 --- a/.changeset/witty-clouds-repeat.md +++ b/.changeset/witty-clouds-repeat.md @@ -2,4 +2,4 @@ "@cloudburn/rules": minor --- -Add a discovery rule that flags stopped Amazon RDS DB instances for cleanup review. +Add discovery rules that flag stopped Amazon RDS DB instances and recently expired EC2 reserved instances for review. diff --git a/docs/reference/rule-ids.md b/docs/reference/rule-ids.md index c28cfb2..239d8af 100644 --- a/docs/reference/rule-ids.md +++ b/docs/reference/rule-ids.md @@ -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 | diff --git a/packages/rules/src/aws/ec2/index.ts b/packages/rules/src/aws/ec2/index.ts index c0442b5..0c52e67 100644 --- a/packages/rules/src/aws/ec2/index.ts +++ b/packages/rules/src/aws/ec2/index.ts @@ -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'; @@ -23,4 +24,5 @@ export const ec2Rules = [ ec2LongRunningInstanceRule, ec2DetailedMonitoringEnabledRule, ec2IdleNatGatewayRule, + ec2ReservedInstanceRecentlyExpiredRule, ]; diff --git a/packages/rules/src/aws/ec2/reserved-instance-recently-expired.ts b/packages/rules/src/aws/ec2/reserved-instance-recently-expired.ts new file mode 100644 index 0000000..3946c28 --- /dev/null +++ b/packages/rules/src/aws/ec2/reserved-instance-recently-expired.ts @@ -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); + }, +}); diff --git a/packages/rules/test/ec2-reserved-instance-recently-expired.test.ts b/packages/rules/test/ec2-reserved-instance-recently-expired.test.ts new file mode 100644 index 0000000..edfe017 --- /dev/null +++ b/packages/rules/test/ec2-reserved-instance-recently-expired.test.ts @@ -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 => ({ + 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(); + }); +}); diff --git a/packages/rules/test/exports.test.ts b/packages/rules/test/exports.test.ts index 487edc1..c4659b3 100644 --- a/packages/rules/test/exports.test.ts +++ b/packages/rules/test/exports.test.ts @@ -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', diff --git a/packages/rules/test/rule-metadata.test.ts b/packages/rules/test/rule-metadata.test.ts index 6c00c3b..01b8e73 100644 --- a/packages/rules/test/rule-metadata.test.ts +++ b/packages/rules/test/rule-metadata.test.ts @@ -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');