Skip to content

Commit 35e15cc

Browse files
maksim-grebeniuk-sonarsourcesonartech
authored andcommitted
SONARPY-3112 Rule S7622: Failure to Handle Paginated Responses in boto3 Calls (#426)
GitOrigin-RevId: ae3e141a470b429ef332125a08b9e0ec4eef22ff
1 parent 0f1640c commit 35e15cc

File tree

13 files changed

+303
-8
lines changed

13 files changed

+303
-8
lines changed
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
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.Tree;
25+
import org.sonar.python.tree.TreeUtils;
26+
import org.sonar.python.types.v2.TypeCheckMap;
27+
28+
@Rule(key = "S7622")
29+
public class AwsMissingPaginationCheck extends PythonSubscriptionCheck {
30+
31+
private static final Set<String> SENSITIVE_METHODS_FQNS = Set.of(
32+
"botocore.client.BaseClient.list_objects_v2",
33+
"botocore.client.BaseClient.scan"
34+
);
35+
36+
private TypeCheckMap<Boolean> sensitiveTypesCheckMap;
37+
38+
@Override
39+
public void initialize(Context context) {
40+
context.registerSyntaxNodeConsumer(Tree.Kind.FILE_INPUT, this::initializeCheck);
41+
context.registerSyntaxNodeConsumer(Tree.Kind.CALL_EXPR, this::check);
42+
}
43+
44+
private void initializeCheck(SubscriptionContext ctx) {
45+
sensitiveTypesCheckMap = new TypeCheckMap<>();
46+
SENSITIVE_METHODS_FQNS.stream()
47+
.map(fqn -> ctx.typeChecker().typeCheckBuilder().isTypeWithFqn(fqn))
48+
.forEach(check -> sensitiveTypesCheckMap.put(check, true));
49+
50+
}
51+
52+
private void check(SubscriptionContext ctx) {
53+
var callExpression = (CallExpression) ctx.syntaxNode();
54+
if (sensitiveTypesCheckMap.containsForType(TreeUtils.inferSingleAssignedExpressionType(callExpression.callee()))) {
55+
ctx.addIssue(callExpression, "Use a paginator to retrieve all results from this boto3 operation.");
56+
}
57+
}
58+
59+
60+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ public Stream<Class<?>> getChecks() {
132132
AwsLambdaCrossCallCheck.class,
133133
AwsLambdaReservedEnvironmentVariableCheck.class,
134134
AwsLambdaReturnValueAreSerializableCheck.class,
135+
AwsMissingPaginationCheck.class,
135136
BackslashInStringCheck.class,
136137
BackticksUsageCheck.class,
137138
BareRaiseInFinallyCheck.class,
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<p>This rule raises an issue when <code>boto3</code> operations that support pagination are called without using paginators or manual pagination
2+
handling.</p>
3+
<h2>Why is this an issue?</h2>
4+
<p>Many AWS services use pagination to limit the number of items returned in a single API call. For example, S3’s <code>list_objects_v2()</code>
5+
returns a maximum of <strong>1000</strong> objects per call, and DynamoDB’s <code>scan()</code> returns up to <strong>1MB</strong> of data per call.
6+
When you call these operations through boto3 without proper pagination handling, you only receive the first page of results. This means your
7+
application silently operates on incomplete data, which can lead to incorrect logic and missed operations on resources that exist beyond the first
8+
page.</p>
9+
<h3>What is the potential impact?</h3>
10+
<p>Operating on incomplete data can cause missing critical resources, incorrect business logic based on partial datasets, and security vulnerabilities
11+
where policies or access changes are not applied to the full resource set. These issues are often silent and difficult to detect in testing
12+
environments with smaller datasets.</p>
13+
<h2>How to fix it</h2>
14+
<p>Use <code>boto3’s</code> built-in paginators to automatically handle pagination and retrieve all results. Paginators provide a simple interface
15+
that handles the complexity of checking for continuation tokens and making multiple API calls. Replace direct service calls with
16+
<code>paginator.paginate()</code> calls and iterate through all pages.</p>
17+
<h3>Code examples</h3>
18+
<h4>Noncompliant code example</h4>
19+
<pre data-diff-id="1" data-diff-type="noncompliant">
20+
import boto3
21+
s3 = boto3.client("s3")
22+
23+
def lambda_handler(event, context):
24+
keys = []
25+
response = s3.list_objects_v2(Bucket="my-bucket") # Noncompliant
26+
for obj in response.get("Contents", []):
27+
keys.append(obj["Key"])
28+
return keys
29+
</pre>
30+
<h4>Compliant solution</h4>
31+
<pre data-diff-id="1" data-diff-type="compliant">
32+
import boto3
33+
s3 = boto3.client("s3")
34+
35+
def lambda_handler(event, context):
36+
keys = []
37+
paginator = s3.get_paginator("list_objects_v2")
38+
for page in paginator.paginate(Bucket="my-bucket"):
39+
for obj in page.get("Contents", []):
40+
keys.append(obj["Key"])
41+
return keys
42+
</pre>
43+
<h2>Resources</h2>
44+
<h3>Documentation</h3>
45+
<ul>
46+
<li> <a href="https://boto3.amazonaws.com/v1/documentation/api/latest/guide/paginators.html">Boto3 Paginators Guide</a> </li>
47+
<li> <a href="https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3/client/list_objects_v2.html">S3 list_objects_v2 API
48+
Reference</a> </li>
49+
<li> <a href="https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb/paginator/Query.html">DynamoDB Query Paginator
50+
Reference</a> </li>
51+
</ul>
52+
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"title": "boto3 operations that support pagination should be performed using paginators or manual pagination handling",
3+
"type": "CODE_SMELL",
4+
"status": "ready",
5+
"remediation": {
6+
"func": "Constant\/Issue",
7+
"constantCost": "5min"
8+
},
9+
"tags": [
10+
"aws"
11+
],
12+
"defaultSeverity": "Major",
13+
"ruleSpecification": "RSPEC-7622",
14+
"sqKey": "S7622",
15+
"scope": "All",
16+
"quickfix": "unknown",
17+
"code": {
18+
"impacts": {
19+
"MAINTAINABILITY": "MEDIUM"
20+
},
21+
"attribute": "CONVENTIONAL"
22+
}
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
@@ -292,6 +292,7 @@
292292
"S7519",
293293
"S7617",
294294
"S7613",
295+
"S7622",
295296
"S7632"
296297
]
297298
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
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 AwsMissingPaginationCheckTest {
23+
24+
@Test
25+
void test() {
26+
PythonCheckVerifier.verify("src/test/resources/checks/awsMissingPagination.py", new AwsMissingPaginationCheck());
27+
}
28+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import boto3
2+
3+
s3 = boto3.client('s3')
4+
dynamodb = boto3.client('dynamodb')
5+
6+
def foo():
7+
response = s3.list_objects_v2(Bucket="my-bucket") # Noncompliant
8+
response = dynamodb.scan(TableName="table") # Noncompliant

python-frontend/src/main/java/org/sonar/python/types/v2/TypeCheckMap.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
import java.util.Map;
2121
import java.util.Optional;
2222
import javax.annotation.CheckForNull;
23-
import org.sonar.plugins.python.api.TriBool;
2423
import org.sonar.plugins.python.api.types.v2.PythonType;
2524

2625
public class TypeCheckMap<V> {
@@ -55,8 +54,12 @@ public V getForType(PythonType type) {
5554
public Optional<V> getOptionalForType(PythonType type) {
5655
return map.entrySet()
5756
.stream()
58-
.filter(entry -> entry.getKey().check(type) == TriBool.TRUE)
57+
.filter(entry -> entry.getKey().check(type).isTrue())
5958
.findFirst()
6059
.map(Map.Entry::getValue);
6160
}
61+
62+
public boolean containsForType(PythonType type) {
63+
return getOptionalForType(type).isPresent();
64+
}
6265
}

python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/botocore.client.protobuf

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11

2-
botocore.client�
2+
botocore.client�
33

44
BaseClientbotocore.client.BaseClient"*SonarPythonAnalyzerFakeStub.CustomStubBase*�
55
invoke!botocore.client.BaseClient.invoke"
@@ -11,7 +11,94 @@ BaseClientbotocore.client.BaseClient"*SonarPythonAnalyzerFakeStub.CustomStubBa
1111
InvocationType
1212
builtins.str" builtins.str*)
1313
Payload
14-
builtins.str" builtins.str*�
14+
builtins.str" builtins.str*�
15+
list_objects_v2*botocore.client.BaseClient.list_objects_v2"
16+
Any*B
17+
self8
18+
botocore.client.BaseClient"botocore.client.BaseClient*(
19+
Bucket
20+
builtins.str" builtins.str*U
21+
DelimiterD
22+
Union[builtins.str,None]
23+
builtins.str" builtins.str
24+
None *:
25+
EncodingType&
26+
Union[Any,None]
27+
Any
28+
None *S
29+
MaxKeysD
30+
Union[builtins.int,None]
31+
builtins.int" builtins.int
32+
None *R
33+
PrefixD
34+
Union[builtins.str,None]
35+
builtins.str" builtins.str
36+
None *]
37+
ContinuationTokenD
38+
Union[builtins.str,None]
39+
builtins.str" builtins.str
40+
None *Y
41+
42+
FetchOwnerG
43+
Union[builtins.bool,None]
44+
builtins.bool"builtins.bool
45+
None *V
46+
47+
StartAfterD
48+
Union[builtins.str,None]
49+
builtins.str" builtins.str
50+
None *:
51+
RequestPayer&
52+
Union[Any,None]
53+
Any
54+
None *_
55+
ExpectedBucketOwnerD
56+
Union[builtins.str,None]
57+
builtins.str" builtins.str
58+
None *z
59+
OptionalObjectAttributesZ
60+
Union[builtins.list[Any],None],
61+
builtins.list[Any]
62+
Any"builtins.list
63+
None *�
64+
scanbotocore.client.BaseClient.scan"
65+
Any*B
66+
self8
67+
botocore.client.BaseClient"botocore.client.BaseClient*+
68+
TableName
69+
builtins.str" builtins.str*-
70+
IndexName
71+
builtins.str" builtins.str *a
72+
AttributesToGetJ
73+
builtins.list[builtins.str]
74+
builtins.str" builtins.str"builtins.list *)
75+
Limit
76+
builtins.int" builtins.int **
77+
Select
78+
builtins.str" builtins.str *
79+
80+
ScanFilter
81+
Any *7
82+
ConditionalOperator
83+
builtins.str" builtins.str *
84+
ExclusiveStartKey
85+
Any *:
86+
ReturnConsumedCapacity
87+
builtins.str" builtins.str *1
88+
TotalSegments
89+
builtins.int" builtins.int *+
90+
Segment
91+
builtins.int" builtins.int *8
92+
ProjectionExpression
93+
builtins.str" builtins.str *4
94+
FilterExpression
95+
builtins.str" builtins.str *'
96+
ExpressionAttributeNames
97+
Any *(
98+
ExpressionAttributeValues
99+
Any *4
100+
ConsistentRead
101+
builtins.bool"builtins.bool *�
15102
__annotations__botocore.client.__annotations__W
16103
builtins.dict[builtins.str,Any]
17104
builtins.str" builtins.str

python-frontend/src/test/java/org/sonar/python/types/v2/TypeCheckMapTest.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,8 @@ void test() {
4444
Assertions.assertThat(map.getForType(strClassType)).isNull();
4545
Assertions.assertThat(map.getForType(fooClassType)).isEqualTo(2);
4646
Assertions.assertThat(map.getForType(PythonType.UNKNOWN)).isNull();
47+
Assertions.assertThat(map.containsForType(intClassType)).isTrue();
48+
Assertions.assertThat(map.containsForType(strClassType)).isFalse();
49+
Assertions.assertThat(map.containsForType(fooClassType)).isTrue();
4750
}
4851
}

0 commit comments

Comments
 (0)