diff --git a/.changeset/quiet-sdk-hydrators.md b/.changeset/quiet-sdk-hydrators.md new file mode 100644 index 0000000..47e15f4 --- /dev/null +++ b/.changeset/quiet-sdk-hydrators.md @@ -0,0 +1,5 @@ +--- +"@cloudburn/sdk": minor +--- + +Add AWS discovery datasets and hydrators for Lambda function metrics plus enriched RDS instance, reservation, CPU, and snapshot metadata needed by the new ELB, Lambda, and RDS built-in rules. diff --git a/.changeset/steady-rules-bloom.md b/.changeset/steady-rules-bloom.md new file mode 100644 index 0000000..4422554 --- /dev/null +++ b/.changeset/steady-rules-bloom.md @@ -0,0 +1,5 @@ +--- +"@cloudburn/rules": minor +--- + +Add new AWS discovery rules for unused Network Load Balancers, Lambda error-rate and timeout review, and RDS reserved coverage, Graviton review, low CPU utilization, unsupported engine versions, and orphaned snapshots. diff --git a/docs/reference/rule-ids.md b/docs/reference/rule-ids.md index d7df95c..f6f4a8a 100644 --- a/docs/reference/rule-ids.md +++ b/docs/reference/rule-ids.md @@ -45,16 +45,24 @@ Format: `CLDBRN-{PROVIDER}-{SERVICE}-{N}` | `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-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 | | `CLDBRN-AWS-RDS-2` | RDS DB Instance Idle | rds | discovery | Implemented | +| `CLDBRN-AWS-RDS-3` | RDS DB Instance Missing Reserved Coverage | rds | discovery | Implemented | +| `CLDBRN-AWS-RDS-4` | RDS DB Instance Without Graviton | rds | discovery | Implemented | +| `CLDBRN-AWS-RDS-5` | RDS DB Instance Low CPU Utilization | rds | discovery | Implemented | +| `CLDBRN-AWS-RDS-6` | RDS DB Instance Unsupported Engine Version | rds | discovery | Implemented | +| `CLDBRN-AWS-RDS-7` | RDS Snapshot Without Source DB Instance | rds | discovery | Implemented | | `CLDBRN-AWS-REDSHIFT-1` | Redshift Cluster Low CPU Utilization | redshift | discovery | Implemented | | `CLDBRN-AWS-REDSHIFT-2` | Redshift Cluster Missing Reserved Coverage | redshift | discovery | Implemented | | `CLDBRN-AWS-REDSHIFT-3` | Redshift Cluster Pause Resume Not Enabled | redshift | discovery | Implemented | | `CLDBRN-AWS-S3-1` | S3 Missing Lifecycle Configuration | s3 | iac, discovery | Implemented | | `CLDBRN-AWS-S3-2` | S3 Bucket Storage Class Not Optimized | s3 | iac, discovery | Implemented | | `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-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`. @@ -86,12 +94,26 @@ 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-ELB-1` and `CLDBRN-AWS-ELB-3` flag load balancers with no attached target groups or no registered targets across attached target groups. +`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-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. +`CLDBRN-AWS-LAMBDA-2` uses 7-day CloudWatch totals and flags only functions whose observed `Errors / Invocations` ratio is greater than `10%`. + +`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-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. + +`CLDBRN-AWS-RDS-5` reviews only `available` DB instances and treats a complete 30-day average `CPUUtilization` of `10%` or lower as low utilization. + +`CLDBRN-AWS-RDS-6` flags only RDS MySQL `5.7.x` and PostgreSQL `11.x` DB instances for extended-support review. + +`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. + `CLDBRN-AWS-REDSHIFT-1` reviews only `available` clusters and treats a 14-day average `CPUUtilization` of 10% or lower as low utilization. `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. diff --git a/packages/cloudburn/test/rules-list.e2e.test.ts b/packages/cloudburn/test/rules-list.e2e.test.ts index 424fb8b..e9e3da0 100644 --- a/packages/cloudburn/test/rules-list.e2e.test.ts +++ b/packages/cloudburn/test/rules-list.e2e.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -describe('rules list e2e', () => { +describe('rules list e2e', { timeout: 30_000 }, () => { afterEach(() => { vi.resetModules(); vi.restoreAllMocks(); diff --git a/packages/rules/src/aws/elb/index.ts b/packages/rules/src/aws/elb/index.ts index 69c1ef1..d4bea54 100644 --- a/packages/rules/src/aws/elb/index.ts +++ b/packages/rules/src/aws/elb/index.ts @@ -1,6 +1,12 @@ import { elbAlbWithoutTargetsRule } from './alb-without-targets.js'; import { elbClassicWithoutInstancesRule } from './classic-without-instances.js'; import { elbGatewayWithoutTargetsRule } from './gateway-without-targets.js'; +import { elbNetworkWithoutTargetsRule } from './network-without-targets.js'; /** Aggregate AWS ELB rule definitions. */ -export const elbRules = [elbAlbWithoutTargetsRule, elbClassicWithoutInstancesRule, elbGatewayWithoutTargetsRule]; +export const elbRules = [ + elbAlbWithoutTargetsRule, + elbClassicWithoutInstancesRule, + elbGatewayWithoutTargetsRule, + elbNetworkWithoutTargetsRule, +]; diff --git a/packages/rules/src/aws/elb/network-without-targets.ts b/packages/rules/src/aws/elb/network-without-targets.ts new file mode 100644 index 0000000..01395fa --- /dev/null +++ b/packages/rules/src/aws/elb/network-without-targets.ts @@ -0,0 +1,30 @@ +import { createFinding, createFindingMatch, createRule } from '../../shared/helpers.js'; +import { hasNoRegisteredTargets } from './shared.js'; + +const RULE_ID = 'CLDBRN-AWS-ELB-4'; +const RULE_SERVICE = 'elb'; +const RULE_MESSAGE = 'Network Load Balancers with no registered targets should be deleted.'; + +/** Flag NLBs that have no attached target groups or only empty target groups. */ +export const elbNetworkWithoutTargetsRule = createRule({ + id: RULE_ID, + name: 'Network Load Balancer Without Targets', + description: 'Flag Network Load Balancers that have no attached target groups or no registered targets.', + message: RULE_MESSAGE, + provider: 'aws', + service: RULE_SERVICE, + supports: ['discovery'], + discoveryDependencies: ['aws-ec2-load-balancers', 'aws-ec2-target-groups'], + evaluateLive: ({ resources }) => { + const targetGroups = resources.get('aws-ec2-target-groups'); + const findings = resources + .get('aws-ec2-load-balancers') + .filter((loadBalancer) => loadBalancer.loadBalancerType === 'network') + .filter((loadBalancer) => hasNoRegisteredTargets(loadBalancer, targetGroups)) + .map((loadBalancer) => + 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/lambda/excessive-timeout.ts b/packages/rules/src/aws/lambda/excessive-timeout.ts new file mode 100644 index 0000000..ddfafc5 --- /dev/null +++ b/packages/rules/src/aws/lambda/excessive-timeout.ts @@ -0,0 +1,47 @@ +import { createFinding, createFindingMatch, createRule } from '../../shared/helpers.js'; + +const RULE_ID = 'CLDBRN-AWS-LAMBDA-3'; +const RULE_SERVICE = 'lambda'; +const RULE_MESSAGE = 'Lambda functions should not keep timeouts far above their observed average duration.'; +// Review only generously configured functions whose timeout is at least 30s and 5x the observed average duration. +const MIN_TIMEOUT_REVIEW_SECONDS = 30; +const EXCESSIVE_TIMEOUT_RATIO = 5; +const getFunctionKey = (accountId: string, region: string, functionName: string): string => + `${accountId}:${region}:${functionName}`; + +/** Flag Lambda functions whose configured timeout far exceeds observed average execution time. */ +export const lambdaExcessiveTimeoutRule = createRule({ + id: RULE_ID, + name: 'Lambda Function Excessive Timeout', + description: + 'Flag Lambda functions whose configured timeout is at least 30 seconds and 5x their 7-day average duration.', + 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) => { + const metric = metricsByFunctionKey.get(getFunctionKey(fn.accountId, fn.region, fn.functionName)); + + return ( + fn.timeoutSeconds >= MIN_TIMEOUT_REVIEW_SECONDS && + metric?.averageDurationMsLast7Days !== null && + metric?.averageDurationMsLast7Days !== undefined && + metric.averageDurationMsLast7Days > 0 && + fn.timeoutSeconds * 1000 >= metric.averageDurationMsLast7Days * EXCESSIVE_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/aws/lambda/high-error-rate.ts b/packages/rules/src/aws/lambda/high-error-rate.ts new file mode 100644 index 0000000..94f7f54 --- /dev/null +++ b/packages/rules/src/aws/lambda/high-error-rate.ts @@ -0,0 +1,46 @@ +import { createFinding, createFindingMatch, createRule } from '../../shared/helpers.js'; + +const RULE_ID = 'CLDBRN-AWS-LAMBDA-2'; +const RULE_SERVICE = 'lambda'; +const RULE_MESSAGE = 'Lambda functions should not sustain an error rate above 10% over the last 7 days.'; +// Error-rate review requires complete 7-day totals and only flags functions above 10%. +const HIGH_ERROR_RATE_THRESHOLD = 0.1; +const getFunctionKey = (accountId: string, region: string, functionName: string): string => + `${accountId}:${region}:${functionName}`; + +/** Flag Lambda functions whose recent error rate exceeds the review threshold. */ +export const lambdaHighErrorRateRule = createRule({ + id: RULE_ID, + name: 'Lambda Function High Error Rate', + description: 'Flag Lambda functions whose 7-day error rate is greater than 10%.', + 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) => { + const metric = metricsByFunctionKey.get(getFunctionKey(fn.accountId, fn.region, fn.functionName)); + + return ( + metric?.totalInvocationsLast7Days !== null && + metric?.totalInvocationsLast7Days !== undefined && + metric.totalInvocationsLast7Days > 0 && + metric.totalErrorsLast7Days !== null && + metric.totalErrorsLast7Days !== undefined && + metric.totalErrorsLast7Days / metric.totalInvocationsLast7Days > HIGH_ERROR_RATE_THRESHOLD + ); + }) + .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/aws/lambda/index.ts b/packages/rules/src/aws/lambda/index.ts index 04bba37..929ee1d 100644 --- a/packages/rules/src/aws/lambda/index.ts +++ b/packages/rules/src/aws/lambda/index.ts @@ -1,5 +1,7 @@ import { lambdaCostOptimalArchitectureRule } from './cost-optimal-architecture.js'; +import { lambdaExcessiveTimeoutRule } from './excessive-timeout.js'; +import { lambdaHighErrorRateRule } from './high-error-rate.js'; // Intent: aggregate AWS Lambda rule definitions. // TODO(cloudburn): add memory-rightsizing and idle-function checks. -export const lambdaRules = [lambdaCostOptimalArchitectureRule]; +export const lambdaRules = [lambdaCostOptimalArchitectureRule, lambdaHighErrorRateRule, lambdaExcessiveTimeoutRule]; diff --git a/packages/rules/src/aws/rds/graviton-review.ts b/packages/rules/src/aws/rds/graviton-review.ts new file mode 100644 index 0000000..86fc018 --- /dev/null +++ b/packages/rules/src/aws/rds/graviton-review.ts @@ -0,0 +1,31 @@ +import { createFinding, createFindingMatch, createRule } from '../../shared/helpers.js'; +import { isAwsRdsGravitonFamily, shouldReviewAwsRdsInstanceClassForGraviton } from './preferred-instance-families.js'; + +const RULE_ID = 'CLDBRN-AWS-RDS-4'; +const RULE_SERVICE = 'rds'; +const RULE_MESSAGE = 'RDS DB instances without a Graviton equivalent in use should be reviewed.'; + +/** Flag RDS DB instances still using reviewable non-Graviton families. */ +export const rdsGravitonReviewRule = createRule({ + id: RULE_ID, + name: 'RDS DB Instance Without Graviton', + description: + 'Flag RDS DB instances that still use non-Graviton instance families when a clear Graviton-based equivalent exists.', + 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) => + !isAwsRdsGravitonFamily(instance.instanceClass) && + shouldReviewAwsRdsInstanceClassForGraviton(instance.instanceClass), + ) + .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/src/aws/rds/index.ts b/packages/rules/src/aws/rds/index.ts index 6b8b43e..3f76fd7 100644 --- a/packages/rules/src/aws/rds/index.ts +++ b/packages/rules/src/aws/rds/index.ts @@ -1,6 +1,19 @@ +import { rdsGravitonReviewRule } from './graviton-review.js'; import { rdsIdleInstanceRule } from './idle-instance.js'; +import { rdsLowCpuUtilizationRule } from './low-cpu-utilization.js'; import { rdsPreferredInstanceClassRule } from './preferred-instance-classes.js'; +import { rdsReservedCoverageRule } from './reserved-coverage.js'; +import { rdsUnsupportedEngineVersionRule } from './unsupported-engine-version.js'; +import { rdsUnusedSnapshotsRule } from './unused-snapshots.js'; // Intent: aggregate AWS RDS rule definitions. // TODO(cloudburn): add idle-instance and single-AZ production checks. -export const rdsRules = [rdsPreferredInstanceClassRule, rdsIdleInstanceRule]; +export const rdsRules = [ + rdsPreferredInstanceClassRule, + rdsIdleInstanceRule, + rdsReservedCoverageRule, + rdsGravitonReviewRule, + rdsLowCpuUtilizationRule, + rdsUnsupportedEngineVersionRule, + rdsUnusedSnapshotsRule, +]; diff --git a/packages/rules/src/aws/rds/low-cpu-utilization.ts b/packages/rules/src/aws/rds/low-cpu-utilization.ts new file mode 100644 index 0000000..8557c73 --- /dev/null +++ b/packages/rules/src/aws/rds/low-cpu-utilization.ts @@ -0,0 +1,54 @@ +import { createFinding, createFindingMatch, createRule } from '../../shared/helpers.js'; + +const RULE_ID = 'CLDBRN-AWS-RDS-5'; +const RULE_SERVICE = 'rds'; +const RULE_MESSAGE = 'RDS DB instances with low CPU utilization should be reviewed.'; +// Review provisioned databases whose 30-day average CPU stays at or below 10%. +const LOW_CPU_THRESHOLD = 10; +const getInstanceKey = (accountId: string, region: string, dbInstanceIdentifier: string): string => + `${accountId}:${region}:${dbInstanceIdentifier}`; + +/** Flag available RDS DB instances with sustained low CPU utilization. */ +export const rdsLowCpuUtilizationRule = createRule({ + id: RULE_ID, + name: 'RDS DB Instance Low CPU Utilization', + description: 'Flag available RDS DB instances whose 30-day average CPU stays at or below 10%.', + message: RULE_MESSAGE, + provider: 'aws', + service: RULE_SERVICE, + supports: ['discovery'], + discoveryDependencies: ['aws-rds-instances', 'aws-rds-instance-cpu-metrics'], + evaluateLive: ({ resources }) => { + const instancesById = new Map( + resources + .get('aws-rds-instances') + .map( + (instance) => + [getInstanceKey(instance.accountId, instance.region, instance.dbInstanceIdentifier), instance] as const, + ), + ); + + const findings = resources + .get('aws-rds-instance-cpu-metrics') + .filter((metric) => { + const instance = instancesById.get( + getInstanceKey(metric.accountId, metric.region, metric.dbInstanceIdentifier), + ); + + return ( + instance?.dbInstanceStatus === 'available' && + metric.averageCpuUtilizationLast30Days !== null && + metric.averageCpuUtilizationLast30Days <= LOW_CPU_THRESHOLD + ); + }) + .flatMap((metric) => { + const instance = instancesById.get( + getInstanceKey(metric.accountId, metric.region, metric.dbInstanceIdentifier), + ); + + return 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/src/aws/rds/preferred-instance-families.ts b/packages/rules/src/aws/rds/preferred-instance-families.ts index d20b5ce..4385611 100644 --- a/packages/rules/src/aws/rds/preferred-instance-families.ts +++ b/packages/rules/src/aws/rds/preferred-instance-families.ts @@ -7,7 +7,19 @@ * specialized families are treated as `unclassified` so the rule stays * conservative until the policy is updated. */ -const awsRdsPreferredInstanceFamilies = new Set(['m8g', 'm8gd', 'm7i', 'm7g', 'r8g', 'r8gd', 'r7i', 'r7g', 't4g']); +const awsRdsPreferredInstanceFamilies = new Set([ + 'm8g', + 'm8gd', + 'm8i', + 'm7i', + 'm7g', + 'r8g', + 'r8gd', + 'r8i', + 'r7i', + 'r7g', + 't4g', +]); const awsRdsNonPreferredInstanceFamilies = new Set([ 'm1', @@ -36,9 +48,55 @@ const awsRdsNonPreferredInstanceFamilies = new Set([ 't3', ]); +const awsRdsEquivalentGravitonReviewFamilies = new Set([ + 'm5', + 'm5d', + 'm6i', + 'm6id', + 'm6in', + 'm7i', + 'm8i', + 'r5', + 'r5b', + 'r5d', + 'r6i', + 'r6id', + 'r6in', + 'r7i', + 'r8i', + 't2', + 't3', +]); + +const awsRdsGravitonFamilies = new Set([ + 'm6g', + 'm6gd', + 'm7g', + 'm8g', + 'm8gd', + 'r6g', + 'r6gd', + 'r7g', + 'r8g', + 'r8gd', + 't4g', +]); + /** Preferred-family policy states used by the RDS preferred-class rule. */ export type AwsRdsPreferredInstanceFamilyState = 'preferred' | 'non-preferred' | 'unclassified'; +/** + * Returns the family portion of a literal RDS DB instance class. + * + * @param instanceClass - Literal RDS DB instance class such as `db.m8g.large`. + * @returns The normalized family name, or `null` when the class is malformed. + */ +export const getAwsRdsInstanceFamily = (instanceClass: string): string | null => { + const family = /^db\.([a-z0-9-]+)/iu.exec(instanceClass)?.[1]?.toLowerCase(); + + return family ?? null; +}; + /** * Returns the curated preferred-family state for a literal RDS DB instance class. * @@ -46,7 +104,7 @@ export type AwsRdsPreferredInstanceFamilyState = 'preferred' | 'non-preferred' | * @returns The preferred-family classification for the DB instance class. */ export const getAwsRdsPreferredInstanceFamilyState = (instanceClass: string): AwsRdsPreferredInstanceFamilyState => { - const family = /^db\.([a-z0-9-]+)/iu.exec(instanceClass)?.[1]?.toLowerCase(); + const family = getAwsRdsInstanceFamily(instanceClass); if (!family) { return 'unclassified'; @@ -62,3 +120,28 @@ export const getAwsRdsPreferredInstanceFamilyState = (instanceClass: string): Aw return 'unclassified'; }; + +/** + * Returns whether a literal RDS DB instance class belongs to a curated Graviton family. + * + * @param instanceClass - Literal RDS DB instance class such as `db.m7g.large`. + * @returns Whether the class belongs to a curated Graviton family. + */ +export const isAwsRdsGravitonFamily = (instanceClass: string): boolean => { + const family = getAwsRdsInstanceFamily(instanceClass); + + return family ? awsRdsGravitonFamilies.has(family) : false; +}; + +/** + * Returns whether a literal RDS DB instance class belongs to a family that CloudBurn + * reviews for a Graviton migration. + * + * @param instanceClass - Literal RDS DB instance class such as `db.m7i.large`. + * @returns Whether the class belongs to a curated Graviton review family. + */ +export const shouldReviewAwsRdsInstanceClassForGraviton = (instanceClass: string): boolean => { + const family = getAwsRdsInstanceFamily(instanceClass); + + return family ? awsRdsEquivalentGravitonReviewFamilies.has(family) : false; +}; diff --git a/packages/rules/src/aws/rds/reserved-coverage.ts b/packages/rules/src/aws/rds/reserved-coverage.ts new file mode 100644 index 0000000..845ae28 --- /dev/null +++ b/packages/rules/src/aws/rds/reserved-coverage.ts @@ -0,0 +1,123 @@ +import { createFinding, createFindingMatch, createRule } from '../../shared/helpers.js'; + +const RULE_ID = 'CLDBRN-AWS-RDS-3'; +const RULE_SERVICE = 'rds'; +const RULE_MESSAGE = 'Long-running RDS DB instances should have reserved instance coverage.'; +const DAY_MS = 24 * 60 * 60 * 1000; +// Review steady-state databases twice a year for reservation fit. +const LONG_RUNNING_INSTANCE_DAYS = 180; + +const normalizeRdsEngine = (value: string | undefined): string | null => { + const normalized = value?.toLowerCase(); + + if (!normalized) { + return null; + } + + // Reserved product descriptions can append license-model suffixes such as `(li)` or `(byol)`. + const normalizedWithoutLicenseSuffix = normalized.replace(/\s*\([^)]*\)$/u, ''); + + if (normalizedWithoutLicenseSuffix.includes('aurora-mysql') || normalizedWithoutLicenseSuffix.includes('mysql')) { + return 'mysql'; + } + + if ( + normalizedWithoutLicenseSuffix.includes('aurora-postgresql') || + normalizedWithoutLicenseSuffix.includes('postgres') + ) { + return 'postgres'; + } + + return normalizedWithoutLicenseSuffix; +}; + +const createCoverageKey = ( + accountId: string, + region: string, + instanceClass: string, + multiAz: boolean, + engine: string, +): string => `${accountId}:${region}:${instanceClass}:${multiAz ? 'multi-az' : 'single-az'}:${engine}`; + +const getCoverageCandidateEngines = (engine: string): string[] => [engine, '*']; + +const consumeCoverage = ( + remainingCoverage: Map, + accountId: string, + region: string, + instanceClass: string, + multiAz: boolean, + engine: string, +): boolean => { + for (const candidateEngine of getCoverageCandidateEngines(engine)) { + const coverageKey = createCoverageKey(accountId, region, instanceClass, multiAz, candidateEngine); + const availableCount = remainingCoverage.get(coverageKey) ?? 0; + + if (availableCount <= 0) { + continue; + } + + remainingCoverage.set(coverageKey, availableCount - 1); + return true; + } + + return false; +}; + +/** Flag long-running RDS DB instances that lack active reserved-instance coverage. */ +export const rdsReservedCoverageRule = createRule({ + id: RULE_ID, + name: 'RDS DB Instance Missing Reserved Coverage', + description: 'Flag long-running RDS DB instances that do not have matching active reserved-instance coverage.', + message: RULE_MESSAGE, + provider: 'aws', + service: RULE_SERVICE, + supports: ['discovery'], + discoveryDependencies: ['aws-rds-instances', 'aws-rds-reserved-instances'], + evaluateLive: ({ resources }) => { + const now = Date.now(); + const cutoff = now - LONG_RUNNING_INSTANCE_DAYS * DAY_MS; + const remainingCoverage = new Map(); + + for (const reservedInstance of resources.get('aws-rds-reserved-instances')) { + if (reservedInstance.state !== 'active' || reservedInstance.instanceCount <= 0) { + continue; + } + + const normalizedEngine = normalizeRdsEngine(reservedInstance.productDescription) ?? '*'; + const coverageKey = createCoverageKey( + reservedInstance.accountId, + reservedInstance.region, + reservedInstance.instanceClass, + reservedInstance.multiAz ?? false, + normalizedEngine, + ); + + remainingCoverage.set(coverageKey, (remainingCoverage.get(coverageKey) ?? 0) + reservedInstance.instanceCount); + } + + const findings = resources + .get('aws-rds-instances') + .filter((instance) => { + const createTime = instance.instanceCreateTime ? Date.parse(instance.instanceCreateTime) : Number.NaN; + + if (instance.dbInstanceStatus !== 'available' || Number.isNaN(createTime) || createTime > cutoff) { + return false; + } + + const normalizedEngine = normalizeRdsEngine(instance.engine) ?? '*'; + + return !consumeCoverage( + remainingCoverage, + instance.accountId, + instance.region, + instance.instanceClass, + instance.multiAz ?? false, + normalizedEngine, + ); + }) + .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/src/aws/rds/unsupported-engine-version.ts b/packages/rules/src/aws/rds/unsupported-engine-version.ts new file mode 100644 index 0000000..7662afa --- /dev/null +++ b/packages/rules/src/aws/rds/unsupported-engine-version.ts @@ -0,0 +1,40 @@ +import { createFinding, createFindingMatch, createRule } from '../../shared/helpers.js'; + +const RULE_ID = 'CLDBRN-AWS-RDS-6'; +const RULE_SERVICE = 'rds'; +const RULE_MESSAGE = + 'RDS MySQL 5.7 and PostgreSQL 11 DB instances should be upgraded to avoid extended support charges.'; + +const isUnsupportedRdsEngineVersion = (engine?: string, engineVersion?: string): boolean => { + const normalizedEngine = engine?.toLowerCase(); + + if (!normalizedEngine || !engineVersion) { + return false; + } + + return ( + (normalizedEngine === 'mysql' && engineVersion.startsWith('5.7')) || + (normalizedEngine === 'postgres' && engineVersion.startsWith('11')) + ); +}; + +/** Flag RDS DB instances on engine versions that incur extended support charges. */ +export const rdsUnsupportedEngineVersionRule = createRule({ + id: RULE_ID, + name: 'RDS DB Instance Unsupported Engine Version', + description: + 'Flag RDS MySQL 5.7 and PostgreSQL 11 DB instances that can incur extended support charges until they are upgraded.', + 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) => isUnsupportedRdsEngineVersion(instance.engine, instance.engineVersion)) + .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/src/aws/rds/unused-snapshots.ts b/packages/rules/src/aws/rds/unused-snapshots.ts new file mode 100644 index 0000000..042d08c --- /dev/null +++ b/packages/rules/src/aws/rds/unused-snapshots.ts @@ -0,0 +1,49 @@ +import { createFinding, createFindingMatch, createRule } from '../../shared/helpers.js'; + +const RULE_ID = 'CLDBRN-AWS-RDS-7'; +const RULE_SERVICE = 'rds'; +const RULE_MESSAGE = 'RDS snapshots without a source DB instance should be reviewed for cleanup.'; +const DAY_MS = 24 * 60 * 60 * 1000; +// Give orphaned snapshots a 30-day grace period before review to avoid flagging recent intentional retention. +const ORPHANED_SNAPSHOT_GRACE_DAYS = 30; +const getInstanceKey = (accountId: string, region: string, dbInstanceIdentifier: string): string => + `${accountId}:${region}:${dbInstanceIdentifier}`; + +/** Flag aged RDS snapshots whose source DB instance no longer exists. */ +export const rdsUnusedSnapshotsRule = createRule({ + id: RULE_ID, + name: 'RDS Snapshot Without Source DB Instance', + description: 'Flag RDS snapshots older than 30 days whose source DB instance no longer exists.', + message: RULE_MESSAGE, + provider: 'aws', + service: RULE_SERVICE, + supports: ['discovery'], + discoveryDependencies: ['aws-rds-snapshots', 'aws-rds-instances'], + evaluateLive: ({ resources }) => { + const now = Date.now(); + const cutoff = now - ORPHANED_SNAPSHOT_GRACE_DAYS * DAY_MS; + const activeInstanceIds = new Set( + resources + .get('aws-rds-instances') + .map((instance) => getInstanceKey(instance.accountId, instance.region, instance.dbInstanceIdentifier)), + ); + + const findings = resources + .get('aws-rds-snapshots') + .filter((snapshot) => { + if ( + !snapshot.dbInstanceIdentifier || + activeInstanceIds.has(getInstanceKey(snapshot.accountId, snapshot.region, snapshot.dbInstanceIdentifier)) + ) { + return false; + } + + const snapshotCreateTime = snapshot.snapshotCreateTime ? Date.parse(snapshot.snapshotCreateTime) : Number.NaN; + + return !Number.isNaN(snapshotCreateTime) && snapshotCreateTime <= cutoff; + }) + .map((snapshot) => createFindingMatch(snapshot.dbSnapshotIdentifier, snapshot.region, snapshot.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 4689f88..2f927ec 100644 --- a/packages/rules/src/index.ts +++ b/packages/rules/src/index.ts @@ -39,8 +39,12 @@ export type { AwsEmrCluster, AwsEmrClusterMetric, AwsLambdaFunction, + AwsLambdaFunctionMetric, AwsRdsInstance, AwsRdsInstanceActivity, + AwsRdsInstanceCpuMetric, + AwsRdsReservedInstance, + AwsRdsSnapshot, AwsRedshiftCluster, AwsRedshiftClusterMetric, AwsRedshiftReservedNode, diff --git a/packages/rules/src/shared/metadata.ts b/packages/rules/src/shared/metadata.ts index eb453d7..abe4470 100644 --- a/packages/rules/src/shared/metadata.ts +++ b/packages/rules/src/shared/metadata.ts @@ -182,6 +182,21 @@ export type AwsLambdaFunction = { functionName: string; /** Normalized function architectures. Missing AWS API values default to `['x86_64']`. */ architectures: string[]; + /** Configured function timeout in seconds. */ + timeoutSeconds: number; + region: string; + accountId: string; +}; + +/** Discovered AWS Lambda function with recent error and duration summaries. */ +export type AwsLambdaFunctionMetric = { + functionName: string; + /** `null` means CloudWatch did not return a usable 7-day invocation total. */ + totalInvocationsLast7Days: number | null; + /** `null` means CloudWatch did not return a usable 7-day error total. */ + totalErrorsLast7Days: number | null; + /** `null` means CloudWatch did not return a usable 7-day average duration. */ + averageDurationMsLast7Days: number | null; region: string; accountId: string; }; @@ -189,7 +204,12 @@ export type AwsLambdaFunction = { /** Discovered AWS RDS DB instance with its normalized instance class. */ export type AwsRdsInstance = { dbInstanceIdentifier: string; + dbInstanceStatus?: string; + engine?: string; + engineVersion?: string; instanceClass: string; + instanceCreateTime?: string; + multiAz?: boolean; region: string; accountId: string; }; @@ -204,6 +224,38 @@ export type AwsRdsInstanceActivity = { accountId: string; }; +/** Discovered RDS reserved DB instance normalized for coverage checks. */ +export type AwsRdsReservedInstance = { + reservedDbInstanceId: string; + instanceClass: string; + instanceCount: number; + multiAz?: boolean; + productDescription?: string; + state?: string; + startTime?: string; + region: string; + accountId: string; +}; + +/** Discovered RDS DB instance with its 30-day CPU summary. */ +export type AwsRdsInstanceCpuMetric = { + dbInstanceIdentifier: string; + /** `null` means CloudWatch returned incomplete datapoints for the 30-day lookback window. */ + averageCpuUtilizationLast30Days: number | null; + region: string; + accountId: string; +}; + +/** Discovered RDS DB snapshot normalized for orphaned snapshot review. */ +export type AwsRdsSnapshot = { + dbSnapshotIdentifier: string; + dbInstanceIdentifier?: string; + snapshotCreateTime?: string; + snapshotType?: string; + region: string; + accountId: string; +}; + /** Discovered EC2 instance with its low-utilization summary. */ export type AwsEc2InstanceUtilization = { instanceId: string; @@ -416,8 +468,12 @@ export type DiscoveryDatasetKey = | 'aws-emr-clusters' | 'aws-emr-cluster-metrics' | 'aws-lambda-functions' + | 'aws-lambda-function-metrics' | 'aws-rds-instance-activity' + | 'aws-rds-instance-cpu-metrics' | 'aws-rds-instances' + | 'aws-rds-reserved-instances' + | 'aws-rds-snapshots' | 'aws-redshift-clusters' | 'aws-redshift-cluster-metrics' | 'aws-redshift-reserved-nodes' @@ -449,8 +505,12 @@ export type DiscoveryDatasetMap = { 'aws-emr-clusters': AwsEmrCluster[]; 'aws-emr-cluster-metrics': AwsEmrClusterMetric[]; 'aws-lambda-functions': AwsLambdaFunction[]; + 'aws-lambda-function-metrics': AwsLambdaFunctionMetric[]; 'aws-rds-instance-activity': AwsRdsInstanceActivity[]; + 'aws-rds-instance-cpu-metrics': AwsRdsInstanceCpuMetric[]; 'aws-rds-instances': AwsRdsInstance[]; + 'aws-rds-reserved-instances': AwsRdsReservedInstance[]; + 'aws-rds-snapshots': AwsRdsSnapshot[]; 'aws-redshift-clusters': AwsRedshiftCluster[]; 'aws-redshift-cluster-metrics': AwsRedshiftClusterMetric[]; 'aws-redshift-reserved-nodes': AwsRedshiftReservedNode[]; diff --git a/packages/rules/test/cost-optimal-architecture.test.ts b/packages/rules/test/cost-optimal-architecture.test.ts index 0a0a5c1..30b0e92 100644 --- a/packages/rules/test/cost-optimal-architecture.test.ts +++ b/packages/rules/test/cost-optimal-architecture.test.ts @@ -8,6 +8,7 @@ const createLambdaFunction = (overrides: Partial = {}): AwsLa architectures: ['x86_64'], region: 'us-east-1', accountId: '123456789012', + timeoutSeconds: 60, ...overrides, }); diff --git a/packages/rules/test/elb-network-without-targets.test.ts b/packages/rules/test/elb-network-without-targets.test.ts new file mode 100644 index 0000000..f93316f --- /dev/null +++ b/packages/rules/test/elb-network-without-targets.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; +import { elbNetworkWithoutTargetsRule } from '../src/aws/elb/network-without-targets.js'; +import type { AwsEc2LoadBalancer, 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/nlb/123'], + instanceCount: 0, + loadBalancerArn: 'arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/net/nlb/123', + loadBalancerName: 'nlb', + loadBalancerType: 'network', + region: 'us-east-1', + ...overrides, +}); + +const createTargetGroup = (overrides: Partial = {}): AwsEc2TargetGroup => ({ + accountId: '123456789012', + loadBalancerArns: ['arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/net/nlb/123'], + region: 'us-east-1', + registeredTargetCount: 0, + targetGroupArn: 'arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/nlb/123', + ...overrides, +}); + +describe('elbNetworkWithoutTargetsRule', () => { + it('flags Network Load Balancers with no attached target groups', () => { + const finding = elbNetworkWithoutTargetsRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-ec2-load-balancers': [createLoadBalancer({ attachedTargetGroupArns: [] })], + 'aws-ec2-target-groups': [], + }), + }); + + expect(finding?.findings).toEqual([ + { + accountId: '123456789012', + region: 'us-east-1', + resourceId: 'arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/net/nlb/123', + }, + ]); + }); + + it('flags Network Load Balancers whose attached target groups have no registered targets', () => { + const finding = elbNetworkWithoutTargetsRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + '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/net/nlb/123', + }, + ]); + }); + + it('skips Network Load Balancers with registered targets', () => { + const finding = elbNetworkWithoutTargetsRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-ec2-load-balancers': [createLoadBalancer()], + 'aws-ec2-target-groups': [createTargetGroup({ registeredTargetCount: 2 })], + }), + }); + + expect(finding).toBeNull(); + }); + + it('skips non-network load balancers even when they have no targets', () => { + const finding = elbNetworkWithoutTargetsRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-ec2-load-balancers': [createLoadBalancer({ loadBalancerType: 'application' })], + '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 3c734b4..576e546 100644 --- a/packages/rules/test/exports.test.ts +++ b/packages/rules/test/exports.test.ts @@ -68,9 +68,17 @@ describe('rule exports', () => { 'CLDBRN-AWS-ELB-1', 'CLDBRN-AWS-ELB-2', 'CLDBRN-AWS-ELB-3', + 'CLDBRN-AWS-ELB-4', 'CLDBRN-AWS-EMR-1', 'CLDBRN-AWS-EMR-2', + 'CLDBRN-AWS-LAMBDA-2', + 'CLDBRN-AWS-LAMBDA-3', 'CLDBRN-AWS-RDS-2', + 'CLDBRN-AWS-RDS-3', + 'CLDBRN-AWS-RDS-4', + 'CLDBRN-AWS-RDS-5', + 'CLDBRN-AWS-RDS-6', + 'CLDBRN-AWS-RDS-7', 'CLDBRN-AWS-REDSHIFT-1', 'CLDBRN-AWS-REDSHIFT-2', 'CLDBRN-AWS-REDSHIFT-3', @@ -192,7 +200,12 @@ describe('rule exports', () => { const liveRdsInstance: AwsRdsInstance = { accountId: '123456789012', dbInstanceIdentifier: 'legacy-db', + dbInstanceStatus: 'available', + engine: 'mysql', + engineVersion: '8.0.39', instanceClass: 'db.m6i.large', + instanceCreateTime: '2025-01-01T00:00:00.000Z', + multiAz: false, region: 'us-east-1', }; const trail: AwsCloudTrailTrail = { diff --git a/packages/rules/test/lambda-excessive-timeout.test.ts b/packages/rules/test/lambda-excessive-timeout.test.ts new file mode 100644 index 0000000..20e803e --- /dev/null +++ b/packages/rules/test/lambda-excessive-timeout.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest'; +import { lambdaExcessiveTimeoutRule } from '../src/aws/lambda/excessive-timeout.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', + region: 'us-east-1', + timeoutSeconds: 60, + ...overrides, +}); + +const createLambdaFunctionMetric = (overrides: Partial = {}): AwsLambdaFunctionMetric => ({ + accountId: '123456789012', + averageDurationMsLast7Days: 5_000, + functionName: 'my-function', + region: 'us-east-1', + totalErrorsLast7Days: 0, + totalInvocationsLast7Days: 100, + ...overrides, +}); + +describe('lambdaExcessiveTimeoutRule', () => { + it('flags functions whose timeout is at least 30 seconds and 5x their average duration', () => { + const finding = lambdaExcessiveTimeoutRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-lambda-functions': [createLambdaFunction({ timeoutSeconds: 60 })], + 'aws-lambda-function-metrics': [createLambdaFunctionMetric({ averageDurationMsLast7Days: 10_000 })], + }), + }); + + expect(finding).toEqual({ + ruleId: 'CLDBRN-AWS-LAMBDA-3', + service: 'lambda', + source: 'discovery', + message: 'Lambda functions should not keep timeouts far above their observed average duration.', + findings: [ + { + accountId: '123456789012', + region: 'us-east-1', + resourceId: 'my-function', + }, + ], + }); + }); + + it('skips functions whose timeout stays close to their average duration', () => { + const finding = lambdaExcessiveTimeoutRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-lambda-functions': [createLambdaFunction({ timeoutSeconds: 20 })], + 'aws-lambda-function-metrics': [createLambdaFunctionMetric({ averageDurationMsLast7Days: 10_000 })], + }), + }); + + expect(finding).toBeNull(); + }); + + it('skips functions when duration coverage is unavailable', () => { + const finding = lambdaExcessiveTimeoutRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-lambda-functions': [createLambdaFunction()], + 'aws-lambda-function-metrics': [createLambdaFunctionMetric({ averageDurationMsLast7Days: null })], + }), + }); + + expect(finding).toBeNull(); + }); + + it('matches duration metrics by account and region when duplicate function names exist', () => { + const finding = lambdaExcessiveTimeoutRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-lambda-functions': [ + createLambdaFunction({ timeoutSeconds: 60 }), + createLambdaFunction({ + accountId: '210987654321', + region: 'us-west-2', + timeoutSeconds: 60, + }), + ], + 'aws-lambda-function-metrics': [ + createLambdaFunctionMetric({ averageDurationMsLast7Days: 10_000 }), + createLambdaFunctionMetric({ + accountId: '210987654321', + averageDurationMsLast7Days: 20_000, + region: 'us-west-2', + }), + ], + }), + }); + + expect(finding?.findings).toEqual([ + { + accountId: '123456789012', + region: 'us-east-1', + resourceId: 'my-function', + }, + ]); + }); +}); diff --git a/packages/rules/test/lambda-high-error-rate.test.ts b/packages/rules/test/lambda-high-error-rate.test.ts new file mode 100644 index 0000000..5411c82 --- /dev/null +++ b/packages/rules/test/lambda-high-error-rate.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest'; +import { lambdaHighErrorRateRule } from '../src/aws/lambda/high-error-rate.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', + region: 'us-east-1', + timeoutSeconds: 60, + ...overrides, +}); + +const createLambdaFunctionMetric = (overrides: Partial = {}): AwsLambdaFunctionMetric => ({ + accountId: '123456789012', + averageDurationMsLast7Days: 1500, + functionName: 'my-function', + region: 'us-east-1', + totalErrorsLast7Days: 11, + totalInvocationsLast7Days: 100, + ...overrides, +}); + +describe('lambdaHighErrorRateRule', () => { + it('flags Lambda functions whose 7-day error rate exceeds 10 percent', () => { + const finding = lambdaHighErrorRateRule.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-2', + service: 'lambda', + source: 'discovery', + message: 'Lambda functions should not sustain an error rate above 10% over the last 7 days.', + findings: [ + { + accountId: '123456789012', + region: 'us-east-1', + resourceId: 'my-function', + }, + ], + }); + }); + + it('skips functions whose 7-day error rate stays at or below 10 percent', () => { + const finding = lambdaHighErrorRateRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-lambda-functions': [createLambdaFunction()], + 'aws-lambda-function-metrics': [createLambdaFunctionMetric({ totalErrorsLast7Days: 10 })], + }), + }); + + expect(finding).toBeNull(); + }); + + it('skips functions when invocation coverage is unavailable', () => { + const finding = lambdaHighErrorRateRule.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(); + }); + + it('matches metrics by account and region when duplicate function names exist', () => { + const finding = lambdaHighErrorRateRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-lambda-functions': [ + createLambdaFunction(), + createLambdaFunction({ + accountId: '210987654321', + region: 'us-west-2', + }), + ], + 'aws-lambda-function-metrics': [ + createLambdaFunctionMetric(), + createLambdaFunctionMetric({ + accountId: '210987654321', + region: 'us-west-2', + totalErrorsLast7Days: 0, + }), + ], + }), + }); + + expect(finding?.findings).toEqual([ + { + accountId: '123456789012', + region: 'us-east-1', + resourceId: 'my-function', + }, + ]); + }); +}); diff --git a/packages/rules/test/rds-graviton-review.test.ts b/packages/rules/test/rds-graviton-review.test.ts new file mode 100644 index 0000000..4c71bb0 --- /dev/null +++ b/packages/rules/test/rds-graviton-review.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { rdsGravitonReviewRule } from '../src/aws/rds/graviton-review.js'; +import type { AwsRdsInstance } from '../src/index.js'; +import { LiveResourceBag } from '../src/index.js'; + +const createInstance = (overrides: Partial = {}): AwsRdsInstance => ({ + accountId: '123456789012', + dbInstanceIdentifier: 'prod-db', + dbInstanceStatus: 'available', + engine: 'mysql', + engineVersion: '8.0.39', + instanceClass: 'db.m6i.large', + instanceCreateTime: '2025-01-01T00:00:00.000Z', + multiAz: false, + region: 'us-east-1', + ...overrides, +}); + +describe('rdsGravitonReviewRule', () => { + it('flags RDS instance classes with a curated Graviton equivalent', () => { + const finding = rdsGravitonReviewRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-rds-instances': [createInstance()], + }), + }); + + expect(finding?.findings).toEqual([ + { + accountId: '123456789012', + region: 'us-east-1', + resourceId: 'prod-db', + }, + ]); + }); + + it('skips RDS instance classes already using Graviton families', () => { + const finding = rdsGravitonReviewRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-rds-instances': [createInstance({ instanceClass: 'db.m7g.large' })], + }), + }); + + expect(finding).toBeNull(); + }); + + it('skips unclassified RDS instance families', () => { + const finding = rdsGravitonReviewRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-rds-instances': [createInstance({ instanceClass: 'db.x2g.large' })], + }), + }); + + expect(finding).toBeNull(); + }); +}); diff --git a/packages/rules/test/rds-low-cpu-utilization.test.ts b/packages/rules/test/rds-low-cpu-utilization.test.ts new file mode 100644 index 0000000..9be9ed3 --- /dev/null +++ b/packages/rules/test/rds-low-cpu-utilization.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; +import { rdsLowCpuUtilizationRule } from '../src/aws/rds/low-cpu-utilization.js'; +import type { AwsRdsInstance, AwsRdsInstanceCpuMetric } from '../src/index.js'; +import { LiveResourceBag } from '../src/index.js'; + +const createInstance = (overrides: Partial = {}): AwsRdsInstance => ({ + accountId: '123456789012', + dbInstanceIdentifier: 'prod-db', + dbInstanceStatus: 'available', + engine: 'mysql', + engineVersion: '8.0.39', + instanceClass: 'db.m6i.large', + instanceCreateTime: '2025-01-01T00:00:00.000Z', + multiAz: false, + region: 'us-east-1', + ...overrides, +}); + +const createMetric = (overrides: Partial = {}): AwsRdsInstanceCpuMetric => ({ + accountId: '123456789012', + averageCpuUtilizationLast30Days: 8, + dbInstanceIdentifier: 'prod-db', + region: 'us-east-1', + ...overrides, +}); + +describe('rdsLowCpuUtilizationRule', () => { + it('flags available RDS instances with 30-day average CPU at or below 10 percent', () => { + const finding = rdsLowCpuUtilizationRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-rds-instances': [createInstance()], + 'aws-rds-instance-cpu-metrics': [createMetric()], + }), + }); + + expect(finding?.findings).toEqual([ + { + accountId: '123456789012', + region: 'us-east-1', + resourceId: 'prod-db', + }, + ]); + }); + + it('skips RDS instances whose CPU average stays above 10 percent', () => { + const finding = rdsLowCpuUtilizationRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-rds-instances': [createInstance()], + 'aws-rds-instance-cpu-metrics': [createMetric({ averageCpuUtilizationLast30Days: 12 })], + }), + }); + + expect(finding).toBeNull(); + }); + + it('skips RDS instances when CPU coverage is incomplete', () => { + const finding = rdsLowCpuUtilizationRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-rds-instances': [createInstance()], + 'aws-rds-instance-cpu-metrics': [createMetric({ averageCpuUtilizationLast30Days: null })], + }), + }); + + expect(finding).toBeNull(); + }); + + it('matches CPU metrics by account and region when duplicate instance identifiers exist', () => { + const finding = rdsLowCpuUtilizationRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-rds-instances': [ + createInstance(), + createInstance({ + accountId: '210987654321', + region: 'us-west-2', + }), + ], + 'aws-rds-instance-cpu-metrics': [ + createMetric(), + createMetric({ + accountId: '210987654321', + averageCpuUtilizationLast30Days: 15, + region: 'us-west-2', + }), + ], + }), + }); + + expect(finding?.findings).toEqual([ + { + accountId: '123456789012', + region: 'us-east-1', + resourceId: 'prod-db', + }, + ]); + }); +}); diff --git a/packages/rules/test/rds-preferred-instance-class.test.ts b/packages/rules/test/rds-preferred-instance-class.test.ts index 2cbffe7..77ce1d0 100644 --- a/packages/rules/test/rds-preferred-instance-class.test.ts +++ b/packages/rules/test/rds-preferred-instance-class.test.ts @@ -138,10 +138,18 @@ describe('rdsPreferredInstanceClassRule', () => { const finding = rdsPreferredInstanceClassRule.evaluateStatic?.({ resources: new StaticResourceBag({ 'aws-rds-instances': [ + createStaticRdsInstance({ + instanceClass: 'db.m8i.large', + resourceId: 'aws_db_instance.current_intel_general', + }), createStaticRdsInstance({ instanceClass: 'db.m8g.large', resourceId: 'aws_db_instance.current_general', }), + createStaticRdsInstance({ + instanceClass: 'db.r8i.2xlarge', + resourceId: 'aws_db_instance.current_intel_memory', + }), createStaticRdsInstance({ instanceClass: 'db.r8gd.2xlarge', resourceId: 'aws_db_instance.current_memory', diff --git a/packages/rules/test/rds-reserved-coverage.test.ts b/packages/rules/test/rds-reserved-coverage.test.ts new file mode 100644 index 0000000..c4a846a --- /dev/null +++ b/packages/rules/test/rds-reserved-coverage.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest'; +import { rdsReservedCoverageRule } from '../src/aws/rds/reserved-coverage.js'; +import type { AwsRdsInstance, AwsRdsReservedInstance } from '../src/index.js'; +import { LiveResourceBag } from '../src/index.js'; + +const createInstance = (overrides: Partial = {}): AwsRdsInstance => ({ + accountId: '123456789012', + dbInstanceIdentifier: 'prod-db', + dbInstanceStatus: 'available', + engine: 'mysql', + engineVersion: '8.0.39', + instanceClass: 'db.m6i.large', + instanceCreateTime: '2025-01-01T00:00:00.000Z', + multiAz: false, + region: 'us-east-1', + ...overrides, +}); + +const createReservedInstance = (overrides: Partial = {}): AwsRdsReservedInstance => ({ + accountId: '123456789012', + instanceClass: 'db.m6i.large', + instanceCount: 1, + multiAz: false, + productDescription: 'mysql', + region: 'us-east-1', + reservedDbInstanceId: 'ri-123', + state: 'active', + ...overrides, +}); + +describe('rdsReservedCoverageRule', () => { + it('flags long-running RDS instances without active reserved coverage', () => { + const finding = rdsReservedCoverageRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-rds-instances': [createInstance()], + 'aws-rds-reserved-instances': [], + }), + }); + + expect(finding).toEqual({ + ruleId: 'CLDBRN-AWS-RDS-3', + service: 'rds', + source: 'discovery', + message: 'Long-running RDS DB instances should have reserved instance coverage.', + findings: [ + { + accountId: '123456789012', + region: 'us-east-1', + resourceId: 'prod-db', + }, + ], + }); + }); + + it('skips long-running RDS instances when matching active reserved coverage exists', () => { + const finding = rdsReservedCoverageRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-rds-instances': [createInstance()], + 'aws-rds-reserved-instances': [createReservedInstance()], + }), + }); + + expect(finding).toBeNull(); + }); + + it('skips instances that are not yet long-running', () => { + const finding = rdsReservedCoverageRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-rds-instances': [createInstance({ instanceCreateTime: '2026-02-20T00:00:00.000Z' })], + 'aws-rds-reserved-instances': [], + }), + }); + + expect(finding).toBeNull(); + }); + + it('does not consume reserved coverage from a different account', () => { + const finding = rdsReservedCoverageRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-rds-instances': [createInstance()], + 'aws-rds-reserved-instances': [createReservedInstance({ accountId: '210987654321' })], + }), + }); + + expect(finding?.findings).toEqual([ + { + accountId: '123456789012', + region: 'us-east-1', + resourceId: 'prod-db', + }, + ]); + }); + + it('matches Oracle reserved coverage when product descriptions include a license suffix', () => { + const finding = rdsReservedCoverageRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-rds-instances': [ + createInstance({ + dbInstanceIdentifier: 'oracle-db', + engine: 'oracle-se2', + instanceClass: 'db.r6i.large', + }), + ], + 'aws-rds-reserved-instances': [ + createReservedInstance({ + instanceClass: 'db.r6i.large', + productDescription: 'oracle-se2(li)', + reservedDbInstanceId: 'ri-oracle', + }), + ], + }), + }); + + expect(finding).toBeNull(); + }); +}); diff --git a/packages/rules/test/rds-unsupported-engine-version.test.ts b/packages/rules/test/rds-unsupported-engine-version.test.ts new file mode 100644 index 0000000..8b0d167 --- /dev/null +++ b/packages/rules/test/rds-unsupported-engine-version.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { rdsUnsupportedEngineVersionRule } from '../src/aws/rds/unsupported-engine-version.js'; +import type { AwsRdsInstance } from '../src/index.js'; +import { LiveResourceBag } from '../src/index.js'; + +const createInstance = (overrides: Partial = {}): AwsRdsInstance => ({ + accountId: '123456789012', + dbInstanceIdentifier: 'legacy-db', + dbInstanceStatus: 'available', + engine: 'mysql', + engineVersion: '5.7.44', + instanceClass: 'db.m6i.large', + instanceCreateTime: '2025-01-01T00:00:00.000Z', + multiAz: false, + region: 'us-east-1', + ...overrides, +}); + +describe('rdsUnsupportedEngineVersionRule', () => { + it('flags MySQL 5.7 instances that can incur extended support charges', () => { + const finding = rdsUnsupportedEngineVersionRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-rds-instances': [createInstance()], + }), + }); + + expect(finding?.findings).toEqual([ + { + accountId: '123456789012', + region: 'us-east-1', + resourceId: 'legacy-db', + }, + ]); + }); + + it('flags PostgreSQL 11 instances that can incur extended support charges', () => { + const finding = rdsUnsupportedEngineVersionRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-rds-instances': [createInstance({ engine: 'postgres', engineVersion: '11.22' })], + }), + }); + + expect(finding?.findings).toEqual([ + { + accountId: '123456789012', + region: 'us-east-1', + resourceId: 'legacy-db', + }, + ]); + }); + + it('skips supported engine versions', () => { + const finding = rdsUnsupportedEngineVersionRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-rds-instances': [createInstance({ engineVersion: '8.0.39' })], + }), + }); + + expect(finding).toBeNull(); + }); +}); diff --git a/packages/rules/test/rds-unused-snapshots.test.ts b/packages/rules/test/rds-unused-snapshots.test.ts new file mode 100644 index 0000000..7c175fb --- /dev/null +++ b/packages/rules/test/rds-unused-snapshots.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; +import { rdsUnusedSnapshotsRule } from '../src/aws/rds/unused-snapshots.js'; +import type { AwsRdsInstance, AwsRdsSnapshot } from '../src/index.js'; +import { LiveResourceBag } from '../src/index.js'; + +const createInstance = (overrides: Partial = {}): AwsRdsInstance => ({ + accountId: '123456789012', + dbInstanceIdentifier: 'active-db', + dbInstanceStatus: 'available', + engine: 'mysql', + engineVersion: '8.0.39', + instanceClass: 'db.m6i.large', + instanceCreateTime: '2025-01-01T00:00:00.000Z', + multiAz: false, + region: 'us-east-1', + ...overrides, +}); + +const createSnapshot = (overrides: Partial = {}): AwsRdsSnapshot => ({ + accountId: '123456789012', + dbInstanceIdentifier: 'deleted-db', + dbSnapshotIdentifier: 'snapshot-123', + region: 'us-east-1', + snapshotCreateTime: '2026-01-01T00:00:00.000Z', + snapshotType: 'manual', + ...overrides, +}); + +describe('rdsUnusedSnapshotsRule', () => { + it('flags orphaned RDS snapshots older than the grace period', () => { + const finding = rdsUnusedSnapshotsRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-rds-instances': [createInstance()], + 'aws-rds-snapshots': [createSnapshot()], + }), + }); + + expect(finding?.findings).toEqual([ + { + accountId: '123456789012', + region: 'us-east-1', + resourceId: 'snapshot-123', + }, + ]); + }); + + it('skips snapshots whose source DB instance still exists', () => { + const finding = rdsUnusedSnapshotsRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-rds-instances': [createInstance({ dbInstanceIdentifier: 'deleted-db' })], + 'aws-rds-snapshots': [createSnapshot()], + }), + }); + + expect(finding).toBeNull(); + }); + + it('skips recently created orphaned snapshots during the grace period', () => { + const finding = rdsUnusedSnapshotsRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-rds-instances': [createInstance()], + 'aws-rds-snapshots': [createSnapshot({ snapshotCreateTime: '2026-03-10T00:00:00.000Z' })], + }), + }); + + expect(finding).toBeNull(); + }); + + it('does not treat instances from other accounts or regions as the snapshot source', () => { + const finding = rdsUnusedSnapshotsRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-rds-instances': [ + createInstance({ + accountId: '210987654321', + dbInstanceIdentifier: 'deleted-db', + region: 'us-west-2', + }), + ], + 'aws-rds-snapshots': [createSnapshot()], + }), + }); + + expect(finding?.findings).toEqual([ + { + accountId: '123456789012', + region: 'us-east-1', + resourceId: 'snapshot-123', + }, + ]); + }); +}); diff --git a/packages/rules/test/rule-metadata.test.ts b/packages/rules/test/rule-metadata.test.ts index ba0ab7e..4ddf8ea 100644 --- a/packages/rules/test/rule-metadata.test.ts +++ b/packages/rules/test/rule-metadata.test.ts @@ -609,6 +609,55 @@ describe('rule metadata', () => { }); }); + it('defines the expected ELB network-without-targets rule metadata', () => { + const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-ELB-4'); + + expect(rule).toBeDefined(); + expect(rule).toMatchObject({ + id: 'CLDBRN-AWS-ELB-4', + name: 'Network Load Balancer Without Targets', + description: 'Flag Network Load Balancers that have no attached target groups or no registered targets.', + message: 'Network Load Balancers with no registered targets should be deleted.', + provider: 'aws', + service: 'elb', + supports: ['discovery'], + discoveryDependencies: ['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'); + + expect(rule).toBeDefined(); + expect(rule).toMatchObject({ + id: 'CLDBRN-AWS-LAMBDA-2', + name: 'Lambda Function High Error Rate', + description: 'Flag Lambda functions whose 7-day error rate is greater than 10%.', + message: 'Lambda functions should not sustain an error rate above 10% over the last 7 days.', + provider: 'aws', + service: 'lambda', + supports: ['discovery'], + discoveryDependencies: ['aws-lambda-functions', 'aws-lambda-function-metrics'], + }); + }); + + it('defines the expected Lambda excessive-timeout rule metadata', () => { + const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-LAMBDA-3'); + + expect(rule).toBeDefined(); + expect(rule).toMatchObject({ + id: 'CLDBRN-AWS-LAMBDA-3', + name: 'Lambda Function Excessive Timeout', + description: + 'Flag Lambda functions whose configured timeout is at least 30 seconds and 5x their 7-day average duration.', + message: 'Lambda functions should not keep timeouts far above their observed average duration.', + 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'); @@ -625,6 +674,88 @@ describe('rule metadata', () => { }); }); + it('defines the expected RDS reserved-coverage rule metadata', () => { + const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-RDS-3'); + + expect(rule).toBeDefined(); + expect(rule).toMatchObject({ + id: 'CLDBRN-AWS-RDS-3', + name: 'RDS DB Instance Missing Reserved Coverage', + description: 'Flag long-running RDS DB instances that do not have matching active reserved-instance coverage.', + message: 'Long-running RDS DB instances should have reserved instance coverage.', + provider: 'aws', + service: 'rds', + supports: ['discovery'], + discoveryDependencies: ['aws-rds-instances', 'aws-rds-reserved-instances'], + }); + }); + + it('defines the expected RDS Graviton review rule metadata', () => { + const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-RDS-4'); + + expect(rule).toBeDefined(); + expect(rule).toMatchObject({ + id: 'CLDBRN-AWS-RDS-4', + name: 'RDS DB Instance Without Graviton', + description: + 'Flag RDS DB instances that still use non-Graviton instance families when a clear Graviton-based equivalent exists.', + message: 'RDS DB instances without a Graviton equivalent in use should be reviewed.', + provider: 'aws', + service: 'rds', + supports: ['discovery'], + discoveryDependencies: ['aws-rds-instances'], + }); + }); + + it('defines the expected RDS low-cpu rule metadata', () => { + const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-RDS-5'); + + expect(rule).toBeDefined(); + expect(rule).toMatchObject({ + id: 'CLDBRN-AWS-RDS-5', + name: 'RDS DB Instance Low CPU Utilization', + description: 'Flag available RDS DB instances whose 30-day average CPU stays at or below 10%.', + message: 'RDS DB instances with low CPU utilization should be reviewed.', + provider: 'aws', + service: 'rds', + supports: ['discovery'], + discoveryDependencies: ['aws-rds-instances', 'aws-rds-instance-cpu-metrics'], + }); + }); + + it('defines the expected RDS unsupported-engine-version rule metadata', () => { + const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-RDS-6'); + + expect(rule).toBeDefined(); + expect(rule).toMatchObject({ + id: 'CLDBRN-AWS-RDS-6', + name: 'RDS DB Instance Unsupported Engine Version', + description: + 'Flag RDS MySQL 5.7 and PostgreSQL 11 DB instances that can incur extended support charges until they are upgraded.', + message: 'RDS MySQL 5.7 and PostgreSQL 11 DB instances should be upgraded to avoid extended support charges.', + provider: 'aws', + service: 'rds', + supports: ['discovery'], + discoveryDependencies: ['aws-rds-instances'], + }); + }); + + it('defines the expected RDS unused-snapshots rule metadata', () => { + const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-RDS-7'); + + expect(rule).toBeDefined(); + expect(rule).toMatchObject({ + id: 'CLDBRN-AWS-RDS-7', + name: 'RDS Snapshot Without Source DB Instance', + description: 'Flag RDS snapshots older than 30 days whose source DB instance no longer exists.', + message: 'RDS snapshots without a source DB instance should be reviewed for cleanup.', + provider: 'aws', + service: 'rds', + supports: ['discovery'], + discoveryDependencies: ['aws-rds-snapshots', 'aws-rds-instances'], + }); + }); + it('defines the expected Redshift low-cpu rule metadata', () => { const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-REDSHIFT-1'); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index af323a4..ec6ec49 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -34,8 +34,12 @@ export type { AwsEmrCluster, AwsEmrClusterMetric, AwsLambdaFunction, + AwsLambdaFunctionMetric, AwsRdsInstance, AwsRdsInstanceActivity, + AwsRdsInstanceCpuMetric, + AwsRdsReservedInstance, + AwsRdsSnapshot, AwsRedshiftCluster, AwsRedshiftClusterMetric, AwsRedshiftReservedNode, diff --git a/packages/sdk/src/providers/aws/discovery-registry.ts b/packages/sdk/src/providers/aws/discovery-registry.ts index afa2f5d..3efa4fc 100644 --- a/packages/sdk/src/providers/aws/discovery-registry.ts +++ b/packages/sdk/src/providers/aws/discovery-registry.ts @@ -15,9 +15,9 @@ import { hydrateAwsEksNodegroups } from './resources/eks.js'; import { hydrateAwsElastiCacheClusters, hydrateAwsElastiCacheReservedNodes } from './resources/elasticache.js'; import { hydrateAwsEc2LoadBalancers, hydrateAwsEc2TargetGroups } from './resources/elbv2.js'; import { hydrateAwsEmrClusterMetrics, hydrateAwsEmrClusters } from './resources/emr.js'; -import { hydrateAwsLambdaFunctions } from './resources/lambda.js'; -import { hydrateAwsRdsInstances } from './resources/rds.js'; -import { hydrateAwsRdsInstanceActivity } from './resources/rds-activity.js'; +import { hydrateAwsLambdaFunctionMetrics, hydrateAwsLambdaFunctions } from './resources/lambda.js'; +import { hydrateAwsRdsInstances, hydrateAwsRdsReservedInstances, hydrateAwsRdsSnapshots } from './resources/rds.js'; +import { hydrateAwsRdsInstanceActivity, hydrateAwsRdsInstanceCpuMetrics } from './resources/rds-activity.js'; import { hydrateAwsRedshiftClusterMetrics, hydrateAwsRedshiftClusters, @@ -209,18 +209,44 @@ const awsDiscoveryDatasetRegistry: { service: 'lambda', load: hydrateAwsLambdaFunctions, }, + 'aws-lambda-function-metrics': { + datasetKey: 'aws-lambda-function-metrics', + resourceTypes: ['lambda:function'], + service: 'lambda', + load: hydrateAwsLambdaFunctionMetrics, + }, 'aws-rds-instance-activity': { datasetKey: 'aws-rds-instance-activity', resourceTypes: ['rds:db'], service: 'rds', load: hydrateAwsRdsInstanceActivity, }, + 'aws-rds-instance-cpu-metrics': { + datasetKey: 'aws-rds-instance-cpu-metrics', + resourceTypes: ['rds:db'], + service: 'rds', + load: hydrateAwsRdsInstanceCpuMetrics, + }, 'aws-rds-instances': { datasetKey: 'aws-rds-instances', resourceTypes: ['rds:db'], service: 'rds', load: hydrateAwsRdsInstances, }, + 'aws-rds-reserved-instances': { + datasetKey: 'aws-rds-reserved-instances', + // Resource Explorer does not surface RDS reserved instances, so DB + // resources seed the regions we need to query with DescribeReservedDBInstances. + resourceTypes: ['rds:db'], + service: 'rds', + load: hydrateAwsRdsReservedInstances, + }, + 'aws-rds-snapshots': { + datasetKey: 'aws-rds-snapshots', + resourceTypes: ['rds:snapshot'], + service: 'rds', + load: hydrateAwsRdsSnapshots, + }, 'aws-redshift-clusters': { datasetKey: 'aws-redshift-clusters', resourceTypes: ['redshift:cluster'], diff --git a/packages/sdk/src/providers/aws/resources/lambda.ts b/packages/sdk/src/providers/aws/resources/lambda.ts index a458405..e9fe431 100644 --- a/packages/sdk/src/providers/aws/resources/lambda.ts +++ b/packages/sdk/src/providers/aws/resources/lambda.ts @@ -1,10 +1,18 @@ import { GetFunctionConfigurationCommand } from '@aws-sdk/client-lambda'; -import type { AwsDiscoveredResource, AwsLambdaFunction } from '@cloudburn/rules'; +import type { AwsDiscoveredResource, AwsLambdaFunction, AwsLambdaFunctionMetric } from '@cloudburn/rules'; import { createLambdaClient } from '../client.js'; +import { fetchCloudWatchSignals } from './cloudwatch.js'; import { chunkItems, withAwsServiceErrorContext } from './utils.js'; const DEFAULT_LAMBDA_ARCHITECTURES = ['x86_64']; +const DEFAULT_LAMBDA_TIMEOUT_SECONDS = 3; const LAMBDA_CONFIGURATION_CONCURRENCY = 5; +const SEVEN_DAYS_IN_SECONDS = 7 * 24 * 60 * 60; + +const getSum = (values: Array<{ value: number }>): number => values.reduce((sum, point) => sum + point.value, 0); + +const getAverage = (values: Array<{ value: number }>): number | null => + values.length === 0 ? null : getSum(values) / values.length; const inferFunctionName = (arn: string): string | null => { const arnSegments = arn.split(':'); @@ -63,6 +71,7 @@ export const hydrateAwsLambdaFunctions = async (resources: AwsDiscoveredResource architectures: response.Architectures?.map(String) ?? [...DEFAULT_LAMBDA_ARCHITECTURES], functionName, region, + timeoutSeconds: response.Timeout ?? DEFAULT_LAMBDA_TIMEOUT_SECONDS, } satisfies AwsLambdaFunction; }), ); @@ -76,3 +85,77 @@ export const hydrateAwsLambdaFunctions = async (resources: AwsDiscoveredResource return hydratedPages.flat().sort((left, right) => left.functionName.localeCompare(right.functionName)); }; + +/** + * Hydrates discovered Lambda functions with their recent invocation, error, and duration summaries. + * + * @param resources - Catalog resources filtered to Lambda function resource types. + * @returns Hydrated Lambda function metric models for rule evaluation. + */ +export const hydrateAwsLambdaFunctionMetrics = async ( + resources: AwsDiscoveredResource[], +): Promise => { + const functions = await hydrateAwsLambdaFunctions(resources); + const functionsByRegion = new Map(); + + for (const fn of functions) { + const regionFunctions = functionsByRegion.get(fn.region) ?? []; + regionFunctions.push(fn); + functionsByRegion.set(fn.region, regionFunctions); + } + + const hydratedPages = await Promise.all( + [...functionsByRegion.entries()].map(async ([region, regionFunctions]) => { + const metricData = await fetchCloudWatchSignals({ + endTime: new Date(), + queries: regionFunctions.flatMap((fn, index) => [ + { + dimensions: [{ Name: 'FunctionName', Value: fn.functionName }], + id: `invocations${index}`, + metricName: 'Invocations', + namespace: 'AWS/Lambda', + period: SEVEN_DAYS_IN_SECONDS, + stat: 'Sum' as const, + }, + { + dimensions: [{ Name: 'FunctionName', Value: fn.functionName }], + id: `errors${index}`, + metricName: 'Errors', + namespace: 'AWS/Lambda', + period: SEVEN_DAYS_IN_SECONDS, + stat: 'Sum' as const, + }, + { + dimensions: [{ Name: 'FunctionName', Value: fn.functionName }], + id: `duration${index}`, + metricName: 'Duration', + namespace: 'AWS/Lambda', + period: SEVEN_DAYS_IN_SECONDS, + stat: 'Average' as const, + }, + ]), + region, + startTime: new Date(Date.now() - SEVEN_DAYS_IN_SECONDS * 1000), + }); + + return regionFunctions.map((fn, index) => { + const invocationPoints = metricData.get(`invocations${index}`) ?? []; + const errorPoints = metricData.get(`errors${index}`) ?? []; + const durationPoints = metricData.get(`duration${index}`) ?? []; + const totalInvocationsLast7Days = invocationPoints.length > 0 ? getSum(invocationPoints) : null; + + return { + accountId: fn.accountId, + averageDurationMsLast7Days: + totalInvocationsLast7Days !== null && totalInvocationsLast7Days > 0 ? getAverage(durationPoints) : null, + functionName: fn.functionName, + region: fn.region, + totalErrorsLast7Days: totalInvocationsLast7Days !== null ? getSum(errorPoints) : null, + totalInvocationsLast7Days, + } satisfies AwsLambdaFunctionMetric; + }); + }), + ); + + return hydratedPages.flat().sort((left, right) => left.functionName.localeCompare(right.functionName)); +}; diff --git a/packages/sdk/src/providers/aws/resources/rds-activity.ts b/packages/sdk/src/providers/aws/resources/rds-activity.ts index ab41192..f674783 100644 --- a/packages/sdk/src/providers/aws/resources/rds-activity.ts +++ b/packages/sdk/src/providers/aws/resources/rds-activity.ts @@ -1,10 +1,12 @@ -import type { AwsDiscoveredResource, AwsRdsInstanceActivity } from '@cloudburn/rules'; +import type { AwsDiscoveredResource, AwsRdsInstanceActivity, AwsRdsInstanceCpuMetric } from '@cloudburn/rules'; import { fetchCloudWatchSignals } from './cloudwatch.js'; import { hydrateAwsRdsInstances } from './rds.js'; const SEVEN_DAYS_IN_SECONDS = 7 * 24 * 60 * 60; +const THIRTY_DAYS_IN_SECONDS = 30 * 24 * 60 * 60; const DAILY_PERIOD_IN_SECONDS = 24 * 60 * 60; const REQUIRED_RDS_DAILY_POINTS = SEVEN_DAYS_IN_SECONDS / DAILY_PERIOD_IN_SECONDS; +const REQUIRED_RDS_DAILY_CPU_POINTS = THIRTY_DAYS_IN_SECONDS / DAILY_PERIOD_IN_SECONDS; /** * Hydrates discovered RDS DB instances with 7-day connection activity. @@ -61,3 +63,60 @@ export const hydrateAwsRdsInstanceActivity = async ( .flat() .sort((left, right) => left.dbInstanceIdentifier.localeCompare(right.dbInstanceIdentifier)); }; + +/** + * Hydrates discovered RDS DB instances with 30-day CPU utilization summaries. + * + * @param resources - Catalog resources filtered to RDS DB instance resource types. + * @returns Hydrated RDS CPU metric models for rule evaluation. Instances with + * no or partial CloudWatch datapoints preserve `averageCpuUtilizationLast30Days` + * as `null`. + */ +export const hydrateAwsRdsInstanceCpuMetrics = async ( + resources: AwsDiscoveredResource[], +): Promise => { + const instances = await hydrateAwsRdsInstances(resources); + const instancesByRegion = new Map(); + + for (const instance of instances) { + const regionInstances = instancesByRegion.get(instance.region) ?? []; + regionInstances.push(instance); + instancesByRegion.set(instance.region, regionInstances); + } + + const hydratedPages = await Promise.all( + [...instancesByRegion.entries()].map(async ([region, regionInstances]) => { + const metricData = await fetchCloudWatchSignals({ + endTime: new Date(), + queries: regionInstances.map((instance, index) => ({ + dimensions: [{ Name: 'DBInstanceIdentifier', Value: instance.dbInstanceIdentifier }], + id: `cpu${index}`, + metricName: 'CPUUtilization', + namespace: 'AWS/RDS', + period: DAILY_PERIOD_IN_SECONDS, + stat: 'Average', + })), + region, + startTime: new Date(Date.now() - THIRTY_DAYS_IN_SECONDS * 1000), + }); + + return regionInstances.map((instance, index) => { + const points = metricData.get(`cpu${index}`) ?? []; + + return { + accountId: instance.accountId, + averageCpuUtilizationLast30Days: + points.length >= REQUIRED_RDS_DAILY_CPU_POINTS + ? points.reduce((sum, point) => sum + point.value, 0) / points.length + : null, + dbInstanceIdentifier: instance.dbInstanceIdentifier, + region, + } satisfies AwsRdsInstanceCpuMetric; + }); + }), + ); + + return hydratedPages + .flat() + .sort((left, right) => left.dbInstanceIdentifier.localeCompare(right.dbInstanceIdentifier)); +}; diff --git a/packages/sdk/src/providers/aws/resources/rds.ts b/packages/sdk/src/providers/aws/resources/rds.ts index a77349d..28e4bb9 100644 --- a/packages/sdk/src/providers/aws/resources/rds.ts +++ b/packages/sdk/src/providers/aws/resources/rds.ts @@ -1,11 +1,22 @@ -import { DescribeDBInstancesCommand } from '@aws-sdk/client-rds'; -import type { AwsDiscoveredResource, AwsRdsInstance } from '@cloudburn/rules'; +import { + DescribeDBInstancesCommand, + DescribeDBSnapshotsCommand, + DescribeReservedDBInstancesCommand, +} from '@aws-sdk/client-rds'; +import type { AwsDiscoveredResource, AwsRdsInstance, AwsRdsReservedInstance, AwsRdsSnapshot } from '@cloudburn/rules'; import { createRdsClient } from '../client.js'; import { chunkItems, withAwsServiceErrorContext } from './utils.js'; const RDS_DB_ARN_PREFIX = 'db:'; +const RDS_SNAPSHOT_ARN_PREFIX = 'snapshot:'; const RDS_HYDRATION_CONCURRENCY = 10; +const isDbSnapshotMissingError = (error: unknown): boolean => + error instanceof Error && + (error.name === 'DBSnapshotNotFound' || + error.name === 'DBSnapshotNotFoundFault' || + error.message.includes('DBSnapshotNotFound')); + const extractDbInstanceIdentifier = (arn: string): string | null => { const resourceSegment = arn.split(':').slice(5).join(':'); @@ -16,6 +27,31 @@ const extractDbInstanceIdentifier = (arn: string): string | null => { return resourceSegment.slice(RDS_DB_ARN_PREFIX.length); }; +const extractDbSnapshotIdentifier = (arn: string): string | null => { + const resourceSegment = arn.split(':').slice(5).join(':'); + + if (!resourceSegment.startsWith(RDS_SNAPSHOT_ARN_PREFIX)) { + return null; + } + + return resourceSegment.slice(RDS_SNAPSHOT_ARN_PREFIX.length); +}; + +const listRegionSeeds = (resources: AwsDiscoveredResource[]): Array<{ region: string; accountId: string }> => { + const regionSeeds = new Map(); + + for (const resource of resources) { + if (!regionSeeds.has(resource.region)) { + regionSeeds.set(resource.region, { + accountId: resource.accountId, + region: resource.region, + }); + } + } + + return [...regionSeeds.values()]; +}; + /** * Hydrates discovered RDS DB instances with normalized instance-class metadata. * @@ -64,7 +100,12 @@ export const hydrateAwsRdsInstances = async (resources: AwsDiscoveredResource[]) return { accountId: resource.accountId, dbInstanceIdentifier: instance.DBInstanceIdentifier, + dbInstanceStatus: instance.DBInstanceStatus, + engine: instance.Engine, + engineVersion: instance.EngineVersion, instanceClass: instance.DBInstanceClass, + instanceCreateTime: instance.InstanceCreateTime?.toISOString(), + multiAz: instance.MultiAZ, region, }; }), @@ -81,3 +122,145 @@ export const hydrateAwsRdsInstances = async (resources: AwsDiscoveredResource[]) .flat() .sort((left, right) => left.dbInstanceIdentifier.localeCompare(right.dbInstanceIdentifier)); }; + +/** + * Hydrates discovered RDS regions with their reserved DB instances for coverage checks. + * + * @param resources - Catalog resources filtered to RDS DB instance resource types. + * @returns Hydrated RDS reserved DB instances for rule evaluation. + */ +export const hydrateAwsRdsReservedInstances = async ( + resources: AwsDiscoveredResource[], +): Promise => { + const hydratedPages = await Promise.all( + listRegionSeeds(resources).map(async ({ region, accountId }) => { + const client = createRdsClient({ region }); + const reservedInstances: AwsRdsReservedInstance[] = []; + let marker: string | undefined; + + do { + const response = await withAwsServiceErrorContext('Amazon RDS', 'DescribeReservedDBInstances', region, () => + client.send( + new DescribeReservedDBInstancesCommand({ + Marker: marker, + }), + ), + ); + + for (const reservedInstance of response.ReservedDBInstances ?? []) { + if (!reservedInstance.ReservedDBInstanceId || !reservedInstance.DBInstanceClass) { + continue; + } + + reservedInstances.push({ + accountId, + instanceClass: reservedInstance.DBInstanceClass, + instanceCount: reservedInstance.DBInstanceCount ?? 0, + multiAz: reservedInstance.MultiAZ, + productDescription: reservedInstance.ProductDescription, + region, + reservedDbInstanceId: reservedInstance.ReservedDBInstanceId, + startTime: reservedInstance.StartTime?.toISOString(), + state: reservedInstance.State, + }); + } + + marker = response.Marker; + } while (marker); + + return reservedInstances; + }), + ); + + return hydratedPages + .flat() + .sort( + (left, right) => + left.accountId.localeCompare(right.accountId) || + left.region.localeCompare(right.region) || + left.reservedDbInstanceId.localeCompare(right.reservedDbInstanceId), + ); +}; + +/** + * Hydrates discovered RDS DB snapshots for orphaned snapshot checks. + * + * @param resources - Catalog resources filtered to RDS DB snapshot resource types. + * @returns Hydrated RDS snapshot models for rule evaluation. + */ +export const hydrateAwsRdsSnapshots = async (resources: AwsDiscoveredResource[]): Promise => { + const resourcesByRegion = new Map>(); + + for (const resource of resources) { + const dbSnapshotIdentifier = extractDbSnapshotIdentifier(resource.arn); + + if (!dbSnapshotIdentifier) { + continue; + } + + const regionResources = resourcesByRegion.get(resource.region) ?? []; + regionResources.push({ + accountId: resource.accountId, + dbSnapshotIdentifier, + }); + resourcesByRegion.set(resource.region, regionResources); + } + + const hydratedPages = await Promise.all( + [...resourcesByRegion.entries()].map(async ([region, regionResources]) => { + const client = createRdsClient({ region }); + const snapshots: AwsRdsSnapshot[] = []; + + for (const batch of chunkItems(regionResources, RDS_HYDRATION_CONCURRENCY)) { + const hydratedBatch = await Promise.all( + batch.map(async (resource) => { + try { + const response = await withAwsServiceErrorContext( + 'Amazon RDS', + 'DescribeDBSnapshots', + region, + () => + client.send( + new DescribeDBSnapshotsCommand({ + DBSnapshotIdentifier: resource.dbSnapshotIdentifier, + }), + ), + { + passthrough: isDbSnapshotMissingError, + }, + ); + const snapshot = response.DBSnapshots?.[0]; + + if (!snapshot?.DBSnapshotIdentifier) { + return null; + } + + return { + accountId: resource.accountId, + dbInstanceIdentifier: snapshot.DBInstanceIdentifier, + dbSnapshotIdentifier: snapshot.DBSnapshotIdentifier, + region, + snapshotCreateTime: snapshot.SnapshotCreateTime?.toISOString(), + snapshotType: snapshot.SnapshotType, + } satisfies AwsRdsSnapshot; + } catch (error) { + if (isDbSnapshotMissingError(error)) { + return null; + } + + throw error; + } + }), + ); + + snapshots.push(...hydratedBatch.flatMap((snapshot) => (snapshot ? [snapshot] : []))); + } + + return snapshots; + }), + ); + + return hydratedPages + .flat() + .sort((left, right) => left.dbSnapshotIdentifier.localeCompare(right.dbSnapshotIdentifier)); +}; diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index fce8513..253382d 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -21,8 +21,12 @@ import type { AwsEmrCluster, AwsEmrClusterMetric, AwsLambdaFunction, + AwsLambdaFunctionMetric, AwsRdsInstance, AwsRdsInstanceActivity, + AwsRdsInstanceCpuMetric, + AwsRdsReservedInstance, + AwsRdsSnapshot, AwsRedshiftCluster, AwsRedshiftClusterMetric, AwsRedshiftReservedNode, @@ -187,8 +191,12 @@ export type { AwsEmrCluster, AwsEmrClusterMetric, AwsLambdaFunction, + AwsLambdaFunctionMetric, AwsRdsInstance, + AwsRdsInstanceCpuMetric, AwsRdsInstanceActivity, + AwsRdsReservedInstance, + AwsRdsSnapshot, AwsRedshiftCluster, AwsRedshiftClusterMetric, AwsRedshiftReservedNode, diff --git a/packages/sdk/test/exports.test.ts b/packages/sdk/test/exports.test.ts index 638ba5e..247cd07 100644 --- a/packages/sdk/test/exports.test.ts +++ b/packages/sdk/test/exports.test.ts @@ -253,6 +253,13 @@ describe('sdk exports', () => { service: 'elb', supports: ['discovery'], }, + { + description: 'Flag Network Load Balancers that have no attached target groups or no registered targets.', + id: 'CLDBRN-AWS-ELB-4', + provider: 'aws', + service: 'elb', + supports: ['discovery'], + }, { description: 'Flag EMR clusters that still use previous-generation EC2 instance types.', id: 'CLDBRN-AWS-EMR-1', @@ -274,6 +281,21 @@ describe('sdk exports', () => { service: 'lambda', supports: ['iac', 'discovery'], }, + { + description: 'Flag Lambda functions whose 7-day error rate is greater than 10%.', + id: 'CLDBRN-AWS-LAMBDA-2', + provider: 'aws', + service: 'lambda', + supports: ['discovery'], + }, + { + description: + 'Flag Lambda functions whose configured timeout is at least 30 seconds and 5x their 7-day average duration.', + id: 'CLDBRN-AWS-LAMBDA-3', + provider: 'aws', + service: 'lambda', + supports: ['discovery'], + }, { description: 'Flag RDS DB instances that do not use curated preferred instance classes.', id: 'CLDBRN-AWS-RDS-1', @@ -288,6 +310,43 @@ describe('sdk exports', () => { service: 'rds', supports: ['discovery'], }, + { + description: 'Flag long-running RDS DB instances that do not have matching active reserved-instance coverage.', + id: 'CLDBRN-AWS-RDS-3', + provider: 'aws', + service: 'rds', + supports: ['discovery'], + }, + { + description: + 'Flag RDS DB instances that still use non-Graviton instance families when a clear Graviton-based equivalent exists.', + id: 'CLDBRN-AWS-RDS-4', + provider: 'aws', + service: 'rds', + supports: ['discovery'], + }, + { + description: 'Flag available RDS DB instances whose 30-day average CPU stays at or below 10%.', + id: 'CLDBRN-AWS-RDS-5', + provider: 'aws', + service: 'rds', + supports: ['discovery'], + }, + { + description: + 'Flag RDS MySQL 5.7 and PostgreSQL 11 DB instances that can incur extended support charges until they are upgraded.', + id: 'CLDBRN-AWS-RDS-6', + provider: 'aws', + service: 'rds', + supports: ['discovery'], + }, + { + description: 'Flag RDS snapshots older than 30 days whose source DB instance no longer exists.', + id: 'CLDBRN-AWS-RDS-7', + provider: 'aws', + service: 'rds', + supports: ['discovery'], + }, { description: 'Flag available Redshift clusters whose 14-day average CPU stays at or below 10%.', id: 'CLDBRN-AWS-REDSHIFT-1', diff --git a/packages/sdk/test/providers/aws-client.test.ts b/packages/sdk/test/providers/aws-client.test.ts index aa0ae81..c794e3f 100644 --- a/packages/sdk/test/providers/aws-client.test.ts +++ b/packages/sdk/test/providers/aws-client.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; const importClientModule = async () => import('../../src/providers/aws/client.js'); -describe('resolveCurrentAwsRegion', () => { +describe('resolveCurrentAwsRegion', { timeout: 15_000 }, () => { afterEach(() => { vi.resetModules(); vi.restoreAllMocks(); diff --git a/packages/sdk/test/providers/aws-discovery.test.ts b/packages/sdk/test/providers/aws-discovery.test.ts index 2976227..8de818a 100644 --- a/packages/sdk/test/providers/aws-discovery.test.ts +++ b/packages/sdk/test/providers/aws-discovery.test.ts @@ -43,9 +43,19 @@ import { } from '../../src/providers/aws/resources/elasticache.js'; import { hydrateAwsEc2LoadBalancers, hydrateAwsEc2TargetGroups } from '../../src/providers/aws/resources/elbv2.js'; import { hydrateAwsEmrClusterMetrics, hydrateAwsEmrClusters } from '../../src/providers/aws/resources/emr.js'; -import { hydrateAwsLambdaFunctions } from '../../src/providers/aws/resources/lambda.js'; -import { hydrateAwsRdsInstances } from '../../src/providers/aws/resources/rds.js'; -import { hydrateAwsRdsInstanceActivity } from '../../src/providers/aws/resources/rds-activity.js'; +import { + hydrateAwsLambdaFunctionMetrics, + hydrateAwsLambdaFunctions, +} from '../../src/providers/aws/resources/lambda.js'; +import { + hydrateAwsRdsInstances, + hydrateAwsRdsReservedInstances, + hydrateAwsRdsSnapshots, +} from '../../src/providers/aws/resources/rds.js'; +import { + hydrateAwsRdsInstanceActivity, + hydrateAwsRdsInstanceCpuMetrics, +} from '../../src/providers/aws/resources/rds-activity.js'; import { hydrateAwsRedshiftClusterMetrics, hydrateAwsRedshiftClusters, @@ -133,6 +143,7 @@ vi.mock('../../src/providers/aws/resources/ec2-reserved-instances.js', () => ({ })); vi.mock('../../src/providers/aws/resources/lambda.js', () => ({ + hydrateAwsLambdaFunctionMetrics: vi.fn(), hydrateAwsLambdaFunctions: vi.fn(), })); @@ -143,10 +154,13 @@ vi.mock('../../src/providers/aws/resources/elbv2.js', () => ({ vi.mock('../../src/providers/aws/resources/rds.js', () => ({ hydrateAwsRdsInstances: vi.fn(), + hydrateAwsRdsReservedInstances: vi.fn(), + hydrateAwsRdsSnapshots: vi.fn(), })); vi.mock('../../src/providers/aws/resources/rds-activity.js', () => ({ hydrateAwsRdsInstanceActivity: vi.fn(), + hydrateAwsRdsInstanceCpuMetrics: vi.fn(), })); vi.mock('../../src/providers/aws/resources/redshift.js', () => ({ @@ -190,9 +204,13 @@ const mockedHydrateAwsEc2ReservedInstances = vi.mocked(hydrateAwsEc2ReservedInst const mockedHydrateAwsEc2LoadBalancers = vi.mocked(hydrateAwsEc2LoadBalancers); const mockedHydrateAwsEc2TargetGroups = vi.mocked(hydrateAwsEc2TargetGroups); const mockedHydrateAwsEksNodegroups = vi.mocked(hydrateAwsEksNodegroups); +const mockedHydrateAwsLambdaFunctionMetrics = vi.mocked(hydrateAwsLambdaFunctionMetrics); const mockedHydrateAwsLambdaFunctions = vi.mocked(hydrateAwsLambdaFunctions); const mockedHydrateAwsRdsInstanceActivity = vi.mocked(hydrateAwsRdsInstanceActivity); +const mockedHydrateAwsRdsInstanceCpuMetrics = vi.mocked(hydrateAwsRdsInstanceCpuMetrics); const mockedHydrateAwsRdsInstances = vi.mocked(hydrateAwsRdsInstances); +const mockedHydrateAwsRdsReservedInstances = vi.mocked(hydrateAwsRdsReservedInstances); +const mockedHydrateAwsRdsSnapshots = vi.mocked(hydrateAwsRdsSnapshots); const mockedHydrateAwsRedshiftClusterMetrics = vi.mocked(hydrateAwsRedshiftClusterMetrics); const mockedHydrateAwsRedshiftClusters = vi.mocked(hydrateAwsRedshiftClusters); const mockedHydrateAwsRedshiftReservedNodes = vi.mocked(hydrateAwsRedshiftReservedNodes); @@ -361,6 +379,14 @@ const catalog: AwsDiscoveryCatalog = { resourceType: 'ec2:snapshot', service: 'ec2', }, + { + accountId: '123456789012', + arn: 'arn:aws:rds:us-east-1:123456789012:snapshot:snapshot-123', + properties: [], + region: 'us-east-1', + resourceType: 'rds:snapshot', + service: 'rds', + }, ], searchRegion: 'us-east-1', }; @@ -425,7 +451,13 @@ describe('discoverAwsResources', () => { }, ]); mockedHydrateAwsLambdaFunctions.mockResolvedValue([ - { accountId: '123456789012', architectures: ['x86_64'], functionName: 'my-func', region: 'us-east-1' }, + { + accountId: '123456789012', + architectures: ['x86_64'], + functionName: 'my-func', + region: 'us-east-1', + timeoutSeconds: 60, + }, ]); mockedHydrateAwsS3BucketAnalyses.mockResolvedValue([ { @@ -511,7 +543,13 @@ describe('discoverAwsResources', () => { }, ]); expect(result.resources.get('aws-lambda-functions')).toEqual([ - { accountId: '123456789012', architectures: ['x86_64'], functionName: 'my-func', region: 'us-east-1' }, + { + accountId: '123456789012', + architectures: ['x86_64'], + functionName: 'my-func', + region: 'us-east-1', + timeoutSeconds: 60, + }, ]); expect(result.resources.get('aws-s3-bucket-analyses')).toEqual([ { @@ -573,6 +611,49 @@ describe('discoverAwsResources', () => { ]); }); + it('hydrates Lambda function metrics when an active rule requires the metrics dataset', async () => { + mockedBuildAwsDiscoveryCatalog.mockResolvedValue({ + indexType: 'LOCAL', + resources: [catalog.resources[3]], + searchRegion: 'us-east-1', + }); + mockedHydrateAwsLambdaFunctionMetrics.mockResolvedValue([ + { + accountId: '123456789012', + averageDurationMsLast7Days: 2_500, + functionName: 'my-func', + region: 'us-east-1', + totalErrorsLast7Days: 12, + totalInvocationsLast7Days: 100, + }, + ]); + + const result = await discoverAwsResources( + [ + createRule({ + discoveryDependencies: ['aws-lambda-function-metrics'], + service: 'lambda', + }), + ], + { mode: 'region', region: 'us-east-1' }, + ); + + expect(mockedBuildAwsDiscoveryCatalog).toHaveBeenCalledWith({ mode: 'region', region: 'us-east-1' }, [ + 'lambda:function', + ]); + expect(mockedHydrateAwsLambdaFunctionMetrics).toHaveBeenCalledWith([catalog.resources[3]]); + expect(result.resources.get('aws-lambda-function-metrics')).toEqual([ + { + accountId: '123456789012', + averageDurationMsLast7Days: 2_500, + functionName: 'my-func', + region: 'us-east-1', + totalErrorsLast7Days: 12, + totalInvocationsLast7Days: 100, + }, + ]); + }); + it('hydrates ECS and EKS datasets from their discovery resource types', async () => { mockedBuildAwsDiscoveryCatalog.mockResolvedValue({ indexType: 'LOCAL', @@ -1111,7 +1192,12 @@ describe('discoverAwsResources', () => { { accountId: '123456789012', dbInstanceIdentifier: 'legacy-db', + dbInstanceStatus: 'available', + engine: 'mysql', + engineVersion: '8.0.39', instanceClass: 'db.m6i.large', + instanceCreateTime: '2025-01-01T00:00:00.000Z', + multiAz: false, region: 'us-east-1', }, ]); @@ -1132,8 +1218,95 @@ describe('discoverAwsResources', () => { { accountId: '123456789012', dbInstanceIdentifier: 'legacy-db', + dbInstanceStatus: 'available', + engine: 'mysql', + engineVersion: '8.0.39', + instanceClass: 'db.m6i.large', + instanceCreateTime: '2025-01-01T00:00:00.000Z', + multiAz: false, + region: 'us-east-1', + }, + ]); + }); + + it('hydrates RDS CPU summaries when an active rule requires low-utilization data', async () => { + mockedBuildAwsDiscoveryCatalog.mockResolvedValue({ + indexType: 'LOCAL', + resources: [catalog.resources[5]], + searchRegion: 'us-east-1', + }); + mockedHydrateAwsRdsInstanceCpuMetrics.mockResolvedValue([ + { + accountId: '123456789012', + averageCpuUtilizationLast30Days: 8, + dbInstanceIdentifier: 'legacy-db', + region: 'us-east-1', + }, + ]); + + const result = await discoverAwsResources( + [ + createRule({ + discoveryDependencies: ['aws-rds-instance-cpu-metrics'], + service: 'rds', + }), + ], + { mode: 'region', region: 'us-east-1' }, + ); + + expect(mockedBuildAwsDiscoveryCatalog).toHaveBeenCalledWith({ mode: 'region', region: 'us-east-1' }, ['rds:db']); + expect(mockedHydrateAwsRdsInstanceCpuMetrics).toHaveBeenCalledWith([catalog.resources[5]]); + expect(result.resources.get('aws-rds-instance-cpu-metrics')).toEqual([ + { + accountId: '123456789012', + averageCpuUtilizationLast30Days: 8, + dbInstanceIdentifier: 'legacy-db', + region: 'us-east-1', + }, + ]); + }); + + it('hydrates RDS reserved instances when an active rule requires reserved coverage data', async () => { + mockedBuildAwsDiscoveryCatalog.mockResolvedValue({ + indexType: 'LOCAL', + resources: [catalog.resources[5]], + searchRegion: 'us-east-1', + }); + mockedHydrateAwsRdsReservedInstances.mockResolvedValue([ + { + accountId: '123456789012', + instanceClass: 'db.m6i.large', + instanceCount: 1, + multiAz: false, + productDescription: 'mysql', + region: 'us-east-1', + reservedDbInstanceId: 'ri-123', + state: 'active', + }, + ]); + + const result = await discoverAwsResources( + [ + createRule({ + discoveryDependencies: ['aws-rds-reserved-instances'], + service: 'rds', + }), + ], + { mode: 'region', region: 'us-east-1' }, + ); + + expect(mockedBuildAwsDiscoveryCatalog).toHaveBeenCalledWith({ mode: 'region', region: 'us-east-1' }, ['rds:db']); + expect(mockedHydrateAwsRdsReservedInstances).toHaveBeenCalledWith([catalog.resources[5]]); + expect(result.resources.get('aws-rds-reserved-instances')).toEqual([ + { + accountId: '123456789012', instanceClass: 'db.m6i.large', + instanceCount: 1, + multiAz: false, + productDescription: 'mysql', region: 'us-east-1', + reservedDbInstanceId: 'ri-123', + state: 'active', }, ]); }); @@ -1267,6 +1440,49 @@ describe('discoverAwsResources', () => { ]); }); + it('hydrates RDS snapshots from snapshot catalog resources', async () => { + mockedBuildAwsDiscoveryCatalog.mockResolvedValue({ + indexType: 'LOCAL', + resources: [catalog.resources[20]], + searchRegion: 'us-east-1', + }); + mockedHydrateAwsRdsSnapshots.mockResolvedValue([ + { + accountId: '123456789012', + dbInstanceIdentifier: 'deleted-db', + dbSnapshotIdentifier: 'snapshot-123', + region: 'us-east-1', + snapshotCreateTime: '2026-01-01T00:00:00.000Z', + snapshotType: 'manual', + }, + ]); + + const result = await discoverAwsResources( + [ + createRule({ + discoveryDependencies: ['aws-rds-snapshots'], + service: 'rds', + }), + ], + { mode: 'region', region: 'us-east-1' }, + ); + + expect(mockedBuildAwsDiscoveryCatalog).toHaveBeenCalledWith({ mode: 'region', region: 'us-east-1' }, [ + 'rds:snapshot', + ]); + expect(mockedHydrateAwsRdsSnapshots).toHaveBeenCalledWith([catalog.resources[20]]); + expect(result.resources.get('aws-rds-snapshots')).toEqual([ + { + accountId: '123456789012', + dbInstanceIdentifier: 'deleted-db', + dbSnapshotIdentifier: 'snapshot-123', + region: 'us-east-1', + snapshotCreateTime: '2026-01-01T00:00:00.000Z', + snapshotType: 'manual', + }, + ]); + }); + it('records a non-fatal diagnostic when one hydrator is access denied and continues loading other datasets', async () => { mockedBuildAwsDiscoveryCatalog.mockResolvedValue(catalog); mockedHydrateAwsEbsVolumes.mockResolvedValue([ diff --git a/packages/sdk/test/providers/aws-lambda-resource.test.ts b/packages/sdk/test/providers/aws-lambda-resource.test.ts index 31916ff..98df9bb 100644 --- a/packages/sdk/test/providers/aws-lambda-resource.test.ts +++ b/packages/sdk/test/providers/aws-lambda-resource.test.ts @@ -1,13 +1,22 @@ import type { GetFunctionConfigurationCommand } from '@aws-sdk/client-lambda'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createLambdaClient } from '../../src/providers/aws/client.js'; -import { hydrateAwsLambdaFunctions } from '../../src/providers/aws/resources/lambda.js'; +import { fetchCloudWatchSignals } from '../../src/providers/aws/resources/cloudwatch.js'; +import { + hydrateAwsLambdaFunctionMetrics, + hydrateAwsLambdaFunctions, +} from '../../src/providers/aws/resources/lambda.js'; vi.mock('../../src/providers/aws/client.js', () => ({ createLambdaClient: vi.fn(), })); +vi.mock('../../src/providers/aws/resources/cloudwatch.js', () => ({ + fetchCloudWatchSignals: vi.fn(), +})); + const mockedCreateLambdaClient = vi.mocked(createLambdaClient); +const mockedFetchCloudWatchSignals = vi.mocked(fetchCloudWatchSignals); describe('hydrateAwsLambdaFunctions', () => { beforeEach(() => { @@ -59,12 +68,14 @@ describe('hydrateAwsLambdaFunctions', () => { architectures: ['x86_64'], functionName: 'first-function', region: 'us-east-1', + timeoutSeconds: 3, }, { accountId: '123456789012', architectures: ['arm64'], functionName: 'second-function', region: 'us-east-1', + timeoutSeconds: 3, }, ]); }); @@ -117,12 +128,14 @@ describe('hydrateAwsLambdaFunctions', () => { architectures: ['arm64'], functionName: 'first-function', region: 'us-east-1', + timeoutSeconds: 3, }, { accountId: '123456789012', architectures: ['x86_64'], functionName: 'second-function', region: 'us-east-1', + timeoutSeconds: 3, }, ]); }); @@ -189,6 +202,7 @@ describe('hydrateAwsLambdaFunctions', () => { .mockResolvedValueOnce({ Architectures: ['arm64'], FunctionName: 'retry-function', + Timeout: 15, }); mockedCreateLambdaClient.mockReturnValue({ send } as never); @@ -210,9 +224,154 @@ describe('hydrateAwsLambdaFunctions', () => { architectures: ['arm64'], functionName: 'retry-function', region: 'eu-central-1', + timeoutSeconds: 15, }, ]); expect(send).toHaveBeenCalledTimes(3); }); }); + +describe('hydrateAwsLambdaFunctionMetrics', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('hydrates Lambda function metrics from a shared 7-day CloudWatch query set', async () => { + const send = vi + .fn() + .mockResolvedValueOnce({ + Architectures: ['x86_64'], + FunctionName: 'first-function', + Timeout: 60, + }) + .mockResolvedValueOnce({ + Architectures: ['arm64'], + FunctionName: 'second-function', + Timeout: 120, + }); + + mockedCreateLambdaClient.mockReturnValue({ send } as never); + mockedFetchCloudWatchSignals.mockResolvedValue( + new Map([ + [ + 'invocations0', + [ + { + timestamp: '2026-03-24T00:00:00.000Z', + value: 100, + }, + ], + ], + [ + 'errors0', + [ + { + timestamp: '2026-03-24T00:00:00.000Z', + value: 12, + }, + ], + ], + [ + 'duration0', + [ + { + timestamp: '2026-03-24T00:00:00.000Z', + value: 2_500, + }, + ], + ], + [ + 'invocations1', + [ + { + timestamp: '2026-03-24T00:00:00.000Z', + value: 80, + }, + ], + ], + [ + 'duration1', + [ + { + timestamp: '2026-03-24T00:00:00.000Z', + value: 8_000, + }, + ], + ], + ]), + ); + + const metrics = await hydrateAwsLambdaFunctionMetrics([ + { + accountId: '123456789012', + arn: 'arn:aws:lambda:us-east-1:123456789012:function:first-function', + properties: [], + region: 'us-east-1', + resourceType: 'lambda:function', + service: 'lambda', + }, + { + accountId: '123456789012', + arn: 'arn:aws:lambda:us-east-1:123456789012:function:second-function', + properties: [], + region: 'us-east-1', + resourceType: 'lambda:function', + service: 'lambda', + }, + ]); + + expect(mockedFetchCloudWatchSignals).toHaveBeenCalledTimes(1); + expect(metrics).toEqual([ + { + accountId: '123456789012', + averageDurationMsLast7Days: 2_500, + functionName: 'first-function', + region: 'us-east-1', + totalErrorsLast7Days: 12, + totalInvocationsLast7Days: 100, + }, + { + accountId: '123456789012', + averageDurationMsLast7Days: 8_000, + functionName: 'second-function', + region: 'us-east-1', + totalErrorsLast7Days: 0, + totalInvocationsLast7Days: 80, + }, + ]); + }); + + it('preserves unknown metric coverage when Lambda emitted no invocation datapoints', async () => { + const send = vi.fn().mockResolvedValue({ + Architectures: ['x86_64'], + FunctionName: 'quiet-function', + Timeout: 60, + }); + + mockedCreateLambdaClient.mockReturnValue({ send } as never); + mockedFetchCloudWatchSignals.mockResolvedValue(new Map()); + + await expect( + hydrateAwsLambdaFunctionMetrics([ + { + accountId: '123456789012', + arn: 'arn:aws:lambda:us-east-1:123456789012:function:quiet-function', + properties: [], + region: 'us-east-1', + resourceType: 'lambda:function', + service: 'lambda', + }, + ]), + ).resolves.toEqual([ + { + accountId: '123456789012', + averageDurationMsLast7Days: null, + functionName: 'quiet-function', + region: 'us-east-1', + totalErrorsLast7Days: null, + totalInvocationsLast7Days: null, + }, + ]); + }); +}); diff --git a/packages/sdk/test/providers/aws-rds-activity-resource.test.ts b/packages/sdk/test/providers/aws-rds-activity-resource.test.ts index 36fc9e8..d03782e 100644 --- a/packages/sdk/test/providers/aws-rds-activity-resource.test.ts +++ b/packages/sdk/test/providers/aws-rds-activity-resource.test.ts @@ -1,7 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { fetchCloudWatchSignals } from '../../src/providers/aws/resources/cloudwatch.js'; import { hydrateAwsRdsInstances } from '../../src/providers/aws/resources/rds.js'; -import { hydrateAwsRdsInstanceActivity } from '../../src/providers/aws/resources/rds-activity.js'; +import { + hydrateAwsRdsInstanceActivity, + hydrateAwsRdsInstanceCpuMetrics, +} from '../../src/providers/aws/resources/rds-activity.js'; vi.mock('../../src/providers/aws/resources/cloudwatch.js', () => ({ fetchCloudWatchSignals: vi.fn(), @@ -90,3 +93,51 @@ describe('hydrateAwsRdsInstanceActivity', () => { ]); }); }); + +describe('hydrateAwsRdsInstanceCpuMetrics', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('hydrates RDS CPU metrics with 30-day average utilization', async () => { + mockedHydrateAwsRdsInstances.mockResolvedValue([ + { + accountId: '123456789012', + dbInstanceIdentifier: 'legacy-db', + instanceClass: 'db.m6i.large', + region: 'us-east-1', + }, + ]); + mockedFetchCloudWatchSignals.mockResolvedValue(new Map([['cpu0', createDailyPoints(30, 8)]])); + + await expect(hydrateAwsRdsInstanceCpuMetrics([])).resolves.toEqual([ + { + accountId: '123456789012', + averageCpuUtilizationLast30Days: 8, + dbInstanceIdentifier: 'legacy-db', + region: 'us-east-1', + }, + ]); + }); + + it('preserves unknown CPU coverage when CloudWatch returns partial 30-day data', async () => { + mockedHydrateAwsRdsInstances.mockResolvedValue([ + { + accountId: '123456789012', + dbInstanceIdentifier: 'new-db', + instanceClass: 'db.t4g.micro', + region: 'us-east-1', + }, + ]); + mockedFetchCloudWatchSignals.mockResolvedValue(new Map([['cpu0', createDailyPoints(29, 4)]])); + + await expect(hydrateAwsRdsInstanceCpuMetrics([])).resolves.toEqual([ + { + accountId: '123456789012', + averageCpuUtilizationLast30Days: null, + dbInstanceIdentifier: 'new-db', + region: 'us-east-1', + }, + ]); + }); +}); diff --git a/packages/sdk/test/providers/aws-rds-resource.test.ts b/packages/sdk/test/providers/aws-rds-resource.test.ts index 2205d70..837eef7 100644 --- a/packages/sdk/test/providers/aws-rds-resource.test.ts +++ b/packages/sdk/test/providers/aws-rds-resource.test.ts @@ -1,7 +1,15 @@ -import type { DescribeDBInstancesCommand } from '@aws-sdk/client-rds'; +import type { + DescribeDBInstancesCommand, + DescribeDBSnapshotsCommand, + DescribeReservedDBInstancesCommand, +} from '@aws-sdk/client-rds'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createRdsClient } from '../../src/providers/aws/client.js'; -import { hydrateAwsRdsInstances } from '../../src/providers/aws/resources/rds.js'; +import { + hydrateAwsRdsInstances, + hydrateAwsRdsReservedInstances, + hydrateAwsRdsSnapshots, +} from '../../src/providers/aws/resources/rds.js'; vi.mock('../../src/providers/aws/client.js', () => ({ createRdsClient: vi.fn(), @@ -25,6 +33,11 @@ describe('hydrateAwsRdsInstances', () => { { DBInstanceClass: identifier === 'current-db' ? 'db.r8g.large' : 'db.m6i.large', DBInstanceIdentifier: identifier, + DBInstanceStatus: 'available', + Engine: 'mysql', + EngineVersion: '8.0.39', + InstanceCreateTime: new Date('2025-01-01T00:00:00.000Z'), + MultiAZ: identifier === 'west-db', }, ], }; @@ -65,19 +78,34 @@ describe('hydrateAwsRdsInstances', () => { { accountId: '123456789012', dbInstanceIdentifier: 'current-db', + dbInstanceStatus: 'available', + engine: 'mysql', + engineVersion: '8.0.39', instanceClass: 'db.r8g.large', + instanceCreateTime: '2025-01-01T00:00:00.000Z', + multiAz: false, region: 'us-east-1', }, { accountId: '123456789012', dbInstanceIdentifier: 'legacy-db', + dbInstanceStatus: 'available', + engine: 'mysql', + engineVersion: '8.0.39', instanceClass: 'db.m6i.large', + instanceCreateTime: '2025-01-01T00:00:00.000Z', + multiAz: false, region: 'us-east-1', }, { accountId: '123456789012', dbInstanceIdentifier: 'west-db', + dbInstanceStatus: 'available', + engine: 'mysql', + engineVersion: '8.0.39', instanceClass: 'db.m6i.large', + instanceCreateTime: '2025-01-01T00:00:00.000Z', + multiAz: true, region: 'us-west-2', }, ]); @@ -125,3 +153,192 @@ describe('hydrateAwsRdsInstances', () => { expect(maxInFlight).toBeLessThanOrEqual(10); }); }); + +describe('hydrateAwsRdsReservedInstances', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('hydrates reserved RDS instances per discovered region', async () => { + mockedCreateRdsClient.mockImplementation(({ region }) => { + const send = vi.fn(async (_command: DescribeReservedDBInstancesCommand) => ({ + ReservedDBInstances: [ + { + DBInstanceClass: region === 'us-east-1' ? 'db.m6i.large' : 'db.r6i.large', + DBInstanceCount: 2, + MultiAZ: region === 'us-west-2', + ProductDescription: 'mysql', + ReservedDBInstanceId: `ri-${region}`, + StartTime: new Date('2025-01-01T00:00:00.000Z'), + State: 'active', + }, + ], + })); + + return { send, region } as never; + }); + + const reservedInstances = await hydrateAwsRdsReservedInstances([ + { + accountId: '123456789012', + arn: 'arn:aws:rds:us-east-1:123456789012:db:legacy-db', + properties: [], + region: 'us-east-1', + resourceType: 'rds:db', + service: 'rds', + }, + { + accountId: '123456789012', + arn: 'arn:aws:rds:us-west-2:123456789012:db:west-db', + properties: [], + region: 'us-west-2', + resourceType: 'rds:db', + service: 'rds', + }, + ]); + + expect(mockedCreateRdsClient).toHaveBeenCalledTimes(2); + expect(reservedInstances).toEqual([ + { + accountId: '123456789012', + instanceClass: 'db.m6i.large', + instanceCount: 2, + multiAz: false, + productDescription: 'mysql', + region: 'us-east-1', + reservedDbInstanceId: 'ri-us-east-1', + startTime: '2025-01-01T00:00:00.000Z', + state: 'active', + }, + { + accountId: '123456789012', + instanceClass: 'db.r6i.large', + instanceCount: 2, + multiAz: true, + productDescription: 'mysql', + region: 'us-west-2', + reservedDbInstanceId: 'ri-us-west-2', + startTime: '2025-01-01T00:00:00.000Z', + state: 'active', + }, + ]); + }); + + it('hydrates reserved RDS instances once per discovered region', async () => { + mockedCreateRdsClient.mockImplementation(({ region }) => { + const send = vi.fn(async (_command: DescribeReservedDBInstancesCommand) => ({ + ReservedDBInstances: [ + { + DBInstanceClass: 'db.m6i.large', + DBInstanceCount: 1, + MultiAZ: false, + ProductDescription: 'mysql', + ReservedDBInstanceId: `ri-${region}`, + StartTime: new Date('2025-01-01T00:00:00.000Z'), + State: 'active', + }, + ], + })); + + return { send, region } as never; + }); + + const reservedInstances = await hydrateAwsRdsReservedInstances([ + { + accountId: '123456789012', + arn: 'arn:aws:rds:us-east-1:123456789012:db:legacy-db', + properties: [], + region: 'us-east-1', + resourceType: 'rds:db', + service: 'rds', + }, + { + accountId: '210987654321', + arn: 'arn:aws:rds:us-east-1:210987654321:db:other-db', + properties: [], + region: 'us-east-1', + resourceType: 'rds:db', + service: 'rds', + }, + ]); + + expect(mockedCreateRdsClient).toHaveBeenCalledTimes(1); + expect(reservedInstances).toEqual([ + { + accountId: '123456789012', + instanceClass: 'db.m6i.large', + instanceCount: 1, + multiAz: false, + productDescription: 'mysql', + region: 'us-east-1', + reservedDbInstanceId: 'ri-us-east-1', + startTime: '2025-01-01T00:00:00.000Z', + state: 'active', + }, + ]); + }); +}); + +describe('hydrateAwsRdsSnapshots', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('hydrates RDS snapshots and skips stale snapshot identifiers', async () => { + mockedCreateRdsClient.mockImplementation(({ region }) => { + const send = vi.fn(async (command: DescribeDBSnapshotsCommand) => { + const input = command.input as { DBSnapshotIdentifier?: string }; + const dbSnapshotIdentifier = input.DBSnapshotIdentifier ?? 'unknown'; + + if (dbSnapshotIdentifier === 'missing-snapshot') { + const error = new Error('Snapshot not found'); + error.name = 'DBSnapshotNotFound'; + throw error; + } + + return { + DBSnapshots: [ + { + DBInstanceIdentifier: 'deleted-db', + DBSnapshotIdentifier: dbSnapshotIdentifier, + SnapshotCreateTime: new Date('2026-01-01T00:00:00.000Z'), + SnapshotType: 'manual', + }, + ], + }; + }); + + return { send, region } as never; + }); + + const snapshots = await hydrateAwsRdsSnapshots([ + { + accountId: '123456789012', + arn: 'arn:aws:rds:us-east-1:123456789012:snapshot:snapshot-123', + properties: [], + region: 'us-east-1', + resourceType: 'rds:snapshot', + service: 'rds', + }, + { + accountId: '123456789012', + arn: 'arn:aws:rds:us-east-1:123456789012:snapshot:missing-snapshot', + properties: [], + region: 'us-east-1', + resourceType: 'rds:snapshot', + service: 'rds', + }, + ]); + + expect(snapshots).toEqual([ + { + accountId: '123456789012', + dbInstanceIdentifier: 'deleted-db', + dbSnapshotIdentifier: 'snapshot-123', + region: 'us-east-1', + snapshotCreateTime: '2026-01-01T00:00:00.000Z', + snapshotType: 'manual', + }, + ]); + }); +}); diff --git a/packages/sdk/test/scanner.test.ts b/packages/sdk/test/scanner.test.ts index 486f1a8..4b82f8a 100644 --- a/packages/sdk/test/scanner.test.ts +++ b/packages/sdk/test/scanner.test.ts @@ -230,6 +230,19 @@ describe('CloudBurnClient', () => { }, ], }, + { + ruleId: 'CLDBRN-AWS-RDS-4', + service: 'rds', + source: 'discovery', + message: 'RDS DB instances without a Graviton equivalent in use should be reviewed.', + findings: [ + { + resourceId: 'legacy-db', + region: 'us-east-1', + accountId: '123456789012', + }, + ], + }, ], }, ],