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/icy-bears-compare.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@cloudburn/sdk': minor
---

Add shared S3 analysis support for detecting lifecycle rules that abort incomplete multipart uploads within 7 days.
5 changes: 5 additions & 0 deletions .changeset/sunny-hops-sing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@cloudburn/rules': minor
---

Add an S3 rule for buckets missing lifecycle cleanup for incomplete multipart uploads within 7 days across IaC and discovery.
3 changes: 3 additions & 0 deletions docs/reference/rule-ids.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ Format: `CLDBRN-{PROVIDER}-{SERVICE}-{N}`
| `CLDBRN-AWS-ROUTE53-2` | Route 53 Health Check Unused | route53 | discovery | Implemented |
| `CLDBRN-AWS-S3-1` | S3 Missing Lifecycle Configuration | s3 | iac, discovery | Implemented |
| `CLDBRN-AWS-S3-2` | S3 Bucket Storage Class Not Optimized | s3 | iac, discovery | Implemented |
| `CLDBRN-AWS-S3-3` | S3 Incomplete Multipart Upload Abort Configuration | s3 | iac, discovery | Implemented |
| `CLDBRN-AWS-SECRETSMANAGER-1` | Secrets Manager Secret Unused | secretsmanager | discovery | Implemented |
| `CLDBRN-AWS-LAMBDA-1` | Lambda Cost Optimal Architecture | lambda | iac, discovery | Implemented |
| `CLDBRN-AWS-LAMBDA-2` | Lambda Function High Error Rate | lambda | discovery | Implemented |
Expand Down Expand Up @@ -142,6 +143,8 @@ Format: `CLDBRN-{PROVIDER}-{SERVICE}-{N}`

`CLDBRN-AWS-ROUTE53-2` flags only Route 53 health checks that are not referenced by any discovered record set.

`CLDBRN-AWS-S3-3` flags buckets when no enabled lifecycle rule aborts incomplete multipart uploads within 7 days.

`CLDBRN-AWS-SECRETSMANAGER-1` flags secrets with no `lastAccessedDate` and secrets whose parsed last access is at least `90` days old.

**Status key:**
Expand Down
40 changes: 40 additions & 0 deletions packages/rules/src/aws/s3/incomplete-multipart-upload-abort.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { createFinding, createRule } from '../../shared/helpers.js';
import {
createLiveS3BucketFindingMatch,
createStaticS3BucketFindingMatch,
hasMissingIncompleteMultipartUploadAbort,
} from './shared.js';

const RULE_ID = 'CLDBRN-AWS-S3-3';
const RULE_SERVICE = 's3';
const RULE_MESSAGE = 'S3 buckets should abort incomplete multipart uploads within 7 days.';

/** Flag S3 buckets that do not define an enabled multipart-abort lifecycle rule within 7 days. */
export const s3IncompleteMultipartUploadAbortRule = createRule({
id: RULE_ID,
name: 'S3 Incomplete Multipart Upload Abort Configuration',
description:
'Ensure S3 buckets define an enabled lifecycle rule that aborts incomplete multipart uploads within 7 days.',
message: RULE_MESSAGE,
provider: 'aws',
service: RULE_SERVICE,
supports: ['iac', 'discovery'],
discoveryDependencies: ['aws-s3-bucket-analyses'],
staticDependencies: ['aws-s3-bucket-analyses'],
evaluateLive: ({ resources }) => {
const findings = resources
.get('aws-s3-bucket-analyses')
.filter((bucket) => hasMissingIncompleteMultipartUploadAbort(bucket))
.map((bucket) => createLiveS3BucketFindingMatch(bucket));

return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'discovery', findings);
},
evaluateStatic: ({ resources }) => {
const findings = resources
.get('aws-s3-bucket-analyses')
.filter((bucket) => hasMissingIncompleteMultipartUploadAbort(bucket))
.map((bucket) => createStaticS3BucketFindingMatch(bucket));

return createFinding({ id: RULE_ID, service: RULE_SERVICE, message: RULE_MESSAGE }, 'iac', findings);
},
});
7 changes: 6 additions & 1 deletion packages/rules/src/aws/s3/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { s3IncompleteMultipartUploadAbortRule } from './incomplete-multipart-upload-abort.js';
import { s3MissingLifecycleConfigRule } from './missing-lifecycle-config.js';
import { s3StorageClassOptimizationRule } from './storage-class-optimization.js';

/** Aggregate AWS S3 rule definitions. */
export const s3Rules = [s3MissingLifecycleConfigRule, s3StorageClassOptimizationRule];
export const s3Rules = [
s3MissingLifecycleConfigRule,
s3StorageClassOptimizationRule,
s3IncompleteMultipartUploadAbortRule,
];
4 changes: 4 additions & 0 deletions packages/rules/src/aws/s3/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import type {
export const hasMissingLifecycleConfiguration = (bucket: AwsS3BucketAnalysisFlags): boolean =>
!bucket.hasCostFocusedLifecycle;

/** Returns whether an S3 bucket should be flagged for missing multipart-abort cleanup. */
export const hasMissingIncompleteMultipartUploadAbort = (bucket: AwsS3BucketAnalysisFlags): boolean =>
!bucket.hasAbortIncompleteMultipartUploadAfter7Days;

/** Returns whether an S3 bucket should be flagged for missing storage-class optimization. */
export const hasMissingStorageClassOptimization = (bucket: AwsS3BucketAnalysisFlags): boolean =>
bucket.hasLifecycleSignal &&
Expand Down
1 change: 1 addition & 0 deletions packages/rules/src/shared/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,7 @@ export type AwsDynamoDbAutoscaling = {
export type AwsS3BucketAnalysisFlags = {
hasLifecycleSignal: boolean;
hasCostFocusedLifecycle: boolean;
hasAbortIncompleteMultipartUploadAfter7Days: boolean;
hasIntelligentTieringConfiguration: boolean;
hasIntelligentTieringTransition: boolean;
hasAlternativeStorageClassTransition: boolean;
Expand Down
1 change: 1 addition & 0 deletions packages/rules/test/exports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ describe('rule exports', () => {
'CLDBRN-AWS-ROUTE53-2',
'CLDBRN-AWS-S3-1',
'CLDBRN-AWS-S3-2',
'CLDBRN-AWS-S3-3',
'CLDBRN-AWS-SECRETSMANAGER-1',
]),
);
Expand Down
2 changes: 2 additions & 0 deletions packages/rules/test/live-resource-bag.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ describe('LiveResourceBag', () => {
{
accountId: '123456789012',
bucketName: 'logs-bucket',
hasAbortIncompleteMultipartUploadAfter7Days: false,
hasAlternativeStorageClassTransition: false,
hasCostFocusedLifecycle: false,
hasIntelligentTieringConfiguration: false,
Expand All @@ -23,6 +24,7 @@ describe('LiveResourceBag', () => {
{
accountId: '123456789012',
bucketName: 'logs-bucket',
hasAbortIncompleteMultipartUploadAfter7Days: false,
hasAlternativeStorageClassTransition: false,
hasCostFocusedLifecycle: false,
hasIntelligentTieringConfiguration: false,
Expand Down
18 changes: 18 additions & 0 deletions packages/rules/test/rule-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,24 @@ describe('rule metadata', () => {
});
});

it('defines the expected S3 multipart-abort rule metadata', () => {
const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-S3-3');

expect(rule).toBeDefined();
expect(rule).toMatchObject({
id: 'CLDBRN-AWS-S3-3',
name: 'S3 Incomplete Multipart Upload Abort Configuration',
description:
'Ensure S3 buckets define an enabled lifecycle rule that aborts incomplete multipart uploads within 7 days.',
message: 'S3 buckets should abort incomplete multipart uploads within 7 days.',
provider: 'aws',
service: 's3',
supports: ['iac', 'discovery'],
discoveryDependencies: ['aws-s3-bucket-analyses'],
staticDependencies: ['aws-s3-bucket-analyses'],
});
});

it('defines the expected EC2 S3 endpoint rule metadata', () => {
const rule = awsRules.find((candidate) => candidate.id === 'CLDBRN-AWS-EC2-2');

Expand Down
132 changes: 132 additions & 0 deletions packages/rules/test/s3-incomplete-multipart-upload-abort.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { describe, expect, it } from 'vitest';
import { s3IncompleteMultipartUploadAbortRule } from '../src/aws/s3/incomplete-multipart-upload-abort.js';
import type { AwsS3BucketAnalysis, AwsStaticS3BucketAnalysis } from '../src/index.js';
import { LiveResourceBag, StaticResourceBag } from '../src/index.js';

const createBucketAnalysis = (overrides: Partial<AwsStaticS3BucketAnalysis> = {}): AwsStaticS3BucketAnalysis => ({
hasAbortIncompleteMultipartUploadAfter7Days: false,
hasAlternativeStorageClassTransition: false,
hasCostFocusedLifecycle: false,
hasIntelligentTieringConfiguration: false,
hasIntelligentTieringTransition: false,
hasLifecycleSignal: false,
hasUnclassifiedTransition: false,
location: {
path: 'main.tf',
line: 1,
column: 1,
},
resourceId: 'aws_s3_bucket.logs',
...overrides,
});

const createLiveBucketAnalysis = (overrides: Partial<AwsS3BucketAnalysis> = {}): AwsS3BucketAnalysis => ({
accountId: '123456789012',
bucketName: 'logs-bucket',
hasAbortIncompleteMultipartUploadAfter7Days: false,
hasAlternativeStorageClassTransition: false,
hasCostFocusedLifecycle: false,
hasIntelligentTieringConfiguration: false,
hasIntelligentTieringTransition: false,
hasLifecycleSignal: false,
hasUnclassifiedTransition: false,
region: 'us-east-1',
...overrides,
});

describe('s3IncompleteMultipartUploadAbortRule', () => {
it('flags live buckets without an enabled multipart abort rule within 7 days', () => {
const finding = s3IncompleteMultipartUploadAbortRule.evaluateLive?.({
catalog: {
resources: [],
searchRegion: 'us-east-1',
indexType: 'LOCAL',
},
resources: new LiveResourceBag({
'aws-s3-bucket-analyses': [createLiveBucketAnalysis({ hasLifecycleSignal: true })],
}),
});

expect(s3IncompleteMultipartUploadAbortRule.discoveryDependencies).toEqual(['aws-s3-bucket-analyses']);
expect(s3IncompleteMultipartUploadAbortRule.staticDependencies).toEqual(['aws-s3-bucket-analyses']);
expect(finding).toEqual({
ruleId: 'CLDBRN-AWS-S3-3',
service: 's3',
source: 'discovery',
message: 'S3 buckets should abort incomplete multipart uploads within 7 days.',
findings: [
{
resourceId: 'logs-bucket',
region: 'us-east-1',
accountId: '123456789012',
},
],
});
});

it('flags Terraform buckets without an enabled multipart abort rule within 7 days', () => {
const finding = s3IncompleteMultipartUploadAbortRule.evaluateStatic?.({
resources: new StaticResourceBag({
'aws-s3-bucket-analyses': [createBucketAnalysis({ hasLifecycleSignal: true })],
}),
});

expect(finding).toEqual({
ruleId: 'CLDBRN-AWS-S3-3',
service: 's3',
source: 'iac',
message: 'S3 buckets should abort incomplete multipart uploads within 7 days.',
findings: [
{
resourceId: 'aws_s3_bucket.logs',
location: {
path: 'main.tf',
line: 1,
column: 1,
},
},
],
});
});

it('passes live buckets with an enabled multipart abort rule within 7 days', () => {
const finding = s3IncompleteMultipartUploadAbortRule.evaluateLive?.({
catalog: {
resources: [],
searchRegion: 'us-east-1',
indexType: 'LOCAL',
},
resources: new LiveResourceBag({
'aws-s3-bucket-analyses': [
createLiveBucketAnalysis({
hasAbortIncompleteMultipartUploadAfter7Days: true,
hasLifecycleSignal: true,
}),
],
}),
});

expect(finding).toBeNull();
});

it('passes CloudFormation buckets with an enabled multipart abort rule within 7 days', () => {
const finding = s3IncompleteMultipartUploadAbortRule.evaluateStatic?.({
resources: new StaticResourceBag({
'aws-s3-bucket-analyses': [
createBucketAnalysis({
hasAbortIncompleteMultipartUploadAfter7Days: true,
hasLifecycleSignal: true,
location: {
path: 'template.yaml',
line: 3,
column: 3,
},
resourceId: 'LogsBucket',
}),
],
}),
});

expect(finding).toBeNull();
});
});
2 changes: 2 additions & 0 deletions packages/rules/test/s3-missing-lifecycle-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { AwsS3BucketAnalysis, AwsStaticS3BucketAnalysis } from '../src/inde
import { LiveResourceBag, StaticResourceBag } from '../src/index.js';

const createBucketAnalysis = (overrides: Partial<AwsStaticS3BucketAnalysis> = {}): AwsStaticS3BucketAnalysis => ({
hasAbortIncompleteMultipartUploadAfter7Days: false,
hasAlternativeStorageClassTransition: false,
hasCostFocusedLifecycle: false,
hasIntelligentTieringConfiguration: false,
Expand All @@ -22,6 +23,7 @@ const createBucketAnalysis = (overrides: Partial<AwsStaticS3BucketAnalysis> = {}
const createLiveBucketAnalysis = (overrides: Partial<AwsS3BucketAnalysis> = {}): AwsS3BucketAnalysis => ({
accountId: '123456789012',
bucketName: 'logs-bucket',
hasAbortIncompleteMultipartUploadAfter7Days: false,
hasAlternativeStorageClassTransition: false,
hasCostFocusedLifecycle: false,
hasIntelligentTieringConfiguration: false,
Expand Down
2 changes: 2 additions & 0 deletions packages/rules/test/s3-storage-class-optimization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { AwsS3BucketAnalysis, AwsStaticS3BucketAnalysis } from '../src/inde
import { LiveResourceBag, StaticResourceBag } from '../src/index.js';

const createBucketAnalysis = (overrides: Partial<AwsStaticS3BucketAnalysis> = {}): AwsStaticS3BucketAnalysis => ({
hasAbortIncompleteMultipartUploadAfter7Days: false,
hasAlternativeStorageClassTransition: false,
hasCostFocusedLifecycle: false,
hasIntelligentTieringConfiguration: false,
Expand All @@ -22,6 +23,7 @@ const createBucketAnalysis = (overrides: Partial<AwsStaticS3BucketAnalysis> = {}
const createLiveBucketAnalysis = (overrides: Partial<AwsS3BucketAnalysis> = {}): AwsS3BucketAnalysis => ({
accountId: '123456789012',
bucketName: 'logs-bucket',
hasAbortIncompleteMultipartUploadAfter7Days: false,
hasAlternativeStorageClassTransition: false,
hasCostFocusedLifecycle: false,
hasIntelligentTieringConfiguration: false,
Expand Down
23 changes: 23 additions & 0 deletions packages/sdk/src/providers/aws/resources/s3-analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,16 @@ const getTransitionStorageClasses = (rule: Record<string, unknown>): string[] =>
const hasTransitionAction = (rule: Record<string, unknown>): boolean =>
getTerraformTransitions(rule).length > 0 || getCloudFormationTransitions(rule).length > 0;

const getAbortIncompleteMultipartUploadDays = (rule: Record<string, unknown>): number | null => {
const terraformAbortRule = toRecordArray(rule.abort_incomplete_multipart_upload)[0];
const cloudFormationAbortRule = isRecord(rule.AbortIncompleteMultipartUpload)
? rule.AbortIncompleteMultipartUpload
: null;
const candidateValue = terraformAbortRule?.days_after_initiation ?? cloudFormationAbortRule?.DaysAfterInitiation;

return typeof candidateValue === 'number' ? candidateValue : null;
};

const hasUnclassifiedTransition = (rule: Record<string, unknown>): boolean =>
[...getTerraformTransitions(rule), ...getCloudFormationTransitions(rule)].some(
(transition) => getLiteralStorageClass(transition.storage_class ?? transition.StorageClass) === null,
Expand All @@ -58,6 +68,16 @@ const hasCostFocusedLifecycleRule = (rule: Record<string, unknown>): boolean =>
return ruleHasTerraformExpiration(rule) || ruleHasCloudFormationExpiration(rule) || hasTransitionAction(rule);
};

const hasAbortIncompleteMultipartUploadAfter7Days = (rule: Record<string, unknown>): boolean => {
if (!isLifecycleRuleEnabled(rule)) {
return false;
}

const daysAfterInitiation = getAbortIncompleteMultipartUploadDays(rule);

return daysAfterInitiation !== null && daysAfterInitiation <= 7;
};

const hasEnabledIntelligentTieringConfiguration = (configuration: Record<string, unknown>): boolean => {
const status = configuration.status ?? configuration.Status;

Expand All @@ -82,6 +102,9 @@ export const buildS3BucketAnalysisFlags = (
return {
hasLifecycleSignal: lifecycleRules.length > 0,
hasCostFocusedLifecycle: lifecycleRules.some((rule) => hasCostFocusedLifecycleRule(rule)),
hasAbortIncompleteMultipartUploadAfter7Days: lifecycleRules.some((rule) =>
hasAbortIncompleteMultipartUploadAfter7Days(rule),
),
hasIntelligentTieringConfiguration: intelligentTieringConfigurations.some((configuration) =>
hasEnabledIntelligentTieringConfiguration(configuration),
),
Expand Down
8 changes: 8 additions & 0 deletions packages/sdk/test/exports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,14 @@ describe('sdk exports', () => {
service: 's3',
supports: ['iac', 'discovery'],
},
{
description:
'Ensure S3 buckets define an enabled lifecycle rule that aborts incomplete multipart uploads within 7 days.',
id: 'CLDBRN-AWS-S3-3',
provider: 'aws',
service: 's3',
supports: ['iac', 'discovery'],
},
{
description: 'Flag Secrets Manager secrets not accessed within a threshold (default 90 days).',
id: 'CLDBRN-AWS-SECRETSMANAGER-1',
Expand Down
Loading