diff --git a/python-checks/src/main/java/org/sonar/python/checks/hotspots/ClearTextProtocolsCheck.java b/python-checks/src/main/java/org/sonar/python/checks/hotspots/ClearTextProtocolsCheck.java index 82be0bdb9..3ded7c31c 100644 --- a/python-checks/src/main/java/org/sonar/python/checks/hotspots/ClearTextProtocolsCheck.java +++ b/python-checks/src/main/java/org/sonar/python/checks/hotspots/ClearTextProtocolsCheck.java @@ -24,6 +24,7 @@ import org.sonar.check.Rule; import org.sonar.plugins.python.api.PythonSubscriptionCheck; import org.sonar.plugins.python.api.SubscriptionContext; +import org.sonar.plugins.python.api.TriBool; import org.sonar.plugins.python.api.symbols.Symbol; import org.sonar.plugins.python.api.tree.ArgList; import org.sonar.plugins.python.api.tree.Argument; @@ -37,6 +38,8 @@ import org.sonar.plugins.python.api.tree.RegularArgument; import org.sonar.plugins.python.api.tree.StringElement; import org.sonar.plugins.python.api.tree.Tree; +import org.sonar.plugins.python.api.types.v2.matchers.TypeMatcher; +import org.sonar.plugins.python.api.types.v2.matchers.TypeMatchers; import org.sonar.python.checks.utils.Expressions; import org.sonar.python.checks.cdk.ClearTextProtocolsCheckPart; import org.sonar.python.tree.TreeUtils; @@ -49,11 +52,15 @@ public class ClearTextProtocolsCheck extends PythonSubscriptionCheck { private static final String SENSITIVE_HTTP_SERVER_BIND_FQN = "socketserver.BaseServer.server_bind"; private static final Set SENSITIVE_HTTP_SERVER_METHOD_NAMES = Set.of("serve_forever", "server_bind"); private static final Set SENSITIVE_HTTP_SERVER_CLASSES = Set.of("http.server.HTTPServer", "http.server.ThreadingHTTPServer"); + private static final TypeMatcher STR_METHOD_MATCHER = TypeMatchers.isFunctionOwnerSatisfying(TypeMatchers.isOrExtendsType("builtins.str")); @Override public void initialize(Context context) { context.registerSyntaxNodeConsumer(Tree.Kind.STRING_ELEMENT, ctx -> { Tree node = ctx.syntaxNode(); + if (isStartsWithArgument(node, ctx)) { + return; + } String value = Expressions.unescape((StringElement) node); unsafeProtocol(value) // cleanup slashes @@ -175,6 +182,35 @@ private static Optional unsafeProtocol(String literalValue) { .findFirst(); } + /** + * Checks whether a string element is the prefix of a str.startswith call. + * @param stringElement string element to inspect + * @param context analysis context + * @return true when the element is part of a direct prefix argument + */ + private static boolean isStartsWithArgument(Tree stringElement, SubscriptionContext context) { + Tree stringLiteral = stringElement.parent(); + if (stringLiteral == null) { + return false; + } + Tree argumentParent = stringLiteral.parent(); + if (argumentParent != null && argumentParent.is(Tree.Kind.TUPLE)) { + argumentParent = argumentParent.parent(); + } + if (!(argumentParent instanceof RegularArgument argument) || argument.keywordArgument() != null) { + return false; + } + Tree callTree = TreeUtils.firstAncestorOfKind(argument, Tree.Kind.CALL_EXPR); + if (!(callTree instanceof CallExpression callExpression) + || callExpression.arguments().isEmpty() + || callExpression.arguments().get(0) != argument + || !(callExpression.callee() instanceof QualifiedExpression callee) + || !"startswith".equals(callee.name().name())) { + return false; + } + return STR_METHOD_MATCHER.evaluateFor(callee, context) == TriBool.TRUE; + } + private static Optional isUnsafeLib(String qualifiedName) { if ("telnetlib.Telnet".equals(qualifiedName)) { return Optional.of("telnet"); diff --git a/python-checks/src/test/resources/checks/hotspots/clearTextProtocols.py b/python-checks/src/test/resources/checks/hotspots/clearTextProtocols.py index 0f0680c3f..04bcd6485 100644 --- a/python-checks/src/test/resources/checks/hotspots/clearTextProtocols.py +++ b/python-checks/src/test/resources/checks/hotspots/clearTextProtocols.py @@ -4,6 +4,7 @@ from ftplib import FTP import smtplib import ssl +import urllib.request def clear_text_protocol(): url = "http://" # Noncompliant {{Using HTTP protocol is insecure. Use HTTPS instead.}} @@ -52,6 +53,21 @@ def download(url='ssh://exemple.com'): # Compliant print(url) +def clear_text_protocol_used_as_prefix(url: str): + url.startswith("http://insecure.com") # Compliant + url.startswith(("http://insecure.com", "ftp://insecure.com", "https://secure.com")) # Compliant + + +def clear_text_protocol_used_by_other_code(): + urllib.request.urlopen("http://insecure.com") # Noncompliant + + class ProtocolMatcher: + def startswith(self, prefix): + pass + + ProtocolMatcher().startswith("http://insecure.com") # Noncompliant + + cnx = telnetlib.Telnet("towel.blinkenlights.nl") # Noncompliant {{Using Telnet protocol is insecure. Use SSH instead.}} # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ cnx = Telnet("towel.blinkenlights.nl") # Noncompliant