From bfa28205a4ca58be12a8c188f13af988f359ed61 Mon Sep 17 00:00:00 2001 From: Robert Hanuschke <36295814+robert-hanuschke@users.noreply.github.com> Date: Fri, 1 Nov 2024 03:18:40 +0100 Subject: [PATCH] feat: add rule LambdaEventSourceSQSVisibilityTimeout (#1813) Fixes #1798 New rule for "SQS queue visibility timeout of Lambda Event Source Mapping is at least 6 times timeout of Lambda function". Helps prevent configurations resulting in duplicate processing of queue items due to visibility timeout being too low Recommendation in the [SQS docs](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-configure-lambda-function-trigger.html): > To allow your function time to process each batch of records, set the source queue's visibility timeout to at least six times the [timeout that you configure](https://docs.aws.amazon.com/lambda/latest/dg/configuration-function-common.html#configuration-common-summary) on your function. The extra time allows for Lambda to retry if your function is throttled while processing a previous batch. --- RULES.md | 7 +- .../LambdaEventSourceSQSVisibilityTimeout.ts | 100 +++++++++++ src/rules/lambda/index.ts | 1 + test/rules/Lambda.test.ts | 163 +++++++++++++++++- 4 files changed, 267 insertions(+), 4 deletions(-) create mode 100644 src/rules/lambda/LambdaEventSourceSQSVisibilityTimeout.ts diff --git a/RULES.md b/RULES.md index a5f883a1e7..5915505a8e 100644 --- a/RULES.md +++ b/RULES.md @@ -695,9 +695,10 @@ Unimplemented rules from the AWS PCI DSS 3.2.1 Conformance Pack. A collection of community rules that are not currently included in any of the pre-built NagPacks, but are still available for inclusion in [custom NagPacks](https://github.com/cdklabs/cdk-nag/blob/main/docs/NagPack.md). -| Rule ID | Cause | Explanation | -| --------------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| LambdaFunctionUrlAuth | The Lambda Function URL allows for public, unauthenticated access. | AWS Lambda Function URLs allow you to invoke your function via a HTTPS end-point, setting the authentication to NONE allows anyone on the internet to invoke your function. | +| Rule ID | Cause | Explanation | +| ------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| LambdaFunctionUrlAuth | The Lambda Function URL allows for public, unauthenticated access. | AWS Lambda Function URLs allow you to invoke your function via a HTTPS end-point, setting the authentication to NONE allows anyone on the internet to invoke your function. | +| LambdaEventSourceSQSVisibilityTimeout | The SQS queue visibility timeout of Lambda Event Source Mapping is less than 6 times timeout of Lambda function. | Setting the visibility timeout to [at least 6 times the Lambda function timeout](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-configure-lambda-function-trigger.html) helps prevent configurations resulting in duplicate processing of queue items when the Lambda function execution is retried. | ## Footnotes diff --git a/src/rules/lambda/LambdaEventSourceSQSVisibilityTimeout.ts b/src/rules/lambda/LambdaEventSourceSQSVisibilityTimeout.ts new file mode 100644 index 0000000000..83120f25b6 --- /dev/null +++ b/src/rules/lambda/LambdaEventSourceSQSVisibilityTimeout.ts @@ -0,0 +1,100 @@ +/* +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: Apache-2.0 +*/ + +import { parse } from 'path'; +import { CfnResource, Stack } from 'aws-cdk-lib'; +import { CfnEventSourceMapping, CfnFunction } from 'aws-cdk-lib/aws-lambda'; +import { CfnQueue } from 'aws-cdk-lib/aws-sqs'; +import { NagRuleCompliance } from '../../nag-rules'; +import { flattenCfnReference } from '../../utils/flatten-cfn-reference'; + +/** + * SQS queue visibility timeout of Lambda Event Source Mapping is at least 6 times timeout of Lambda function + * @param node the CfnResource to check + */ +export default Object.defineProperty( + (node: CfnResource): NagRuleCompliance => { + if (node instanceof CfnEventSourceMapping) { + const sourceArn = flattenCfnReference( + Stack.of(node).resolve(node.eventSourceArn) ?? '' + ); + if (!sourceArn) { + return NagRuleCompliance.NOT_APPLICABLE; + } + const sourceSqsQueue = getSourceSqsQueue(node, sourceArn); + if (!sourceSqsQueue) { + return NagRuleCompliance.NOT_APPLICABLE; + } + const queueVisibilityTimeoutSetting = Stack.of(node).resolve( + sourceSqsQueue.visibilityTimeout + ); + const queueVisibilityTimeout = + typeof queueVisibilityTimeoutSetting === 'number' // can be 0, just testing for value truthiness would be wrong + ? queueVisibilityTimeoutSetting + : 30; // default SQS Queue visibility timeout + const lambdaFunctionTimeout = getLambdaFunctionTimeout(node); + if (!lambdaFunctionTimeout) { + return NagRuleCompliance.NOT_APPLICABLE; + } + if (lambdaFunctionTimeout > queueVisibilityTimeout / 6) { + return NagRuleCompliance.NON_COMPLIANT; + } + return NagRuleCompliance.COMPLIANT; + } else { + return NagRuleCompliance.NOT_APPLICABLE; + } + }, + 'name', + { value: parse(__filename).name } +); + +/** + * Helper function to get the SQS queue of the Event Source Mapping + * @param node the CfnEventSourceMapping + * @param sourceArn the already flattened reference to the source Arn + * returns the source CfnQueue or undefined if not found + */ +function getSourceSqsQueue( + node: CfnEventSourceMapping, + sourceArn: string +): CfnQueue | undefined { + for (const child of Stack.of(node).node.findAll()) { + if ( + child instanceof CfnQueue && + flattenCfnReference(Stack.of(node).resolve(child.attrArn)) === sourceArn + ) { + return child; + } + } + return undefined; +} + +/** + * Helper function to get timeout setting of the CfnEventSourceMapping's Lambda function + * @param node the CfnEventSourceMapping + * returns the timeout value of the Lambda function or undefined if not found + */ +function getLambdaFunctionTimeout( + node: CfnEventSourceMapping +): number | undefined { + const functionRef = flattenCfnReference( + Stack.of(node).resolve(node.functionName) + ); + for (const child of Stack.of(node).node.findAll()) { + if ( + child instanceof CfnFunction && + flattenCfnReference(functionRef) === + flattenCfnReference(Stack.of(node).resolve(child.ref)) + ) { + const timeoutSetting = Stack.of(node).resolve(child.timeout); + if (typeof timeoutSetting === 'number') { + return timeoutSetting; + } else { + return 3; // default Lambda function timeout + } + } + } + return undefined; +} diff --git a/src/rules/lambda/index.ts b/src/rules/lambda/index.ts index 8d96afe93e..1076657b08 100644 --- a/src/rules/lambda/index.ts +++ b/src/rules/lambda/index.ts @@ -5,6 +5,7 @@ SPDX-License-Identifier: Apache-2.0 export { default as LambdaConcurrency } from './LambdaConcurrency'; export { default as LambdaDLQ } from './LambdaDLQ'; +export { default as LambdaEventSourceSQSVisibilityTimeout } from './LambdaEventSourceSQSVisibilityTimeout'; export { default as LambdaFunctionPublicAccessProhibited } from './LambdaFunctionPublicAccessProhibited'; export { default as LambdaFunctionUrlAuth } from './LambdaFunctionUrlAuth'; export { default as LambdaInsideVPC } from './LambdaInsideVPC'; diff --git a/test/rules/Lambda.test.ts b/test/rules/Lambda.test.ts index 1ef1b8c79b..de3174fc22 100644 --- a/test/rules/Lambda.test.ts +++ b/test/rules/Lambda.test.ts @@ -2,23 +2,32 @@ Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. SPDX-License-Identifier: Apache-2.0 */ -import { Aspects, Stack } from 'aws-cdk-lib'; +import { Aspects, Duration, Stack } from 'aws-cdk-lib'; +import { + AttributeType, + StreamViewType, + TableV2, +} from 'aws-cdk-lib/aws-dynamodb'; import { Repository } from 'aws-cdk-lib/aws-ecr'; import { + CfnEventSourceMapping, CfnFunction, CfnPermission, CfnUrl, Code, DockerImageCode, DockerImageFunction, + EventSourceMapping, Function, FunctionUrlAuthType, Runtime, } from 'aws-cdk-lib/aws-lambda'; +import { Queue } from 'aws-cdk-lib/aws-sqs'; import { TestPack, TestType, validateStack } from './utils'; import { LambdaConcurrency, LambdaDLQ, + LambdaEventSourceSQSVisibilityTimeout, LambdaFunctionPublicAccessProhibited, LambdaFunctionUrlAuth, LambdaInsideVPC, @@ -28,6 +37,7 @@ import { const testPack = new TestPack([ LambdaConcurrency, LambdaDLQ, + LambdaEventSourceSQSVisibilityTimeout, LambdaFunctionPublicAccessProhibited, LambdaFunctionUrlAuth, LambdaInsideVPC, @@ -117,6 +127,157 @@ describe('AWS Lambda', () => { }); }); + describe('LambdaEventSourceSQSVisibilityTimeout: SQS queue visibility timeout of Lambda Event Source Mapping is at least 6 times timeout of Lambda function', () => { + const ruleId = 'LambdaEventSourceSQSVisibilityTimeout'; + const defaultLambdaFunctionTimeoutSeconds = 3; + const defaultSQSVisibilityTimeoutSeconds = 30; + const minValidMultiplier = 6; + test('Noncompliance 1 - all values defined', () => { + const testVisibilityTimeoutSeconds = 20; + const lambdaFunction = new Function(stack, 'Function', { + code: Code.fromInline('hi'), + timeout: Duration.seconds( + Math.ceil(testVisibilityTimeoutSeconds / minValidMultiplier + 1) + ), + handler: 'index.handler', + runtime: Runtime.NODEJS_20_X, + }); + const sqsQueue = new Queue(stack, 'SqsQueue', { + visibilityTimeout: Duration.seconds(testVisibilityTimeoutSeconds), + }); + new EventSourceMapping(stack, 'EventSourceMapping', { + target: lambdaFunction, + eventSourceArn: sqsQueue.queueArn, + }); + validateStack(stack, ruleId, TestType.NON_COMPLIANCE); + }); + + test('Noncompliance 2 - Lambda timeout defined, SQS visibility timeout default', () => { + const lambdaFunction = new Function(stack, 'Function', { + code: Code.fromInline('hi'), + timeout: Duration.seconds( + Math.ceil(defaultSQSVisibilityTimeoutSeconds / minValidMultiplier + 1) + ), + handler: 'index.handler', + runtime: Runtime.NODEJS_20_X, + }); + const sqsQueue = new Queue(stack, 'SqsQueue', {}); + new EventSourceMapping(stack, 'EventSourceMapping', { + target: lambdaFunction, + eventSourceArn: sqsQueue.queueArn, + }); + validateStack(stack, ruleId, TestType.NON_COMPLIANCE); + }); + + test('Noncompliance 3 - Lambda timeout default, SQS visibility timeout defined', () => { + const lambdaFunction = new Function(stack, 'Function', { + code: Code.fromInline('hi'), + handler: 'index.handler', + runtime: Runtime.NODEJS_20_X, + }); + const sqsQueue = new Queue(stack, 'SqsQueue', { + visibilityTimeout: Duration.seconds( + defaultLambdaFunctionTimeoutSeconds * minValidMultiplier - 1 + ), + }); + new EventSourceMapping(stack, 'EventSourceMapping', { + target: lambdaFunction, + eventSourceArn: sqsQueue.queueArn, + }); + validateStack(stack, ruleId, TestType.NON_COMPLIANCE); + }); + + test('Compliance 1 - all values default', () => { + const lambdaFunction = new Function(stack, 'Function', { + code: Code.fromInline('hi'), + handler: 'index.handler', + runtime: Runtime.NODEJS_20_X, + }); + const sqsQueue = new Queue(stack, 'SqsQueue', {}); + new EventSourceMapping(stack, 'EventSourceMapping', { + target: lambdaFunction, + eventSourceArn: sqsQueue.queueArn, + }); + validateStack(stack, ruleId, TestType.COMPLIANCE); + }); + + test('Compliance 2 - Lambda timeout defined, SQS visibility timeout default', () => { + const lambdaFunction = new Function(stack, 'Function', { + code: Code.fromInline('hi'), + timeout: Duration.seconds( + Math.floor(defaultSQSVisibilityTimeoutSeconds / minValidMultiplier) + ), + handler: 'index.handler', + runtime: Runtime.NODEJS_20_X, + }); + const sqsQueue = new Queue(stack, 'SqsQueue', {}); + new EventSourceMapping(stack, 'EventSourceMapping', { + target: lambdaFunction, + eventSourceArn: sqsQueue.queueArn, + }); + validateStack(stack, ruleId, TestType.COMPLIANCE); + }); + + test('Compliance 3 - Lambda timeout default, SQS visibility timeout defined', () => { + const lambdaFunction = new Function(stack, 'Function', { + code: Code.fromInline('hi'), + handler: 'index.handler', + runtime: Runtime.NODEJS_20_X, + }); + const sqsQueue = new Queue(stack, 'SqsQueue', { + visibilityTimeout: Duration.seconds( + defaultLambdaFunctionTimeoutSeconds * minValidMultiplier + ), + }); + new EventSourceMapping(stack, 'EventSourceMapping', { + target: lambdaFunction, + eventSourceArn: sqsQueue.queueArn, + }); + validateStack(stack, ruleId, TestType.COMPLIANCE); + }); + + test('Compliance 4 - eventSourceArn is not of an SQS queue', () => { + const lambdaFunction = new Function(stack, 'Function', { + code: Code.fromInline('hi'), + handler: 'index.handler', + runtime: Runtime.NODEJS_20_X, + }); + const ddbTable = new TableV2(stack, 'DdbTable', { + partitionKey: { name: 'id', type: AttributeType.STRING }, + dynamoStream: StreamViewType.KEYS_ONLY, + }); + new EventSourceMapping(stack, 'EventSourceMapping', { + target: lambdaFunction, + eventSourceArn: ddbTable.tableStreamArn, + }); + validateStack(stack, ruleId, TestType.COMPLIANCE); + }); + + test('Compliance 5 - Kafka source, no eventSourceArn set', () => { + const lambdaFunction = new Function(stack, 'Function', { + code: Code.fromInline('hi'), + handler: 'index.handler', + runtime: Runtime.NODEJS_20_X, + }); + new EventSourceMapping(stack, 'EventSourceMapping', { + target: lambdaFunction, + kafkaBootstrapServers: ['abc.example.com:9096'], + }); + validateStack(stack, ruleId, TestType.COMPLIANCE); + }); + + test('Compliance 6 - Lambda function not found in stack', () => { + const sqsQueue = new Queue(stack, 'SqsQueue', { + visibilityTimeout: Duration.seconds(20), + }); + new CfnEventSourceMapping(stack, 'EventSourceMapping', { + functionName: 'myFunction', + eventSourceArn: sqsQueue.queueArn, + }); + validateStack(stack, ruleId, TestType.COMPLIANCE); + }); + }); + describe('LambdaFunctionPublicAccessProhibited: Lambda function permissions do not grant public access', () => { const ruleId = 'LambdaFunctionPublicAccessProhibited'; test('Noncompliance 1', () => {