Skip to content

Commit 1bff988

Browse files
gildaynahsra
andauthored
✨ New Sonar Remediation Codemod for Unsafe Reflection (#390)
Remediates Sonar findings of type java:S2658. Does not yet cover edge-cases, but I'd like to get some feedback on the happy path while I work on those edge cases. /towards #work --------- Co-authored-by: Arshan Dabirsiaghi <arshan.dabirsiaghi@gmail.com>
1 parent 7a0abc7 commit 1bff988

File tree

18 files changed

+658
-4
lines changed

18 files changed

+658
-4
lines changed

core-codemods/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ dependencies {
3838
testImplementation(project(":framework:codemodder-testutils-llm"))
3939
testRuntimeOnly(testlibs.junit.jupiter.engine)
4040
testImplementation(testlibs.jgit)
41+
testImplementation(testlibs.assertj)
4142
testImplementation("org.testcontainers:testcontainers:1.19.0")
4243
}
4344

core-codemods/src/main/java/io/codemodder/codemods/DefaultCodemods.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ public static List<Class<? extends CodeChanger>> asList() {
6363
SwitchLiteralFirstComparisonsCodemod.class,
6464
SwitchToStandardCharsetsCodemod.class,
6565
UnverifiedJwtCodemod.class,
66+
SonarUnsafeReflectionRemediationCodemod.class,
6667
UpgradeSSLContextTLSCodemod.class,
6768
UpgradeSSLEngineTLSCodemod.class,
6869
UpgradeSSLParametersTLSCodemod.class,
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package io.codemodder.codemods;
2+
3+
import com.github.javaparser.ast.CompilationUnit;
4+
import io.codemodder.*;
5+
import io.codemodder.codetf.DetectorRule;
6+
import io.codemodder.providers.sonar.ProvidedSonarScan;
7+
import io.codemodder.providers.sonar.RuleIssue;
8+
import io.codemodder.providers.sonar.SonarRemediatingJavaParserChanger;
9+
import io.codemodder.remediation.GenericVulnerabilityReporterStrategies;
10+
import io.codemodder.remediation.reflectioninjection.ReflectionInjectionRemediator;
11+
import io.codemodder.sonar.model.Issue;
12+
import java.util.Objects;
13+
import javax.inject.Inject;
14+
15+
/** Sonar remediation codemod for S2658: Classes should not be loaded dynamically. */
16+
@Codemod(
17+
id = "sonar:java/unsafe-reflection-s2658",
18+
reviewGuidance = ReviewGuidance.MERGE_WITHOUT_REVIEW,
19+
importance = Importance.HIGH,
20+
executionPriority = CodemodExecutionPriority.HIGH)
21+
public final class SonarUnsafeReflectionRemediationCodemod
22+
extends SonarRemediatingJavaParserChanger {
23+
24+
private final ReflectionInjectionRemediator remediator;
25+
private final RuleIssue issues;
26+
27+
@Inject
28+
public SonarUnsafeReflectionRemediationCodemod(
29+
@ProvidedSonarScan(ruleId = "java:S2658") final RuleIssue issues) {
30+
super(GenericVulnerabilityReporterStrategies.reflectionInjectionStrategy, issues);
31+
this.remediator = ReflectionInjectionRemediator.DEFAULT;
32+
this.issues = Objects.requireNonNull(issues);
33+
}
34+
35+
@Override
36+
public DetectorRule detectorRule() {
37+
return new DetectorRule(
38+
"java:S2658",
39+
"Classes should not be loaded dynamically",
40+
"https://rules.sonarsource.com/java/RSPEC-2658/");
41+
}
42+
43+
@Override
44+
public CodemodFileScanningResult visit(
45+
final CodemodInvocationContext context, final CompilationUnit cu) {
46+
return remediator.remediateAll(
47+
cu,
48+
context.path().toString(),
49+
detectorRule(),
50+
issues.getResultsByPath(context.path()),
51+
Issue::getKey,
52+
Issue::getLine,
53+
i -> i.getTextRange().getStartOffset());
54+
}
55+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
This change fixes Sonar's [Classes should not be loaded dynamically](https://rules.sonarsource.com/java/RSPEC-2658/) issue by replacing the unsafe class load with a hardened alternative.
2+
3+
The hardened alternative blocks the loading of classes that are well-known to be used by attackers to exploit the application.
4+
5+
Our changes look something like this:
6+
7+
```diff
8+
- Class<MyStrategy> clazz = Class.forName(untrustedInput);
9+
+ Class<MyStrategy> clazz = Reflection.loadAndVerify(untrustedInput);
10+
```
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"summary": "Replaced unsafe usages of `Class.forName` with hardened alternative `Reflection.loadAndVerify`",
3+
"change": "Replaced unsafe usages of `Class.forName` with hardened alternative `Reflection.loadAndVerify`",
4+
"reviewGuidanceJustification": "Reflection.loadAndVerify disallows the loading of classes that are well-known to be dangerous paths to remote code execution. Pathological cases aside, the use of Reflection.loadAndVerify will not disrupt the typical operations of an application.",
5+
"references": [
6+
"https://rules.sonarsource.com/java/RSPEC-2658/"
7+
]
8+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package io.codemodder.codemods;
2+
3+
import io.codemodder.DependencyGAV;
4+
import io.codemodder.testutils.CodemodTestMixin;
5+
import io.codemodder.testutils.Metadata;
6+
7+
/**
8+
* Test for {@link SonarUnsafeReflectionRemediationCodemod}.
9+
*
10+
* <ul>
11+
* <li>Fully qualified class name {@code java.lang.Class.forName(String)}
12+
* <li>static import {@code forName(String)}
13+
* </ul>
14+
*/
15+
@Metadata(
16+
codemodType = SonarUnsafeReflectionRemediationCodemod.class,
17+
testResourceDir = "unsafe-reflection-s2658",
18+
renameTestFile = "src/main/java/com/acme/reflection/UnsafeReflection.java",
19+
expectingFixesAtLines = {25},
20+
dependencies = DependencyGAV.JAVA_SECURITY_TOOLKIT_GAV)
21+
final class SonarUnsafeReflectionRemediationCodemodTest implements CodemodTestMixin {}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package com.acme.reflection;
2+
3+
import io.github.pixee.security.Reflection;
4+
import jakarta.ws.rs.GET;
5+
import jakarta.ws.rs.Path;
6+
import jakarta.ws.rs.QueryParam;
7+
import java.lang.reflect.Constructor;
8+
import java.lang.reflect.InvocationTargetException;
9+
10+
/**
11+
* Hello world resource that uses unsafe reflection to load a translator by a name controllable by
12+
* the end-user.
13+
*/
14+
@Path("/unsafe-reflection")
15+
public class UnsafeReflection {
16+
17+
@GET
18+
public String hello(@QueryParam("translator") final String translationStrategy) {
19+
final var translator = loadTranslatorByName(translationStrategy);
20+
return translator.translate("Hello, world!");
21+
}
22+
23+
private static TranslatorStrategy loadTranslatorByName(final String translationStrategy) {
24+
final Class<?> translatorClazz;
25+
try {
26+
translatorClazz = Reflection.loadAndVerify("com.acme." + translationStrategy);
27+
} catch (ClassNotFoundException e) {
28+
throw new IllegalArgumentException("Invalid translator: " + translationStrategy, e);
29+
}
30+
if (TranslatorStrategy.class.isAssignableFrom(translatorClazz)) {
31+
throw new IllegalArgumentException("Invalid translator: " + translationStrategy);
32+
}
33+
final Constructor<?> translatorCtor;
34+
try {
35+
translatorCtor = translatorClazz.getConstructor();
36+
} catch (NoSuchMethodException e) {
37+
throw new IllegalStateException(
38+
"Translator " + translationStrategy + " is missing a no-args constructor", e);
39+
}
40+
final TranslatorStrategy translator;
41+
try {
42+
translator = (TranslatorStrategy) translatorCtor.newInstance();
43+
} catch (InstantiationException | IllegalAccessException | InvocationTargetException e) {
44+
throw new IllegalStateException("Failed to initialize translator " + translationStrategy, e);
45+
}
46+
return translator;
47+
}
48+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package com.acme.reflection;
2+
3+
import jakarta.ws.rs.GET;
4+
import jakarta.ws.rs.Path;
5+
import jakarta.ws.rs.QueryParam;
6+
import java.lang.reflect.Constructor;
7+
import java.lang.reflect.InvocationTargetException;
8+
9+
/**
10+
* Hello world resource that uses unsafe reflection to load a translator by a name controllable by
11+
* the end-user.
12+
*/
13+
@Path("/unsafe-reflection")
14+
public class UnsafeReflection {
15+
16+
@GET
17+
public String hello(@QueryParam("translator") final String translationStrategy) {
18+
final var translator = loadTranslatorByName(translationStrategy);
19+
return translator.translate("Hello, world!");
20+
}
21+
22+
private static TranslatorStrategy loadTranslatorByName(final String translationStrategy) {
23+
final Class<?> translatorClazz;
24+
try {
25+
translatorClazz = Class.forName("com.acme." + translationStrategy);
26+
} catch (ClassNotFoundException e) {
27+
throw new IllegalArgumentException("Invalid translator: " + translationStrategy, e);
28+
}
29+
if (TranslatorStrategy.class.isAssignableFrom(translatorClazz)) {
30+
throw new IllegalArgumentException("Invalid translator: " + translationStrategy);
31+
}
32+
final Constructor<?> translatorCtor;
33+
try {
34+
translatorCtor = translatorClazz.getConstructor();
35+
} catch (NoSuchMethodException e) {
36+
throw new IllegalStateException(
37+
"Translator " + translationStrategy + " is missing a no-args constructor", e);
38+
}
39+
final TranslatorStrategy translator;
40+
try {
41+
translator = (TranslatorStrategy) translatorCtor.newInstance();
42+
} catch (InstantiationException | IllegalAccessException | InvocationTargetException e) {
43+
throw new IllegalStateException("Failed to initialize translator " + translationStrategy, e);
44+
}
45+
return translator;
46+
}
47+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
{
2+
"total": 1,
3+
"p": 1,
4+
"ps": 100,
5+
"paging": {
6+
"pageIndex": 1,
7+
"pageSize": 100,
8+
"total": 1
9+
},
10+
"effortTotal": 45,
11+
"debtTotal": 45,
12+
"issues": [
13+
{
14+
"key": "AZA29hmxtpn0rIhsMyGN",
15+
"rule": "java:S2658",
16+
"severity": "CRITICAL",
17+
"component": "pixee_bad-java-code:src/main/java/com/acme/reflection/UnsafeReflection.java",
18+
"project": "pixee_bad-java-code",
19+
"line": 25,
20+
"hash": "a156d32a33f23ec9cd7fb3dcbbcebf08",
21+
"textRange": {
22+
"startLine": 25,
23+
"endLine": 25,
24+
"startOffset": 30,
25+
"endOffset": 37
26+
},
27+
"flows": [],
28+
"status": "OPEN",
29+
"message": "Remove this use of dynamic class loading.",
30+
"effort": "45min",
31+
"debt": "45min",
32+
"author": "johnathan.gilday@pixee.ai",
33+
"tags": [],
34+
"creationDate": "2024-06-20T19:50:19+0200",
35+
"updateDate": "2024-06-20T20:43:13+0200",
36+
"type": "VULNERABILITY",
37+
"organization": "pixee",
38+
"cleanCodeAttribute": "CONVENTIONAL",
39+
"cleanCodeAttributeCategory": "CONSISTENT",
40+
"impacts": [
41+
{
42+
"softwareQuality": "SECURITY",
43+
"severity": "HIGH"
44+
}
45+
]
46+
}
47+
],
48+
"components": [
49+
{
50+
"organization": "pixee",
51+
"key": "pixee_bad-java-code",
52+
"uuid": "AZAybnMG2i3IMwLpnrFI",
53+
"enabled": true,
54+
"qualifier": "TRK",
55+
"name": "my-bad-code",
56+
"longName": "my-bad-code"
57+
},
58+
{
59+
"organization": "pixee",
60+
"key": "pixee_bad-java-code:src/main/java/com/acme/reflection/UnsafeReflection.java",
61+
"uuid": "AZA2zRFOgsPIvFR8KBcb",
62+
"enabled": true,
63+
"qualifier": "FIL",
64+
"name": "UnsafeReflection.java",
65+
"longName": "src/main/java/com/acme/reflection/UnsafeReflection.java",
66+
"path": "src/main/java/com/acme/reflection/UnsafeReflection.java"
67+
}
68+
],
69+
"organizations": [
70+
{
71+
"key": "pixee",
72+
"name": "Pixee"
73+
}
74+
],
75+
"facets": []
76+
}

framework/codemodder-base/src/main/java/io/codemodder/RegionNodeMatcher.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,19 @@ public interface RegionNodeMatcher {
3434
&& region.start().column() == range.begin.column;
3535

3636
RegionNodeMatcher MATCHES_LINE = (region, range) -> region.start().line() == range.begin.line;
37+
38+
/**
39+
* Return true when the given {@link Region} is inside the given {@link Range}.
40+
*
41+
* <p>This may be used when the SARIF provider reports a region that is smaller than expected
42+
* (e.g. only the method name instead of the whole method expression). In these cases, the codemod
43+
* developer should take extra care to make sure the method expression matches expectations,
44+
* because this matcher may match multiple nodes erroneously.
45+
*/
46+
RegionNodeMatcher REGION_INSIDE_RANGE =
47+
(region, range) ->
48+
region.start().line() >= range.begin.line
49+
&& region.start().column() >= range.begin.column
50+
&& region.end().line() <= range.end.line
51+
&& region.end().column() <= range.end.column;
3752
}

0 commit comments

Comments
 (0)