From e88d626d7a27e38ee991b2ef4f86f061596f2d4e Mon Sep 17 00:00:00 2001 From: Axon Stone Date: Thu, 26 Mar 2026 12:20:40 +0100 Subject: [PATCH] feat(rules): add S3 multipart upload abort check --- .changeset/icy-bears-compare.md | 5 + .changeset/sunny-hops-sing.md | 5 + docs/reference/rule-ids.md | 3 + .../s3/incomplete-multipart-upload-abort.ts | 40 ++++++ packages/rules/src/aws/s3/index.ts | 7 +- packages/rules/src/aws/s3/shared.ts | 4 + packages/rules/src/shared/metadata.ts | 1 + packages/rules/test/exports.test.ts | 1 + packages/rules/test/live-resource-bag.test.ts | 2 + packages/rules/test/rule-metadata.test.ts | 18 +++ ...-incomplete-multipart-upload-abort.test.ts | 132 ++++++++++++++++++ .../test/s3-missing-lifecycle-config.test.ts | 2 + .../s3-storage-class-optimization.test.ts | 2 + .../providers/aws/resources/s3-analysis.ts | 23 +++ packages/sdk/test/exports.test.ts | 8 ++ .../test/providers/aws-s3-resource.test.ts | 56 ++++++++ .../sdk/test/providers/aws-static.test.ts | 106 ++++++++++++++ packages/sdk/test/scanner.test.ts | 40 ++++++ 18 files changed, 454 insertions(+), 1 deletion(-) create mode 100644 .changeset/icy-bears-compare.md create mode 100644 .changeset/sunny-hops-sing.md create mode 100644 packages/rules/src/aws/s3/incomplete-multipart-upload-abort.ts create mode 100644 packages/rules/test/s3-incomplete-multipart-upload-abort.test.ts diff --git a/.changeset/icy-bears-compare.md b/.changeset/icy-bears-compare.md new file mode 100644 index 0000000..033d18f --- /dev/null +++ b/.changeset/icy-bears-compare.md @@ -0,0 +1,5 @@ +--- +'@cloudburn/sdk': minor +--- + +Add shared S3 analysis support for detecting lifecycle rules that abort incomplete multipart uploads within 7 days. diff --git a/.changeset/sunny-hops-sing.md b/.changeset/sunny-hops-sing.md new file mode 100644 index 0000000..d707f9f --- /dev/null +++ b/.changeset/sunny-hops-sing.md @@ -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. diff --git a/docs/reference/rule-ids.md b/docs/reference/rule-ids.md index 8037c9f..a4f1da2 100644 --- a/docs/reference/rule-ids.md +++ b/docs/reference/rule-ids.md @@ -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 | @@ -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:** diff --git a/packages/rules/src/aws/s3/incomplete-multipart-upload-abort.ts b/packages/rules/src/aws/s3/incomplete-multipart-upload-abort.ts new file mode 100644 index 0000000..105ec93 --- /dev/null +++ b/packages/rules/src/aws/s3/incomplete-multipart-upload-abort.ts @@ -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); + }, +}); diff --git a/packages/rules/src/aws/s3/index.ts b/packages/rules/src/aws/s3/index.ts index fa6f625..5d38124 100644 --- a/packages/rules/src/aws/s3/index.ts +++ b/packages/rules/src/aws/s3/index.ts @@ -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, +]; diff --git a/packages/rules/src/aws/s3/shared.ts b/packages/rules/src/aws/s3/shared.ts index ee9bc2b..5ebeaa8 100644 --- a/packages/rules/src/aws/s3/shared.ts +++ b/packages/rules/src/aws/s3/shared.ts @@ -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 && diff --git a/packages/rules/src/shared/metadata.ts b/packages/rules/src/shared/metadata.ts index 8da2221..b8d83d8 100644 --- a/packages/rules/src/shared/metadata.ts +++ b/packages/rules/src/shared/metadata.ts @@ -450,6 +450,7 @@ export type AwsDynamoDbAutoscaling = { export type AwsS3BucketAnalysisFlags = { hasLifecycleSignal: boolean; hasCostFocusedLifecycle: boolean; + hasAbortIncompleteMultipartUploadAfter7Days: boolean; hasIntelligentTieringConfiguration: boolean; hasIntelligentTieringTransition: boolean; hasAlternativeStorageClassTransition: boolean; diff --git a/packages/rules/test/exports.test.ts b/packages/rules/test/exports.test.ts index d686d2e..8ae8099 100644 --- a/packages/rules/test/exports.test.ts +++ b/packages/rules/test/exports.test.ts @@ -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', ]), ); diff --git a/packages/rules/test/live-resource-bag.test.ts b/packages/rules/test/live-resource-bag.test.ts index e1d64fd..e007b53 100644 --- a/packages/rules/test/live-resource-bag.test.ts +++ b/packages/rules/test/live-resource-bag.test.ts @@ -8,6 +8,7 @@ describe('LiveResourceBag', () => { { accountId: '123456789012', bucketName: 'logs-bucket', + hasAbortIncompleteMultipartUploadAfter7Days: false, hasAlternativeStorageClassTransition: false, hasCostFocusedLifecycle: false, hasIntelligentTieringConfiguration: false, @@ -23,6 +24,7 @@ describe('LiveResourceBag', () => { { accountId: '123456789012', bucketName: 'logs-bucket', + hasAbortIncompleteMultipartUploadAfter7Days: false, hasAlternativeStorageClassTransition: false, hasCostFocusedLifecycle: false, hasIntelligentTieringConfiguration: false, diff --git a/packages/rules/test/rule-metadata.test.ts b/packages/rules/test/rule-metadata.test.ts index 6725329..0de5434 100644 --- a/packages/rules/test/rule-metadata.test.ts +++ b/packages/rules/test/rule-metadata.test.ts @@ -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'); diff --git a/packages/rules/test/s3-incomplete-multipart-upload-abort.test.ts b/packages/rules/test/s3-incomplete-multipart-upload-abort.test.ts new file mode 100644 index 0000000..559f804 --- /dev/null +++ b/packages/rules/test/s3-incomplete-multipart-upload-abort.test.ts @@ -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 => ({ + 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 => ({ + 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(); + }); +}); diff --git a/packages/rules/test/s3-missing-lifecycle-config.test.ts b/packages/rules/test/s3-missing-lifecycle-config.test.ts index 83b9986..22cdb45 100644 --- a/packages/rules/test/s3-missing-lifecycle-config.test.ts +++ b/packages/rules/test/s3-missing-lifecycle-config.test.ts @@ -4,6 +4,7 @@ import type { AwsS3BucketAnalysis, AwsStaticS3BucketAnalysis } from '../src/inde import { LiveResourceBag, StaticResourceBag } from '../src/index.js'; const createBucketAnalysis = (overrides: Partial = {}): AwsStaticS3BucketAnalysis => ({ + hasAbortIncompleteMultipartUploadAfter7Days: false, hasAlternativeStorageClassTransition: false, hasCostFocusedLifecycle: false, hasIntelligentTieringConfiguration: false, @@ -22,6 +23,7 @@ const createBucketAnalysis = (overrides: Partial = {} const createLiveBucketAnalysis = (overrides: Partial = {}): AwsS3BucketAnalysis => ({ accountId: '123456789012', bucketName: 'logs-bucket', + hasAbortIncompleteMultipartUploadAfter7Days: false, hasAlternativeStorageClassTransition: false, hasCostFocusedLifecycle: false, hasIntelligentTieringConfiguration: false, diff --git a/packages/rules/test/s3-storage-class-optimization.test.ts b/packages/rules/test/s3-storage-class-optimization.test.ts index 0912409..25d7fbe 100644 --- a/packages/rules/test/s3-storage-class-optimization.test.ts +++ b/packages/rules/test/s3-storage-class-optimization.test.ts @@ -4,6 +4,7 @@ import type { AwsS3BucketAnalysis, AwsStaticS3BucketAnalysis } from '../src/inde import { LiveResourceBag, StaticResourceBag } from '../src/index.js'; const createBucketAnalysis = (overrides: Partial = {}): AwsStaticS3BucketAnalysis => ({ + hasAbortIncompleteMultipartUploadAfter7Days: false, hasAlternativeStorageClassTransition: false, hasCostFocusedLifecycle: false, hasIntelligentTieringConfiguration: false, @@ -22,6 +23,7 @@ const createBucketAnalysis = (overrides: Partial = {} const createLiveBucketAnalysis = (overrides: Partial = {}): AwsS3BucketAnalysis => ({ accountId: '123456789012', bucketName: 'logs-bucket', + hasAbortIncompleteMultipartUploadAfter7Days: false, hasAlternativeStorageClassTransition: false, hasCostFocusedLifecycle: false, hasIntelligentTieringConfiguration: false, diff --git a/packages/sdk/src/providers/aws/resources/s3-analysis.ts b/packages/sdk/src/providers/aws/resources/s3-analysis.ts index 3f9fa7a..7072575 100644 --- a/packages/sdk/src/providers/aws/resources/s3-analysis.ts +++ b/packages/sdk/src/providers/aws/resources/s3-analysis.ts @@ -42,6 +42,16 @@ const getTransitionStorageClasses = (rule: Record): string[] => const hasTransitionAction = (rule: Record): boolean => getTerraformTransitions(rule).length > 0 || getCloudFormationTransitions(rule).length > 0; +const getAbortIncompleteMultipartUploadDays = (rule: Record): 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): boolean => [...getTerraformTransitions(rule), ...getCloudFormationTransitions(rule)].some( (transition) => getLiteralStorageClass(transition.storage_class ?? transition.StorageClass) === null, @@ -58,6 +68,16 @@ const hasCostFocusedLifecycleRule = (rule: Record): boolean => return ruleHasTerraformExpiration(rule) || ruleHasCloudFormationExpiration(rule) || hasTransitionAction(rule); }; +const hasAbortIncompleteMultipartUploadAfter7Days = (rule: Record): boolean => { + if (!isLifecycleRuleEnabled(rule)) { + return false; + } + + const daysAfterInitiation = getAbortIncompleteMultipartUploadDays(rule); + + return daysAfterInitiation !== null && daysAfterInitiation <= 7; +}; + const hasEnabledIntelligentTieringConfiguration = (configuration: Record): boolean => { const status = configuration.status ?? configuration.Status; @@ -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), ), diff --git a/packages/sdk/test/exports.test.ts b/packages/sdk/test/exports.test.ts index a591e9b..604e571 100644 --- a/packages/sdk/test/exports.test.ts +++ b/packages/sdk/test/exports.test.ts @@ -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', diff --git a/packages/sdk/test/providers/aws-s3-resource.test.ts b/packages/sdk/test/providers/aws-s3-resource.test.ts index bb6e867..953c088 100644 --- a/packages/sdk/test/providers/aws-s3-resource.test.ts +++ b/packages/sdk/test/providers/aws-s3-resource.test.ts @@ -48,6 +48,7 @@ describe('hydrateAwsS3BucketAnalyses', () => { { accountId: '123456789012', bucketName: 'logs-bucket', + hasAbortIncompleteMultipartUploadAfter7Days: false, hasAlternativeStorageClassTransition: false, hasCostFocusedLifecycle: false, hasIntelligentTieringConfiguration: false, @@ -97,6 +98,7 @@ describe('hydrateAwsS3BucketAnalyses', () => { { accountId: '123456789012', bucketName: 'logs-bucket', + hasAbortIncompleteMultipartUploadAfter7Days: false, hasAlternativeStorageClassTransition: false, hasCostFocusedLifecycle: true, hasIntelligentTieringConfiguration: false, @@ -162,6 +164,7 @@ describe('hydrateAwsS3BucketAnalyses', () => { { accountId: '123456789012', bucketName: 'logs-bucket', + hasAbortIncompleteMultipartUploadAfter7Days: false, hasAlternativeStorageClassTransition: false, hasCostFocusedLifecycle: true, hasIntelligentTieringConfiguration: true, @@ -211,6 +214,7 @@ describe('hydrateAwsS3BucketAnalyses', () => { { accountId: '123456789012', bucketName: 'logs-bucket', + hasAbortIncompleteMultipartUploadAfter7Days: false, hasAlternativeStorageClassTransition: true, hasCostFocusedLifecycle: true, hasIntelligentTieringConfiguration: false, @@ -266,6 +270,7 @@ describe('hydrateAwsS3BucketAnalyses', () => { { accountId: '123456789012', bucketName: 'alpha-bucket', + hasAbortIncompleteMultipartUploadAfter7Days: false, hasAlternativeStorageClassTransition: false, hasCostFocusedLifecycle: true, hasIntelligentTieringConfiguration: false, @@ -277,6 +282,7 @@ describe('hydrateAwsS3BucketAnalyses', () => { { accountId: '123456789012', bucketName: 'zeta-bucket', + hasAbortIncompleteMultipartUploadAfter7Days: false, hasAlternativeStorageClassTransition: false, hasCostFocusedLifecycle: false, hasIntelligentTieringConfiguration: false, @@ -288,6 +294,56 @@ describe('hydrateAwsS3BucketAnalyses', () => { ]); }); + it('detects enabled abort-incomplete-multipart rules within 7 days', async () => { + const send = vi.fn( + async (command: GetBucketLifecycleConfigurationCommand | ListBucketIntelligentTieringConfigurationsCommand) => { + if (command.constructor.name === 'GetBucketLifecycleConfigurationCommand') { + return { + Rules: [ + { + AbortIncompleteMultipartUpload: { DaysAfterInitiation: 7 }, + Status: 'Enabled', + }, + ], + }; + } + + return { + IntelligentTieringConfigurationList: [], + IsTruncated: false, + }; + }, + ); + + mockedCreateS3Client.mockReturnValue({ send } as never); + + await expect( + hydrateAwsS3BucketAnalyses([ + { + accountId: '123456789012', + arn: 'arn:aws:s3:::logs-bucket', + properties: [], + region: 'us-east-1', + resourceType: 's3:bucket', + service: 's3', + }, + ]), + ).resolves.toEqual([ + { + accountId: '123456789012', + bucketName: 'logs-bucket', + hasAbortIncompleteMultipartUploadAfter7Days: true, + hasAlternativeStorageClassTransition: false, + hasCostFocusedLifecycle: false, + hasIntelligentTieringConfiguration: false, + hasIntelligentTieringTransition: false, + hasLifecycleSignal: true, + hasUnclassifiedTransition: false, + region: 'us-east-1', + }, + ]); + }); + it('caps in-flight S3 hydration work per region', async () => { let currentInFlight = 0; let maxInFlight = 0; diff --git a/packages/sdk/test/providers/aws-static.test.ts b/packages/sdk/test/providers/aws-static.test.ts index 87ff550..5538693 100644 --- a/packages/sdk/test/providers/aws-static.test.ts +++ b/packages/sdk/test/providers/aws-static.test.ts @@ -756,6 +756,7 @@ describe('aws static dataset registry', () => { ]), ).toEqual([ { + hasAbortIncompleteMultipartUploadAfter7Days: false, hasAlternativeStorageClassTransition: false, hasCostFocusedLifecycle: true, hasIntelligentTieringConfiguration: false, @@ -811,6 +812,7 @@ describe('aws static dataset registry', () => { ]), ).toEqual([ { + hasAbortIncompleteMultipartUploadAfter7Days: false, hasAlternativeStorageClassTransition: false, hasCostFocusedLifecycle: true, hasIntelligentTieringConfiguration: false, @@ -862,6 +864,7 @@ describe('aws static dataset registry', () => { ]), ).toEqual([ { + hasAbortIncompleteMultipartUploadAfter7Days: false, hasAlternativeStorageClassTransition: false, hasCostFocusedLifecycle: true, hasIntelligentTieringConfiguration: false, @@ -877,4 +880,107 @@ describe('aws static dataset registry', () => { }, ]); }); + + it('builds S3 bucket analyses with abort-incomplete-multipart rules from Terraform lifecycle resources', () => { + const definition = getAwsStaticDatasetDefinition('aws-s3-bucket-analyses'); + + expect( + definition?.load([ + createIaCResource({ + type: 'aws_s3_bucket', + name: 'logs', + location: { + path: 'main.tf', + line: 1, + column: 1, + }, + attributes: { + bucket: 'example-logs', + }, + }), + createIaCResource({ + type: 'aws_s3_bucket_lifecycle_configuration', + name: 'logs', + attributes: { + bucket: '${' + 'aws_s3_bucket.logs.id}', + rule: [ + { + abort_incomplete_multipart_upload: [ + { + days_after_initiation: 7, + }, + ], + id: 'abort-multipart', + status: 'Enabled', + }, + ], + }, + }), + ]), + ).toEqual([ + { + hasAbortIncompleteMultipartUploadAfter7Days: true, + hasAlternativeStorageClassTransition: false, + hasCostFocusedLifecycle: false, + hasIntelligentTieringConfiguration: false, + hasIntelligentTieringTransition: false, + hasLifecycleSignal: true, + hasUnclassifiedTransition: false, + location: { + path: 'main.tf', + line: 1, + column: 1, + }, + resourceId: 'aws_s3_bucket.logs', + }, + ]); + }); + + it('builds S3 bucket analyses with abort-incomplete-multipart rules from CloudFormation buckets', () => { + const definition = getAwsStaticDatasetDefinition('aws-s3-bucket-analyses'); + + expect( + definition?.load([ + createIaCResource({ + type: 'AWS::S3::Bucket', + name: 'LogsBucket', + location: { + path: 'template.yaml', + line: 3, + column: 3, + }, + attributes: { + Properties: { + LifecycleConfiguration: { + Rules: [ + { + AbortIncompleteMultipartUpload: { + DaysAfterInitiation: 7, + }, + Status: 'Enabled', + }, + ], + }, + }, + }, + }), + ]), + ).toEqual([ + { + hasAbortIncompleteMultipartUploadAfter7Days: true, + hasAlternativeStorageClassTransition: false, + hasCostFocusedLifecycle: false, + hasIntelligentTieringConfiguration: false, + hasIntelligentTieringTransition: false, + hasLifecycleSignal: true, + hasUnclassifiedTransition: false, + location: { + path: 'template.yaml', + line: 3, + column: 3, + }, + resourceId: 'LogsBucket', + }, + ]); + }); }); diff --git a/packages/sdk/test/scanner.test.ts b/packages/sdk/test/scanner.test.ts index 4b82f8a..41608b2 100644 --- a/packages/sdk/test/scanner.test.ts +++ b/packages/sdk/test/scanner.test.ts @@ -620,6 +620,46 @@ describe('CloudBurnClient', () => { }, ], }, + { + 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.missing_lifecycle', + location: { + path: 'main.tf', + line: 1, + column: 1, + }, + }, + { + resourceId: 'aws_s3_bucket.expire_only', + location: { + path: 'main.tf', + line: 5, + column: 1, + }, + }, + { + resourceId: 'MissingLifecycleBucket', + location: { + path: 'template.yaml', + line: 2, + column: 3, + }, + }, + { + resourceId: 'ExpireOnlyBucket', + location: { + path: 'template.yaml', + line: 4, + column: 3, + }, + }, + ], + }, ], }, ],