Skip to content

Commit 713fb78

Browse files
ghislainpiotsonartech
authored andcommitted
SONARPY-1700 Add support for Argon2 to S5344 (#187)
GitOrigin-RevId: 9fc1d6e48c9376d3a01c8560e729ad18ef5fe0e2
1 parent e8d18b2 commit 713fb78

8 files changed

Lines changed: 190 additions & 10 deletions

File tree

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,17 @@ static boolean isLessThanExponent(Expression expression, int exponent) {
5959
return false;
6060
}
6161

62+
static boolean isEqualTo(Expression expression, int number) {
63+
try {
64+
if (expression.is(Tree.Kind.NAME)) {
65+
return Expressions.singleAssignedNonNameValue(((Name) expression)).map(value -> isEqualTo(value, number)).orElse(false);
66+
}
67+
return expression.is(Tree.Kind.NUMERIC_LITERAL) && ((NumericLiteral) expression).valueAsLong() == number;
68+
} catch (NumberFormatException nfe) {
69+
return false;
70+
}
71+
}
72+
6273
interface CallValidator {
6374
void validate(SubscriptionContext ctx, CallExpression callExpression);
6475
}

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

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,27 +23,33 @@
2323
import org.sonar.check.Rule;
2424
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
2525
import org.sonar.plugins.python.api.SubscriptionContext;
26+
import org.sonar.plugins.python.api.tree.AssignmentStatement;
2627
import org.sonar.plugins.python.api.tree.CallExpression;
2728
import org.sonar.plugins.python.api.tree.Expression;
2829
import org.sonar.plugins.python.api.tree.Name;
2930
import org.sonar.plugins.python.api.tree.QualifiedExpression;
3031
import org.sonar.plugins.python.api.tree.RegularArgument;
3132
import org.sonar.plugins.python.api.tree.StringLiteral;
3233
import org.sonar.plugins.python.api.tree.Tree;
34+
import org.sonar.plugins.python.api.types.v2.TriBool;
3335
import org.sonar.python.checks.hotspots.CommonValidationUtils.ArgumentValidator;
3436
import org.sonar.python.checks.hotspots.CommonValidationUtils.CallValidator;
3537
import org.sonar.python.checks.utils.Expressions;
3638
import org.sonar.python.tree.TreeUtils;
39+
import org.sonar.python.types.v2.TypeCheckBuilder;
3740

41+
import static org.sonar.python.checks.hotspots.CommonValidationUtils.isEqualTo;
3842
import static org.sonar.python.checks.hotspots.CommonValidationUtils.isLessThan;
3943
import static org.sonar.python.semantic.SymbolUtils.qualifiedNameOrEmpty;
44+
import static org.sonar.python.tree.TreeUtils.nthArgumentOrKeyword;
4045
import static org.sonar.python.tree.TreeUtils.nthArgumentOrKeywordOptional;
4146

4247
@Rule(key = "S5344")
4348
public class FastHashingOrPlainTextCheck extends PythonSubscriptionCheck {
4449

4550
private static final String SCRYPT_PARAMETERS_MESSAGE = "Use strong scrypt parameters.";
4651
private static final String PBKDF2_MESSAGE = "Use at least 100 000 iterations.";
52+
private static final String ARGON2_MESSAGE = "Use secure Argon2 parameters.";
4753
private static final Set<String> PBKDF2_ALGOS = Set.of(
4854
"sha1",
4955
"sha256",
@@ -136,13 +142,18 @@ public class FastHashingOrPlainTextCheck extends PythonSubscriptionCheck {
136142
);
137143

138144

139-
private static final Map<String, Collection<CallValidator>> CALL_EXPRESSION_VALIDATORS = Map.of(
140-
"scrypt.hash", List.of(SCRYPT_R, SCRYPT_BUFLEN, SCRYPT_N),
141-
"hashlib.scrypt", List.of(HASHLIB_R, HASHLIB_N, HASHLIB_DKLEN),
142-
"hashlib.pbkdf2_hmac", List.of(HASHLIB_PBKDF2),
143-
"cryptography.hazmat.primitives.kdf.scrypt.Scrypt", List.of(CRYPTOGRAPHY_R, CRYPTOGRAPHY_N, CRYPTOGRAPHY_LENGTH),
144-
"cryptography.hazmat.primitives.kdf.pbkdf2.PBKDF2HMAC", List.of(CRYPTOGRAPHY_PBKDF2),
145-
"passlib.hash.scrypt.using", List.of(PASSLIB_BLOCK_SIZE, PASSLIB_ROUNDS)
145+
private static final Map<String, Collection<CallValidator>> CALL_EXPRESSION_VALIDATORS = Map.ofEntries(
146+
Map.entry("scrypt.hash", List.of(SCRYPT_R, SCRYPT_BUFLEN, SCRYPT_N)),
147+
Map.entry("hashlib.scrypt", List.of(HASHLIB_R, HASHLIB_N, HASHLIB_DKLEN)),
148+
Map.entry("hashlib.pbkdf2_hmac", List.of(HASHLIB_PBKDF2)),
149+
Map.entry("cryptography.hazmat.primitives.kdf.scrypt.Scrypt", List.of(CRYPTOGRAPHY_R, CRYPTOGRAPHY_N, CRYPTOGRAPHY_LENGTH)),
150+
Map.entry("cryptography.hazmat.primitives.kdf.pbkdf2.PBKDF2HMAC", List.of(CRYPTOGRAPHY_PBKDF2)),
151+
Map.entry("passlib.hash.scrypt.using", List.of(PASSLIB_BLOCK_SIZE, PASSLIB_ROUNDS)),
152+
Map.entry("argon2.PasswordHasher", List.of(new Argon2PasswordHasherValidator(0, 1, 2))),
153+
Map.entry("argon2.Parameters", List.of(new Argon2PasswordHasherValidator(4, 5, 6))),
154+
Map.entry("argon2.low_level.hash_secret", List.of(new Argon2PasswordHasherValidator(2, 3, 4))),
155+
Map.entry("argon2.low_level.hash_secret_raw", List.of(new Argon2PasswordHasherValidator(2, 3, 4))),
156+
Map.entry("passlib.handlers.argon2._Argon2Common.using", List.of(new Argon2PasswordHasherValidator(3, 4, 5)))
146157
);
147158

148159
private static final Map<String, Collection<CallValidator>> QUALIFIED_EXPR_VALIDATOR = Map.of(
@@ -152,9 +163,32 @@ public class FastHashingOrPlainTextCheck extends PythonSubscriptionCheck {
152163
);
153164

154165

166+
private TypeCheckBuilder argon2CheapestProfileTypeChecker = null;
155167
@Override
156168
public void initialize(Context context) {
169+
context.registerSyntaxNodeConsumer(Tree.Kind.FILE_INPUT, this::registerTypeCheckers);
157170
context.registerSyntaxNodeConsumer(Tree.Kind.CALL_EXPR, FastHashingOrPlainTextCheck::checkCallExpr);
171+
context.registerSyntaxNodeConsumer(Tree.Kind.NAME, this::checkName);
172+
}
173+
174+
private void registerTypeCheckers(SubscriptionContext subscriptionContext) {
175+
argon2CheapestProfileTypeChecker = subscriptionContext.typeChecker().typeCheckBuilder().isTypeWithFqn("argon2.profiles.CHEAPEST");
176+
}
177+
178+
private void checkName(SubscriptionContext subscriptionContext) {
179+
var name = (Name) subscriptionContext.syntaxNode();
180+
if (argon2CheapestProfileTypeChecker.check(name.typeV2()) != TriBool.TRUE) {
181+
return;
182+
}
183+
var ancestorAssign = ((AssignmentStatement) TreeUtils.firstAncestorOfKind(name, Tree.Kind.ASSIGNMENT_STMT));
184+
if (ancestorAssign != null && isChildOf(ancestorAssign, name)) {
185+
return;
186+
}
187+
subscriptionContext.addIssue(name, "Use a secure Argon2 profile.");
188+
}
189+
190+
private static boolean isChildOf(AssignmentStatement ancestorAssign, Name name) {
191+
return ancestorAssign.lhsExpressions().stream().flatMap(expressionList -> expressionList.children().stream()).anyMatch(tree -> tree == name);
158192
}
159193

160194
private static void checkCallExpr(SubscriptionContext subscriptionContext) {
@@ -204,12 +238,36 @@ public void validate(SubscriptionContext ctx, CallExpression callExpression) {
204238
.ifPresent(arg -> ctx.addIssue(arg, PBKDF2_MESSAGE));
205239
}
206240
}
207-
208241
record MissingArgumentValidator(int position, String keywordName, String message) implements CallValidator {
209242
@Override
210243
public void validate(SubscriptionContext ctx, CallExpression callExpression) {
211244
nthArgumentOrKeywordOptional(position, keywordName, callExpression.arguments()).ifPresentOrElse(regularArgument -> {
212245
}, () -> ctx.addIssue(callExpression.callee(), message));
213246
}
214247
}
248+
249+
record Argon2PasswordHasherValidator(
250+
int timeCostPosition,
251+
int memoryCostPosition,
252+
int parallelismPosition
253+
) implements CallValidator {
254+
255+
@Override
256+
public void validate(SubscriptionContext ctx, CallExpression callExpression) {
257+
var timeCostArgument =
258+
nthArgumentOrKeyword(timeCostPosition, "time_cost", callExpression.arguments());
259+
var memoryCostArgument =
260+
nthArgumentOrKeyword(memoryCostPosition, "memory_cost", callExpression.arguments());
261+
var parallelismArgument =
262+
nthArgumentOrKeyword(parallelismPosition, "parallelism", callExpression.arguments());
263+
264+
var isTimeCostNOk = timeCostArgument != null && isLessThan(timeCostArgument.expression(), 5);
265+
var isMemoryCostNOk = memoryCostArgument != null && isLessThan(memoryCostArgument.expression(), 7168);
266+
var isParallelismNOk = parallelismArgument != null && isEqualTo(parallelismArgument.expression(), 1);
267+
268+
if (isMemoryCostNOk && isTimeCostNOk && isParallelismNOk) {
269+
ctx.addIssue(callExpression.callee(), ARGON2_MESSAGE);
270+
}
271+
}
272+
}
215273
}

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,17 @@ private static void checkCallExpr(SubscriptionContext ctx) {
4141
ctx.addIssue(argument, "Argument is less than 10");
4242
}
4343
});
44+
TreeUtils.nthArgumentOrKeywordOptional(1, "isEqualTo", callExpression.arguments())
45+
.ifPresent(argument -> {
46+
if (CommonValidationUtils.isEqualTo(argument.expression(), 10)) {
47+
ctx.addIssue(argument, "Argument is equal to 10");
48+
}
49+
});
4450
}
4551
}
4652

4753
@Test
4854
void isLessThan() {
4955
PythonCheckVerifier.verify("src/test/resources/checks/commonValidationUtils.py", new isLessThanTestCheck());
50-
51-
5256
}
5357
}

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,14 @@ void test() {
2626
PythonCheckVerifier.verify(Collections.singletonList("src/test/resources/checks/fastHashingOrPlainText.py"), new FastHashingOrPlainTextCheck());
2727
}
2828

29+
@Test
30+
void cheapestAnywhereImportFrom() {
31+
PythonCheckVerifier.verify(Collections.singletonList("src/test/resources/checks/fastHashingOrPlainTextArgon2ImportFrom.py"), new FastHashingOrPlainTextCheck());
32+
}
33+
34+
@Test
35+
void cheapestAnywhereImport() {
36+
PythonCheckVerifier.verify(Collections.singletonList("src/test/resources/checks/fastHashingOrPlainTextArgon2Import.py"), new FastHashingOrPlainTextCheck());
37+
}
38+
2939
}

python-checks/src/test/resources/checks/commonValidationUtils.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,11 @@
88

99
var_wrong = "something"
1010
callExpr(var_wrong)
11+
12+
callExpr(12, 0)
13+
callExpr(12, 10) # Noncompliant {{Argument is equal to 10}}
14+
ten = 10
15+
callExpr(isEqualTo=ten) # Noncompliant {{Argument is equal to 10}}
16+
not_ten = 11
17+
callExpr(isEqualTo=not_ten)
18+
callExpr(12, var_wrong)

python-checks/src/test/resources/checks/fastHashingOrPlainText.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,3 +232,74 @@ def passlib():
232232
pbkdf2_sha256.using(salt) # Noncompliant
233233
pbkdf2_sha512.using(salt) # Noncompliant {{Use at least 100 000 iterations.}}
234234
# ^^^^^^^^^^^^^^^^^^^
235+
236+
## Argon2
237+
238+
def not_cheapest():
239+
from argon2 import PasswordHasher
240+
from argon2.profiles import RFC_9106_HIGH_MEMORY
241+
PasswordHasher.from_parameters(RFC_9106_HIGH_MEMORY)
242+
243+
244+
def manual_unsafe():
245+
from argon2 import PasswordHasher, Parameters
246+
PasswordHasher( # Noncompliant {{Use secure Argon2 parameters.}}
247+
time_cost=4,
248+
memory_cost=7167,
249+
parallelism=1,
250+
)
251+
PasswordHasher(
252+
time_cost=4,
253+
memory_cost=7167,
254+
parallelism=2,
255+
)
256+
PasswordHasher(
257+
time_cost=4,
258+
memory_cost=7167,
259+
)
260+
PasswordHasher(
261+
time_cost=4,
262+
parallelism=1,
263+
)
264+
PasswordHasher(
265+
memory_cost=7167,
266+
parallelism=1,
267+
)
268+
269+
Parameters(type, version, salt_len, hash_len, 1, 1, 1) # Noncompliant
270+
271+
from argon2.low_level import hash_secret, hash_secret_raw
272+
hash_secret( # Noncompliant
273+
password,
274+
salt,
275+
4,
276+
7167,
277+
1,
278+
)
279+
hash_secret(
280+
password,
281+
salt,
282+
6,
283+
7167,
284+
1,
285+
)
286+
287+
hash_secret_raw( # Noncompliant
288+
password,
289+
salt,
290+
4,
291+
7167,
292+
1,
293+
)
294+
hash_secret_raw(
295+
password,
296+
salt,
297+
6,
298+
7167,
299+
1,
300+
)
301+
302+
from passlib.hash import argon2
303+
304+
argon2.using(time_cost=4, memory_cost=7167, parallelism=1) # Noncompliant
305+
argon2.using(time_cost=4, memory_cost=7167, parallelism=2)
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from argon2 import PasswordHasher
2+
import argon2.profiles
3+
4+
some_var = argon2.profiles.CHEAPEST # Noncompliant {{Use a secure Argon2 profile.}}
5+
6+
# ^^^^^^^^@-1
7+
8+
foo(some_var) # Noncompliant
9+
# ^^^^^^^^
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from argon2 import PasswordHasher
2+
from argon2.profiles import CHEAPEST # Noncompliant {{Use a secure Argon2 profile.}}
3+
4+
# ^^^^^^^^@-1
5+
some_var = CHEAPEST # Noncompliant {{Use a secure Argon2 profile.}}
6+
# ^^^^^^^^
7+
8+
foo(some_var) # Noncompliant
9+
# ^^^^^^^^

0 commit comments

Comments
 (0)