Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bright-pears-burn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudburn/rules": minor
---

Add AWS discovery rules for stopped EC2 instances, old manual RDS snapshots, and idle SageMaker endpoints.
5 changes: 5 additions & 0 deletions .changeset/silent-mice-share.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudburn/sdk": minor
---

Add AWS discovery support for EC2 stop timestamps and SageMaker endpoint activity.
2 changes: 1 addition & 1 deletion docs/architecture/sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
161 changes: 82 additions & 79 deletions docs/reference/rule-ids.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions packages/rules/src/aws/ec2/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -25,4 +26,5 @@ export const ec2Rules = [
ec2DetailedMonitoringEnabledRule,
ec2IdleNatGatewayRule,
ec2ReservedInstanceRecentlyExpiredRule,
ec2StoppedInstanceRule,
];
37 changes: 37 additions & 0 deletions packages/rules/src/aws/ec2/stopped-instance.ts
Original file line number Diff line number Diff line change
@@ -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);
},
});
2 changes: 2 additions & 0 deletions packages/rules/src/aws/rds/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -20,4 +21,5 @@ export const rdsRules = [
rdsUnusedSnapshotsRule,
rdsPerformanceInsightsExtendedRetentionRule,
rdsStoppedInstanceRule,
rdsManualSnapshotMaxAgeRule,
];
37 changes: 37 additions & 0 deletions packages/rules/src/aws/rds/manual-snapshot-max-age.ts
Original file line number Diff line number Diff line change
@@ -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);
},
});
42 changes: 42 additions & 0 deletions packages/rules/src/aws/sagemaker/idle-endpoint.ts
Original file line number Diff line number Diff line change
@@ -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);
},
});
3 changes: 2 additions & 1 deletion packages/rules/src/aws/sagemaker/index.ts
Original file line number Diff line number Diff line change
@@ -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];
1 change: 1 addition & 0 deletions packages/rules/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export type {
AwsRoute53Zone,
AwsS3BucketAnalysis,
AwsS3BucketAnalysisFlags,
AwsSageMakerEndpointActivity,
AwsSageMakerNotebookInstance,
AwsSecretsManagerSecret,
AwsStaticApiGatewayStage,
Expand Down
17 changes: 17 additions & 0 deletions packages/rules/src/shared/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ export type AwsEc2Instance = {
architecture?: string;
launchTime?: string;
state?: string;
stoppedAt?: string;
region: string;
accountId: string;
};
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -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[];
};
Expand Down
98 changes: 98 additions & 0 deletions packages/rules/test/ec2-stopped-instance.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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();
});
});
Loading
Loading