Skip to content

Commit

Permalink
New Sonar SSRF codemod (#449)
Browse files Browse the repository at this point in the history
/close #work

Could not find any examples of sonar findings for SSRF vulnerabilities
using `Url` or `HTTPUrlConnection`. This codemod only covers
vulnerabilities found using the `RestTemplate` class from spring.
  • Loading branch information
andrecsilva authored Oct 1, 2024
1 parent 9196cb2 commit 6fc81f2
Show file tree
Hide file tree
Showing 8 changed files with 593 additions and 26 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ public static List<Class<? extends CodeChanger>> asList() {
SonarXXECodemod.class,
SonarSQLInjectionCodemod.class,
SonarUnsafeReflectionRemediationCodemod.class,
SonarSSRFCodemod.class,
SQLParameterizerCodemod.class,
SSRFCodemod.class,
StackTraceExposureCodemod.class,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package io.codemodder.codemods;

import com.github.javaparser.ast.CompilationUnit;
import io.codemodder.*;
import io.codemodder.codemods.remediators.ssrf.SSRFRemediator;
import io.codemodder.codetf.DetectorRule;
import io.codemodder.providers.sonar.ProvidedSonarScan;
import io.codemodder.providers.sonar.RuleIssue;
import io.codemodder.providers.sonar.SonarRemediatingJavaParserChanger;
import io.codemodder.remediation.GenericRemediationMetadata;
import io.codemodder.sonar.model.Issue;
import io.codemodder.sonar.model.SonarFinding;
import java.util.List;
import java.util.Objects;
import javax.inject.Inject;

/** Fixes SSRF issues found by sonar rule javasecurity:S5144. */
@Codemod(
id = "sonar:java/ssrf-s5144",
reviewGuidance = ReviewGuidance.MERGE_WITHOUT_REVIEW,
executionPriority = CodemodExecutionPriority.HIGH,
importance = Importance.HIGH)
public final class SonarSSRFCodemod extends SonarRemediatingJavaParserChanger {

private final SSRFRemediator remediator;
private final RuleIssue issues;

@Inject
public SonarSSRFCodemod(
@ProvidedSonarScan(ruleId = "javasecurity:S5144") final RuleIssue issues) {
super(GenericRemediationMetadata.SSRF.reporter(), issues);
this.issues = Objects.requireNonNull(issues);
this.remediator = SSRFRemediator.DEFAULT;
}

@Override
public DetectorRule detectorRule() {
return new DetectorRule(
"javasecurity:S5144",
"Server-side requests should not be vulnerable to forging attacks",
"https://rules.sonarsource.com/java/RSPEC-5144/");
}

@Override
public CodemodFileScanningResult visit(
final CodemodInvocationContext context, final CompilationUnit cu) {
List<Issue> issuesForFile = issues.getResultsByPath(context.path());
return remediator.remediateAll(
cu,
context.path().toString(),
detectorRule(),
issuesForFile,
SonarFinding::getKey,
i -> i.getTextRange() != null ? i.getTextRange().getStartLine() : i.getLine(),
i -> i.getTextRange() != null ? i.getTextRange().getEndLine() : null,
i -> i.getTextRange() != null ? i.getTextRange().getStartOffset() : null);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,14 @@
import io.codemodder.remediation.FixCandidate;
import io.codemodder.remediation.FixCandidateSearchResults;
import io.codemodder.remediation.FixCandidateSearcher;
import io.codemodder.remediation.MethodOrConstructor;
import io.github.pixee.security.HostValidator;
import io.github.pixee.security.Urls;
import java.util.ArrayList;
import java.util.List;
import java.util.function.BiPredicate;
import java.util.function.Function;
import org.javatuples.Pair;

final class DefaultSSRFRemediator implements SSRFRemediator {

Expand All @@ -45,8 +48,25 @@ public <T> CodemodFileScanningResult remediateAll(
.withMatcher(mce -> !mce.getArguments().isEmpty())
.build();

FixCandidateSearchResults<T> results =
searcher.search(
// Matches calls like RestTemplate.exchange(...)
// Doesn't actually check that the `exchange` call scope is actually of type RestTemplate
// This is left for the detectors, for now
FixCandidateSearcher<T> rtSearcher =
new FixCandidateSearcher.Builder<T>()
// is method with name
.withMatcher(mce -> mce.isMethodCallWithName("exchange"))
// has a scope
.withMatcher(MethodOrConstructor::isMethodCallWithScope)
// Could be improved further by adding a RestTemplate type check to the scope
.build();

List<CodemodChange> changes = new ArrayList<>();
List<UnfixedFinding> unfixedFindings = new ArrayList<>();

var pairResult =
searchAndFix(
searcher,
(cunit, moc) -> harden(cunit, moc.asObjectCreationExpr()),
cu,
path,
detectorRule,
Expand All @@ -55,30 +75,28 @@ public <T> CodemodFileScanningResult remediateAll(
getStartLine,
getEndLine,
getStartColumn);
changes.addAll(pairResult.getValue0());
unfixedFindings.addAll(pairResult.getValue1());

List<CodemodChange> changes = new ArrayList<>();

for (FixCandidate<T> candidate : results.fixCandidates()) {
ObjectCreationExpr call = (ObjectCreationExpr) candidate.call().asNode();
List<T> issues = candidate.issues();
harden(cu, call);
List<FixedFinding> fixedFindings =
issues.stream()
.map(issue -> new FixedFinding(getKey.apply(issue), detectorRule))
.toList();
CodemodChange change =
CodemodChange.from(
getStartLine.apply(issues.get(0)),
List.of(DependencyGAV.JAVA_SECURITY_TOOLKIT),
fixedFindings);
changes.add(change);
}
var pairResultRT =
searchAndFix(
rtSearcher,
(cunit, moc) -> hardenRT(cunit, moc.asMethodCall()),
cu,
path,
detectorRule,
issuesForFile,
getKey,
getStartLine,
getEndLine,
getStartColumn);
changes.addAll(pairResultRT.getValue0());
unfixedFindings.addAll(pairResultRT.getValue1());

List<UnfixedFinding> unfixedFindings = new ArrayList<>(results.unfixableFindings());
return CodemodFileScanningResult.from(changes, unfixedFindings);
}

private void harden(final CompilationUnit cu, final ObjectCreationExpr newUrlCall) {
private boolean harden(final CompilationUnit cu, final ObjectCreationExpr newUrlCall) {
NodeList<Expression> arguments = newUrlCall.getArguments();

/*
Expand All @@ -92,23 +110,102 @@ private void harden(final CompilationUnit cu, final ObjectCreationExpr newUrlCal
* ...
* URL u = Urls.create(foo, io.github.pixee.security.Urls.HTTP_PROTOCOLS, io.github.pixee.security.HostValidator.ALLOW_ALL)
*/
MethodCallExpr safeCall = wrapInUrlsCreate(cu, arguments);
newUrlCall.replace(safeCall);
return true;
}

private MethodCallExpr wrapInUrlsCreate(
final CompilationUnit cu, final NodeList<Expression> arguments) {
addImportIfMissing(cu, Urls.class.getName());
addImportIfMissing(cu, HostValidator.class.getName());

FieldAccessExpr httpProtocolsExpr = new FieldAccessExpr();
httpProtocolsExpr.setScope(new NameExpr(Urls.class.getSimpleName()));
httpProtocolsExpr.setName("HTTP_PROTOCOLS");

FieldAccessExpr denyCommonTargetsExpr = new FieldAccessExpr();

denyCommonTargetsExpr.setScope(new NameExpr(HostValidator.class.getSimpleName()));
denyCommonTargetsExpr.setName("DENY_COMMON_INFRASTRUCTURE_TARGETS");

NodeList<Expression> newArguments = new NodeList<>();
newArguments.addAll(arguments); // first are all the arguments they were passing to "new URL"
newArguments.addAll(arguments); // add expression
newArguments.add(httpProtocolsExpr); // load the protocols they're allowed
newArguments.add(denyCommonTargetsExpr); // load the host validator
MethodCallExpr safeCall =
new MethodCallExpr(new NameExpr(Urls.class.getSimpleName()), "create", newArguments);
newUrlCall.replace(safeCall);

return new MethodCallExpr(new NameExpr(Urls.class.getSimpleName()), "create", newArguments);
}

private boolean hardenRT(final CompilationUnit cu, final MethodCallExpr call) {
var maybeFirstArg = call.getArguments().stream().findFirst();
if (maybeFirstArg.isPresent()) {
var wrappedArg =
new MethodCallExpr(
wrapInUrlsCreate(cu, new NodeList<>(maybeFirstArg.get().clone())), "toString");
maybeFirstArg.get().replace(wrappedArg);
return true;
}
return false;
}

/**
* Returns a list of changes and unfixed findings for a pair of searcher, that gather relevant
* issues, and a fixer predicate, that returns true if the change is successful.
*/
private <T> Pair<List<CodemodChange>, List<UnfixedFinding>> searchAndFix(
final FixCandidateSearcher<T> searcher,
final BiPredicate<CompilationUnit, MethodOrConstructor> fixer,
final CompilationUnit cu,
final String path,
final DetectorRule detectorRule,
final List<T> issuesForFile,
final Function<T, String> getKey,
final Function<T, Integer> getStartLine,
final Function<T, Integer> getEndLine,
final Function<T, Integer> getStartColumn) {
List<CodemodChange> changes = new ArrayList<>();
List<UnfixedFinding> unfixedFindings = new ArrayList<>();

FixCandidateSearchResults<T> results =
searcher.search(
cu,
path,
detectorRule,
issuesForFile,
getKey,
getStartLine,
getEndLine,
getStartColumn);

for (FixCandidate<T> candidate : results.fixCandidates()) {
MethodOrConstructor call = candidate.call();
List<T> issues = candidate.issues();
if (fixer.test(cu, call)) {
List<FixedFinding> fixedFindings =
issues.stream()
.map(issue -> new FixedFinding(getKey.apply(issue), detectorRule))
.toList();
CodemodChange change =
CodemodChange.from(
getStartLine.apply(issues.get(0)),
List.of(DependencyGAV.JAVA_SECURITY_TOOLKIT),
fixedFindings);
changes.add(change);
} else {
issues.forEach(
issue -> {
final String id = getKey.apply(issue);
final UnfixedFinding unfixableFinding =
new UnfixedFinding(
id,
detectorRule,
path,
getStartLine.apply(issues.get(0)),
"State changing effects possible or unrecognized code shape");
unfixedFindings.add(unfixableFinding);
});
}
}
return Pair.with(changes, unfixedFindings);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package io.codemodder.codemods;

import io.codemodder.DependencyGAV;
import io.codemodder.testutils.CodemodTestMixin;
import io.codemodder.testutils.Metadata;
import org.junit.jupiter.api.Nested;

final class SonarSSRFCodemodTest {

@Nested
@Metadata(
codemodType = SonarSSRFCodemod.class,
testResourceDir = "sonar-ssrf-s5144/resttemplate",
renameTestFile =
"src/main/java/org/owasp/webgoat/lessons/passwordreset/ResetLinkAssignmentForgotPassword.java",
expectingFixesAtLines = {104},
dependencies = DependencyGAV.JAVA_SECURITY_TOOLKIT_GAV)
class RestTemplateTest implements CodemodTestMixin {}
}
Loading

0 comments on commit 6fc81f2

Please sign in to comment.