Skip to content

Commit d09bc4f

Browse files
Seppli11sonartech
authored andcommitted
SONARPY-3105 Rule S7618: Explicit Network Call Timeouts in AWS Lambda (#413)
GitOrigin-RevId: c45c595d8266ce1000302f0b48e4036afebab830
1 parent 9d9f59f commit d09bc4f

40 files changed

Lines changed: 762 additions & 156 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import org.sonar.python.checks.cdk.DisabledSNSTopicEncryptionCheck;
2424
import org.sonar.python.checks.cdk.IamPolicyPublicAccessCheck;
2525
import org.sonar.python.checks.cdk.IamPrivilegeEscalationCheck;
26+
import org.sonar.python.checks.cdk.NetworkCallsWithoutTimeoutsInLambdaCheck;
2627
import org.sonar.python.checks.cdk.PrivilegePolicyCheck;
2728
import org.sonar.python.checks.cdk.PublicApiIsSecuritySensitiveCheck;
2829
import org.sonar.python.checks.cdk.PublicNetworkAccessToCloudResourcesCheck;
@@ -380,6 +381,7 @@ public Stream<Class<?>> getChecks() {
380381
StrongCryptographicKeysCheck.class,
381382
SklearnCachedPipelineDontAccessTransformersCheck.class,
382383
MissingHyperParameterCheck.class,
384+
NetworkCallsWithoutTimeoutsInLambdaCheck.class,
383385
SklearnPipelineSpecifyMemoryArgumentCheck.class,
384386
SklearnPipelineParameterAreCorrectCheck.class,
385387
SuperfluousCurlyBraceCheck.class,
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
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.cdk;
18+
19+
import java.util.Optional;
20+
import java.util.Set;
21+
import javax.annotation.CheckForNull;
22+
import org.sonar.check.Rule;
23+
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
24+
import org.sonar.plugins.python.api.SubscriptionContext;
25+
import org.sonar.plugins.python.api.tree.CallExpression;
26+
import org.sonar.plugins.python.api.tree.Expression;
27+
import org.sonar.plugins.python.api.tree.Name;
28+
import org.sonar.plugins.python.api.tree.RegularArgument;
29+
import org.sonar.plugins.python.api.tree.Tree;
30+
import org.sonar.plugins.python.api.types.v2.PythonType;
31+
import org.sonar.python.checks.utils.AwsLambdaChecksUtils;
32+
import org.sonar.python.checks.utils.Expressions;
33+
import org.sonar.python.tree.TreeUtils;
34+
import org.sonar.python.types.v2.TypeCheckBuilder;
35+
import org.sonar.python.types.v2.TypeCheckMap;
36+
37+
@Rule(key = "S7618")
38+
public class NetworkCallsWithoutTimeoutsInLambdaCheck extends PythonSubscriptionCheck {
39+
40+
private static final String MESSAGE = "Set an explicit timeout for this network call to prevent hanging executions in Lambda functions.";
41+
42+
private static final int BOTO3_CLIENT_RESOURCE_CONFIG_NTH_ARGUMENT = 9;
43+
44+
private static final Set<String> REQUESTS_METHODS = Set.of("get", "post", "put", "delete", "head", "options", "patch",
45+
"request");
46+
47+
private static final Set<String> BOTO3_ENTRY_FUNCTIONS = Set.of(
48+
"boto3.client", "boto3.resource",
49+
"boto3.session.Session.client", "boto3.session.Session.resource");
50+
51+
private TypeCheckMap<Object> requestsTypeChecks;
52+
private TypeCheckMap<Object> boto3EntryFunctionTypeChecks;
53+
private TypeCheckBuilder configConstructorTypeCheck;
54+
55+
private boolean isLambdaHandlerInThisFile = false;
56+
57+
@Override
58+
public void initialize(Context context) {
59+
context.registerSyntaxNodeConsumer(Tree.Kind.FILE_INPUT, this::initializeTypeCheckMaps);
60+
context.registerSyntaxNodeConsumer(Tree.Kind.CALL_EXPR, this::checkNetworkCall);
61+
}
62+
63+
private void initializeTypeCheckMaps(SubscriptionContext ctx) {
64+
isLambdaHandlerInThisFile = AwsLambdaChecksUtils.isLambdaHandlerInThisFile(ctx, ctx.syntaxNode());
65+
66+
requestsTypeChecks = new TypeCheckMap<>();
67+
68+
Object marker = new Object();
69+
70+
for (String method : REQUESTS_METHODS) {
71+
requestsTypeChecks.put(ctx.typeChecker().typeCheckBuilder().isTypeWithName("requests." + method), marker);
72+
requestsTypeChecks.put(ctx.typeChecker().typeCheckBuilder().isTypeWithName("requests.sessions.Session." + method),
73+
marker);
74+
}
75+
76+
boto3EntryFunctionTypeChecks = new TypeCheckMap<>();
77+
for (String entryFunction : BOTO3_ENTRY_FUNCTIONS) {
78+
boto3EntryFunctionTypeChecks.put(ctx.typeChecker().typeCheckBuilder().isTypeWithName(entryFunction), marker);
79+
}
80+
81+
configConstructorTypeCheck = ctx.typeChecker().typeCheckBuilder().isTypeWithName("botocore.config.Config");
82+
}
83+
84+
private void checkNetworkCall(SubscriptionContext ctx) {
85+
CallExpression callExpression = (CallExpression) ctx.syntaxNode();
86+
87+
if (!isLambdaHandlerInThisFile) {
88+
return;
89+
}
90+
91+
checkRequestsCall(ctx, callExpression);
92+
checkBoto3Call(ctx, callExpression);
93+
}
94+
95+
private void checkRequestsCall(SubscriptionContext ctx, CallExpression callExpression) {
96+
var type = callExpression.callee().typeV2();
97+
if (requestsTypeChecks.getOptionalForType(type).isPresent()) {
98+
checkTimeoutParameter(ctx, callExpression);
99+
}
100+
}
101+
102+
private static void checkTimeoutParameter(SubscriptionContext ctx, CallExpression callExpression) {
103+
var timeoutArg = CdkUtils.getArgument(ctx, callExpression, "timeout");
104+
if (timeoutArg.isEmpty()) {
105+
ctx.addIssue(callExpression.callee(), MESSAGE);
106+
} else {
107+
timeoutArg.get().addIssueIf(CdkPredicate.isNone(), MESSAGE, callExpression);
108+
}
109+
}
110+
111+
private void checkBoto3Call(SubscriptionContext ctx, CallExpression callExpression) {
112+
var type = callExpression.callee().typeV2();
113+
114+
if (!isBoto3ClientOrResource(type)) {
115+
return;
116+
}
117+
118+
var configArg = getBotocoreConfigArgument(callExpression);
119+
if (configArg == null) {
120+
ctx.addIssue(callExpression.callee(), MESSAGE);
121+
} else {
122+
var configConstructor = getConfigArgumentConstructorCallExpr(configArg);
123+
// if configConstructor == null, we couldn't figure out what the config argument
124+
// is -> no issue raised to prevent FPs
125+
if (configConstructor != null && isInvalidConfigConstructorCall(configConstructor)) {
126+
ctx.addIssue(callExpression.callee(), MESSAGE);
127+
}
128+
}
129+
}
130+
131+
private boolean isBoto3ClientOrResource(PythonType type) {
132+
return boto3EntryFunctionTypeChecks.getOptionalForType(type).isPresent();
133+
}
134+
135+
@CheckForNull
136+
private static RegularArgument getBotocoreConfigArgument(CallExpression callExpression) {
137+
return TreeUtils.nthArgumentOrKeyword(BOTO3_CLIENT_RESOURCE_CONFIG_NTH_ARGUMENT, "config",
138+
callExpression.arguments());
139+
}
140+
141+
@CheckForNull
142+
private static CallExpression getConfigArgumentConstructorCallExpr(RegularArgument arg) {
143+
Expression argExpr = arg.expression();
144+
if (argExpr instanceof Name argName) {
145+
return getSingleAssignedConfigConstructorCallExpr(argName).orElse(null);
146+
} else if (argExpr instanceof CallExpression callExpr) {
147+
return callExpr;
148+
}
149+
return null;
150+
}
151+
152+
private static Optional<CallExpression> getSingleAssignedConfigConstructorCallExpr(Name configVarName) {
153+
return Expressions.singleAssignedNonNameValue(configVarName)
154+
.flatMap(TreeUtils.toOptionalInstanceOfMapper(CallExpression.class));
155+
}
156+
157+
private boolean isInvalidConfigConstructorCall(CallExpression callExpression) {
158+
return configConstructorTypeCheck.check(callExpression.callee().typeV2()).isTrue()
159+
&& !hasArgumentWithName(callExpression, "read_timeout")
160+
&& !hasArgumentWithName(callExpression, "connect_timeout");
161+
}
162+
163+
private static boolean hasArgumentWithName(CallExpression callExpression, String name) {
164+
return TreeUtils.argumentByKeyword(name, callExpression.arguments()) != null;
165+
}
166+
}

python-checks/src/main/java/org/sonar/python/checks/utils/AwsLambdaChecksUtils.java

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,40 @@
1616
*/
1717
package org.sonar.python.checks.utils;
1818

19+
import java.util.ArrayList;
20+
import java.util.List;
1921
import org.sonar.plugins.python.api.SubscriptionContext;
2022
import org.sonar.plugins.python.api.project.configuration.ProjectConfiguration;
23+
import org.sonar.plugins.python.api.tree.BaseTreeVisitor;
24+
import org.sonar.plugins.python.api.tree.FileInput;
2125
import org.sonar.plugins.python.api.tree.FunctionDef;
26+
import org.sonar.plugins.python.api.tree.Tree;
2227
import org.sonar.plugins.python.api.types.v2.FunctionType;
2328
import org.sonar.python.semantic.v2.callgraph.CallGraph;
2429
import org.sonar.python.semantic.v2.callgraph.CallGraphWalker;
30+
import org.sonar.python.tree.TreeUtils;
2531

2632
public class AwsLambdaChecksUtils {
2733

2834
private AwsLambdaChecksUtils() {
2935
}
36+
37+
public static boolean isLambdaHandlerInThisFile(SubscriptionContext ctx, Tree tree) {
38+
FileInput root = null;
39+
if(tree instanceof FileInput fileInput) {
40+
root = fileInput;
41+
} else {
42+
root = TreeUtils.firstAncestorOfClass(tree, FileInput.class);
43+
}
44+
45+
var functionDefCollector = new FunctionDefCollector();
46+
root.accept(functionDefCollector);
47+
List<FunctionDef> functionDefs = functionDefCollector.getFunctionDefs();
48+
49+
return functionDefs.stream()
50+
.anyMatch(functionDef -> isLambdaHandler(ctx, functionDef));
51+
}
52+
3053
public static boolean isLambdaHandler(SubscriptionContext ctx, FunctionDef functionDef) {
3154
return isLambdaHandler(ctx.projectConfiguration(), ctx.callGraph(), functionDef);
3255
}
@@ -57,4 +80,18 @@ private static boolean isFqnCalledFromLambdaHandler(CallGraph callGraph, Project
5780
.isUsedFrom(fqn, node -> isLambdaHandlerFqn(projectConfiguration, node.fqn()))
5881
.isTrue();
5982
}
83+
84+
private static class FunctionDefCollector extends BaseTreeVisitor {
85+
private final List<FunctionDef> functionDefs = new ArrayList<>();
86+
87+
@Override
88+
public void visitFunctionDef(FunctionDef functionDef) {
89+
functionDefs.add(functionDef);
90+
super.visitFunctionDef(functionDef);
91+
}
92+
93+
public List<FunctionDef> getFunctionDefs() {
94+
return functionDefs;
95+
}
96+
}
6097
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<p>This rule raises an issue when network calls in AWS Lambda functions are made without explicit timeout parameters.</p>
2+
<h2>Why is this an issue?</h2>
3+
<p>AWS Lambda functions are ephemeral, event-driven compute services that frequently interact with external systems including other AWS services via
4+
boto3, external APIs through HTTP requests, databases, and message brokers. When these network calls are made without explicit timeout parameters, the
5+
Lambda function becomes vulnerable to indefinite hanging if the remote service becomes unresponsive due to network issues, service overload, or
6+
connectivity problems. Unlike traditional server environments where hanging requests might affect only a single user session, Lambda functions that
7+
hang continue to consume billable compute time until the function’s maximum execution timeout is reached, which can be up to 15 minutes. This creates
8+
a cascading effect where network reliability issues directly translate to increased operational costs and unpredictable system behavior.</p>
9+
<h3>What is the potential impact?</h3>
10+
<p>Hanging executions lead to increased AWS costs due to wasted compute time while waiting for unresponsive services. The lack of explicit timeouts
11+
causes unpredictable failure behavior, making it difficult to distinguish between functional errors and network stalls, which complicates debugging
12+
and monitoring. When the Lambda function’s maximum timeout is reached, the execution is abruptly terminated, preventing graceful error handling,
13+
proper logging, and cleanup operations. In connection pooling scenarios, hanging requests can exhaust available connections, and the unpredictable
14+
delays can cause cascading failures in upstream services that depend on the Lambda function’s response.</p>
15+
<h2>How to fix it in requests</h2>
16+
<p>For HTTP requests using the requests library, always specify the timeout parameter. Use a tuple <code>(connect_timeout, read_timeout)</code> for
17+
granular control over connection establishment and data reading timeouts. Wrap the call in try-except blocks to handle timeout exceptions gracefully.
18+
Consider externalizing timeout values through environment variables for easier configuration management.</p>
19+
<h3>Code examples</h3>
20+
<h4>Noncompliant code example</h4>
21+
<pre data-diff-id="1" data-diff-type="noncompliant">
22+
import requests
23+
24+
def lambda_handler(event, context):
25+
response = requests.get('https://api.example.com/data') # Noncompliant
26+
return response.json()
27+
</pre>
28+
<h4>Compliant solution</h4>
29+
<pre data-diff-id="1" data-diff-type="compliant">
30+
import requests
31+
import os
32+
33+
def lambda_handler(event, context):
34+
try:
35+
timeout = (float(os.environ.get('CONNECT_TIMEOUT', 3)),
36+
float(os.environ.get('READ_TIMEOUT', 10)))
37+
response = requests.get('https://api.example.com/data', timeout=timeout)
38+
return response.json()
39+
except requests.exceptions.Timeout:
40+
return {'error': 'Request timed out'}
41+
</pre>
42+
<h2>How to fix it in boto3</h2>
43+
<p>For AWS service calls using boto3, configure timeouts using botocore.config.Config when creating clients. Set both <code>connect_timeout</code> and
44+
<code>read_timeout</code> parameters to prevent hanging on connection establishment and data reading respectively. Handle botocore timeout exceptions
45+
appropriately in your error handling logic.</p>
46+
<h3>Code examples</h3>
47+
<h4>Noncompliant code example</h4>
48+
<pre data-diff-id="2" data-diff-type="noncompliant">
49+
import boto3
50+
51+
def lambda_handler(event, context):
52+
s3 = boto3.client('s3') # Noncompliant
53+
response = s3.get_object(Bucket='my-bucket', Key='my-key')
54+
return response['Body'].read()
55+
</pre>
56+
<h4>Compliant solution</h4>
57+
<pre data-diff-id="2" data-diff-type="compliant">
58+
import boto3
59+
from botocore.config import Config
60+
from botocore.exceptions import ReadTimeoutError, ConnectTimeoutError
61+
62+
def lambda_handler(event, context):
63+
try:
64+
config = Config(connect_timeout=5, read_timeout=10)
65+
s3 = boto3.client('s3', config=config)
66+
response = s3.get_object(Bucket='my-bucket', Key='my-key')
67+
return response['Body'].read()
68+
except (ReadTimeoutError, ConnectTimeoutError):
69+
return {'error': 'AWS service call timed out'}
70+
</pre>
71+
<h2>Resources</h2>
72+
<h3>Documentation</h3>
73+
<ul>
74+
<li> requests Documentation - <a href="https://requests.readthedocs.io/en/latest/user/advanced/#timeouts">Timeouts</a> </li>
75+
<li> boto3 Documentation - <a href="https://boto3.amazonaws.com/v1/documentation/api/latest/guide/configuration.html">Configuration</a> </li>
76+
<li> AWS Documentation - <a href="https://aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter/">Timeouts, retries, and backoff
77+
with jitter</a> </li>
78+
</ul>
79+
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"title": "Network calls in AWS Lambda functions shouldn\u0027t be made without explicit timeout parameters",
3+
"type": "BUG",
4+
"status": "ready",
5+
"remediation": {
6+
"func": "Constant\/Issue",
7+
"constantCost": "5min"
8+
},
9+
"tags": [
10+
"aws"
11+
],
12+
"defaultSeverity": "Major",
13+
"ruleSpecification": "RSPEC-7618",
14+
"sqKey": "S7618",
15+
"scope": "All",
16+
"quickfix": "infeasible",
17+
"code": {
18+
"impacts": {
19+
"MAINTAINABILITY": "MEDIUM",
20+
"RELIABILITY": "HIGH"
21+
},
22+
"attribute": "EFFICIENT"
23+
}
24+
}

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
@@ -292,6 +292,7 @@
292292
"S7519",
293293
"S7613",
294294
"S7617",
295+
"S7618",
295296
"S7621",
296297
"S7622",
297298
"S7632"
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
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.cdk;
18+
19+
import java.util.List;
20+
import org.junit.jupiter.api.Test;
21+
import org.sonar.python.checks.utils.PythonCheckVerifier;
22+
23+
class NetworkCallsWithoutTimeoutsInLambdaCheckTest {
24+
25+
@Test
26+
void test() {
27+
PythonCheckVerifier.verify(
28+
List.of("src/test/resources/checks/networkCallsWithoutTimeoutsInLambda/with_lambda_handler.py"),
29+
new NetworkCallsWithoutTimeoutsInLambdaCheck()
30+
);
31+
}
32+
33+
@Test
34+
void test_no_lambda_handler() {
35+
PythonCheckVerifier.verifyNoIssue(
36+
List.of("src/test/resources/checks/networkCallsWithoutTimeoutsInLambda/without_lambda_handler.py"),
37+
new NetworkCallsWithoutTimeoutsInLambdaCheck()
38+
);
39+
}
40+
41+
}

0 commit comments

Comments
 (0)