org.sonar.python.checks.VerifiedSslTlsCertificateCheck Maven / Gradle / Ivy
/*
* SonarQube Python Plugin
* Copyright (C) 2011-2024 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
* See the Sonar Source-Available License for more details.
*
* You should have received a copy of the Sonar Source-Available License
* along with this program; if not, see https://sonarsource.com/license/ssal/
*/
package org.sonar.python.checks;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Predicate;
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.symbols.Symbol;
import org.sonar.plugins.python.api.symbols.Usage;
import org.sonar.plugins.python.api.tree.Argument;
import org.sonar.plugins.python.api.tree.AssignmentStatement;
import org.sonar.plugins.python.api.tree.BinaryExpression;
import org.sonar.plugins.python.api.tree.CallExpression;
import org.sonar.plugins.python.api.tree.DictionaryLiteral;
import org.sonar.plugins.python.api.tree.Expression;
import org.sonar.plugins.python.api.tree.HasSymbol;
import org.sonar.plugins.python.api.tree.KeyValuePair;
import org.sonar.plugins.python.api.tree.Name;
import org.sonar.plugins.python.api.tree.NumericLiteral;
import org.sonar.plugins.python.api.tree.QualifiedExpression;
import org.sonar.plugins.python.api.tree.RegularArgument;
import org.sonar.plugins.python.api.tree.StringLiteral;
import org.sonar.plugins.python.api.tree.Token;
import org.sonar.plugins.python.api.tree.Tree;
import org.sonar.plugins.python.api.tree.UnpackingExpression;
import org.sonar.plugins.python.api.tree.WithItem;
import org.sonar.plugins.python.api.tree.WithStatement;
import org.sonar.python.checks.utils.Expressions;
import org.sonar.python.tree.RegularArgumentImpl;
import org.sonar.python.tree.TreeUtils;
import static java.util.Optional.ofNullable;
// https://jira.sonarsource.com/browse/RSPEC-4830
@Rule(key = "S4830")
public class VerifiedSslTlsCertificateCheck extends PythonSubscriptionCheck {
private static final String MESSAGE = "Enable server certificate validation on this SSL/TLS connection.";
private static final String VERIFY_NONE = Fqn.ssl("VERIFY_NONE");
@Override
public void initialize(Context context) {
context.registerSyntaxNodeConsumer(Tree.Kind.WITH_STMT, VerifiedSslTlsCertificateCheck::verifyAioHttpWithSession);
context.registerSyntaxNodeConsumer(Tree.Kind.CALL_EXPR, VerifiedSslTlsCertificateCheck::sslSetVerifyCheck);
context.registerSyntaxNodeConsumer(Tree.Kind.CALL_EXPR, VerifiedSslTlsCertificateCheck::requestsCheck);
context.registerSyntaxNodeConsumer(Tree.Kind.REGULAR_ARGUMENT, VerifiedSslTlsCertificateCheck::standardSslCheckForRegularArgument);
context.registerSyntaxNodeConsumer(Tree.Kind.ASSIGNMENT_STMT, VerifiedSslTlsCertificateCheck::standardSslCheckForAssignmentStatement);
}
private static void verifyAioHttpWithSession(SubscriptionContext ctx) {
var withStatement = (WithStatement) ctx.syntaxNode();
withStatement.withItems()
.stream()
.filter(item -> Optional.of(item)
.map(WithItem::test)
.flatMap(TreeUtils.toOptionalInstanceOfMapper(CallExpression.class))
.map(CallExpression::calleeSymbol)
.map(Symbol::fullyQualifiedName)
.filter("aiohttp.ClientSession"::equals)
.isPresent())
.map(WithItem::expression)
.map(TreeUtils.toOptionalInstanceOfMapper(Name.class))
.filter(Optional::isPresent)
.map(Optional::get)
.map(HasSymbol::symbol)
.filter(Objects::nonNull)
.forEach(symbol -> verifyAioHttpSessionSymbolUsages(ctx, symbol));
}
private static void verifyAioHttpSessionSymbolUsages(SubscriptionContext ctx, Symbol sessionSymbol) {
sessionSymbol.usages()
.stream()
.filter(usage -> usage.kind() == Usage.Kind.OTHER)
.map(Usage::tree)
.map(t -> TreeUtils.firstAncestorOfKind(t, Tree.Kind.CALL_EXPR))
.map(TreeUtils.toOptionalInstanceOfMapper(CallExpression.class))
.filter(Optional::isPresent)
.map(Optional::get)
.forEach(sessionCallExpr -> verifyVulnerableMethods(ctx, sessionCallExpr, VERIFY_SSL_ARG_NAMES));
}
/** Fully qualified name of the set_verify
used in sslSetVerifyCheck
. */
private static final String SET_VERIFY = Fqn.context("set_verify");
/**
* Check for the OpenSSL.SSL.Context.set_verify
flag settings.
*
* Searches for `set_verify` invocations on instances of `OpenSSL.SSL.Context`,
* extracts the flags from the first argument, checks that the combination of flags is secure.
*
* @param subscriptionContext the subscription context passed by Context.registerSyntaxNodeConsumer
.
*/
private static void sslSetVerifyCheck(SubscriptionContext subscriptionContext) {
CallExpression callExpr = (CallExpression) subscriptionContext.syntaxNode();
boolean isSetVerifyInvocation = ofNullable(callExpr.calleeSymbol())
.map(Symbol::fullyQualifiedName)
.filter(SET_VERIFY::equals)
.isPresent();
if (isSetVerifyInvocation) {
List args = callExpr.arguments();
if (!args.isEmpty()) {
Tree flagsArgument = args.get(0);
if (flagsArgument.is(Tree.Kind.REGULAR_ARGUMENT)) {
Set flags = extractFlags(((RegularArgumentImpl) flagsArgument).expression());
checkFlagSettings(flags).ifPresent(issue -> subscriptionContext.addIssue(issue.token, MESSAGE));
}
}
}
}
/** Helper methods for generating FQNs frequently used in this check. */
private static class Fqn {
private static String context(@SuppressWarnings("SameParameterValue") String method) {
return ssl("Context." + method);
}
private static String ssl(String property) {
return "OpenSSL.SSL." + property;
}
}
/**
* Recursively deconstructs binary trees of expressions separated with `|`-ors,
* and collects the leafs that look like qualified expressions.
*/
private static HashSet extractFlags(Tree flagsSubexpr) {
if (flagsSubexpr.is(Tree.Kind.QUALIFIED_EXPR)) {
// Base case: e.g. `SSL.VERIFY_NONE`
return new HashSet<>(Collections.singletonList((QualifiedExpression) flagsSubexpr));
} else if (flagsSubexpr.is(Tree.Kind.BITWISE_OR)) {
// recurse into left and right branch
BinaryExpression orExpr = (BinaryExpression) flagsSubexpr;
HashSet flags = extractFlags(orExpr.leftOperand());
flags.addAll(extractFlags(orExpr.rightOperand()));
return flags;
} else {
// failed to interpret. Ignore leaf.
return new HashSet<>();
}
}
/**
* Checks whether a combination of flags is valid,
* optionally returns a message and a token if there is something wrong.
*/
private static Optional checkFlagSettings(Set flags) {
for (QualifiedExpression qe : flags) {
Symbol symb = qe.symbol();
if (symb != null) {
String fqn = symb.fullyQualifiedName();
if (VERIFY_NONE.equals(fqn)) {
return Optional.of(new IssueReport(
"Omitting the check of the peer certificate is dangerous.",
qe.lastToken()));
}
}
}
return Optional.empty();
}
/** Message and a token closest to the problematic position. Glorified Pair<A,B>
. */
private static class IssueReport {
final String message;
final Token token;
private IssueReport(String message, Token token) {
this.message = message;
this.token = token;
}
}
public static final Set VERIFY_ARG_NAME = Set.of("verify");
public static final Set VERIFY_SSL_ARG_NAMES = Set.of("verify_ssl", "ssl");
/**
* Set of FQNs of methods in requests
-module that have the vulnerable verify
-option.
*/
private static final Set CALLS_WHERE_TO_ENFORCE_TRUE_ARGUMENT = Set.of(
"requests.api.request",
"requests.api.get",
"requests.api.head",
"requests.api.post",
"requests.api.put",
"requests.api.delete",
"requests.api.patch",
"requests.api.options",
"httpx.request",
"httpx.stream",
"httpx.get",
"httpx.options",
"httpx.head",
"httpx.post",
"httpx.put",
"httpx.patch",
"httpx.delete",
"httpx.Client",
"httpx.AsyncClient");
private static void requestsCheck(SubscriptionContext subscriptionContext) {
var callExpr = (CallExpression) subscriptionContext.syntaxNode();
var isVulnerableMethod = ofNullable(callExpr.calleeSymbol())
.map(Symbol::fullyQualifiedName)
.filter(CALLS_WHERE_TO_ENFORCE_TRUE_ARGUMENT::contains)
.isPresent();
if (isVulnerableMethod) {
verifyVulnerableMethods(subscriptionContext, callExpr, VERIFY_ARG_NAME);
}
}
private static void verifyVulnerableMethods(SubscriptionContext ctx, CallExpression callExpr, Set argumentNames) {
var verifyRhs = searchVerifyAssignment(callExpr, argumentNames)
.or(() -> searchVerifyInKwargs(callExpr, argumentNames));
verifyRhs.ifPresent(sensitiveSettingExpressions -> sensitiveSettingExpressions
.stream()
.filter(rhs -> Expressions.isFalsy(rhs) || isFalsyCollection(rhs))
.findFirst()
.ifPresent(rhs -> addIssue(ctx, sensitiveSettingExpressions, rhs))
);
}
private static void addIssue(SubscriptionContext ctx, List sensitiveSettingExpressions, Expression rhs) {
var issue = ctx.addIssue(rhs, MESSAGE);
// report everything except the last one as secondary locations.
sensitiveSettingExpressions.stream()
.filter(v -> v != rhs)
.forEach(v -> issue.secondary(v, "Dictionary is passed here as **kwargs."));
}
/**
* Attempts to find the expression in verify = expr
explicitly keyworded parameter assignment.
*
* @return The expr
part on the right hand side of the assignment.
*/
private static Optional> searchVerifyAssignment(CallExpression callExpr, Set argumentNames) {
var args = callExpr.arguments()
.stream()
.filter(RegularArgument.class::isInstance)
.map(RegularArgument.class::cast)
.filter(regArg -> Optional.of(regArg)
.map(RegularArgument::keywordArgument)
.map(Name::name)
.filter(argumentNames::contains)
.isPresent())
.map(RegularArgument::expression)
.toList();
return Optional.of(args).filter(Predicate.not(List::isEmpty));
}
/**
* Attempts to find the rhs
in some definition kwargs = { 'verify': rhs }
* of kwargs
used in the arguments of the given callExpression
.
*
* Returns list of problematic expressions in the reverse order of importance (the kwargs
-argument comes
* first, the setting in the dictionary comes last).
*/
private static Optional> searchVerifyInKwargs(CallExpression callExpression, Set argumentNames) {
// Finds first unpacking argument (**kwargs),
// attempts to find the definition with the dictionary,
// then attempts to find a bad setting in the dictionary,
// and finally returns the list with both the `kwargs`-argument and the bad setting in the dictionary.
return callExpression.arguments().stream()
.filter(UnpackingExpression.class::isInstance)
.map(arg -> ((UnpackingExpression) arg).expression())
.filter(Name.class::isInstance)
.findFirst()
.flatMap(name -> Optional.ofNullable(Expressions.singleAssignedValue((Name) name))
.filter(DictionaryLiteral.class::isInstance)
.flatMap(dict -> searchDangerousVerifySettingInDictionary((DictionaryLiteral) dict, argumentNames)
.map(settingInDict -> Arrays.asList(name, settingInDict))));
}
/** Searches for a dangerous falsy verify: False
in a dictionary literal. */
private static Optional searchDangerousVerifySettingInDictionary(DictionaryLiteral dict, Set argumentNames) {
return dict.elements().stream()
.filter(KeyValuePair.class::isInstance)
.map(KeyValuePair.class::cast)
.filter(kvp -> Optional.of(kvp.key())
.filter(StringLiteral.class::isInstance)
.map(StringLiteral.class::cast)
.map(StringLiteral::trimmedQuotesValue)
.filter(argumentNames::contains)
.isPresent())
.findFirst()
.map(KeyValuePair::value);
}
/**
* Checks whether an expression is obviously a falsy collection (e.g. set()
or range(0)
).
*/
private static boolean isFalsyCollection(Expression expr) {
if (expr instanceof CallExpression callExpr) {
Optional fqnOpt = Optional.ofNullable(callExpr.calleeSymbol()).map(Symbol::fullyQualifiedName);
if (fqnOpt.isPresent()) {
String fqn = fqnOpt.get();
return isFalsyNoArgCollectionConstruction(callExpr, fqn) || isFalsyRange(callExpr, fqn);
}
}
return false;
}
/** FQNs of collection constructors that yield a falsy collection if invoked without arguments. */
private static final Set NO_ARG_FALSY_COLLECTION_CONSTRUCTORS = new HashSet<>(Arrays.asList(
"set", "list", "dict"));
/** Detects expressions like dict()
or list()
. */
private static boolean isFalsyNoArgCollectionConstruction(CallExpression callExpr, String fqn) {
return NO_ARG_FALSY_COLLECTION_CONSTRUCTORS.contains(fqn) && callExpr.arguments().isEmpty();
}
private static boolean isFalsyRange(CallExpression callExpr, String fqn) {
if ("range".equals(fqn) && callExpr.arguments().size() == 1) {
// `range(0)` is also falsy
Argument firstArg = callExpr.arguments().get(0);
if (firstArg instanceof RegularArgument regArg) {
Expression firstArgExpr = regArg.expression();
if (firstArgExpr.is(Tree.Kind.NUMERIC_LITERAL)) {
NumericLiteral num = (NumericLiteral) firstArgExpr;
return num.valueAsLong() == 0L;
}
}
}
return false;
}
private static void standardSslCheckForAssignmentStatement(SubscriptionContext subscriptionContext) {
AssignmentStatement asgnStmt = (AssignmentStatement) subscriptionContext.syntaxNode();
Optional vulnTokOpt = isVulnerableMethodCall(asgnStmt.assignedValue());
vulnTokOpt.ifPresent(vulnTok -> asgnStmt
.lhsExpressions()
.stream()
.flatMap(it -> it.expressions().stream())
.findFirst()
.filter(Name.class::isInstance)
.map(expr -> ((Name) expr).symbol())
.ifPresent(symb -> {
for (Usage u : selectRelevantModifyingUsages(symb.usages(), vulnTok.token.line())) {
searchForVerifyModeOverride(u).ifPresent(vulnTok::overrideBy);
}
if (vulnTok.isVulnerable) {
subscriptionContext.addIssue(vulnTok.token, MESSAGE);
}
}));
}
private static void standardSslCheckForRegularArgument(SubscriptionContext subscriptionContext) {
var argument = (RegularArgument) subscriptionContext.syntaxNode();
isVulnerableMethodCall(argument.expression())
.ifPresent(vulnTok -> subscriptionContext.addIssue(vulnTok.token, MESSAGE));
}
/** Finds the next higher line where a binding usage occurs. */
private static int findNextAssignmentLine(List usages, int firstAssignmentLine) {
int closestHigher = Integer.MAX_VALUE;
for (Usage u : usages) {
if (u.isBindingUsage()) {
int line = u.tree().firstToken().line();
if (line > firstAssignmentLine && line <= closestHigher) {
closestHigher = line;
}
}
}
return closestHigher;
}
/**
* Selects all non-binding usages between first assignment and next assignment.
*
* We assume that in a vast majority of cases, there will be no complex control flow between the instantiation
* of the context and the modification of the settings, thus selecting and sorting usages by line numbers
* should suffice here.
*/
private static List selectRelevantModifyingUsages(List usages, int firstAssignmentLine) {
int nextAssignmentLine = findNextAssignmentLine(usages, firstAssignmentLine);
ArrayList result = new ArrayList();
usages.stream().filter(u -> {
int line = u.tree().firstToken().line();
return !u.isBindingUsage() && line > firstAssignmentLine && line < nextAssignmentLine;
}).forEach(u -> result.add(u));
result.sort(Comparator.comparing(u -> u.tree().firstToken().line()));
return result;
}
/**
* Map from FQNs of sensitive context factories to the boolean that determines whether default settings are dangerous.
*/
private static final Map VULNERABLE_CONTEXT_FACTORIES = Map.of(
"ssl._create_unverified_context", true,
"ssl._create_stdlib_context", true,
"ssl.create_default_context", false,
"ssl._create_default_https_context", false);
/** Pair and a mutable cell for combining all updates to verify_mode
. */
private static class VulnerabilityAndProblematicToken {
boolean isInvisibleDefaultPreset;
boolean isVulnerable;
Token token;
VulnerabilityAndProblematicToken(
boolean isVulnerable,
Token token,
boolean isInvisibleDefaultPreset) {
this.isVulnerable = isVulnerable;
this.token = token;
this.isInvisibleDefaultPreset = isInvisibleDefaultPreset;
}
void overrideBy(VulnerabilityAndProblematicToken overridingAssignment) {
this.isInvisibleDefaultPreset = false;
this.isVulnerable = overridingAssignment.isVulnerable;
this.token = overridingAssignment.token;
}
}
/**
* Searches an expression for a factory invocation of shape ssl.somehow_create_context
,
* if found, returns the token of the callee, together with the boolean that indicates whether the default settings
* are dangerous.
*/
private static Optional isVulnerableMethodCall(Expression expr) {
if (expr instanceof CallExpression callExpression) {
Symbol calleeSymbol = callExpression.calleeSymbol();
if (calleeSymbol != null) {
String fqn = calleeSymbol.fullyQualifiedName();
if (fqn != null && VULNERABLE_CONTEXT_FACTORIES.containsKey(fqn)) {
boolean isVulnerable = VULNERABLE_CONTEXT_FACTORIES.get(fqn);
return Optional.of(new VulnerabilityAndProblematicToken(
isVulnerable,
callExpression.callee().lastToken(),
true));
}
}
}
return Optional.empty();
}
private static Optional searchForVerifyModeOverride(Usage u) {
if (!u.isBindingUsage()) {
return Optional.of(u)
.map(Usage::tree)
.map(Tree::parent)
.filter(QualifiedExpression.class::isInstance)
.map(QualifiedExpression.class::cast)
.filter(qe -> "verify_mode".equals(qe.name().name()))
.map(QualifiedExpression::parent)
.map(Tree::parent)
.filter(AssignmentStatement.class::isInstance)
.map(ae -> ((AssignmentStatement) ae).assignedValue())
.filter(QualifiedExpression.class::isInstance)
.flatMap(qe -> Optional
.ofNullable(((QualifiedExpression) qe).symbol())
.map(symb -> new VulnerabilityAndProblematicToken(
"ssl.CERT_NONE".equals(symb.fullyQualifiedName()),
qe.lastToken(),
false)));
}
return Optional.empty();
}
}
© 2015 - 2024 Weber Informatics LLC | Privacy Policy