From bcd96a1212a863b474ab89eda224763ffca6466e Mon Sep 17 00:00:00 2001 From: Danny Steenman Date: Thu, 9 Apr 2026 11:21:44 +0200 Subject: [PATCH 1/3] feat(rules): add AWS discovery cleanup rules --- .changeset/bright-pears-burn.md | 5 + .changeset/silent-mice-share.md | 5 + docs/architecture/sdk.md | 2 +- docs/reference/rule-ids.md | 3 + packages/rules/src/aws/ec2/index.ts | 2 + .../rules/src/aws/ec2/stopped-instance.ts | 37 ++++ packages/rules/src/aws/rds/index.ts | 2 + .../src/aws/rds/manual-snapshot-max-age.ts | 37 ++++ .../rules/src/aws/sagemaker/idle-endpoint.ts | 42 ++++ packages/rules/src/aws/sagemaker/index.ts | 3 +- packages/rules/src/index.ts | 1 + packages/rules/src/shared/metadata.ts | 17 ++ .../rules/test/ec2-stopped-instance.test.ts | 98 +++++++++ packages/rules/test/exports.test.ts | 20 ++ .../test/rds-manual-snapshot-max-age.test.ts | 96 ++++++++ .../rules/test/rds-unused-snapshots.test.ts | 13 +- packages/rules/test/rule-metadata.test.ts | 48 ++++ .../test/sagemaker-idle-endpoint.test.ts | 107 +++++++++ packages/sdk/src/index.ts | 2 + .../src/providers/aws/discovery-registry.ts | 8 +- .../sdk/src/providers/aws/resources/ec2.ts | 18 ++ .../src/providers/aws/resources/sagemaker.ts | 207 +++++++++++++++++- packages/sdk/src/types.ts | 4 + packages/sdk/test/exports.test.ts | 23 ++ .../sdk/test/providers/aws-discovery.test.ts | 77 ++++++- .../test/providers/aws-ec2-resource.test.ts | 56 +++++ .../providers/aws-sagemaker-resource.test.ts | 144 +++++++++++- 27 files changed, 1067 insertions(+), 10 deletions(-) create mode 100644 .changeset/bright-pears-burn.md create mode 100644 .changeset/silent-mice-share.md create mode 100644 packages/rules/src/aws/ec2/stopped-instance.ts create mode 100644 packages/rules/src/aws/rds/manual-snapshot-max-age.ts create mode 100644 packages/rules/src/aws/sagemaker/idle-endpoint.ts create mode 100644 packages/rules/test/ec2-stopped-instance.test.ts create mode 100644 packages/rules/test/rds-manual-snapshot-max-age.test.ts create mode 100644 packages/rules/test/sagemaker-idle-endpoint.test.ts diff --git a/.changeset/bright-pears-burn.md b/.changeset/bright-pears-burn.md new file mode 100644 index 0000000..d760a5f --- /dev/null +++ b/.changeset/bright-pears-burn.md @@ -0,0 +1,5 @@ +--- +"@cloudburn/rules": minor +--- + +Add AWS discovery rules for stopped EC2 instances, old manual RDS snapshots, and idle SageMaker endpoints. diff --git a/.changeset/silent-mice-share.md b/.changeset/silent-mice-share.md new file mode 100644 index 0000000..a85b338 --- /dev/null +++ b/.changeset/silent-mice-share.md @@ -0,0 +1,5 @@ +--- +"@cloudburn/sdk": minor +--- + +Add AWS discovery support for EC2 stop timestamps and SageMaker endpoint activity. diff --git a/docs/architecture/sdk.md b/docs/architecture/sdk.md index 7ecd3b8..252e061 100644 --- a/docs/architecture/sdk.md +++ b/docs/architecture/sdk.md @@ -83,7 +83,7 @@ Current live-discovery behavior: - Resource Explorer inventory failures and dataset loader failures are fatal. The SDK does not degrade to partial live results. - Missing Lambda `Architectures` values from AWS are normalized to `['x86_64']`, matching the AWS default architecture. - Lambda hydrators limit in-flight `GetFunctionConfiguration` calls per region to avoid API throttling in large accounts. -- Live scans require Resource Explorer access plus narrow hydrator permissions such as `apigateway:GetStage`, `application-autoscaling:DescribeScalableTargets`, `application-autoscaling:DescribeScalingPolicies`, `ce:GetCostAndUsage`, `cloudfront:GetDistribution`, `cloudfront:ListDistributions`, `cloudtrail:DescribeTrails`, `cloudwatch:GetMetricData`, `dynamodb:DescribeTable`, `ecs:DescribeContainerInstances`, `ecs:DescribeServices`, `ec2:DescribeInstances`, `ec2:DescribeNatGateways`, `ec2:DescribeVolumes`, `eks:ListNodegroups`, `eks:DescribeNodegroup`, `lambda:GetFunctionConfiguration`, `rds:DescribeDBInstances`, `route53:ListHealthChecks`, `route53:ListHostedZones`, `route53:ListResourceRecordSets`, `s3:GetLifecycleConfiguration`, `s3:GetIntelligentTieringConfiguration`, `sagemaker:DescribeNotebookInstance`, and `secretsmanager:DescribeSecret`. +- Live scans require Resource Explorer access plus narrow hydrator permissions such as `apigateway:GetStage`, `application-autoscaling:DescribeScalableTargets`, `application-autoscaling:DescribeScalingPolicies`, `ce:GetCostAndUsage`, `cloudfront:GetDistribution`, `cloudfront:ListDistributions`, `cloudtrail:DescribeTrails`, `cloudwatch:GetMetricData`, `dynamodb:DescribeTable`, `ecs:DescribeContainerInstances`, `ecs:DescribeServices`, `ec2:DescribeInstances`, `ec2:DescribeNatGateways`, `ec2:DescribeVolumes`, `eks:ListNodegroups`, `eks:DescribeNodegroup`, `lambda:GetFunctionConfiguration`, `rds:DescribeDBInstances`, `route53:ListHealthChecks`, `route53:ListHostedZones`, `route53:ListResourceRecordSets`, `s3:GetLifecycleConfiguration`, `s3:GetIntelligentTieringConfiguration`, `sagemaker:DescribeEndpoint`, `sagemaker:DescribeEndpointConfig`, `sagemaker:DescribeNotebookInstance`, and `secretsmanager:DescribeSecret`. ## Public Result Shape diff --git a/docs/reference/rule-ids.md b/docs/reference/rule-ids.md index 239d8af..b4d04ed 100644 --- a/docs/reference/rule-ids.md +++ b/docs/reference/rule-ids.md @@ -43,6 +43,7 @@ Format: `CLDBRN-{PROVIDER}-{SERVICE}-{N}` | `CLDBRN-AWS-EC2-10` | Flags IaC-defined instances only when detailed monitoring is explicitly enabled. | ec2 | iac | | `CLDBRN-AWS-EC2-11` | Flags only NAT gateways in the `available` state and requires complete 7-day `BytesInFromDestination` and `BytesOutToDestination` coverage, with both totals equal to `0`. | ec2 | discovery | | `CLDBRN-AWS-EC2-12` | Flags only EC2 reserved instances whose `endTime` fell within the last 30 days, surfacing them for renewal follow-up review. | ec2 | discovery | +| `CLDBRN-AWS-EC2-13` | Flags only EC2 instances whose discovered state is `stopped` and whose parsed stop timestamp is at least `30` days old. Instances with missing or unparseable stop timestamps are skipped. | ec2 | discovery | | `CLDBRN-AWS-ECS-1` | Flags only EC2-backed container instances whose instance families have a curated Graviton-equivalent path. Fargate and unclassified backing instances are skipped. | ecs | discovery | | `CLDBRN-AWS-ECS-2` | Flags only ECS clusters with a complete 14-day `AWS/ECS` CPU history and an average below `10%`. | ecs | discovery | | `CLDBRN-AWS-ECS-3` | Flags only active `REPLICA` ECS services and requires both a scalable target and at least one scaling policy. | ecs | discovery, iac | @@ -77,6 +78,7 @@ Format: `CLDBRN-{PROVIDER}-{SERVICE}-{N}` | `CLDBRN-AWS-RDS-7` | Flags only snapshots whose source DB instance no longer exists and whose parsed create time is at least `30` days old. | rds | discovery | | `CLDBRN-AWS-RDS-8` | Flags only DB instances with Performance Insights enabled and a retention period above the included 7-day baseline. | rds | iac | | `CLDBRN-AWS-RDS-9` | Flags only RDS DB instances whose discovered `dbInstanceStatus` is `stopped`, surfacing them for cleanup review. | rds | discovery | +| `CLDBRN-AWS-RDS-10` | Flags only manual RDS snapshots whose parsed `snapshotCreateTime` is at least `90` days old. Automated snapshots and snapshots with invalid timestamps are skipped. | rds | discovery | | `CLDBRN-AWS-REDSHIFT-1` | Reviews only `available` clusters and treats a 14-day average `CPUUtilization` of 10% or lower as low utilization. | redshift | discovery | | `CLDBRN-AWS-REDSHIFT-2` | Reviews only `available` clusters with a parsed create time at least 180 days old and requires active reserved-node coverage for the same node type. | redshift | discovery | | `CLDBRN-AWS-REDSHIFT-3` | Flags only `available`, VPC-backed clusters with automated snapshots enabled, no HSM, and no Multi-AZ deployment when either the pause or resume schedule is missing. | redshift | discovery, iac | @@ -87,6 +89,7 @@ Format: `CLDBRN-{PROVIDER}-{SERVICE}-{N}` | `CLDBRN-AWS-S3-3` | Flags buckets when no enabled lifecycle rule aborts incomplete multipart uploads within 7 days. | s3 | iac, discovery | | `CLDBRN-AWS-S3-4` | Flags only versioned buckets and requires either noncurrent-version expiration or transition cleanup to avoid unbounded version growth. | s3 | iac | | `CLDBRN-AWS-SAGEMAKER-1` | Flags only notebook instances whose normalized status remains `InService`. | sagemaker | discovery | +| `CLDBRN-AWS-SAGEMAKER-2` | Flags only endpoints whose normalized status remains `InService`, whose parsed `creationTime` is at least `14` days old, and whose complete 14-day `Invocations` total stays at `0`. Endpoints with incomplete metrics are skipped. | sagemaker | discovery | | `CLDBRN-AWS-SECRETSMANAGER-1` | Flags secrets with no `lastAccessedDate` and secrets whose parsed last access is at least `90` days old. | secretsmanager | discovery | | `CLDBRN-AWS-LAMBDA-1` | Recommend arm64 architecture when compatible. | lambda | iac, discovery | | `CLDBRN-AWS-LAMBDA-2` | Uses 7-day CloudWatch totals and flags only functions whose observed `Errors / Invocations` ratio is greater than `10%`. | lambda | discovery | diff --git a/packages/rules/src/aws/ec2/index.ts b/packages/rules/src/aws/ec2/index.ts index 0c52e67..fceb107 100644 --- a/packages/rules/src/aws/ec2/index.ts +++ b/packages/rules/src/aws/ec2/index.ts @@ -9,6 +9,7 @@ import { ec2PreferredInstanceTypeRule } from './preferred-instance-types.js'; import { ec2ReservedInstanceExpiringRule } from './reserved-instance-expiring.js'; import { ec2ReservedInstanceRecentlyExpiredRule } from './reserved-instance-recently-expired.js'; import { ec2S3InterfaceEndpointRule } from './s3-interface-endpoint.js'; +import { ec2StoppedInstanceRule } from './stopped-instance.js'; import { ec2UnassociatedElasticIpRule } from './unassociated-elastic-ip.js'; /** Aggregate AWS EC2 rule definitions. */ @@ -25,4 +26,5 @@ export const ec2Rules = [ ec2DetailedMonitoringEnabledRule, ec2IdleNatGatewayRule, ec2ReservedInstanceRecentlyExpiredRule, + ec2StoppedInstanceRule, ]; diff --git a/packages/rules/src/aws/ec2/stopped-instance.ts b/packages/rules/src/aws/ec2/stopped-instance.ts new file mode 100644 index 0000000..7fd5a2c --- /dev/null +++ b/packages/rules/src/aws/ec2/stopped-instance.ts @@ -0,0 +1,37 @@ +import { createFinding, createFindingMatch, createRule } from '../../shared/helpers.js'; + +const RULE_ID = 'CLDBRN-AWS-EC2-13'; +const RULE_SERVICE = 'ec2'; +const RULE_MESSAGE = 'Stopped EC2 instances with a parsed stop time older than 30 days should be reviewed for cleanup.'; + +const DAY_MS = 24 * 60 * 60 * 1000; +const STOPPED_INSTANCE_MAX_AGE_DAYS = 30; + +/** Flag stopped EC2 instances whose parsed stop time is older than 30 days. */ +export const ec2StoppedInstanceRule = createRule({ + id: RULE_ID, + name: 'EC2 Instance Stopped', + description: 'Flag stopped EC2 instances whose parsed stop time is at least 30 days old.', + message: RULE_MESSAGE, + provider: 'aws', + service: RULE_SERVICE, + supports: ['discovery'], + discoveryDependencies: ['aws-ec2-instances'], + evaluateLive: ({ resources }) => { + const cutoff = Date.now() - STOPPED_INSTANCE_MAX_AGE_DAYS * DAY_MS; + const findings = resources + .get('aws-ec2-instances') + .filter((instance) => { + if (instance.state !== 'stopped' || !instance.stoppedAt) { + return false; + } + + const stoppedAt = Date.parse(instance.stoppedAt); + + return Number.isFinite(stoppedAt) && stoppedAt <= cutoff; + }) + .map((instance) => createFindingMatch(instance.instanceId, 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 743e198..01d518f 100644 --- a/packages/rules/src/aws/rds/index.ts +++ b/packages/rules/src/aws/rds/index.ts @@ -1,6 +1,7 @@ import { rdsGravitonReviewRule } from './graviton-review.js'; import { rdsIdleInstanceRule } from './idle-instance.js'; import { rdsLowCpuUtilizationRule } from './low-cpu-utilization.js'; +import { rdsManualSnapshotMaxAgeRule } from './manual-snapshot-max-age.js'; import { rdsPerformanceInsightsExtendedRetentionRule } from './performance-insights-extended-retention.js'; import { rdsPreferredInstanceClassRule } from './preferred-instance-classes.js'; import { rdsReservedCoverageRule } from './reserved-coverage.js'; @@ -20,4 +21,5 @@ export const rdsRules = [ rdsUnusedSnapshotsRule, rdsPerformanceInsightsExtendedRetentionRule, rdsStoppedInstanceRule, + rdsManualSnapshotMaxAgeRule, ]; diff --git a/packages/rules/src/aws/rds/manual-snapshot-max-age.ts b/packages/rules/src/aws/rds/manual-snapshot-max-age.ts new file mode 100644 index 0000000..d5c8f1c --- /dev/null +++ b/packages/rules/src/aws/rds/manual-snapshot-max-age.ts @@ -0,0 +1,37 @@ +import { createFinding, createFindingMatch, createRule } from '../../shared/helpers.js'; + +const RULE_ID = 'CLDBRN-AWS-RDS-10'; +const RULE_SERVICE = 'rds'; +const RULE_MESSAGE = 'Manual RDS snapshots older than 90 days should be reviewed for cleanup.'; + +const DAY_MS = 24 * 60 * 60 * 1000; +const SNAPSHOT_MAX_AGE_DAYS = 90; + +/** Flag manual RDS snapshots older than 90 days. */ +export const rdsManualSnapshotMaxAgeRule = createRule({ + id: RULE_ID, + name: 'RDS Manual Snapshot Max Age Exceeded', + description: 'Flag manual RDS snapshots older than 90 days.', + message: RULE_MESSAGE, + provider: 'aws', + service: RULE_SERVICE, + supports: ['discovery'], + discoveryDependencies: ['aws-rds-snapshots'], + evaluateLive: ({ resources }) => { + const cutoff = Date.now() - SNAPSHOT_MAX_AGE_DAYS * DAY_MS; + const findings = resources + .get('aws-rds-snapshots') + .filter((snapshot) => { + if (snapshot.snapshotType !== 'manual' || !snapshot.snapshotCreateTime) { + return false; + } + + const snapshotCreateTime = Date.parse(snapshot.snapshotCreateTime); + + return Number.isFinite(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/aws/sagemaker/idle-endpoint.ts b/packages/rules/src/aws/sagemaker/idle-endpoint.ts new file mode 100644 index 0000000..256f13f --- /dev/null +++ b/packages/rules/src/aws/sagemaker/idle-endpoint.ts @@ -0,0 +1,42 @@ +import { createFinding, createFindingMatch, createRule } from '../../shared/helpers.js'; + +const RULE_ID = 'CLDBRN-AWS-SAGEMAKER-2'; +const RULE_SERVICE = 'sagemaker'; +const RULE_MESSAGE = + 'SageMaker endpoints in service with zero invocations over 14 days should be reviewed for cleanup.'; + +const DAY_MS = 24 * 60 * 60 * 1000; +const ENDPOINT_IDLE_WINDOW_DAYS = 14; + +/** Flag SageMaker endpoints that are in service, old enough, and idle for 14 days. */ +export const sagemakerIdleEndpointRule = createRule({ + id: RULE_ID, + name: 'SageMaker Endpoint Idle', + description: 'Flag SageMaker endpoints in service whose 14-day invocation total is zero.', + message: RULE_MESSAGE, + provider: 'aws', + service: RULE_SERVICE, + supports: ['discovery'], + discoveryDependencies: ['aws-sagemaker-endpoint-activity'], + evaluateLive: ({ resources }) => { + const cutoff = Date.now() - ENDPOINT_IDLE_WINDOW_DAYS * DAY_MS; + const findings = resources + .get('aws-sagemaker-endpoint-activity') + .filter((endpoint) => { + if ( + endpoint.endpointStatus !== 'InService' || + endpoint.totalInvocationsLast14Days !== 0 || + !endpoint.creationTime + ) { + return false; + } + + const creationTime = Date.parse(endpoint.creationTime); + + return Number.isFinite(creationTime) && creationTime <= cutoff; + }) + .map((endpoint) => createFindingMatch(endpoint.endpointName, endpoint.region, endpoint.accountId)); + + return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'discovery', findings); + }, +}); diff --git a/packages/rules/src/aws/sagemaker/index.ts b/packages/rules/src/aws/sagemaker/index.ts index fc91871..dc446bd 100644 --- a/packages/rules/src/aws/sagemaker/index.ts +++ b/packages/rules/src/aws/sagemaker/index.ts @@ -1,4 +1,5 @@ +import { sagemakerIdleEndpointRule } from './idle-endpoint.js'; import { sagemakerRunningNotebookInstanceRule } from './running-notebook-instance.js'; /** Aggregate AWS SageMaker rule definitions. */ -export const sagemakerRules = [sagemakerRunningNotebookInstanceRule]; +export const sagemakerRules = [sagemakerRunningNotebookInstanceRule, sagemakerIdleEndpointRule]; diff --git a/packages/rules/src/index.ts b/packages/rules/src/index.ts index afb5148..a1b487d 100644 --- a/packages/rules/src/index.ts +++ b/packages/rules/src/index.ts @@ -68,6 +68,7 @@ export type { AwsRoute53Zone, AwsS3BucketAnalysis, AwsS3BucketAnalysisFlags, + AwsSageMakerEndpointActivity, AwsSageMakerNotebookInstance, AwsSecretsManagerSecret, AwsStaticApiGatewayStage, diff --git a/packages/rules/src/shared/metadata.ts b/packages/rules/src/shared/metadata.ts index 9d9c5e6..cafc7e7 100644 --- a/packages/rules/src/shared/metadata.ts +++ b/packages/rules/src/shared/metadata.ts @@ -168,6 +168,7 @@ export type AwsEc2Instance = { architecture?: string; launchTime?: string; state?: string; + stoppedAt?: string; region: string; accountId: string; }; @@ -311,6 +312,20 @@ export type AwsSageMakerNotebookInstance = { accountId: string; }; +/** Discovered SageMaker endpoint with 14-day invocation totals for idle checks. */ +export type AwsSageMakerEndpointActivity = { + endpointArn: string; + endpointName: string; + endpointStatus: string; + endpointConfigName: string; + creationTime?: string; + lastModifiedTime?: string; + /** `null` means CloudWatch did not return complete invocation coverage for the 14-day window. */ + totalInvocationsLast14Days: number | null; + region: string; + accountId: string; +}; + /** Discovered AWS RDS DB instance with its normalized instance class. */ export type AwsRdsInstance = { dbInstanceIdentifier: string; @@ -704,6 +719,7 @@ export type DiscoveryDatasetKey = | 'aws-route53-records' | 'aws-route53-zones' | 'aws-s3-bucket-analyses' + | 'aws-sagemaker-endpoint-activity' | 'aws-sagemaker-notebook-instances' | 'aws-secretsmanager-secrets'; @@ -760,6 +776,7 @@ export type DiscoveryDatasetMap = { 'aws-route53-records': AwsRoute53Record[]; 'aws-route53-zones': AwsRoute53Zone[]; 'aws-s3-bucket-analyses': AwsS3BucketAnalysis[]; + 'aws-sagemaker-endpoint-activity': AwsSageMakerEndpointActivity[]; 'aws-sagemaker-notebook-instances': AwsSageMakerNotebookInstance[]; 'aws-secretsmanager-secrets': AwsSecretsManagerSecret[]; }; diff --git a/packages/rules/test/ec2-stopped-instance.test.ts b/packages/rules/test/ec2-stopped-instance.test.ts new file mode 100644 index 0000000..535bbe1 --- /dev/null +++ b/packages/rules/test/ec2-stopped-instance.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ec2StoppedInstanceRule } from '../src/aws/ec2/stopped-instance.js'; +import type { AwsEc2Instance } from '../src/index.js'; +import { LiveResourceBag } from '../src/index.js'; + +const createInstance = (overrides: Partial = {}): AwsEc2Instance => ({ + accountId: '123456789012', + architecture: 'x86_64', + instanceId: 'i-stopped-old', + instanceType: 'm7i.large', + launchTime: '2025-01-01T00:00:00.000Z', + region: 'us-east-1', + state: 'stopped', + stoppedAt: '2025-11-15T00:00:00.000Z', + ...overrides, +}); + +describe('ec2StoppedInstanceRule', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('flags stopped instances whose parsed stop time is at least 30 days old', () => { + const finding = ec2StoppedInstanceRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-ec2-instances': [createInstance()], + }), + }); + + expect(finding).toEqual({ + ruleId: 'CLDBRN-AWS-EC2-13', + service: 'ec2', + source: 'discovery', + message: 'Stopped EC2 instances with a parsed stop time older than 30 days should be reviewed for cleanup.', + findings: [ + { + accountId: '123456789012', + region: 'us-east-1', + resourceId: 'i-stopped-old', + }, + ], + }); + }); + + it('skips instances that are not currently stopped or are stopped recently', () => { + const finding = ec2StoppedInstanceRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-ec2-instances': [ + createInstance({ + instanceId: 'i-running', + state: 'running', + }), + createInstance({ + instanceId: 'i-stopped-recently', + stoppedAt: '2025-12-20T00:00:00.000Z', + }), + ], + }), + }); + + expect(finding).toBeNull(); + }); + + it('skips stopped instances whose stop time could not be parsed', () => { + const finding = ec2StoppedInstanceRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-ec2-instances': [ + createInstance({ + instanceId: 'i-stopped-unknown-time', + stoppedAt: undefined, + }), + ], + }), + }); + + expect(finding).toBeNull(); + }); +}); diff --git a/packages/rules/test/exports.test.ts b/packages/rules/test/exports.test.ts index c4659b3..93782c5 100644 --- a/packages/rules/test/exports.test.ts +++ b/packages/rules/test/exports.test.ts @@ -31,6 +31,7 @@ import type { AwsRoute53HealthCheck, AwsRoute53Record, AwsRoute53Zone, + AwsSageMakerEndpointActivity, AwsSageMakerNotebookInstance, AwsSecretsManagerSecret, AwsStaticRdsInstance, @@ -81,6 +82,7 @@ describe('rule exports', () => { 'CLDBRN-AWS-EC2-10', 'CLDBRN-AWS-EC2-11', 'CLDBRN-AWS-EC2-12', + 'CLDBRN-AWS-EC2-13', 'CLDBRN-AWS-ECS-1', 'CLDBRN-AWS-ECS-2', 'CLDBRN-AWS-ECS-3', @@ -117,6 +119,7 @@ describe('rule exports', () => { 'CLDBRN-AWS-RDS-7', 'CLDBRN-AWS-RDS-8', 'CLDBRN-AWS-RDS-9', + 'CLDBRN-AWS-RDS-10', 'CLDBRN-AWS-REDSHIFT-1', 'CLDBRN-AWS-REDSHIFT-2', 'CLDBRN-AWS-REDSHIFT-3', @@ -127,6 +130,7 @@ describe('rule exports', () => { 'CLDBRN-AWS-S3-3', 'CLDBRN-AWS-S3-4', 'CLDBRN-AWS-SAGEMAKER-1', + 'CLDBRN-AWS-SAGEMAKER-2', 'CLDBRN-AWS-SECRETSMANAGER-1', ]), ); @@ -155,12 +159,14 @@ describe('rule exports', () => { launchTime: '2026-03-01T00:00:00.000Z', region: 'us-east-1', state: 'running', + stoppedAt: '2026-03-15T00:00:00.000Z', }; expect(instance.instanceType).toBe('m8azn.large'); expect(instance.state).toBe('running'); expect(instance.architecture).toBe('x86_64'); expect(instance.launchTime).toBe('2026-03-01T00:00:00.000Z'); + expect(instance.stoppedAt).toBe('2026-03-15T00:00:00.000Z'); expect(apiGatewayStage.stageName).toBe('prod'); expect(apiGatewayStage.cacheClusterEnabled).toBe(false); @@ -437,6 +443,17 @@ describe('rule exports', () => { notebookInstanceStatus: 'InService', region: 'eu-west-1', }; + const endpointActivity: AwsSageMakerEndpointActivity = { + accountId: '123456789012', + creationTime: '2026-02-01T00:00:00.000Z', + endpointArn: 'arn:aws:sagemaker:eu-west-1:123456789012:endpoint/orders-endpoint', + endpointConfigName: 'orders-endpoint-config', + endpointName: 'orders-endpoint', + endpointStatus: 'InService', + lastModifiedTime: '2026-03-01T00:00:00.000Z', + region: 'eu-west-1', + totalInvocationsLast14Days: 0, + }; const apiGatewayDatasetKey: DiscoveryDatasetKey = 'aws-apigateway-stages'; const cloudFrontDatasetKey: DiscoveryDatasetKey = 'aws-cloudfront-distributions'; @@ -464,6 +481,7 @@ describe('rule exports', () => { const route53HealthCheckDatasetKey: DiscoveryDatasetKey = 'aws-route53-health-checks'; const route53RecordDatasetKey: DiscoveryDatasetKey = 'aws-route53-records'; const route53ZoneDatasetKey: DiscoveryDatasetKey = 'aws-route53-zones'; + const sagemakerEndpointDatasetKey: DiscoveryDatasetKey = 'aws-sagemaker-endpoint-activity'; const sagemakerDatasetKey: DiscoveryDatasetKey = 'aws-sagemaker-notebook-instances'; const secretsManagerDatasetKey: DiscoveryDatasetKey = 'aws-secretsmanager-secrets'; const targetGroupDatasetKey: DiscoveryDatasetKey = 'aws-ec2-target-groups'; @@ -495,6 +513,7 @@ describe('rule exports', () => { expect(route53HealthCheckDatasetKey).toBe('aws-route53-health-checks'); expect(route53RecordDatasetKey).toBe('aws-route53-records'); expect(route53ZoneDatasetKey).toBe('aws-route53-zones'); + expect(sagemakerEndpointDatasetKey).toBe('aws-sagemaker-endpoint-activity'); expect(sagemakerDatasetKey).toBe('aws-sagemaker-notebook-instances'); expect(secretsManagerDatasetKey).toBe('aws-secretsmanager-secrets'); expect(cloudFrontDistribution.priceClass).toBe('PriceClass_All'); @@ -508,6 +527,7 @@ describe('rule exports', () => { expect(route53Zone.zoneName).toBe('example.com.'); expect(route53Record.ttl).toBe(300); expect(route53HealthCheck.healthCheckId).toBe('abcd1234'); + expect(endpointActivity.totalInvocationsLast14Days).toBe(0); expect(notebookInstance.notebookInstanceStatus).toBe('InService'); expect(secret.secretName).toBe('db-password'); expect(cacheCluster.cacheClusterStatus).toBe('available'); diff --git a/packages/rules/test/rds-manual-snapshot-max-age.test.ts b/packages/rules/test/rds-manual-snapshot-max-age.test.ts new file mode 100644 index 0000000..4c37cb3 --- /dev/null +++ b/packages/rules/test/rds-manual-snapshot-max-age.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { rdsManualSnapshotMaxAgeRule } from '../src/aws/rds/manual-snapshot-max-age.js'; +import type { AwsRdsSnapshot } from '../src/index.js'; +import { LiveResourceBag } from '../src/index.js'; + +const createSnapshot = (overrides: Partial = {}): AwsRdsSnapshot => ({ + accountId: '123456789012', + dbInstanceIdentifier: 'orders-db', + dbSnapshotIdentifier: 'orders-db-manual-old', + region: 'us-east-1', + snapshotCreateTime: '2025-09-01T00:00:00.000Z', + snapshotType: 'manual', + ...overrides, +}); + +describe('rdsManualSnapshotMaxAgeRule', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('flags manual snapshots older than 90 days', () => { + const finding = rdsManualSnapshotMaxAgeRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-rds-snapshots': [createSnapshot()], + }), + }); + + expect(finding).toEqual({ + ruleId: 'CLDBRN-AWS-RDS-10', + service: 'rds', + source: 'discovery', + message: 'Manual RDS snapshots older than 90 days should be reviewed for cleanup.', + findings: [ + { + accountId: '123456789012', + region: 'us-east-1', + resourceId: 'orders-db-manual-old', + }, + ], + }); + }); + + it('skips snapshots that are younger than the review window', () => { + const finding = rdsManualSnapshotMaxAgeRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-rds-snapshots': [ + createSnapshot({ + dbSnapshotIdentifier: 'orders-db-manual-recent', + snapshotCreateTime: '2025-12-15T00:00:00.000Z', + }), + ], + }), + }); + + expect(finding).toBeNull(); + }); + + it('skips automated snapshots and snapshots with invalid timestamps', () => { + const finding = rdsManualSnapshotMaxAgeRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'us-east-1', + }, + resources: new LiveResourceBag({ + 'aws-rds-snapshots': [ + createSnapshot({ + dbSnapshotIdentifier: 'orders-db-automated-old', + snapshotType: 'automated', + }), + createSnapshot({ + dbSnapshotIdentifier: 'orders-db-manual-invalid', + snapshotCreateTime: 'not-a-timestamp', + }), + ], + }), + }); + + expect(finding).toBeNull(); + }); +}); diff --git a/packages/rules/test/rds-unused-snapshots.test.ts b/packages/rules/test/rds-unused-snapshots.test.ts index 7c175fb..fa0212d 100644 --- a/packages/rules/test/rds-unused-snapshots.test.ts +++ b/packages/rules/test/rds-unused-snapshots.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } 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'; @@ -27,6 +27,15 @@ const createSnapshot = (overrides: Partial = {}): AwsRdsSnapshot }); describe('rdsUnusedSnapshotsRule', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-31T00:00:00.000Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + it('flags orphaned RDS snapshots older than the grace period', () => { const finding = rdsUnusedSnapshotsRule.evaluateLive?.({ catalog: { @@ -74,7 +83,7 @@ describe('rdsUnusedSnapshotsRule', () => { }, resources: new LiveResourceBag({ 'aws-rds-instances': [createInstance()], - 'aws-rds-snapshots': [createSnapshot({ snapshotCreateTime: '2026-03-10T00:00:00.000Z' })], + 'aws-rds-snapshots': [createSnapshot({ snapshotCreateTime: '2026-01-15T00:00:00.000Z' })], }), }); diff --git a/packages/rules/test/rule-metadata.test.ts b/packages/rules/test/rule-metadata.test.ts index 01b8e73..e929ffe 100644 --- a/packages/rules/test/rule-metadata.test.ts +++ b/packages/rules/test/rule-metadata.test.ts @@ -752,6 +752,22 @@ describe('rule metadata', () => { }); }); + it('defines the expected EC2 stopped-instance rule metadata', () => { + const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-EC2-13'); + + expect(rule).toBeDefined(); + expect(rule).toMatchObject({ + id: 'CLDBRN-AWS-EC2-13', + name: 'EC2 Instance Stopped', + description: 'Flag stopped EC2 instances whose parsed stop time is at least 30 days old.', + message: 'Stopped EC2 instances with a parsed stop time older than 30 days should be reviewed for cleanup.', + provider: 'aws', + service: 'ec2', + supports: ['discovery'], + discoveryDependencies: ['aws-ec2-instances'], + }); + }); + it('defines the expected ELB ALB-without-targets rule metadata', () => { const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-ELB-1'); @@ -1034,6 +1050,22 @@ describe('rule metadata', () => { }); }); + it('defines the expected RDS manual-snapshot-max-age rule metadata', () => { + const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-RDS-10'); + + expect(rule).toBeDefined(); + expect(rule).toMatchObject({ + id: 'CLDBRN-AWS-RDS-10', + name: 'RDS Manual Snapshot Max Age Exceeded', + description: 'Flag manual RDS snapshots older than 90 days.', + message: 'Manual RDS snapshots older than 90 days should be reviewed for cleanup.', + provider: 'aws', + service: 'rds', + supports: ['discovery'], + discoveryDependencies: ['aws-rds-snapshots'], + }); + }); + it('defines the expected Redshift low-cpu rule metadata', () => { const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-REDSHIFT-1'); @@ -1116,6 +1148,22 @@ describe('rule metadata', () => { }); }); + it('defines the expected SageMaker idle-endpoint rule metadata', () => { + const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-SAGEMAKER-2'); + + expect(rule).toBeDefined(); + expect(rule).toMatchObject({ + id: 'CLDBRN-AWS-SAGEMAKER-2', + name: 'SageMaker Endpoint Idle', + description: 'Flag SageMaker endpoints in service whose 14-day invocation total is zero.', + message: 'SageMaker endpoints in service with zero invocations over 14 days should be reviewed for cleanup.', + provider: 'aws', + service: 'sagemaker', + supports: ['discovery'], + discoveryDependencies: ['aws-sagemaker-endpoint-activity'], + }); + }); + it('defines the expected CloudFront price-class rule metadata', () => { const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-CLOUDFRONT-1'); diff --git a/packages/rules/test/sagemaker-idle-endpoint.test.ts b/packages/rules/test/sagemaker-idle-endpoint.test.ts new file mode 100644 index 0000000..a169b61 --- /dev/null +++ b/packages/rules/test/sagemaker-idle-endpoint.test.ts @@ -0,0 +1,107 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { sagemakerIdleEndpointRule } from '../src/aws/sagemaker/idle-endpoint.js'; +import type { AwsSageMakerEndpointActivity } from '../src/index.js'; +import { LiveResourceBag } from '../src/index.js'; + +const createEndpoint = (overrides: Partial = {}): AwsSageMakerEndpointActivity => ({ + accountId: '123456789012', + creationTime: '2025-12-01T00:00:00.000Z', + endpointArn: 'arn:aws:sagemaker:eu-west-1:123456789012:endpoint/orders-endpoint', + endpointConfigName: 'orders-endpoint-config', + endpointName: 'orders-endpoint', + endpointStatus: 'InService', + lastModifiedTime: '2025-12-15T00:00:00.000Z', + region: 'eu-west-1', + totalInvocationsLast14Days: 0, + ...overrides, +}); + +describe('sagemakerIdleEndpointRule', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('flags InService endpoints older than 14 days with zero invocations', () => { + const finding = sagemakerIdleEndpointRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'eu-west-1', + }, + resources: new LiveResourceBag({ + 'aws-sagemaker-endpoint-activity': [createEndpoint()], + }), + }); + + expect(finding).toEqual({ + findings: [ + { + accountId: '123456789012', + region: 'eu-west-1', + resourceId: 'orders-endpoint', + }, + ], + message: 'SageMaker endpoints in service with zero invocations over 14 days should be reviewed for cleanup.', + ruleId: 'CLDBRN-AWS-SAGEMAKER-2', + service: 'sagemaker', + source: 'discovery', + }); + }); + + it('skips endpoints that are too new, not in service, or have traffic', () => { + const finding = sagemakerIdleEndpointRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'eu-west-1', + }, + resources: new LiveResourceBag({ + 'aws-sagemaker-endpoint-activity': [ + createEndpoint({ + endpointName: 'active-endpoint', + totalInvocationsLast14Days: 12, + }), + createEndpoint({ + creationTime: '2025-12-25T00:00:00.000Z', + endpointName: 'new-endpoint', + }), + createEndpoint({ + endpointName: 'updating-endpoint', + endpointStatus: 'Updating', + }), + ], + }), + }); + + expect(finding).toBeNull(); + }); + + it('skips endpoints with incomplete metrics or missing creation time', () => { + const finding = sagemakerIdleEndpointRule.evaluateLive?.({ + catalog: { + indexType: 'LOCAL', + resources: [], + searchRegion: 'eu-west-1', + }, + resources: new LiveResourceBag({ + 'aws-sagemaker-endpoint-activity': [ + createEndpoint({ + endpointName: 'missing-metrics', + totalInvocationsLast14Days: null, + }), + createEndpoint({ + creationTime: undefined, + endpointName: 'unknown-age', + }), + ], + }), + }); + + expect(finding).toBeNull(); + }); +}); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 0abeda6..dee806e 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -52,6 +52,8 @@ export type { AwsRoute53HealthCheck, AwsRoute53Record, AwsRoute53Zone, + AwsSageMakerEndpointActivity, + AwsSageMakerNotebookInstance, AwsSecretsManagerSecret, AwsSupportedResourceType, BuiltInRuleMetadata, diff --git a/packages/sdk/src/providers/aws/discovery-registry.ts b/packages/sdk/src/providers/aws/discovery-registry.ts index e0dd3b7..b160ec3 100644 --- a/packages/sdk/src/providers/aws/discovery-registry.ts +++ b/packages/sdk/src/providers/aws/discovery-registry.ts @@ -55,7 +55,7 @@ import { hydrateAwsRoute53Zones, } from './resources/route53.js'; import { hydrateAwsS3BucketAnalyses } from './resources/s3.js'; -import { hydrateAwsSageMakerNotebookInstances } from './resources/sagemaker.js'; +import { hydrateAwsSageMakerEndpointActivity, hydrateAwsSageMakerNotebookInstances } from './resources/sagemaker.js'; import { hydrateAwsSecretsManagerSecrets } from './resources/secretsmanager.js'; import { hydrateAwsEc2VpcEndpointActivity } from './resources/vpc-endpoints.js'; @@ -435,6 +435,12 @@ const awsDiscoveryDatasetRegistry: { service: 's3', load: hydrateAwsS3BucketAnalyses, }, + 'aws-sagemaker-endpoint-activity': { + datasetKey: 'aws-sagemaker-endpoint-activity', + resourceTypes: ['sagemaker:endpoint'], + service: 'sagemaker', + load: hydrateAwsSageMakerEndpointActivity, + }, 'aws-sagemaker-notebook-instances': { datasetKey: 'aws-sagemaker-notebook-instances', resourceTypes: ['sagemaker:notebook-instance'], diff --git a/packages/sdk/src/providers/aws/resources/ec2.ts b/packages/sdk/src/providers/aws/resources/ec2.ts index 0271e49..45200d5 100644 --- a/packages/sdk/src/providers/aws/resources/ec2.ts +++ b/packages/sdk/src/providers/aws/resources/ec2.ts @@ -5,6 +5,7 @@ import { chunkItems, withAwsServiceErrorContext } from './utils.js'; const EC2_INSTANCE_ARN_PREFIX = 'instance/'; const EC2_DESCRIBE_BATCH_SIZE = 100; +const STOPPED_STATE_TRANSITION_REASON_PATTERN = /\((\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) UTC\)$/u; const extractInstanceId = (arn: string): string | null => { const arnSegments = arn.split(':'); @@ -17,6 +18,22 @@ const extractInstanceId = (arn: string): string | null => { return resourceSegment.slice(EC2_INSTANCE_ARN_PREFIX.length); }; +const parseStoppedAt = (state: string | undefined, stateTransitionReason: string | undefined): string | undefined => { + if (state !== 'stopped' || !stateTransitionReason) { + return undefined; + } + + const match = STOPPED_STATE_TRANSITION_REASON_PATTERN.exec(stateTransitionReason); + + if (!match?.[1]) { + return undefined; + } + + const stoppedAt = Date.parse(`${match[1].replace(' ', 'T')}Z`); + + return Number.isFinite(stoppedAt) ? new Date(stoppedAt).toISOString() : undefined; +}; + /** * Hydrates discovered EC2 instances with their instance type. * @@ -75,6 +92,7 @@ export const hydrateAwsEc2Instances = async (resources: AwsDiscoveredResource[]) launchTime: instance.LaunchTime?.toISOString(), region, state: instance.State?.Name, + stoppedAt: parseStoppedAt(instance.State?.Name, instance.StateTransitionReason), }); } } diff --git a/packages/sdk/src/providers/aws/resources/sagemaker.ts b/packages/sdk/src/providers/aws/resources/sagemaker.ts index 1c87564..6deff94 100644 --- a/packages/sdk/src/providers/aws/resources/sagemaker.ts +++ b/packages/sdk/src/providers/aws/resources/sagemaker.ts @@ -1,9 +1,22 @@ -import { DescribeNotebookInstanceCommand } from '@aws-sdk/client-sagemaker'; -import type { AwsDiscoveredResource, AwsSageMakerNotebookInstance } from '@cloudburn/rules'; +import { + DescribeEndpointCommand, + DescribeEndpointConfigCommand, + DescribeNotebookInstanceCommand, +} from '@aws-sdk/client-sagemaker'; +import type { + AwsDiscoveredResource, + AwsSageMakerEndpointActivity, + AwsSageMakerNotebookInstance, +} from '@cloudburn/rules'; import { createSageMakerClient } from '../client.js'; +import { fetchCloudWatchSignals } from './cloudwatch.js'; import { chunkItems, extractTerminalResourceIdentifier, withAwsServiceErrorContext } from './utils.js'; const NOTEBOOK_INSTANCE_BATCH_SIZE = 10; +const ENDPOINT_BATCH_SIZE = 10; +const FOURTEEN_DAYS_IN_SECONDS = 14 * 24 * 60 * 60; +const DAILY_PERIOD_IN_SECONDS = 24 * 60 * 60; +const REQUIRED_ENDPOINT_DAILY_POINTS = FOURTEEN_DAYS_IN_SECONDS / DAILY_PERIOD_IN_SECONDS; const isNotebookInstanceMissingError = (error: unknown): boolean => { if (!(error instanceof Error)) { @@ -20,6 +33,34 @@ const isNotebookInstanceMissingError = (error: unknown): boolean => { ); }; +const isEndpointMissingError = (error: unknown): boolean => { + if (!(error instanceof Error)) { + return false; + } + + const candidates = [error.name, error.message].map((value) => value.toLowerCase()); + + return ( + candidates.some((value) => value.includes('resourcenotfound')) || + candidates.some((value) => value.includes('could not find endpoint')) || + candidates.some((value) => value.includes('endpoint') && value.includes('not found')) + ); +}; + +const isEndpointConfigMissingError = (error: unknown): boolean => { + if (!(error instanceof Error)) { + return false; + } + + const candidates = [error.name, error.message].map((value) => value.toLowerCase()); + + return ( + candidates.some((value) => value.includes('resourcenotfound')) || + candidates.some((value) => value.includes('could not find endpoint config')) || + candidates.some((value) => value.includes('endpoint config') && value.includes('not found')) + ); +}; + /** * Hydrates discovered SageMaker notebook instances with their runtime metadata. * @@ -103,3 +144,165 @@ export const hydrateAwsSageMakerNotebookInstances = async ( .flat() .sort((left, right) => left.notebookInstanceName.localeCompare(right.notebookInstanceName)); }; + +/** + * Hydrates discovered SageMaker endpoints with endpoint metadata and a 14-day + * invocation total aggregated across production variants. + * + * @param resources - Catalog resources filtered to SageMaker endpoint resource types. + * @returns Hydrated SageMaker endpoints for rule evaluation. + */ +export const hydrateAwsSageMakerEndpointActivity = async ( + resources: AwsDiscoveredResource[], +): Promise => { + const resourcesByRegion = new Map>(); + + for (const resource of resources) { + const endpointName = extractTerminalResourceIdentifier(resource.name, resource.arn); + + if (!endpointName) { + continue; + } + + const regionResources = resourcesByRegion.get(resource.region) ?? []; + regionResources.push({ + accountId: resource.accountId, + endpointArn: resource.arn, + endpointName, + }); + resourcesByRegion.set(resource.region, regionResources); + } + + const hydratedPages = await Promise.all( + [...resourcesByRegion.entries()].map(async ([region, regionResources]) => { + const client = createSageMakerClient({ region }); + const endpoints: AwsSageMakerEndpointActivity[] = []; + + for (const batch of chunkItems(regionResources, ENDPOINT_BATCH_SIZE)) { + const hydratedBatch = await Promise.all( + batch.map(async (resource) => { + try { + const endpointResponse = await withAwsServiceErrorContext( + 'Amazon SageMaker', + 'DescribeEndpoint', + region, + () => + client.send( + new DescribeEndpointCommand({ + EndpointName: resource.endpointName, + }), + ), + { + passthrough: isEndpointMissingError, + }, + ); + + if ( + !endpointResponse.EndpointArn || + !endpointResponse.EndpointName || + !endpointResponse.EndpointStatus || + !endpointResponse.EndpointConfigName + ) { + return null; + } + + const endpointConfigResponse = await withAwsServiceErrorContext( + 'Amazon SageMaker', + 'DescribeEndpointConfig', + region, + () => + client.send( + new DescribeEndpointConfigCommand({ + EndpointConfigName: endpointResponse.EndpointConfigName, + }), + ), + { + passthrough: isEndpointConfigMissingError, + }, + ); + + return { + accountId: resource.accountId, + creationTime: endpointResponse.CreationTime?.toISOString(), + endpointArn: endpointResponse.EndpointArn, + endpointConfigName: endpointResponse.EndpointConfigName, + endpointName: endpointResponse.EndpointName, + endpointStatus: endpointResponse.EndpointStatus, + lastModifiedTime: endpointResponse.LastModifiedTime?.toISOString(), + productionVariantNames: (endpointConfigResponse.ProductionVariants ?? []).flatMap((variant) => + variant.VariantName ? [variant.VariantName] : [], + ), + }; + } catch (error) { + if (isEndpointMissingError(error) || isEndpointConfigMissingError(error)) { + return null; + } + + throw error; + } + }), + ); + + const completeEndpoints = hydratedBatch.flatMap((endpoint) => (endpoint ? [endpoint] : [])); + + const metricData = + completeEndpoints.length > 0 + ? await fetchCloudWatchSignals({ + endTime: new Date(), + queries: completeEndpoints.flatMap((endpoint, endpointIndex) => + endpoint.productionVariantNames.map((variantName, variantIndex) => ({ + dimensions: [ + { Name: 'EndpointName', Value: endpoint.endpointName }, + { Name: 'VariantName', Value: variantName }, + ], + id: `endpoint${endpointIndex}variant${variantIndex}`, + metricName: 'Invocations', + namespace: 'AWS/SageMaker', + period: DAILY_PERIOD_IN_SECONDS, + stat: 'Sum' as const, + })), + ), + region, + startTime: new Date(Date.now() - FOURTEEN_DAYS_IN_SECONDS * 1000), + }) + : new Map(); + + endpoints.push( + ...completeEndpoints.map((endpoint, endpointIndex) => { + const totalInvocationsLast14Days = + endpoint.productionVariantNames.length > 0 && + endpoint.productionVariantNames.every( + (_variantName, variantIndex) => + (metricData.get(`endpoint${endpointIndex}variant${variantIndex}`) ?? []).length >= + REQUIRED_ENDPOINT_DAILY_POINTS, + ) + ? endpoint.productionVariantNames.reduce((sum, _variantName, variantIndex) => { + const points = metricData.get(`endpoint${endpointIndex}variant${variantIndex}`) ?? []; + + return ( + sum + points.reduce((pointSum: number, point: { value: number }) => pointSum + point.value, 0) + ); + }, 0) + : null; + + return { + accountId: endpoint.accountId, + creationTime: endpoint.creationTime, + endpointArn: endpoint.endpointArn, + endpointConfigName: endpoint.endpointConfigName, + endpointName: endpoint.endpointName, + endpointStatus: endpoint.endpointStatus, + lastModifiedTime: endpoint.lastModifiedTime, + region, + totalInvocationsLast14Days, + } satisfies AwsSageMakerEndpointActivity; + }), + ); + } + + return endpoints; + }), + ); + + return hydratedPages.flat().sort((left, right) => left.endpointName.localeCompare(right.endpointName)); +}; diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 0623f16..cff5850 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -40,6 +40,8 @@ import type { AwsRoute53Zone, AwsS3BucketAnalysis, AwsS3BucketAnalysisFlags, + AwsSageMakerEndpointActivity, + AwsSageMakerNotebookInstance, AwsSecretsManagerSecret, AwsStaticEbsVolume, AwsStaticEc2Instance, @@ -226,6 +228,8 @@ export type { AwsRoute53Zone, AwsS3BucketAnalysis, AwsS3BucketAnalysisFlags, + AwsSageMakerEndpointActivity, + AwsSageMakerNotebookInstance, AwsSecretsManagerSecret, AwsStaticEbsVolume, AwsStaticEc2Instance, diff --git a/packages/sdk/test/exports.test.ts b/packages/sdk/test/exports.test.ts index 11b2486..32e4525 100644 --- a/packages/sdk/test/exports.test.ts +++ b/packages/sdk/test/exports.test.ts @@ -21,6 +21,8 @@ import { type AwsRoute53HealthCheck, type AwsRoute53Record, type AwsRoute53Zone, + type AwsSageMakerEndpointActivity, + type AwsSageMakerNotebookInstance, type AwsSecretsManagerSecret, builtInRuleMetadata, parseIaC, @@ -251,6 +253,25 @@ describe('sdk exports', () => { secretArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:db-password-AbCdEf', secretName: 'db-password', }; + const notebookInstance: AwsSageMakerNotebookInstance = { + accountId: '123456789012', + instanceType: 'ml.t3.medium', + lastModifiedTime: '2026-03-01T00:00:00.000Z', + notebookInstanceName: 'analytics-notebook', + notebookInstanceStatus: 'InService', + region: 'eu-west-1', + }; + const endpointActivity: AwsSageMakerEndpointActivity = { + accountId: '123456789012', + creationTime: '2026-02-01T00:00:00.000Z', + endpointArn: 'arn:aws:sagemaker:eu-west-1:123456789012:endpoint/orders-endpoint', + endpointConfigName: 'orders-endpoint-config', + endpointName: 'orders-endpoint', + endpointStatus: 'InService', + lastModifiedTime: '2026-03-01T00:00:00.000Z', + region: 'eu-west-1', + totalInvocationsLast14Days: 0, + }; expect(apiGatewayStage.stageName).toBe('prod'); expect(trail.trailName).toBe('org-trail'); @@ -272,6 +293,8 @@ describe('sdk exports', () => { expect(route53Record.ttl).toBe(300); expect(route53HealthCheck.healthCheckId).toBe('abcd1234'); expect(secret.secretName).toBe('db-password'); + expect(notebookInstance.notebookInstanceStatus).toBe('InService'); + expect(endpointActivity.totalInvocationsLast14Days).toBe(0); }); it('clones supports arrays so metadata consumers cannot mutate source rule definitions', () => { diff --git a/packages/sdk/test/providers/aws-discovery.test.ts b/packages/sdk/test/providers/aws-discovery.test.ts index e20738c..82a059a 100644 --- a/packages/sdk/test/providers/aws-discovery.test.ts +++ b/packages/sdk/test/providers/aws-discovery.test.ts @@ -89,7 +89,10 @@ import { hydrateAwsRoute53Zones, } from '../../src/providers/aws/resources/route53.js'; import { hydrateAwsS3BucketAnalyses } from '../../src/providers/aws/resources/s3.js'; -import { hydrateAwsSageMakerNotebookInstances } from '../../src/providers/aws/resources/sagemaker.js'; +import { + hydrateAwsSageMakerEndpointActivity, + hydrateAwsSageMakerNotebookInstances, +} from '../../src/providers/aws/resources/sagemaker.js'; import { hydrateAwsSecretsManagerSecrets } from '../../src/providers/aws/resources/secretsmanager.js'; vi.mock('../../src/providers/aws/client.js', async (importOriginal) => { @@ -241,6 +244,7 @@ vi.mock('../../src/providers/aws/resources/s3.js', () => ({ })); vi.mock('../../src/providers/aws/resources/sagemaker.js', () => ({ + hydrateAwsSageMakerEndpointActivity: vi.fn(), hydrateAwsSageMakerNotebookInstances: vi.fn(), })); @@ -311,6 +315,7 @@ const mockedHydrateAwsRoute53HealthChecks = vi.mocked(hydrateAwsRoute53HealthChe const mockedHydrateAwsRoute53Records = vi.mocked(hydrateAwsRoute53Records); const mockedHydrateAwsRoute53Zones = vi.mocked(hydrateAwsRoute53Zones); const mockedHydrateAwsS3BucketAnalyses = vi.mocked(hydrateAwsS3BucketAnalyses); +const mockedHydrateAwsSageMakerEndpointActivity = vi.mocked(hydrateAwsSageMakerEndpointActivity); const mockedHydrateAwsSageMakerNotebookInstances = vi.mocked(hydrateAwsSageMakerNotebookInstances); const mockedHydrateAwsSecretsManagerSecrets = vi.mocked(hydrateAwsSecretsManagerSecrets); const loadContextMatcher = expect.objectContaining({ loadDataset: expect.any(Function) }); @@ -2247,6 +2252,76 @@ describe('discoverAwsResources', () => { ]); }); + it('hydrates SageMaker endpoint activity when an active rule requires endpoint data', async () => { + mockedBuildAwsDiscoveryCatalog.mockResolvedValue({ + indexType: 'LOCAL', + resources: [ + { + accountId: '123456789012', + arn: 'arn:aws:sagemaker:eu-west-1:123456789012:endpoint/orders-endpoint', + properties: [], + region: 'eu-west-1', + resourceType: 'sagemaker:endpoint', + service: 'sagemaker', + }, + ], + searchRegion: 'eu-west-1', + }); + mockedHydrateAwsSageMakerEndpointActivity.mockResolvedValue([ + { + accountId: '123456789012', + creationTime: '2025-12-01T00:00:00.000Z', + endpointArn: 'arn:aws:sagemaker:eu-west-1:123456789012:endpoint/orders-endpoint', + endpointConfigName: 'orders-endpoint-config', + endpointName: 'orders-endpoint', + endpointStatus: 'InService', + lastModifiedTime: '2025-12-15T00:00:00.000Z', + region: 'eu-west-1', + totalInvocationsLast14Days: 0, + }, + ]); + + const result = await discoverAwsResources( + [ + createRule({ + discoveryDependencies: ['aws-sagemaker-endpoint-activity'], + service: 'sagemaker', + }), + ], + { mode: 'regions', regions: ['eu-west-1'] }, + ); + + expect(mockedBuildAwsDiscoveryCatalog).toHaveBeenCalledWith({ mode: 'regions', regions: ['eu-west-1'] }, [ + 'sagemaker:endpoint', + ]); + expect(mockedHydrateAwsSageMakerEndpointActivity).toHaveBeenCalledWith( + [ + { + accountId: '123456789012', + arn: 'arn:aws:sagemaker:eu-west-1:123456789012:endpoint/orders-endpoint', + properties: [], + region: 'eu-west-1', + resourceType: 'sagemaker:endpoint', + service: 'sagemaker', + }, + ], + loadContextMatcher, + ); + expect(result.resources.get('aws-sagemaker-endpoint-activity')).toEqual([ + { + accountId: '123456789012', + creationTime: '2025-12-01T00:00:00.000Z', + endpointArn: 'arn:aws:sagemaker:eu-west-1:123456789012:endpoint/orders-endpoint', + endpointConfigName: 'orders-endpoint-config', + endpointName: 'orders-endpoint', + endpointStatus: 'InService', + lastModifiedTime: '2025-12-15T00:00:00.000Z', + region: 'eu-west-1', + totalInvocationsLast14Days: 0, + }, + ]); + }); + 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-ec2-resource.test.ts b/packages/sdk/test/providers/aws-ec2-resource.test.ts index f14ad16..9b94562 100644 --- a/packages/sdk/test/providers/aws-ec2-resource.test.ts +++ b/packages/sdk/test/providers/aws-ec2-resource.test.ts @@ -35,6 +35,12 @@ describe('hydrateAwsEc2Instances', () => { : instanceId === 'i-west' ? new Date('2025-12-31T00:00:00.000Z') : new Date('2025-09-01T00:00:00.000Z'), + StateTransitionReason: + instanceId === 'i-current' + ? undefined + : instanceId === 'i-west' + ? 'User initiated (2025-12-15 10:30:00 UTC)' + : 'User initiated (2025-10-01 08:00:00 UTC)', State: { Name: instanceId === 'i-current' ? 'running' : 'stopped', }, @@ -94,6 +100,7 @@ describe('hydrateAwsEc2Instances', () => { launchTime: '2025-09-01T00:00:00.000Z', region: 'us-east-1', state: 'stopped', + stoppedAt: '2025-10-01T08:00:00.000Z', }, { accountId: '123456789012', @@ -103,6 +110,55 @@ describe('hydrateAwsEc2Instances', () => { launchTime: '2025-12-31T00:00:00.000Z', region: 'us-west-2', state: 'stopped', + stoppedAt: '2025-12-15T10:30:00.000Z', + }, + ]); + }); + + it('leaves stoppedAt unset when the stop reason cannot be parsed', async () => { + mockedCreateEc2Client.mockImplementation(({ region }) => { + const send = vi.fn(async () => ({ + Reservations: [ + { + Instances: [ + { + Architecture: 'x86_64', + InstanceId: 'i-unparseable', + InstanceType: 'm7i.large', + LaunchTime: new Date('2025-03-01T00:00:00.000Z'), + State: { + Name: 'stopped', + }, + StateTransitionReason: 'User initiated', + }, + ], + }, + ], + })); + + return { send, region } as never; + }); + + await expect( + hydrateAwsEc2Instances([ + { + accountId: '123456789012', + arn: 'arn:aws:ec2:us-east-1:123456789012:instance/i-unparseable', + properties: [], + region: 'us-east-1', + resourceType: 'ec2:instance', + service: 'ec2', + }, + ]), + ).resolves.toEqual([ + { + accountId: '123456789012', + architecture: 'x86_64', + instanceId: 'i-unparseable', + instanceType: 'm7i.large', + launchTime: '2025-03-01T00:00:00.000Z', + region: 'us-east-1', + state: 'stopped', }, ]); }); diff --git a/packages/sdk/test/providers/aws-sagemaker-resource.test.ts b/packages/sdk/test/providers/aws-sagemaker-resource.test.ts index 395456a..4af6ab8 100644 --- a/packages/sdk/test/providers/aws-sagemaker-resource.test.ts +++ b/packages/sdk/test/providers/aws-sagemaker-resource.test.ts @@ -1,13 +1,26 @@ -import type { DescribeNotebookInstanceCommand } from '@aws-sdk/client-sagemaker'; +import type { + DescribeEndpointCommand, + DescribeEndpointConfigCommand, + DescribeNotebookInstanceCommand, +} from '@aws-sdk/client-sagemaker'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createSageMakerClient } from '../../src/providers/aws/client.js'; -import { hydrateAwsSageMakerNotebookInstances } from '../../src/providers/aws/resources/sagemaker.js'; +import { fetchCloudWatchSignals } from '../../src/providers/aws/resources/cloudwatch.js'; +import { + hydrateAwsSageMakerEndpointActivity, + hydrateAwsSageMakerNotebookInstances, +} from '../../src/providers/aws/resources/sagemaker.js'; vi.mock('../../src/providers/aws/client.js', () => ({ createSageMakerClient: vi.fn(), })); +vi.mock('../../src/providers/aws/resources/cloudwatch.js', () => ({ + fetchCloudWatchSignals: vi.fn(), +})); + const mockedCreateSageMakerClient = vi.mocked(createSageMakerClient); +const mockedFetchCloudWatchSignals = vi.mocked(fetchCloudWatchSignals); describe('hydrateAwsSageMakerNotebookInstances', () => { beforeEach(() => { @@ -70,4 +83,131 @@ describe('hydrateAwsSageMakerNotebookInstances', () => { ]), ).resolves.toEqual([]); }); + + it('hydrates SageMaker endpoints with 14-day invocation totals across variants', async () => { + mockedCreateSageMakerClient.mockReturnValue({ + send: vi.fn(async (command: DescribeEndpointCommand | DescribeEndpointConfigCommand) => { + const input = command.input as { EndpointConfigName?: string; EndpointName?: string }; + + if (input.EndpointName) { + return { + CreationTime: new Date('2025-12-01T00:00:00.000Z'), + EndpointArn: 'arn:aws:sagemaker:eu-west-1:123456789012:endpoint/orders-endpoint', + EndpointConfigName: 'orders-endpoint-config', + EndpointName: 'orders-endpoint', + EndpointStatus: 'InService', + LastModifiedTime: new Date('2025-12-15T00:00:00.000Z'), + }; + } + + return { + ProductionVariants: [{ VariantName: 'blue' }, { VariantName: 'green' }], + }; + }), + } as never); + mockedFetchCloudWatchSignals.mockResolvedValue( + new Map([ + [ + 'endpoint0variant0', + Array.from({ length: 14 }, (_value, index) => ({ + timestamp: `2025-12-${String(index + 1).padStart(2, '0')}T00:00:00.000Z`, + value: 0, + })), + ], + [ + 'endpoint0variant1', + Array.from({ length: 14 }, (_value, index) => ({ + timestamp: `2025-12-${String(index + 1).padStart(2, '0')}T00:00:00.000Z`, + value: 1, + })), + ], + ]), + ); + + await expect( + hydrateAwsSageMakerEndpointActivity([ + { + accountId: '123456789012', + arn: 'arn:aws:sagemaker:eu-west-1:123456789012:endpoint/orders-endpoint', + properties: [], + region: 'eu-west-1', + resourceType: 'sagemaker:endpoint', + service: 'sagemaker', + }, + ]), + ).resolves.toEqual([ + { + accountId: '123456789012', + creationTime: '2025-12-01T00:00:00.000Z', + endpointArn: 'arn:aws:sagemaker:eu-west-1:123456789012:endpoint/orders-endpoint', + endpointConfigName: 'orders-endpoint-config', + endpointName: 'orders-endpoint', + endpointStatus: 'InService', + lastModifiedTime: '2025-12-15T00:00:00.000Z', + region: 'eu-west-1', + totalInvocationsLast14Days: 14, + }, + ]); + }); + + it('preserves null invocation totals when endpoint metrics are incomplete', async () => { + mockedCreateSageMakerClient.mockReturnValue({ + send: vi.fn(async (command: DescribeEndpointCommand | DescribeEndpointConfigCommand) => { + const input = command.input as { EndpointConfigName?: string; EndpointName?: string }; + + if (input.EndpointName) { + return { + CreationTime: new Date('2025-12-01T00:00:00.000Z'), + EndpointArn: 'arn:aws:sagemaker:eu-west-1:123456789012:endpoint/orders-endpoint', + EndpointConfigName: 'orders-endpoint-config', + EndpointName: 'orders-endpoint', + EndpointStatus: 'InService', + LastModifiedTime: new Date('2025-12-15T00:00:00.000Z'), + }; + } + + return { + ProductionVariants: [{ VariantName: 'blue' }], + }; + }), + } as never); + mockedFetchCloudWatchSignals.mockResolvedValue( + new Map([ + [ + 'endpoint0variant0', + [ + { + timestamp: '2025-12-01T00:00:00.000Z', + value: 0, + }, + ], + ], + ]), + ); + + await expect( + hydrateAwsSageMakerEndpointActivity([ + { + accountId: '123456789012', + arn: 'arn:aws:sagemaker:eu-west-1:123456789012:endpoint/orders-endpoint', + properties: [], + region: 'eu-west-1', + resourceType: 'sagemaker:endpoint', + service: 'sagemaker', + }, + ]), + ).resolves.toEqual([ + { + accountId: '123456789012', + creationTime: '2025-12-01T00:00:00.000Z', + endpointArn: 'arn:aws:sagemaker:eu-west-1:123456789012:endpoint/orders-endpoint', + endpointConfigName: 'orders-endpoint-config', + endpointName: 'orders-endpoint', + endpointStatus: 'InService', + lastModifiedTime: '2025-12-15T00:00:00.000Z', + region: 'eu-west-1', + totalInvocationsLast14Days: null, + }, + ]); + }); }); From ae99a7c7bc53b3bf7a5e760b3b8839fdfd6d27ff Mon Sep 17 00:00:00 2001 From: Danny Steenman Date: Thu, 9 Apr 2026 11:24:03 +0200 Subject: [PATCH 2/3] docs: format rule ids table --- docs/reference/rule-ids.md | 162 ++++++++++++++++++------------------- 1 file changed, 81 insertions(+), 81 deletions(-) diff --git a/docs/reference/rule-ids.md b/docs/reference/rule-ids.md index b4d04ed..c15ab86 100644 --- a/docs/reference/rule-ids.md +++ b/docs/reference/rule-ids.md @@ -14,88 +14,88 @@ Format: `CLDBRN-{PROVIDER}-{SERVICE}-{N}` ## Rule Table -| ID | Description | Service | Supports | -| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | -------------- | -| `CLDBRN-AWS-APIGATEWAY-1` | Flags REST API stages when `cacheClusterEnabled` is not explicitly `true`. | apigateway | discovery, iac | -| `CLDBRN-AWS-CLOUDFRONT-1` | Reviews only distributions using `PriceClass_All`. | cloudfront | discovery, iac | -| `CLDBRN-AWS-CLOUDFRONT-2` | Requires a complete 30-day `Requests` history and flags only distributions whose total request count stays below `100`. | cloudfront | discovery | -| `CLDBRN-AWS-CLOUDTRAIL-1` | Flag redundant multi-region CloudTrail trails when more than one trail covers the same account. | cloudtrail | discovery | -| `CLDBRN-AWS-CLOUDTRAIL-2` | Flag redundant single-region CloudTrail trails when more than one trail covers the same region. | cloudtrail | discovery | -| `CLDBRN-AWS-CLOUDWATCH-1` | Flag CloudWatch log groups that do not define retention and are not delivery-managed. | cloudwatch | discovery, iac | -| `CLDBRN-AWS-CLOUDWATCH-2` | Flags log groups whose most recent observed stream activity is missing or older than 90 days. Delivery-managed log groups remain exempt. | cloudwatch | discovery | -| `CLDBRN-AWS-CLOUDWATCH-3` | Reviews only log groups storing at least `1 GiB` and flags them when no metric filters are configured. | cloudwatch | discovery | -| `CLDBRN-AWS-COSTGUARDRAILS-1` | Flags accounts whose AWS Budgets summary reports zero configured budgets. | costguardrails | discovery | -| `CLDBRN-AWS-COSTGUARDRAILS-2` | Flags accounts whose Cost Anomaly Detection summary reports zero anomaly monitors. | costguardrails | discovery | -| `CLDBRN-AWS-COSTEXPLORER-1` | Compares the last two full months and flags only services with an existing prior-month baseline and a cost increase greater than `10` cost units. | costexplorer | discovery | -| `CLDBRN-AWS-DYNAMODB-1` | Flags only tables whose parsed `latestStreamLabel` is older than `90` days. Tables without a stream label are skipped. | dynamodb | discovery | -| `CLDBRN-AWS-DYNAMODB-2` | Reviews only provisioned-capacity tables and flags them when no table-level read or write autoscaling targets are configured. | dynamodb | discovery, iac | -| `CLDBRN-AWS-DYNAMODB-3` | Reviews only provisioned-capacity tables and flags them when 30 days of consumed read and write capacity both sum to zero. | dynamodb | discovery | -| `CLDBRN-AWS-DYNAMODB-4` | Reviews only provisioned-capacity tables and flags them when statically resolved read or write autoscaling ranges have identical min and max capacity values. | dynamodb | iac | -| `CLDBRN-AWS-EC2-1` | Flag direct EC2 instances that do not use curated preferred instance types. | ec2 | iac, discovery | -| `CLDBRN-AWS-EC2-2` | Flag S3 interface endpoints when a gateway endpoint is the cheaper in-VPC option. | ec2 | iac | -| `CLDBRN-AWS-EC2-3` | Flag Elastic IP allocations that are not associated with an EC2 resource. | ec2 | discovery, iac | -| `CLDBRN-AWS-EC2-4` | Flag interface VPC endpoints that have processed no traffic in the last 30 days. | ec2 | discovery | -| `CLDBRN-AWS-EC2-5` | Flag EC2 instances whose CPU and network usage stay below the low-utilization threshold for at least 4 of the previous 14 days. | ec2 | discovery | -| `CLDBRN-AWS-EC2-6` | Flags only families with a curated Graviton-equivalent path. Instances without architecture metadata or outside the curated family set are skipped. | ec2 | discovery, iac | -| `CLDBRN-AWS-EC2-7` | Reviews only active reserved instances with an `endTime` inside the next 60 days. | ec2 | discovery | -| `CLDBRN-AWS-EC2-8` | Treats `2xlarge` and above, plus `metal`, as the large-instance review threshold. | ec2 | discovery, iac | -| `CLDBRN-AWS-EC2-9` | Flags only instances with a parsed launch timestamp at least 180 days old. | ec2 | discovery | -| `CLDBRN-AWS-EC2-10` | Flags IaC-defined instances only when detailed monitoring is explicitly enabled. | ec2 | iac | -| `CLDBRN-AWS-EC2-11` | Flags only NAT gateways in the `available` state and requires complete 7-day `BytesInFromDestination` and `BytesOutToDestination` coverage, with both totals equal to `0`. | ec2 | discovery | -| `CLDBRN-AWS-EC2-12` | Flags only EC2 reserved instances whose `endTime` fell within the last 30 days, surfacing them for renewal follow-up review. | ec2 | discovery | -| `CLDBRN-AWS-EC2-13` | Flags only EC2 instances whose discovered state is `stopped` and whose parsed stop timestamp is at least `30` days old. Instances with missing or unparseable stop timestamps are skipped. | ec2 | discovery | -| `CLDBRN-AWS-ECS-1` | Flags only EC2-backed container instances whose instance families have a curated Graviton-equivalent path. Fargate and unclassified backing instances are skipped. | ecs | discovery | -| `CLDBRN-AWS-ECS-2` | Flags only ECS clusters with a complete 14-day `AWS/ECS` CPU history and an average below `10%`. | ecs | discovery | -| `CLDBRN-AWS-ECS-3` | Flags only active `REPLICA` ECS services and requires both a scalable target and at least one scaling policy. | ecs | discovery, iac | -| `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`. | ebs | discovery, iac | -| `CLDBRN-AWS-EBS-2` | Flag EBS volumes that are not attached to any EC2 instance. | ebs | discovery | -| `CLDBRN-AWS-EBS-3` | Flag EBS volumes whose attached EC2 instances are all in the stopped state. | ebs | discovery | -| `CLDBRN-AWS-EBS-4` | Treats volumes above `100 GiB` as oversized enough to warrant explicit review. | ebs | discovery, iac | -| `CLDBRN-AWS-EBS-5` | Flags only `io1` and `io2` volumes whose provisioned IOPS exceed `32000`. | ebs | discovery, iac | -| `CLDBRN-AWS-EBS-6` | Flags only `io1` and `io2` volumes at `16000` IOPS or below, using an IOPS-only gp3 eligibility heuristic without throughput checks. | ebs | discovery, iac | -| `CLDBRN-AWS-EBS-7` | Flags only `completed` snapshots with a parsed `StartTime` older than `90` days. | ebs | discovery | -| `CLDBRN-AWS-EBS-8` | Flags only `gp3` volumes whose provisioned throughput is above the included `125 MiB/s` baseline. | ebs | iac | -| `CLDBRN-AWS-EBS-9` | Flags only `gp3` volumes whose provisioned or defaulted IOPS exceed the included `3000` baseline. | ebs | iac | -| `CLDBRN-AWS-ECR-1` | Flag ECR repositories that do not define a lifecycle policy. | ecr | iac, discovery | -| `CLDBRN-AWS-ECR-2` | Reviews only repositories with a lifecycle policy and flags them when the statically parsed policy does not expire untagged images. | ecr | iac | -| `CLDBRN-AWS-ECR-3` | Reviews only repositories with a lifecycle policy and flags them when the statically parsed policy does not cap tagged image retention. | ecr | iac | -| `CLDBRN-AWS-EKS-1` | Flags only managed node groups with classifiable non-Arm instance families. Arm AMIs and unclassified node groups are skipped. | eks | discovery, iac | -| `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. | elasticache | discovery | -| `CLDBRN-AWS-ELASTICACHE-2` | Currently supports Redis and Valkey clusters, requires a complete 14-day metric history, and flags only `available` clusters whose computed hit rate stays below `5%` while average current connections stay below `2`. | elasticache | discovery | -| `CLDBRN-AWS-ELB-1` | Flags load balancers with no attached target groups or no registered targets across attached target groups. | elb | discovery | -| `CLDBRN-AWS-ELB-2` | Flag Classic Load Balancers that have zero attached instances. | elb | discovery | -| `CLDBRN-AWS-ELB-3` | Flags load balancers with no attached target groups or no registered targets across attached target groups. | elb | discovery | -| `CLDBRN-AWS-ELB-4` | Flags load balancers with no attached target groups or no registered targets across attached target groups. | elb | discovery | -| `CLDBRN-AWS-ELB-5` | Requires a complete 14-day `RequestCount` history, treats fewer than `10` requests per day as idle, and skips load balancers already covered by the stricter empty-target cleanup rules. | elb | discovery | -| `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. | emr | discovery, iac | -| `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. | emr | discovery | -| `CLDBRN-AWS-RDS-1` | Flag RDS DB instances that do not use curated preferred instance classes. | rds | iac, discovery | -| `CLDBRN-AWS-RDS-2` | Flag RDS DB instances that have no database connections in the last 7 days. | rds | discovery | -| `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. | rds | discovery | -| `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. | rds | discovery, iac | -| `CLDBRN-AWS-RDS-5` | Reviews only `available` DB instances and treats a complete 30-day average `CPUUtilization` of `10%` or lower as low utilization. | rds | discovery | -| `CLDBRN-AWS-RDS-6` | Flags only RDS MySQL `5.7.x` and PostgreSQL `11.x` DB instances for extended-support review. | rds | discovery, iac | -| `CLDBRN-AWS-RDS-7` | Flags only snapshots whose source DB instance no longer exists and whose parsed create time is at least `30` days old. | rds | discovery | -| `CLDBRN-AWS-RDS-8` | Flags only DB instances with Performance Insights enabled and a retention period above the included 7-day baseline. | rds | iac | -| `CLDBRN-AWS-RDS-9` | Flags only RDS DB instances whose discovered `dbInstanceStatus` is `stopped`, surfacing them for cleanup review. | rds | discovery | -| `CLDBRN-AWS-RDS-10` | Flags only manual RDS snapshots whose parsed `snapshotCreateTime` is at least `90` days old. Automated snapshots and snapshots with invalid timestamps are skipped. | rds | discovery | -| `CLDBRN-AWS-REDSHIFT-1` | Reviews only `available` clusters and treats a 14-day average `CPUUtilization` of 10% or lower as low utilization. | redshift | discovery | -| `CLDBRN-AWS-REDSHIFT-2` | Reviews only `available` clusters with a parsed create time at least 180 days old and requires active reserved-node coverage for the same node type. | redshift | discovery | -| `CLDBRN-AWS-REDSHIFT-3` | Flags only `available`, VPC-backed clusters with automated snapshots enabled, no HSM, and no Multi-AZ deployment when either the pause or resume schedule is missing. | redshift | discovery, iac | -| `CLDBRN-AWS-ROUTE53-1` | Reviews only non-alias records and treats `3600` seconds as the low-TTL floor. | route53 | discovery, iac | -| `CLDBRN-AWS-ROUTE53-2` | Flags only Route 53 health checks that are not referenced by any in-scope record set. | route53 | discovery, iac | -| `CLDBRN-AWS-S3-1` | Ensure S3 buckets define lifecycle management policies. | s3 | iac, discovery | -| `CLDBRN-AWS-S3-2` | Recommend Intelligent-Tiering or another explicit storage-class transition for lifecycle-managed buckets. | s3 | iac, discovery | -| `CLDBRN-AWS-S3-3` | Flags buckets when no enabled lifecycle rule aborts incomplete multipart uploads within 7 days. | s3 | iac, discovery | -| `CLDBRN-AWS-S3-4` | Flags only versioned buckets and requires either noncurrent-version expiration or transition cleanup to avoid unbounded version growth. | s3 | iac | -| `CLDBRN-AWS-SAGEMAKER-1` | Flags only notebook instances whose normalized status remains `InService`. | sagemaker | discovery | +| ID | Description | Service | Supports | +| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | -------------- | +| `CLDBRN-AWS-APIGATEWAY-1` | Flags REST API stages when `cacheClusterEnabled` is not explicitly `true`. | apigateway | discovery, iac | +| `CLDBRN-AWS-CLOUDFRONT-1` | Reviews only distributions using `PriceClass_All`. | cloudfront | discovery, iac | +| `CLDBRN-AWS-CLOUDFRONT-2` | Requires a complete 30-day `Requests` history and flags only distributions whose total request count stays below `100`. | cloudfront | discovery | +| `CLDBRN-AWS-CLOUDTRAIL-1` | Flag redundant multi-region CloudTrail trails when more than one trail covers the same account. | cloudtrail | discovery | +| `CLDBRN-AWS-CLOUDTRAIL-2` | Flag redundant single-region CloudTrail trails when more than one trail covers the same region. | cloudtrail | discovery | +| `CLDBRN-AWS-CLOUDWATCH-1` | Flag CloudWatch log groups that do not define retention and are not delivery-managed. | cloudwatch | discovery, iac | +| `CLDBRN-AWS-CLOUDWATCH-2` | Flags log groups whose most recent observed stream activity is missing or older than 90 days. Delivery-managed log groups remain exempt. | cloudwatch | discovery | +| `CLDBRN-AWS-CLOUDWATCH-3` | Reviews only log groups storing at least `1 GiB` and flags them when no metric filters are configured. | cloudwatch | discovery | +| `CLDBRN-AWS-COSTGUARDRAILS-1` | Flags accounts whose AWS Budgets summary reports zero configured budgets. | costguardrails | discovery | +| `CLDBRN-AWS-COSTGUARDRAILS-2` | Flags accounts whose Cost Anomaly Detection summary reports zero anomaly monitors. | costguardrails | discovery | +| `CLDBRN-AWS-COSTEXPLORER-1` | Compares the last two full months and flags only services with an existing prior-month baseline and a cost increase greater than `10` cost units. | costexplorer | discovery | +| `CLDBRN-AWS-DYNAMODB-1` | Flags only tables whose parsed `latestStreamLabel` is older than `90` days. Tables without a stream label are skipped. | dynamodb | discovery | +| `CLDBRN-AWS-DYNAMODB-2` | Reviews only provisioned-capacity tables and flags them when no table-level read or write autoscaling targets are configured. | dynamodb | discovery, iac | +| `CLDBRN-AWS-DYNAMODB-3` | Reviews only provisioned-capacity tables and flags them when 30 days of consumed read and write capacity both sum to zero. | dynamodb | discovery | +| `CLDBRN-AWS-DYNAMODB-4` | Reviews only provisioned-capacity tables and flags them when statically resolved read or write autoscaling ranges have identical min and max capacity values. | dynamodb | iac | +| `CLDBRN-AWS-EC2-1` | Flag direct EC2 instances that do not use curated preferred instance types. | ec2 | iac, discovery | +| `CLDBRN-AWS-EC2-2` | Flag S3 interface endpoints when a gateway endpoint is the cheaper in-VPC option. | ec2 | iac | +| `CLDBRN-AWS-EC2-3` | Flag Elastic IP allocations that are not associated with an EC2 resource. | ec2 | discovery, iac | +| `CLDBRN-AWS-EC2-4` | Flag interface VPC endpoints that have processed no traffic in the last 30 days. | ec2 | discovery | +| `CLDBRN-AWS-EC2-5` | Flag EC2 instances whose CPU and network usage stay below the low-utilization threshold for at least 4 of the previous 14 days. | ec2 | discovery | +| `CLDBRN-AWS-EC2-6` | Flags only families with a curated Graviton-equivalent path. Instances without architecture metadata or outside the curated family set are skipped. | ec2 | discovery, iac | +| `CLDBRN-AWS-EC2-7` | Reviews only active reserved instances with an `endTime` inside the next 60 days. | ec2 | discovery | +| `CLDBRN-AWS-EC2-8` | Treats `2xlarge` and above, plus `metal`, as the large-instance review threshold. | ec2 | discovery, iac | +| `CLDBRN-AWS-EC2-9` | Flags only instances with a parsed launch timestamp at least 180 days old. | ec2 | discovery | +| `CLDBRN-AWS-EC2-10` | Flags IaC-defined instances only when detailed monitoring is explicitly enabled. | ec2 | iac | +| `CLDBRN-AWS-EC2-11` | Flags only NAT gateways in the `available` state and requires complete 7-day `BytesInFromDestination` and `BytesOutToDestination` coverage, with both totals equal to `0`. | ec2 | discovery | +| `CLDBRN-AWS-EC2-12` | Flags only EC2 reserved instances whose `endTime` fell within the last 30 days, surfacing them for renewal follow-up review. | ec2 | discovery | +| `CLDBRN-AWS-EC2-13` | Flags only EC2 instances whose discovered state is `stopped` and whose parsed stop timestamp is at least `30` days old. Instances with missing or unparseable stop timestamps are skipped. | ec2 | discovery | +| `CLDBRN-AWS-ECS-1` | Flags only EC2-backed container instances whose instance families have a curated Graviton-equivalent path. Fargate and unclassified backing instances are skipped. | ecs | discovery | +| `CLDBRN-AWS-ECS-2` | Flags only ECS clusters with a complete 14-day `AWS/ECS` CPU history and an average below `10%`. | ecs | discovery | +| `CLDBRN-AWS-ECS-3` | Flags only active `REPLICA` ECS services and requires both a scalable target and at least one scaling policy. | ecs | discovery, iac | +| `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`. | ebs | discovery, iac | +| `CLDBRN-AWS-EBS-2` | Flag EBS volumes that are not attached to any EC2 instance. | ebs | discovery | +| `CLDBRN-AWS-EBS-3` | Flag EBS volumes whose attached EC2 instances are all in the stopped state. | ebs | discovery | +| `CLDBRN-AWS-EBS-4` | Treats volumes above `100 GiB` as oversized enough to warrant explicit review. | ebs | discovery, iac | +| `CLDBRN-AWS-EBS-5` | Flags only `io1` and `io2` volumes whose provisioned IOPS exceed `32000`. | ebs | discovery, iac | +| `CLDBRN-AWS-EBS-6` | Flags only `io1` and `io2` volumes at `16000` IOPS or below, using an IOPS-only gp3 eligibility heuristic without throughput checks. | ebs | discovery, iac | +| `CLDBRN-AWS-EBS-7` | Flags only `completed` snapshots with a parsed `StartTime` older than `90` days. | ebs | discovery | +| `CLDBRN-AWS-EBS-8` | Flags only `gp3` volumes whose provisioned throughput is above the included `125 MiB/s` baseline. | ebs | iac | +| `CLDBRN-AWS-EBS-9` | Flags only `gp3` volumes whose provisioned or defaulted IOPS exceed the included `3000` baseline. | ebs | iac | +| `CLDBRN-AWS-ECR-1` | Flag ECR repositories that do not define a lifecycle policy. | ecr | iac, discovery | +| `CLDBRN-AWS-ECR-2` | Reviews only repositories with a lifecycle policy and flags them when the statically parsed policy does not expire untagged images. | ecr | iac | +| `CLDBRN-AWS-ECR-3` | Reviews only repositories with a lifecycle policy and flags them when the statically parsed policy does not cap tagged image retention. | ecr | iac | +| `CLDBRN-AWS-EKS-1` | Flags only managed node groups with classifiable non-Arm instance families. Arm AMIs and unclassified node groups are skipped. | eks | discovery, iac | +| `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. | elasticache | discovery | +| `CLDBRN-AWS-ELASTICACHE-2` | Currently supports Redis and Valkey clusters, requires a complete 14-day metric history, and flags only `available` clusters whose computed hit rate stays below `5%` while average current connections stay below `2`. | elasticache | discovery | +| `CLDBRN-AWS-ELB-1` | Flags load balancers with no attached target groups or no registered targets across attached target groups. | elb | discovery | +| `CLDBRN-AWS-ELB-2` | Flag Classic Load Balancers that have zero attached instances. | elb | discovery | +| `CLDBRN-AWS-ELB-3` | Flags load balancers with no attached target groups or no registered targets across attached target groups. | elb | discovery | +| `CLDBRN-AWS-ELB-4` | Flags load balancers with no attached target groups or no registered targets across attached target groups. | elb | discovery | +| `CLDBRN-AWS-ELB-5` | Requires a complete 14-day `RequestCount` history, treats fewer than `10` requests per day as idle, and skips load balancers already covered by the stricter empty-target cleanup rules. | elb | discovery | +| `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. | emr | discovery, iac | +| `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. | emr | discovery | +| `CLDBRN-AWS-RDS-1` | Flag RDS DB instances that do not use curated preferred instance classes. | rds | iac, discovery | +| `CLDBRN-AWS-RDS-2` | Flag RDS DB instances that have no database connections in the last 7 days. | rds | discovery | +| `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. | rds | discovery | +| `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. | rds | discovery, iac | +| `CLDBRN-AWS-RDS-5` | Reviews only `available` DB instances and treats a complete 30-day average `CPUUtilization` of `10%` or lower as low utilization. | rds | discovery | +| `CLDBRN-AWS-RDS-6` | Flags only RDS MySQL `5.7.x` and PostgreSQL `11.x` DB instances for extended-support review. | rds | discovery, iac | +| `CLDBRN-AWS-RDS-7` | Flags only snapshots whose source DB instance no longer exists and whose parsed create time is at least `30` days old. | rds | discovery | +| `CLDBRN-AWS-RDS-8` | Flags only DB instances with Performance Insights enabled and a retention period above the included 7-day baseline. | rds | iac | +| `CLDBRN-AWS-RDS-9` | Flags only RDS DB instances whose discovered `dbInstanceStatus` is `stopped`, surfacing them for cleanup review. | rds | discovery | +| `CLDBRN-AWS-RDS-10` | Flags only manual RDS snapshots whose parsed `snapshotCreateTime` is at least `90` days old. Automated snapshots and snapshots with invalid timestamps are skipped. | rds | discovery | +| `CLDBRN-AWS-REDSHIFT-1` | Reviews only `available` clusters and treats a 14-day average `CPUUtilization` of 10% or lower as low utilization. | redshift | discovery | +| `CLDBRN-AWS-REDSHIFT-2` | Reviews only `available` clusters with a parsed create time at least 180 days old and requires active reserved-node coverage for the same node type. | redshift | discovery | +| `CLDBRN-AWS-REDSHIFT-3` | Flags only `available`, VPC-backed clusters with automated snapshots enabled, no HSM, and no Multi-AZ deployment when either the pause or resume schedule is missing. | redshift | discovery, iac | +| `CLDBRN-AWS-ROUTE53-1` | Reviews only non-alias records and treats `3600` seconds as the low-TTL floor. | route53 | discovery, iac | +| `CLDBRN-AWS-ROUTE53-2` | Flags only Route 53 health checks that are not referenced by any in-scope record set. | route53 | discovery, iac | +| `CLDBRN-AWS-S3-1` | Ensure S3 buckets define lifecycle management policies. | s3 | iac, discovery | +| `CLDBRN-AWS-S3-2` | Recommend Intelligent-Tiering or another explicit storage-class transition for lifecycle-managed buckets. | s3 | iac, discovery | +| `CLDBRN-AWS-S3-3` | Flags buckets when no enabled lifecycle rule aborts incomplete multipart uploads within 7 days. | s3 | iac, discovery | +| `CLDBRN-AWS-S3-4` | Flags only versioned buckets and requires either noncurrent-version expiration or transition cleanup to avoid unbounded version growth. | s3 | iac | +| `CLDBRN-AWS-SAGEMAKER-1` | Flags only notebook instances whose normalized status remains `InService`. | sagemaker | discovery | | `CLDBRN-AWS-SAGEMAKER-2` | Flags only endpoints whose normalized status remains `InService`, whose parsed `creationTime` is at least `14` days old, and whose complete 14-day `Invocations` total stays at `0`. Endpoints with incomplete metrics are skipped. | sagemaker | discovery | -| `CLDBRN-AWS-SECRETSMANAGER-1` | Flags secrets with no `lastAccessedDate` and secrets whose parsed last access is at least `90` days old. | secretsmanager | discovery | -| `CLDBRN-AWS-LAMBDA-1` | Recommend arm64 architecture when compatible. | lambda | iac, discovery | -| `CLDBRN-AWS-LAMBDA-2` | Uses 7-day CloudWatch totals and flags only functions whose observed `Errors / Invocations` ratio is greater than `10%`. | lambda | discovery | -| `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. | lambda | discovery | -| `CLDBRN-AWS-LAMBDA-4` | Reviews only functions configured above `256 MB`, requires invocation history, and flags them when the observed 7-day average duration uses less than `30%` of the configured timeout. | lambda | discovery | -| `CLDBRN-AWS-LAMBDA-5` | Flags explicit provisioned concurrency configuration when provisioned concurrent executions are greater than zero. | lambda | iac | +| `CLDBRN-AWS-SECRETSMANAGER-1` | Flags secrets with no `lastAccessedDate` and secrets whose parsed last access is at least `90` days old. | secretsmanager | discovery | +| `CLDBRN-AWS-LAMBDA-1` | Recommend arm64 architecture when compatible. | lambda | iac, discovery | +| `CLDBRN-AWS-LAMBDA-2` | Uses 7-day CloudWatch totals and flags only functions whose observed `Errors / Invocations` ratio is greater than `10%`. | lambda | discovery | +| `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. | lambda | discovery | +| `CLDBRN-AWS-LAMBDA-4` | Reviews only functions configured above `256 MB`, requires invocation history, and flags them when the observed 7-day average duration uses less than `30%` of the configured timeout. | lambda | discovery | +| `CLDBRN-AWS-LAMBDA-5` | Flags explicit provisioned concurrency configuration when provisioned concurrent executions are greater than zero. | lambda | iac | ## Presets From e9a67209d19321f302b242cbf69cf31d5d5e0601 Mon Sep 17 00:00:00 2001 From: Danny Steenman Date: Thu, 9 Apr 2026 12:57:56 +0200 Subject: [PATCH 3/3] fix(sdk): handle stopped EC2 GMT times and idle SageMaker endpoints --- .../sdk/src/providers/aws/resources/ec2.ts | 2 +- .../src/providers/aws/resources/sagemaker.ts | 14 +-- .../test/providers/aws-ec2-resource.test.ts | 49 +++++++++++ .../providers/aws-sagemaker-resource.test.ts | 86 +++++++++++++++++++ 4 files changed, 144 insertions(+), 7 deletions(-) diff --git a/packages/sdk/src/providers/aws/resources/ec2.ts b/packages/sdk/src/providers/aws/resources/ec2.ts index 45200d5..c07dd08 100644 --- a/packages/sdk/src/providers/aws/resources/ec2.ts +++ b/packages/sdk/src/providers/aws/resources/ec2.ts @@ -5,7 +5,7 @@ import { chunkItems, withAwsServiceErrorContext } from './utils.js'; const EC2_INSTANCE_ARN_PREFIX = 'instance/'; const EC2_DESCRIBE_BATCH_SIZE = 100; -const STOPPED_STATE_TRANSITION_REASON_PATTERN = /\((\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) UTC\)$/u; +const STOPPED_STATE_TRANSITION_REASON_PATTERN = /\((\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) (?:UTC|GMT)\)$/u; const extractInstanceId = (arn: string): string | null => { const arnSegments = arn.split(':'); diff --git a/packages/sdk/src/providers/aws/resources/sagemaker.ts b/packages/sdk/src/providers/aws/resources/sagemaker.ts index 6deff94..7f77fff 100644 --- a/packages/sdk/src/providers/aws/resources/sagemaker.ts +++ b/packages/sdk/src/providers/aws/resources/sagemaker.ts @@ -57,7 +57,9 @@ const isEndpointConfigMissingError = (error: unknown): boolean => { return ( candidates.some((value) => value.includes('resourcenotfound')) || candidates.some((value) => value.includes('could not find endpoint config')) || - candidates.some((value) => value.includes('endpoint config') && value.includes('not found')) + candidates.some((value) => value.includes('could not find the endpoint configuration')) || + candidates.some((value) => value.includes('endpoint config') && value.includes('not found')) || + candidates.some((value) => value.includes('endpoint configuration') && value.includes('not found')) ); }; @@ -271,11 +273,11 @@ export const hydrateAwsSageMakerEndpointActivity = async ( ...completeEndpoints.map((endpoint, endpointIndex) => { const totalInvocationsLast14Days = endpoint.productionVariantNames.length > 0 && - endpoint.productionVariantNames.every( - (_variantName, variantIndex) => - (metricData.get(`endpoint${endpointIndex}variant${variantIndex}`) ?? []).length >= - REQUIRED_ENDPOINT_DAILY_POINTS, - ) + endpoint.productionVariantNames.every((_variantName, variantIndex) => { + const points = metricData.get(`endpoint${endpointIndex}variant${variantIndex}`) ?? []; + + return points.length === 0 || points.length >= REQUIRED_ENDPOINT_DAILY_POINTS; + }) ? endpoint.productionVariantNames.reduce((sum, _variantName, variantIndex) => { const points = metricData.get(`endpoint${endpointIndex}variant${variantIndex}`) ?? []; diff --git a/packages/sdk/test/providers/aws-ec2-resource.test.ts b/packages/sdk/test/providers/aws-ec2-resource.test.ts index 9b94562..cb868b8 100644 --- a/packages/sdk/test/providers/aws-ec2-resource.test.ts +++ b/packages/sdk/test/providers/aws-ec2-resource.test.ts @@ -163,6 +163,55 @@ describe('hydrateAwsEc2Instances', () => { ]); }); + it('parses stoppedAt when the stop reason uses the GMT suffix', async () => { + mockedCreateEc2Client.mockImplementation(({ region }) => { + const send = vi.fn(async () => ({ + Reservations: [ + { + Instances: [ + { + Architecture: 'x86_64', + InstanceId: 'i-gmt', + InstanceType: 'm7i.large', + LaunchTime: new Date('2025-03-01T00:00:00.000Z'), + State: { + Name: 'stopped', + }, + StateTransitionReason: 'User initiated (2025-03-15 10:30:00 GMT)', + }, + ], + }, + ], + })); + + return { send, region } as never; + }); + + await expect( + hydrateAwsEc2Instances([ + { + accountId: '123456789012', + arn: 'arn:aws:ec2:us-east-1:123456789012:instance/i-gmt', + properties: [], + region: 'us-east-1', + resourceType: 'ec2:instance', + service: 'ec2', + }, + ]), + ).resolves.toEqual([ + { + accountId: '123456789012', + architecture: 'x86_64', + instanceId: 'i-gmt', + instanceType: 'm7i.large', + launchTime: '2025-03-01T00:00:00.000Z', + region: 'us-east-1', + state: 'stopped', + stoppedAt: '2025-03-15T10:30:00.000Z', + }, + ]); + }); + it('skips resources with invalid EC2 instance arns', async () => { const instances = await hydrateAwsEc2Instances([ { diff --git a/packages/sdk/test/providers/aws-sagemaker-resource.test.ts b/packages/sdk/test/providers/aws-sagemaker-resource.test.ts index 4af6ab8..a0f9ee3 100644 --- a/packages/sdk/test/providers/aws-sagemaker-resource.test.ts +++ b/packages/sdk/test/providers/aws-sagemaker-resource.test.ts @@ -210,4 +210,90 @@ describe('hydrateAwsSageMakerNotebookInstances', () => { }, ]); }); + + it('treats empty invocation series as zero total for idle endpoints', async () => { + mockedCreateSageMakerClient.mockReturnValue({ + send: vi.fn(async (command: DescribeEndpointCommand | DescribeEndpointConfigCommand) => { + const input = command.input as { EndpointConfigName?: string; EndpointName?: string }; + + if (input.EndpointName) { + return { + CreationTime: new Date('2025-12-01T00:00:00.000Z'), + EndpointArn: 'arn:aws:sagemaker:eu-west-1:123456789012:endpoint/orders-endpoint', + EndpointConfigName: 'orders-endpoint-config', + EndpointName: 'orders-endpoint', + EndpointStatus: 'InService', + LastModifiedTime: new Date('2025-12-15T00:00:00.000Z'), + }; + } + + return { + ProductionVariants: [{ VariantName: 'blue' }], + }; + }), + } as never); + mockedFetchCloudWatchSignals.mockResolvedValue(new Map([['endpoint0variant0', []]])); + + await expect( + hydrateAwsSageMakerEndpointActivity([ + { + accountId: '123456789012', + arn: 'arn:aws:sagemaker:eu-west-1:123456789012:endpoint/orders-endpoint', + properties: [], + region: 'eu-west-1', + resourceType: 'sagemaker:endpoint', + service: 'sagemaker', + }, + ]), + ).resolves.toEqual([ + { + accountId: '123456789012', + creationTime: '2025-12-01T00:00:00.000Z', + endpointArn: 'arn:aws:sagemaker:eu-west-1:123456789012:endpoint/orders-endpoint', + endpointConfigName: 'orders-endpoint-config', + endpointName: 'orders-endpoint', + endpointStatus: 'InService', + lastModifiedTime: '2025-12-15T00:00:00.000Z', + region: 'eu-west-1', + totalInvocationsLast14Days: 0, + }, + ]); + }); + + it('skips endpoints whose endpoint configuration was deleted', async () => { + const missingEndpointConfig = new Error('ValidationException: Could not find the endpoint configuration.'); + missingEndpointConfig.name = 'ValidationException'; + + mockedCreateSageMakerClient.mockReturnValue({ + send: vi.fn(async (command: DescribeEndpointCommand | DescribeEndpointConfigCommand) => { + const input = command.input as { EndpointConfigName?: string; EndpointName?: string }; + + if (input.EndpointName) { + return { + CreationTime: new Date('2025-12-01T00:00:00.000Z'), + EndpointArn: 'arn:aws:sagemaker:eu-west-1:123456789012:endpoint/orders-endpoint', + EndpointConfigName: 'orders-endpoint-config', + EndpointName: 'orders-endpoint', + EndpointStatus: 'InService', + LastModifiedTime: new Date('2025-12-15T00:00:00.000Z'), + }; + } + + throw missingEndpointConfig; + }), + } as never); + + await expect( + hydrateAwsSageMakerEndpointActivity([ + { + accountId: '123456789012', + arn: 'arn:aws:sagemaker:eu-west-1:123456789012:endpoint/orders-endpoint', + properties: [], + region: 'eu-west-1', + resourceType: 'sagemaker:endpoint', + service: 'sagemaker', + }, + ]), + ).resolves.toEqual([]); + }); });