Skip to content

Commit 11a73a9

Browse files
joke1196sonartech
authored andcommitted
SONARPY-3102: Rule S7613: Returned Values Must Be JSON Serializable. (#408)
GitOrigin-RevId: bc9a24fc289f418167a750142a0676d70a7934ad
1 parent 8681db3 commit 11a73a9

File tree

8 files changed

+554
-3
lines changed

8 files changed

+554
-3
lines changed
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
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.Optional;
20+
import java.util.Set;
21+
import java.util.stream.Stream;
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.TriBool;
26+
import org.sonar.plugins.python.api.symbols.ClassSymbol;
27+
import org.sonar.plugins.python.api.symbols.Symbol;
28+
import org.sonar.plugins.python.api.symbols.Usage;
29+
import org.sonar.plugins.python.api.tree.CallExpression;
30+
import org.sonar.plugins.python.api.tree.DictionaryLiteral;
31+
import org.sonar.plugins.python.api.tree.Expression;
32+
import org.sonar.plugins.python.api.tree.FunctionDef;
33+
import org.sonar.plugins.python.api.tree.KeyValuePair;
34+
import org.sonar.plugins.python.api.tree.ListLiteral;
35+
import org.sonar.plugins.python.api.tree.Name;
36+
import org.sonar.plugins.python.api.tree.QualifiedExpression;
37+
import org.sonar.plugins.python.api.tree.RegularArgument;
38+
import org.sonar.plugins.python.api.tree.ReturnStatement;
39+
import org.sonar.plugins.python.api.tree.Tree;
40+
import org.sonar.plugins.python.api.tree.Tuple;
41+
import org.sonar.python.checks.utils.AwsLambdaChecksUtils;
42+
import org.sonar.python.checks.utils.Expressions;
43+
import org.sonar.python.tree.TreeUtils;
44+
import org.sonar.python.types.v2.TypeCheckBuilder;
45+
import org.sonar.python.types.v2.TypeCheckMap;
46+
47+
@Rule(key = "S7613")
48+
public class AwsLambdaReturnValueAreSerializableCheck extends PythonSubscriptionCheck {
49+
50+
private static final String MESSAGE = "Fix the return value to be JSON serializable.";
51+
private static final String SECONDARY_LOCATION_MESSAGE = "The non-serializable value is set here.";
52+
53+
// This should be moved to type checking once SONARPY-3223 is done
54+
private static final Set<String> NON_SERIALIZABLE_FQNS = Set.of(
55+
"datetime.datetime.now",
56+
"datetime.datetime.utcnow",
57+
"datetime.datetime.today",
58+
"datetime.datetime.fromtimestamp",
59+
"datetime.datetime.utcfromtimestamp",
60+
"datetime.date",
61+
"datetime.date.today",
62+
"datetime.date.fromtimestamp",
63+
"datetime.time");
64+
65+
// Method names that indicate serialization
66+
private static final Set<String> SERIALIZATION_METHOD_NAMES = Set.of(
67+
"to_dict",
68+
"dict",
69+
"asdict",
70+
"serialize",
71+
"json");
72+
73+
private TypeCheckBuilder listType;
74+
private TypeCheckBuilder setType;
75+
76+
private TypeCheckMap<Object> serializationFunctions;
77+
private TypeCheckMap<Object> nonSerializableTypes;
78+
79+
record IssueLocation(Tree mainLocation, Optional<Tree> secondaryLocation) {
80+
public IssueLocation(Tree mainLocation) {
81+
this(mainLocation, Optional.empty());
82+
}
83+
}
84+
85+
@Override
86+
public void initialize(Context context) {
87+
context.registerSyntaxNodeConsumer(Tree.Kind.FILE_INPUT, this::initializeTypeChecker);
88+
context.registerSyntaxNodeConsumer(Tree.Kind.RETURN_STMT, this::checkReturnStatement);
89+
}
90+
91+
private void initializeTypeChecker(SubscriptionContext ctx) {
92+
listType = ctx.typeChecker().typeCheckBuilder().isBuiltinWithName("list");
93+
94+
setType = ctx.typeChecker().typeCheckBuilder().isBuiltinWithName("set");
95+
96+
var object = new Object();
97+
serializationFunctions = new TypeCheckMap<>();
98+
serializationFunctions.put(ctx.typeChecker().typeCheckBuilder().isTypeWithFqn("dataclasses.asdict"), object);
99+
serializationFunctions.put(ctx.typeChecker().typeCheckBuilder().isTypeWithFqn("json.dumps"), object);
100+
serializationFunctions.put(ctx.typeChecker().typeCheckBuilder().isTypeWithFqn("json.loads"), object);
101+
102+
nonSerializableTypes = new TypeCheckMap<>();
103+
nonSerializableTypes.put(setType, object);
104+
nonSerializableTypes.put(ctx.typeChecker().typeCheckBuilder().isTypeOrInstanceWithName("re.Pattern"), object);
105+
nonSerializableTypes.put(ctx.typeChecker().typeCheckBuilder().isTypeOrInstanceWithName("decimal.Decimal"), object);
106+
// Covers all io hierachy: StringIO, BytesIO etc...
107+
nonSerializableTypes.put(ctx.typeChecker().typeCheckBuilder().isInstanceOf("typing.IO"), object);
108+
nonSerializableTypes.put(ctx.typeChecker().typeCheckBuilder().isBuiltinWithName("complex"), object);
109+
nonSerializableTypes.put( ctx.typeChecker().typeCheckBuilder().isBuiltinWithName("bytes"), object);
110+
nonSerializableTypes.put( ctx.typeChecker().typeCheckBuilder().isBuiltinWithName("bytearray"), object);
111+
nonSerializableTypes.put( ctx.typeChecker().typeCheckBuilder().isBuiltinWithName("frozenset"), object);
112+
}
113+
114+
private void checkReturnStatement(SubscriptionContext ctx) {
115+
ReturnStatement returnStmt = (ReturnStatement) ctx.syntaxNode();
116+
117+
Tree parentFunction = TreeUtils.firstAncestorOfKind(returnStmt, Tree.Kind.FUNCDEF);
118+
if (parentFunction == null) {
119+
return;
120+
}
121+
122+
FunctionDef function = (FunctionDef) parentFunction;
123+
if (!AwsLambdaChecksUtils.isLambdaHandler(ctx, function)) {
124+
return;
125+
}
126+
127+
if (returnStmt.expressions().isEmpty()) {
128+
return;
129+
}
130+
131+
Expression returnExpr = returnStmt.expressions().get(0);
132+
getNonSerializableExpr(returnExpr)
133+
.forEach(location -> {
134+
PreciseIssue issue = ctx.addIssue(location.mainLocation, MESSAGE);
135+
location.secondaryLocation.ifPresent(secondaryLocation -> issue.secondary(secondaryLocation, SECONDARY_LOCATION_MESSAGE));
136+
});
137+
}
138+
139+
private Stream<IssueLocation> getNonSerializableExpr(Expression expr) {
140+
switch (expr.getKind()) {
141+
case CALL_EXPR:
142+
return getNonSerializableCallExpr((CallExpression) expr);
143+
case DICTIONARY_LITERAL:
144+
return getNonSerializableInDictionaryLiteral((DictionaryLiteral) expr);
145+
case LIST_LITERAL:
146+
return getNonSerializableInListLiteral((ListLiteral) expr);
147+
case TUPLE:
148+
return getNonSerializableInTuple((Tuple) expr);
149+
case NAME:
150+
return getNonSerializableFromName((Name) expr);
151+
default:
152+
return getNonSerializableFromTypeOrFqn(expr);
153+
}
154+
}
155+
156+
private Stream<IssueLocation> getNonSerializableCallExpr(CallExpression callExpr) {
157+
// First check if this is a serialization method call like user.to_dict()
158+
if (isSerializationMethodCall(callExpr)) {
159+
return Stream.of();
160+
}
161+
162+
Symbol calleeSymbol = callExpr.calleeSymbol();
163+
String fullyQualifiedName = calleeSymbol != null ? calleeSymbol.fullyQualifiedName() : null;
164+
if (fullyQualifiedName == null) {
165+
return Stream.of();
166+
}
167+
// Check if this is a list call converting non-serializable types to serializable ones
168+
if (listType.check(callExpr.callee().typeV2()).equals(TriBool.TRUE)) {
169+
return callExpr.arguments().stream()
170+
.flatMap(TreeUtils.toStreamInstanceOfMapper(RegularArgument.class))
171+
.map(RegularArgument::expression)
172+
.filter(argExpr -> !isSet(argExpr))
173+
.flatMap(this::getNonSerializableExpr);
174+
}
175+
176+
if (serializationFunctions.getOptionalForType(callExpr.callee().typeV2()).isPresent()) {
177+
Stream.of();
178+
}
179+
180+
if (nonSerializableTypes.getOptionalForType(callExpr.typeV2()).isPresent() ||
181+
NON_SERIALIZABLE_FQNS.contains(fullyQualifiedName) ||
182+
isUserDefinedClassWithoutSerializationMethods(calleeSymbol)) {
183+
return Stream.of(new IssueLocation(callExpr));
184+
}
185+
return Stream.of();
186+
}
187+
188+
private static boolean isSerializationMethodCall(CallExpression callExpr) {
189+
if (callExpr.callee().is(Tree.Kind.QUALIFIED_EXPR)) {
190+
QualifiedExpression qualifiedExpr = (QualifiedExpression) callExpr.callee();
191+
String methodName = qualifiedExpr.name().name();
192+
return SERIALIZATION_METHOD_NAMES.contains(methodName);
193+
}
194+
return false;
195+
}
196+
197+
private static boolean isUserDefinedClassWithoutSerializationMethods(Symbol symbol) {
198+
return symbol.usages().stream()
199+
.filter(usage -> usage.kind() == Usage.Kind.CLASS_DECLARATION)
200+
.map(Usage::tree)
201+
.findFirst()
202+
.flatMap(TreeUtils::getSymbolFromTree)
203+
.filter(ClassSymbol.class::isInstance)
204+
.map(ClassSymbol.class::cast)
205+
.map(classSymbol -> !classSymbol.canHaveMember("__dict__") && !classSymbol.canHaveMember("__json__"))
206+
.orElse(false);
207+
}
208+
209+
private Stream<IssueLocation> getNonSerializableInDictionaryLiteral(DictionaryLiteral dictLiteral) {
210+
// Check dictionary keys and values recursively
211+
return dictLiteral.elements().stream()
212+
.flatMap(TreeUtils.toStreamInstanceOfMapper(KeyValuePair.class))
213+
.flatMap(kvPair -> Stream.concat(getNonSerializableExpr(kvPair.key()), getNonSerializableExpr(kvPair.value())));
214+
}
215+
216+
private Stream<IssueLocation> getNonSerializableInListLiteral(ListLiteral listLiteral) {
217+
return listLiteral.elements().expressions().stream()
218+
.flatMap(this::getNonSerializableExpr);
219+
}
220+
221+
private Stream<IssueLocation> getNonSerializableInTuple(Tuple tuple) {
222+
return tuple.elements().stream()
223+
.flatMap(this::getNonSerializableExpr);
224+
}
225+
226+
private Stream<IssueLocation> getNonSerializableFromName(Name name) {
227+
var assignedValue = Expressions.singleAssignedValue(name);
228+
if (assignedValue == null) {
229+
return getNonSerializableFromFuncDef(name);
230+
}
231+
return getNonSerializableExpr(assignedValue)
232+
.map(assignedValueLocation -> new IssueLocation(name, Optional.of(assignedValueLocation.mainLocation)));
233+
}
234+
235+
private static Stream<IssueLocation> getNonSerializableFromFuncDef(Name name) {
236+
return TreeUtils.getSymbolFromTree(name).stream()
237+
.flatMap(symbol -> symbol.usages().stream())
238+
.filter(usage -> usage.kind() == Usage.Kind.FUNC_DECLARATION)
239+
.findFirst()
240+
.map(usage -> new IssueLocation(name)).stream();
241+
}
242+
243+
private boolean isSet(Expression expr) {
244+
return setType.check(expr.typeV2()) == TriBool.TRUE;
245+
}
246+
247+
private Stream<IssueLocation> getNonSerializableFromTypeOrFqn(Expression expr) {
248+
if (nonSerializableTypes.getOptionalForType(expr.typeV2()).isPresent()) {
249+
return Stream.of(new IssueLocation(expr));
250+
}
251+
252+
return TreeUtils.getSymbolFromTree(expr)
253+
.map(Symbol::fullyQualifiedName)
254+
.filter(NON_SERIALIZABLE_FQNS::contains)
255+
.map(fqn -> new IssueLocation(expr)).stream();
256+
}
257+
}

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
@@ -130,6 +130,7 @@ public Stream<Class<?>> getChecks() {
130130
AsyncWithContextManagerCheck.class,
131131
AwsLambdaCrossCallCheck.class,
132132
AWSLambdaReservedEnvironmentVariableCheck.class,
133+
AwsLambdaReturnValueAreSerializableCheck.class,
133134
BackslashInStringCheck.class,
134135
BackticksUsageCheck.class,
135136
BareRaiseInFinallyCheck.class,

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,9 @@ public static boolean isOnlyLambdaHandler(SubscriptionContext ctx, FunctionDef f
4747

4848
private static boolean isLambdaHandlerFqn(ProjectConfiguration projectConfiguration, String fqn) {
4949
return projectConfiguration.awsProjectConfiguration()
50-
.awsLambdaHandlers()
51-
.stream()
52-
.anyMatch(handler -> handler.fullyQualifiedName().equals(fqn));
50+
.awsLambdaHandlers()
51+
.stream()
52+
.anyMatch(handler -> handler.fullyQualifiedName().equals(fqn));
5353
}
5454

5555
private static boolean isFqnCalledFromLambdaHandler(CallGraph callGraph, ProjectConfiguration projectConfiguration, String fqn) {
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<p>This rule raises an issue when AWS Lambda handlers return values that are not JSON serializable.</p>
2+
<h2>Why is this an issue?</h2>
3+
<p>For synchronous AWS Lambda invocations, like via API Gateway or direct SDK calls, the value returned by the handler is automatically serialized
4+
into a JSON string before being sent back in the response. If the return value contains objects that are not native JSON types like
5+
<code>datetime</code> objects, sets, or custom class instances, the serialization will fail, causing a <code>TypeError</code>. This will prevent the
6+
Lambda from returning a valid response to the client.</p>
7+
<h2>How to fix it</h2>
8+
<p>Convert non-JSON-serializable objects to their string representation or to JSON-serializable types before returning them:</p>
9+
<ul>
10+
<li> For <code>datetime</code> objects: Convert to ISO format strings using <code>.isoformat()</code> </li>
11+
<li> For sets: Convert to lists using <code>list(set_object)</code> </li>
12+
<li> For custom objects: convert them to dictionaries using the <code>__dict__</code> field, <code>dataclasses.asdict(…​)</code> for dataclasses or
13+
a custom <code>todict()</code> method. </li>
14+
</ul>
15+
<p>See the following examples for more details on how to handle custom classes:</p>
16+
<pre>
17+
import json
18+
import dataclasses
19+
20+
# A custom class representing a user
21+
@dataclasses.dataclass
22+
class User:
23+
name: str
24+
age: int
25+
26+
def to_dict(self) -&gt; dict:
27+
return { "name": self.name, "age": self.age }
28+
29+
user = User("Alice", 30)
30+
31+
# Method 1: Using __dict__ field
32+
json.dumps(user.__dict__)
33+
34+
# Method 2: Using dataclasses.asdict()
35+
json.dumps(dataclasses.asdict(user))
36+
37+
# Method 3: Using custom to_dict() method
38+
json.dumps(user.to_dict())
39+
</pre>
40+
<h3>Code examples</h3>
41+
<h4>Noncompliant code example</h4>
42+
<pre data-diff-id="1" data-diff-type="noncompliant">
43+
import datetime
44+
45+
def lambda_handler(event, context):
46+
return {
47+
"message": "Request processed successfully",
48+
"timestamp": datetime.datetime.now() # Noncompliant: not JSON serializable
49+
}
50+
</pre>
51+
<h4>Compliant solution</h4>
52+
<pre data-diff-id="1" data-diff-type="compliant">
53+
import datetime
54+
55+
def lambda_handler(event, context):
56+
return {
57+
"message": "Request processed successfully",
58+
"timestamp": datetime.datetime.now().isoformat() # Compliant: converted to string
59+
}
60+
</pre>
61+
<h2>Resources</h2>
62+
<h3>Documentation</h3>
63+
<ul>
64+
<li> AWS Documentation - <a href="https://docs.aws.amazon.com/lambda/latest/dg/python-handler.html#python-handler-return">Define Lambda function
65+
handler in Python </a> </li>
66+
<li> Python Documentation - <a href="https://docs.python.org/3/library/json.html">json — JSON encoder and decoder</a> </li>
67+
<li> Python Documentation - <a href="https://docs.python.org/3/reference/datamodel.html#object.<em>dict</em>">object.__dict__ field</a> </li>
68+
<li> Python Documentation - <a href="https://docs.python.org/3/library/datetime.html#datetime.date.isoformat">datetime.date.isoformat()</a> </li>
69+
<li> Python Documentation - <a href="https://docs.python.org/3/library/dataclasses.html#dataclasses.asdict">dateclasses.asdict()</a> </li>
70+
</ul>
71+
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"title": "AWS Lambda handlers should return only JSON serializable values",
3+
"type": "BUG",
4+
"status": "ready",
5+
"remediation": {
6+
"func": "Constant\/Issue",
7+
"constantCost": "5min"
8+
},
9+
"tags": [],
10+
"defaultSeverity": "Major",
11+
"ruleSpecification": "RSPEC-7613",
12+
"sqKey": "S7613",
13+
"scope": "All",
14+
"quickfix": "infeasible",
15+
"code": {
16+
"impacts": {
17+
"MAINTAINABILITY": "LOW",
18+
"RELIABILITY": "HIGH"
19+
},
20+
"attribute": "CONVENTIONAL"
21+
}
22+
}

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
@@ -290,6 +290,7 @@
290290
"S7517",
291291
"S7519",
292292
"S7617",
293+
"S7613",
293294
"S7632"
294295
]
295296
}

0 commit comments

Comments
 (0)