Skip to content

Commit d7ca869

Browse files
authored
SONARPY-2422 Revert checking for FQN instead of a heuristic (#2225)
1 parent 417b167 commit d7ca869

File tree

7 files changed

+69
-12
lines changed

7 files changed

+69
-12
lines changed

python-checks/src/main/java/org/sonar/python/checks/hotspots/CsrfDisabledCheck.java

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,13 @@
2020
import java.util.Arrays;
2121
import java.util.HashSet;
2222
import java.util.List;
23+
import java.util.Locale;
24+
import java.util.Objects;
2325
import java.util.Optional;
2426
import java.util.Set;
2527
import java.util.function.Predicate;
2628
import java.util.regex.Pattern;
29+
import java.util.stream.Stream;
2730
import org.sonar.check.Rule;
2831
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
2932
import org.sonar.plugins.python.api.SubscriptionContext;
@@ -114,14 +117,16 @@ private static Predicate<Expression> isListAnyMatch(Predicate<Expression> pred)
114117
"django.views.decorators.csrf.csrf_exempt",
115118
"flask_wtf.csrf.CSRFProtect.exempt"));
116119

117-
private static boolean isDangerousDecorator(Decorator expression) {
118-
return DANGEROUS_DECORATORS.stream().anyMatch(dangerousFqn -> TreeUtils.isDecoratorWithFQN(expression, dangerousFqn));
119-
}
120-
121120
/** Raises issue whenever a decorator with something about "CSRF" and "exempt" in the combined name is found. */
122121
private static void decoratorCsrfExemptCheck(SubscriptionContext subscriptionContext) {
123122
Decorator decorator = (Decorator) subscriptionContext.syntaxNode();
124-
if(isDangerousDecorator(decorator)) {
123+
List<String> names = Stream.of(TreeUtils.decoratorNameFromExpression(decorator.expression()))
124+
.filter(Objects::nonNull)
125+
.flatMap(s -> Arrays.stream(s.split("\\.")))
126+
.toList();
127+
boolean isDangerous = names.stream().anyMatch(s -> s.toLowerCase(Locale.US).contains("csrf")) &&
128+
names.stream().anyMatch(s -> s.toLowerCase(Locale.US).contains("exempt"));
129+
if (isDangerous) {
125130
subscriptionContext.addIssue(decorator.lastToken(), MESSAGE);
126131
}
127132
}

python-checks/src/main/java/org/sonar/python/checks/hotspots/UnsafeHttpMethodsCheck.java

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import java.util.Arrays;
2020
import java.util.HashSet;
2121
import java.util.List;
22+
import java.util.Objects;
2223
import java.util.Optional;
2324
import java.util.Set;
2425
import org.sonar.check.Rule;
@@ -30,14 +31,17 @@
3031
import org.sonar.plugins.python.api.tree.CallExpression;
3132
import org.sonar.plugins.python.api.tree.Decorator;
3233
import org.sonar.plugins.python.api.tree.Expression;
34+
import org.sonar.plugins.python.api.tree.FileInput;
3335
import org.sonar.plugins.python.api.tree.FunctionDef;
3436
import org.sonar.plugins.python.api.tree.ListLiteral;
3537
import org.sonar.plugins.python.api.tree.RegularArgument;
3638
import org.sonar.plugins.python.api.tree.StringLiteral;
3739
import org.sonar.python.semantic.FunctionSymbolImpl;
3840
import org.sonar.python.tree.FunctionDefImpl;
41+
import org.sonar.python.tree.TreeUtils;
3942

4043
import static org.sonar.plugins.python.api.tree.Tree.Kind.CALL_EXPR;
44+
import static org.sonar.plugins.python.api.tree.Tree.Kind.FILE_INPUT;
4145
import static org.sonar.plugins.python.api.tree.Tree.Kind.FUNCDEF;
4246
import static org.sonar.plugins.python.api.tree.Tree.Kind.LIST_LITERAL;
4347
import static org.sonar.plugins.python.api.tree.Tree.Kind.REGULAR_ARGUMENT;
@@ -133,13 +137,25 @@ private static Optional<CallExpression> getFlaskViewDecorator(FunctionDef functi
133137

134138
private static boolean isFlaskRouteDecorator(CallExpression callExpression) {
135139
Symbol calleeSymbol = callExpression.calleeSymbol();
136-
return calleeSymbol != null && "flask.scaffold.Scaffold.route".equals(calleeSymbol.fullyQualifiedName());
140+
if (calleeSymbol == null) {
141+
return false;
142+
}
143+
return "route".equals(calleeSymbol.name());
137144
}
138145

139146
private static void checkFlaskView(CallExpression callExpression, SubscriptionContext ctx) {
140147
RegularArgument methodsArg = argumentByKeyword("methods", callExpression.arguments());
141-
if (methodsArg != null && hasBothUnsafeAndSafeHttpMethods(methodsArg)) {
148+
if (methodsArg != null && hasBothUnsafeAndSafeHttpMethods(methodsArg) && isFlaskImported(callExpression)) {
142149
ctx.addIssue(callExpression, MESSAGE);
143150
}
144151
}
152+
153+
private static boolean isFlaskImported(CallExpression callExpression) {
154+
return Optional.ofNullable(TreeUtils.firstAncestorOfKind(callExpression, FILE_INPUT))
155+
.filter(fileInput -> ((FileInput) fileInput).globalVariables().stream()
156+
.map(Symbol::fullyQualifiedName)
157+
.filter(Objects::nonNull)
158+
.anyMatch(fqn -> fqn.contains("flask")))
159+
.isPresent();
160+
}
145161
}

python-checks/src/test/java/org/sonar/python/checks/hotspots/CsrfDisabledCheckTest.java

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,18 @@
1616
*/
1717
package org.sonar.python.checks.hotspots;
1818

19+
import java.util.List;
1920
import org.junit.jupiter.api.Test;
2021
import org.sonar.python.checks.utils.PythonCheckVerifier;
2122

2223
class CsrfDisabledCheckTest {
23-
2424
private static void testFile(String relPath) {
25-
String path = "src/test/resources/checks/hotspots/csrfDisabledCheck/" + relPath;
26-
PythonCheckVerifier.verify(path, new CsrfDisabledCheck());
25+
testFile(List.of(relPath));
26+
}
27+
28+
private static void testFile(List<String> relPaths) {
29+
List<String> absolutePaths = relPaths.stream().map(path -> "src/test/resources/checks/hotspots/csrfDisabledCheck/" + path).toList();
30+
PythonCheckVerifier.verify(absolutePaths, new CsrfDisabledCheck());
2731
}
2832

2933
@Test
@@ -53,7 +57,7 @@ void testGloballyMissingCSRFProtect(){
5357

5458
@Test
5559
void testExemptDecorators() {
56-
testFile("flask/flaskExempt.py");
60+
testFile(List.of("flask/flaskExempt.py", "flask/exportedCsrfProtect.py"));
5761
testFile("django/djangoExempt.py");
5862
}
5963

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from flask import Flask
2+
from flask_wtf.csrf import CSRFProtect
3+
4+
app = Flask(__name__)
5+
csrfProtect = CSRFProtect()
6+
csrfProtect.init_app(app) # Compliant
Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
def exemptExample():
22
from flask import Flask
33
from flask_wtf.csrf import CSRFProtect
4+
from exportedCsrfProtect import csrfProtect as exported_csrf_protect
45

56
app = Flask(__name__)
67
csrfProtect = CSRFProtect()
@@ -9,5 +10,15 @@ def exemptExample():
910
@app.route('/csrftest1/', methods=['POST'])
1011
@csrfProtect.exempt # Noncompliant {{Make sure disabling CSRF protection is safe here.}}
1112
# ^^^^^^
12-
def csrftestpost():
13+
def csrftestpost1():
14+
pass
15+
16+
@app.route('/csrftest1/', methods=['POST'])
17+
@exported_csrf_protect.exempt # Noncompliant
18+
def csrftestpost2():
19+
pass
20+
21+
@app.route('/csrftest1/', methods=['POST'])
22+
@exported_csrf_protect.something_else
23+
def csrftestpost3():
1324
pass
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from flask import Blueprint, request
2+
from flask import Response
3+
4+
routes = Blueprint('methods', __name__)

python-checks/src/test/resources/checks/hotspots/unsafeHttpMethods/flask/views.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from flask import Blueprint, request
22
from flask import Response
3+
from exportedBlueprint import routes as other_routes
34

45
methods = Blueprint('methods', __name__)
56

@@ -8,6 +9,16 @@
89
def sensitive1():
910
return Response("Method: " + request.method, 200)
1011

12+
# 127.0.0.1:5000/methods/sensitive1
13+
@other_routes.route('/sensitive1', methods=['GET', 'POST']) # Noncompliant
14+
def sensitive1_other_routes():
15+
return Response("Method: " + request.method, 200)
16+
17+
# 127.0.0.1:5000/methods/compliant1
18+
@other_routes.route('/compliant1') # Compliant
19+
def compliant1_other_routes():
20+
return Response("Method: " + request.method, 200)
21+
1122
# 127.0.0.1:5000/methods/compliant1
1223
@methods.route('/compliant1') # Compliant
1324
def compliant1():

0 commit comments

Comments
 (0)