Skip to content

Commit 0f1640c

Browse files
thomas-serre-sonarsourcesonartech
authored andcommitted
SONARPY-3110 Rule S6243: AWS clients should be reused across Lambda invocations (#422)
GitOrigin-RevId: cc5fe87a8bfc3507786bcdeb47a8f977aa2d6835
1 parent 11a73a9 commit 0f1640c

File tree

9 files changed

+263
-4
lines changed

9 files changed

+263
-4
lines changed
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/*
2+
* SonarQube Python Plugin
3+
* Copyright (C) 2011-2025 SonarSource SA
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
12+
* See the Sonar Source-Available License for more details.
13+
*
14+
* You should have received a copy of the Sonar Source-Available License
15+
* along with this program; if not, see https://sonarsource.com/license/ssal/
16+
*/
17+
package org.sonar.python.checks;
18+
19+
import java.util.Set;
20+
import org.sonar.check.Rule;
21+
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
22+
import org.sonar.plugins.python.api.SubscriptionContext;
23+
import org.sonar.plugins.python.api.tree.CallExpression;
24+
import org.sonar.plugins.python.api.tree.FunctionDef;
25+
import org.sonar.plugins.python.api.tree.Tree;
26+
import org.sonar.python.checks.utils.AwsLambdaChecksUtils;
27+
import org.sonar.python.tree.TreeUtils;
28+
import org.sonar.python.types.v2.TypeCheckMap;
29+
30+
@Rule(key = "S6243")
31+
public class AwsLambdaClientInstantiationCheck extends PythonSubscriptionCheck {
32+
33+
private static final String CLIENT_ISSUE_MESSAGE = "Initialize this AWS client outside the Lambda handler function.";
34+
private static final String DATABASE_ISSUE_MESSAGE = "Initialize this database connection outside the Lambda handler function.";
35+
private static final String ORM_ISSUE_MESSAGE = "Initialize this ORM connection outside the Lambda handler function.";
36+
37+
private static final Set<String> CLIENT_FQNS = Set.of(
38+
"boto3.client",
39+
"boto3.resource",
40+
"boto3.session.Session"
41+
);
42+
43+
private static final Set<String> DATABASE_FQNS = Set.of(
44+
"pymysql.connect",
45+
"mysql.connector.connect",
46+
"psycopg2.connect",
47+
"pymongo.MongoClient",
48+
"sqlite3.dbapi2.connect",
49+
"redis.Redis",
50+
"redis.StrictRedis"
51+
);
52+
53+
private static final Set<String> ORM_FQNS = Set.of(
54+
"sqlalchemy.orm.sessionmaker",
55+
"peewee.PostgresqlDatabase",
56+
"peewee.MySQLDatabase",
57+
"peewee.SqliteDatabase",
58+
"mongoengine.connect"
59+
);
60+
61+
private final TypeCheckMap<String> isClientOrResourceTypeCheckMap = new TypeCheckMap<>();
62+
63+
public void initialize(Context context) {
64+
context.registerSyntaxNodeConsumer(Tree.Kind.FILE_INPUT, this::setupTypeChecker);
65+
context.registerSyntaxNodeConsumer(Tree.Kind.CALL_EXPR, this::checkCall);
66+
}
67+
68+
private void setupTypeChecker(SubscriptionContext ctx) {
69+
CLIENT_FQNS.forEach(fqn -> isClientOrResourceTypeCheckMap.put(ctx.typeChecker().typeCheckBuilder().isTypeWithFqn(fqn), CLIENT_ISSUE_MESSAGE));
70+
DATABASE_FQNS.forEach(fqn -> isClientOrResourceTypeCheckMap.put(ctx.typeChecker().typeCheckBuilder().isTypeWithFqn(fqn), DATABASE_ISSUE_MESSAGE));
71+
ORM_FQNS.forEach(fqn -> isClientOrResourceTypeCheckMap.put(ctx.typeChecker().typeCheckBuilder().isTypeWithFqn(fqn), ORM_ISSUE_MESSAGE));
72+
}
73+
74+
private void checkCall(SubscriptionContext ctx) {
75+
CallExpression callExpression = (CallExpression) ctx.syntaxNode();
76+
77+
if (!isInAWSLambdaFunction(callExpression, ctx)) {
78+
return;
79+
}
80+
81+
String message = isClientOrResourceTypeCheckMap.getForType(callExpression.callee().typeV2());
82+
if (message != null) {
83+
ctx.addIssue(callExpression, message);
84+
}
85+
}
86+
87+
private static boolean isInAWSLambdaFunction(CallExpression callExpression, SubscriptionContext ctx) {
88+
Tree parentFunctionDef = TreeUtils.firstAncestorOfKind(callExpression.parent(), Tree.Kind.FUNCDEF);
89+
if (parentFunctionDef == null) {
90+
return false;
91+
}
92+
return AwsLambdaChecksUtils.isLambdaHandler(ctx, (FunctionDef) parentFunctionDef);
93+
}
94+
}
95+
96+
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
import org.sonar.python.types.v2.TypeCheckBuilder;
3636

3737
@Rule(key = "S7617")
38-
public class AWSLambdaReservedEnvironmentVariableCheck extends PythonSubscriptionCheck {
38+
public class AwsLambdaReservedEnvironmentVariableCheck extends PythonSubscriptionCheck {
3939

4040
private static final Set<String> AWS_RESERVED_ENVIRONMENT_VARIABLES = Set.of(
4141
"_HANDLER",

python-checks/src/main/java/org/sonar/python/checks/OpenSourceCheckList.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,9 @@ public Stream<Class<?>> getChecks() {
128128
AsyncAwsLambdaHandlerCheck.class,
129129
AsyncLongSleepCheck.class,
130130
AsyncWithContextManagerCheck.class,
131+
AwsLambdaClientInstantiationCheck.class,
131132
AwsLambdaCrossCallCheck.class,
132-
AWSLambdaReservedEnvironmentVariableCheck.class,
133+
AwsLambdaReservedEnvironmentVariableCheck.class,
133134
AwsLambdaReturnValueAreSerializableCheck.class,
134135
BackslashInStringCheck.class,
135136
BackticksUsageCheck.class,
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<p>This rule raises an issue when resources are recreated on every Lambda function invocation instead of being reused.</p>
2+
<h2>Why is this an issue?</h2>
3+
<p>Resources that can be reused across multiple invocations of the Lambda function should be initialized at module level, outside the handler
4+
function. When the same Lambda container is reused for multiple function invocations, existing instances including SDK clients and database
5+
connections can be reused without recreating them. It is a good practice to initialize AWS clients, like boto3, and database connections outside the
6+
handler function to avoid recreating them on every Lambda invocation. Failing to do so can lead to performance degradation, increased latency, and
7+
even higher AWS costs.</p>
8+
<h3>What is the potential impact?</h3>
9+
<p>Performance degradation is the primary concern, as recreating resources adds significant latency to each Lambda invocation. This can result in
10+
slower response times and higher costs due to increased execution duration.</p>
11+
<p>Availability risks may also emerge under high load scenarios, as the additional overhead from resource recreation can cause functions to timeout
12+
more frequently or hit concurrency limits sooner than necessary.</p>
13+
<p>Increased API throttling is another potential issue, as recreating resources may lead to more frequent authentication requests and connection
14+
establishments, potentially triggering rate limits.</p>
15+
<h3>Exceptions</h3>
16+
<p>If the resource contains sensitive information that should not be shared across invocations, it may be necessary to initialize it within the
17+
handler function. However, this should be done cautiously and only when absolutely necessary.</p>
18+
<h2>How to fix it</h2>
19+
<p>Move the resource initialization outside the Lambda handler function to the module level. This ensures resources are created once when the Lambda
20+
environment is initialized and reused across invocations.</p>
21+
<h3>Code examples</h3>
22+
<h4>Noncompliant code example</h4>
23+
<pre data-diff-id="1" data-diff-type="noncompliant">
24+
import boto3
25+
26+
def lambda_handler(event, context):
27+
s3_client = boto3.client('s3') # Noncompliant: Client created inside handler
28+
29+
response = s3_client.get_object(Bucket='my-bucket', Key='my-key')
30+
return response['Body'].read()
31+
</pre>
32+
<h4>Compliant solution</h4>
33+
<pre data-diff-id="1" data-diff-type="compliant">
34+
import boto3
35+
36+
s3_client = boto3.client('s3') # Compliant
37+
38+
def lambda_handler(event, context):
39+
response = s3_client.get_object(Bucket='my-bucket', Key='my-key')
40+
return response['Body'].read()
41+
</pre>
42+
<h2>Resources</h2>
43+
<ul>
44+
<li> AWS Documentation - <a href="https://docs.aws.amazon.com/lambda/latest/dg/python-handler.html#python-handler-best-practices">Python handler
45+
best practices</a> </li>
46+
</ul>
47+
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"title": "Reusable resources should be initialized at construction time of Lambda functions",
3+
"type": "CODE_SMELL",
4+
"code": {
5+
"impacts": {
6+
"MAINTAINABILITY": "MEDIUM"
7+
},
8+
"attribute": "EFFICIENT"
9+
},
10+
"status": "ready",
11+
"remediation": {
12+
"func": "Constant\/Issue",
13+
"constantCost": "20min"
14+
},
15+
"tags": [
16+
"aws"
17+
],
18+
"defaultSeverity": "Major",
19+
"ruleSpecification": "RSPEC-6243",
20+
"sqKey": "S6243",
21+
"scope": "Main",
22+
"quickfix": "unknown"
23+
}

python-checks/src/main/resources/org/sonar/l10n/py/rules/python/Sonar_way_profile.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@
168168
"S6002",
169169
"S6019",
170170
"S6035",
171+
"S6243",
171172
"S6246",
172173
"S6249",
173174
"S6252",
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
* SonarQube Python Plugin
3+
* Copyright (C) 2011-2025 SonarSource SA
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
12+
* See the Sonar Source-Available License for more details.
13+
*
14+
* You should have received a copy of the Sonar Source-Available License
15+
* along with this program; if not, see https://sonarsource.com/license/ssal/
16+
*/
17+
package org.sonar.python.checks;
18+
19+
import org.junit.jupiter.api.Test;
20+
import org.sonar.python.checks.utils.PythonCheckVerifier;
21+
22+
class AwsLambdaClientInstantiationCheckTest {
23+
@Test
24+
void test() {
25+
PythonCheckVerifier.verify("src/test/resources/checks/awsLambdaClientInstantiation.py", new AwsLambdaClientInstantiationCheck());
26+
}
27+
28+
}
29+
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,10 @@
1919
import org.junit.jupiter.api.Test;
2020
import org.sonar.python.checks.utils.PythonCheckVerifier;
2121

22-
class AWSLambdaReservedEnvironmentVariableCheckTest {
22+
class AwsLambdaReservedEnvironmentVariableCheckTest {
2323
@Test
2424
void test() {
25-
PythonCheckVerifier.verify("src/test/resources/checks/awsLambdaReservedEnvironmentVariable.py", new AWSLambdaReservedEnvironmentVariableCheck());
25+
PythonCheckVerifier.verify("src/test/resources/checks/awsLambdaReservedEnvironmentVariable.py", new AwsLambdaReservedEnvironmentVariableCheck());
2626
}
2727

2828
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import boto3
2+
3+
import pymysql.connect
4+
import mysql.connector
5+
import psycopg2
6+
import sqlite3
7+
import redis
8+
import peewee
9+
import mongoengine
10+
import pymongo
11+
12+
from sqlalchemy import create_engine
13+
from sqlalchemy.orm import sessionmaker
14+
15+
16+
def lambda_handler(event, context):
17+
s3_client = boto3.client('s3') # Noncompliant {{Initialize this AWS client outside the Lambda handler function.}}
18+
# ^^^^^^^^^^^^^^^^^^
19+
20+
s3 = boto3.resource('s3') # FN SONARPY-3224
21+
session = boto3.session.Session() # FN SONARPY-3224
22+
23+
pymysql_connection = pymysql.connect(host='localhost') # Noncompliant {{Initialize this database connection outside the Lambda handler function.}}
24+
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
25+
mysql_connection = mysql.connector.connect(host="localhost") # Noncompliant
26+
mongo_client = pymongo.MongoClient() # Noncompliant
27+
sqlite3_connection = sqlite3.connect("tutorial.db") # Noncompliant
28+
# psycopg2_connection = psycopg2.connect("localhost") # FN SONARPY-3224
29+
redis_client = redis.Redis(host='localhost', port=6379, db=0) # FN SONARPY-3224
30+
strict_redis_client = redis.StrictRedis(host='localhost', port=6379, db=0) # FN SONARPY-3224
31+
32+
engine = create_engine("postgresql+psycopg2://scott:tiger@localhost/")
33+
sqlalchemy_session = sessionmaker(engine) # Noncompliant {{Initialize this ORM connection outside the Lambda handler function.}}
34+
# ^^^^^^^^^^^^^^^^^^^^
35+
36+
peewee_sqlite_db = peewee.SqliteDatabase('/path/to/app.db', pragmas={'journal_mode': 'wal', 'cache_size': -1024 * 64}) # Noncompliant
37+
peewee_mysql_db = peewee.MySQLDatabase('my_app', user='app', password='db_password', host='10.1.0.8', port=3306) # Noncompliant
38+
peewee_pg_db = peewee.PostgresqlDatabase('my_app', user='postgres', password='secret', host='10.1.0.9', port=5432) # Noncompliant
39+
40+
mongoengine_connection = mongoengine.connect('project1') # Noncompliant
41+
42+
43+
s3_client = boto3.client('s3')
44+
s3 = boto3.resource('s3')
45+
session = boto3.session.Session()
46+
pymysql_connection = pymysql.connect(host='localhost')
47+
mysql_connection = mysql.connector.connect(host="localhost")
48+
mongo_client = pymongo.MongoClient()
49+
50+
psycopg2_connection = psycopg2.connect("localhost")
51+
sqlite3_connection = sqlite3.connect("tutorial.db")
52+
redis_client = redis.Redis(host='localhost', port=6379, db=0)
53+
strict_redis_client = redis.StrictRedis(host='localhost', port=6379, db=0)
54+
55+
engine = create_engine("postgresql+psycopg2://scott:tiger@localhost/")
56+
sqlalchemy_session = sessionmaker(engine)
57+
58+
peewee_sqlite_db = peewee.SqliteDatabase('/path/to/app.db', pragmas={'journal_mode': 'wal', 'cache_size': -1024 * 64})
59+
peewee_mysql_db = peewee.MySQLDatabase('my_app', user='app', password='db_password', host='10.1.0.8', port=3306)
60+
peewee_pg_db = peewee.PostgresqlDatabase('my_app', user='postgres', password='secret', host='10.1.0.9', port=5432)
61+
62+
mongoengine_connection = mongoengine.connect('project1')

0 commit comments

Comments
 (0)