]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-12718 add WS api/hotspots/show
authorSébastien Lesaint <sebastien.lesaint@sonarsource.com>
Tue, 3 Dec 2019 15:50:08 +0000 (16:50 +0100)
committerSonarTech <sonartech@sonarsource.com>
Mon, 13 Jan 2020 19:46:26 +0000 (20:46 +0100)
19 files changed:
server/sonar-webserver-webapi/src/main/java/org/sonar/server/hotspot/ws/HotspotRuleDescription.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/main/java/org/sonar/server/hotspot/ws/HotspotWsResponseFormatter.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/main/java/org/sonar/server/hotspot/ws/HotspotsWsModule.java
server/sonar-webserver-webapi/src/main/java/org/sonar/server/hotspot/ws/SearchAction.java
server/sonar-webserver-webapi/src/main/java/org/sonar/server/hotspot/ws/ShowAction.java
server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/TextRangeResponseFormatter.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/IssueWsModule.java
server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/SearchResponseData.java
server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/SearchResponseFormat.java
server/sonar-webserver-webapi/src/test/java/org/sonar/server/hotspot/ws/HotspotRuleDescriptionTest.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/test/java/org/sonar/server/hotspot/ws/HotspotsWsModuleTest.java
server/sonar-webserver-webapi/src/test/java/org/sonar/server/hotspot/ws/SearchActionTest.java
server/sonar-webserver-webapi/src/test/java/org/sonar/server/hotspot/ws/ShowActionTest.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/ws/IssueWsModuleTest.java
server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/ws/SearchActionComponentsTest.java
server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/ws/SearchActionFacetsTest.java
server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/ws/SearchActionTest.java
server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/ws/SearchActionTestOnSonarCloud.java
sonar-ws/src/main/protobuf/ws-hotspots.proto

diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/hotspot/ws/HotspotRuleDescription.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/hotspot/ws/HotspotRuleDescription.java
new file mode 100644 (file)
index 0000000..205bc72
--- /dev/null
@@ -0,0 +1,123 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 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 GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * 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 GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.server.hotspot.ws;
+
+import java.util.Optional;
+import javax.annotation.CheckForNull;
+import javax.annotation.Nullable;
+import org.sonar.db.rule.RuleDefinitionDto;
+
+import static java.lang.Character.isWhitespace;
+import static java.util.Optional.ofNullable;
+
+public class HotspotRuleDescription {
+  private static final HotspotRuleDescription NO_DESCRIPTION = new HotspotRuleDescription(null, null, null);
+
+  @CheckForNull
+  private final String risk;
+  @CheckForNull
+  private final String vulnerable;
+  @CheckForNull
+  private final String fixIt;
+
+  private HotspotRuleDescription(@Nullable String risk, @Nullable String vulnerable, @Nullable String fixIt) {
+    this.risk = risk;
+    this.vulnerable = vulnerable;
+    this.fixIt = fixIt;
+  }
+
+  public static HotspotRuleDescription from(RuleDefinitionDto dto) {
+    String description = dto.getDescription();
+    if (description == null) {
+      return NO_DESCRIPTION;
+    }
+
+    String vulnerableTitle = "<h2>Ask Yourself Whether</h2>";
+    String fixItTitle = "<h2>Recommended Secure Coding Practices</h2>";
+    int vulnerableTitlePosition = description.indexOf(vulnerableTitle);
+    int fixItTitlePosition = description.indexOf(fixItTitle);
+    if (vulnerableTitlePosition == -1 && fixItTitlePosition == -1) {
+      return NO_DESCRIPTION;
+    }
+
+    if (vulnerableTitlePosition == -1) {
+      return new HotspotRuleDescription(
+        trimingSubstring(description, 0, fixItTitlePosition),
+        null,
+        trimingSubstring(description, fixItTitlePosition, description.length())
+      );
+    }
+    if (fixItTitlePosition == -1) {
+      return new HotspotRuleDescription(
+        trimingSubstring(description, 0, vulnerableTitlePosition),
+        trimingSubstring(description, vulnerableTitlePosition, description.length()),
+        null
+      );
+    }
+    return new HotspotRuleDescription(
+      trimingSubstring(description, 0, vulnerableTitlePosition),
+      trimingSubstring(description, vulnerableTitlePosition, fixItTitlePosition),
+      trimingSubstring(description, fixItTitlePosition, description.length())
+    );
+  }
+
+  @CheckForNull
+  private static String trimingSubstring(String description, int beginIndex, int endIndex) {
+    if (beginIndex == endIndex) {
+      return null;
+    }
+
+    int trimmedBeginIndex = beginIndex;
+    while (trimmedBeginIndex < endIndex && isWhitespace(description.charAt(trimmedBeginIndex))) {
+      trimmedBeginIndex++;
+    }
+    int trimmedEndIndex = endIndex;
+    while (trimmedEndIndex > 0 && trimmedEndIndex > trimmedBeginIndex && isWhitespace(description.charAt(trimmedEndIndex - 1))) {
+      trimmedEndIndex--;
+    }
+    if (trimmedBeginIndex == trimmedEndIndex) {
+      return null;
+    }
+
+    return description.substring(trimmedBeginIndex, trimmedEndIndex);
+  }
+
+  public Optional<String> getRisk() {
+    return ofNullable(risk);
+  }
+
+  public Optional<String> getVulnerable() {
+    return ofNullable(vulnerable);
+  }
+
+  public Optional<String> getFixIt() {
+    return ofNullable(fixIt);
+  }
+
+  @Override
+  public String toString() {
+    return "HotspotRuleDescription{" +
+      "risk='" + risk + '\'' +
+      ", vulnerable='" + vulnerable + '\'' +
+      ", fixIt='" + fixIt + '\'' +
+      '}';
+  }
+
+}
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/hotspot/ws/HotspotWsResponseFormatter.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/hotspot/ws/HotspotWsResponseFormatter.java
new file mode 100644 (file)
index 0000000..0ac8889
--- /dev/null
@@ -0,0 +1,47 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 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 GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * 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 GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.server.hotspot.ws;
+
+import org.sonar.db.component.ComponentDto;
+import org.sonar.server.organization.DefaultOrganizationProvider;
+import org.sonarqube.ws.Hotspots;
+
+import static java.util.Optional.ofNullable;
+
+public class HotspotWsResponseFormatter {
+  private final DefaultOrganizationProvider defaultOrganizationProvider;
+
+  public HotspotWsResponseFormatter(DefaultOrganizationProvider defaultOrganizationProvider) {
+    this.defaultOrganizationProvider = defaultOrganizationProvider;
+  }
+
+  Hotspots.Component formatComponent(Hotspots.Component.Builder builder, ComponentDto component) {
+    builder
+      .clear()
+      .setOrganization(defaultOrganizationProvider.get().getKey())
+      .setKey(component.getKey())
+      .setQualifier(component.qualifier())
+      .setName(component.name())
+      .setLongName(component.longName());
+    ofNullable(component.path()).ifPresent(builder::setPath);
+    return builder.build();
+  }
+
+}
index e04dd6d3cce7e7f6b413bec013735a51092c73cc..2a437420b81fe392dca5f24a43268573f1b84eef 100644 (file)
@@ -25,8 +25,9 @@ public class HotspotsWsModule extends Module {
   @Override
   protected void configureModule() {
     add(
+      HotspotWsResponseFormatter.class,
       SearchAction.class,
-      HotspotsWs.class
-    );
+      ShowAction.class,
+      HotspotsWs.class);
   }
 }
index 021901007cfbaf2f1428b775fad66b96ba5d22a0..250278316d80312b2afbd5892e2dc34e2b7b9b6f 100644 (file)
@@ -51,13 +51,12 @@ import org.sonar.server.es.SearchOptions;
 import org.sonar.server.exceptions.NotFoundException;
 import org.sonar.server.issue.index.IssueIndex;
 import org.sonar.server.issue.index.IssueQuery;
-import org.sonar.server.organization.DefaultOrganizationProvider;
 import org.sonar.server.security.SecurityStandards;
 import org.sonar.server.user.UserSession;
 import org.sonarqube.ws.Common;
 import org.sonarqube.ws.Hotspots;
+import org.sonarqube.ws.Hotspots.SearchWsResponse;
 
-import static com.google.common.base.Strings.nullToEmpty;
 import static java.util.Optional.ofNullable;
 import static org.sonar.api.server.ws.WebService.Param.PAGE;
 import static org.sonar.api.server.ws.WebService.Param.PAGE_SIZE;
@@ -68,6 +67,7 @@ import static org.sonar.core.util.stream.MoreCollectors.uniqueIndex;
 import static org.sonar.server.security.SecurityStandards.fromSecurityStandards;
 import static org.sonar.server.ws.KeyExamples.KEY_PROJECT_EXAMPLE_001;
 import static org.sonar.server.ws.WsUtils.writeProtobuf;
+import static org.sonarqube.ws.WsUtils.nullToEmpty;
 
 public class SearchAction implements HotspotsWsAction {
   private static final String PARAM_PROJECT_KEY = "projectKey";
@@ -75,13 +75,14 @@ public class SearchAction implements HotspotsWsAction {
   private final DbClient dbClient;
   private final UserSession userSession;
   private final IssueIndex issueIndex;
-  private final DefaultOrganizationProvider defaultOrganizationProvider;
+  private final HotspotWsResponseFormatter responseFormatter;
 
-  public SearchAction(DbClient dbClient, UserSession userSession, IssueIndex issueIndex, DefaultOrganizationProvider defaultOrganizationProvider) {
+  public SearchAction(DbClient dbClient, UserSession userSession, IssueIndex issueIndex,
+    HotspotWsResponseFormatter responseFormatter) {
     this.dbClient = dbClient;
     this.userSession = userSession;
     this.issueIndex = issueIndex;
-    this.defaultOrganizationProvider = defaultOrganizationProvider;
+    this.responseFormatter = responseFormatter;
   }
 
   @Override
@@ -187,8 +188,8 @@ public class SearchAction implements HotspotsWsAction {
     }
   }
 
-  private Hotspots.SearchWsResponse formatResponse(SearchResponseData searchResponseData) {
-    Hotspots.SearchWsResponse.Builder responseBuilder = Hotspots.SearchWsResponse.newBuilder();
+  private SearchWsResponse formatResponse(SearchResponseData searchResponseData) {
+    SearchWsResponse.Builder responseBuilder = SearchWsResponse.newBuilder();
     formatPaging(searchResponseData, responseBuilder);
     if (!searchResponseData.isEmpty()) {
       formatHotspots(searchResponseData, responseBuilder);
@@ -197,7 +198,7 @@ public class SearchAction implements HotspotsWsAction {
     return responseBuilder.build();
   }
 
-  private void formatPaging(SearchResponseData searchResponseData, Hotspots.SearchWsResponse.Builder responseBuilder) {
+  private void formatPaging(SearchResponseData searchResponseData, SearchWsResponse.Builder responseBuilder) {
     Paging paging = searchResponseData.getPaging();
     Common.Paging.Builder pagingBuilder = Common.Paging.newBuilder()
       .setPageIndex(paging.pageIndex())
@@ -207,13 +208,13 @@ public class SearchAction implements HotspotsWsAction {
     responseBuilder.setPaging(pagingBuilder.build());
   }
 
-  private static void formatHotspots(SearchResponseData searchResponseData, Hotspots.SearchWsResponse.Builder responseBuilder) {
+  private void formatHotspots(SearchResponseData searchResponseData, SearchWsResponse.Builder responseBuilder) {
     List<IssueDto> orderedHotspots = searchResponseData.getOrderedHotspots();
     if (orderedHotspots.isEmpty()) {
       return;
     }
 
-    Hotspots.Hotspot.Builder builder = Hotspots.Hotspot.newBuilder();
+    SearchWsResponse.Hotspot.Builder builder = SearchWsResponse.Hotspot.newBuilder();
     for (IssueDto hotspot : orderedHotspots) {
       RuleDefinitionDto rule = searchResponseData.getRule(hotspot.getRuleKey())
         // due to join with table Rule when retrieving data from Issues, this can't happen
@@ -244,7 +245,7 @@ public class SearchAction implements HotspotsWsAction {
     }
   }
 
-  private void formatComponents(SearchResponseData searchResponseData, Hotspots.SearchWsResponse.Builder responseBuilder) {
+  private void formatComponents(SearchResponseData searchResponseData, SearchWsResponse.Builder responseBuilder) {
     Set<ComponentDto> components = searchResponseData.getComponents();
     if (components.isEmpty()) {
       return;
@@ -252,16 +253,7 @@ public class SearchAction implements HotspotsWsAction {
 
     Hotspots.Component.Builder builder = Hotspots.Component.newBuilder();
     for (ComponentDto component : components) {
-      builder
-        .clear()
-        .setOrganization(defaultOrganizationProvider.get().getKey())
-        .setKey(component.getKey())
-        .setQualifier(component.qualifier())
-        .setName(component.name())
-        .setLongName(component.longName());
-      ofNullable(component.path()).ifPresent(builder::setPath);
-
-      responseBuilder.addComponents(builder.build());
+      responseBuilder.addComponents(responseFormatter.formatComponent(builder, component));
     }
   }
 
index f384e4c2872d6968093f912e2498e19918458620..862f73f53acca73583ac10dae4ec2c28138e3575 100644 (file)
@@ -19,6 +19,9 @@
  */
 package org.sonar.server.hotspot.ws;
 
+import java.util.Objects;
+import org.sonar.api.rule.RuleKey;
+import org.sonar.api.rules.RuleType;
 import org.sonar.api.server.ws.Request;
 import org.sonar.api.server.ws.Response;
 import org.sonar.api.server.ws.WebService;
@@ -28,10 +31,20 @@ import org.sonar.db.DbClient;
 import org.sonar.db.DbSession;
 import org.sonar.db.component.ComponentDto;
 import org.sonar.db.issue.IssueDto;
+import org.sonar.db.rule.RuleDefinitionDto;
 import org.sonar.server.exceptions.NotFoundException;
+import org.sonar.server.issue.TextRangeResponseFormatter;
+import org.sonar.server.security.SecurityStandards;
 import org.sonar.server.user.UserSession;
+import org.sonarqube.ws.Hotspots;
+import org.sonarqube.ws.Hotspots.ShowWsResponse;
 
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Strings.nullToEmpty;
 import static java.lang.String.format;
+import static java.util.Optional.ofNullable;
+import static org.sonar.api.utils.DateUtils.formatDateTime;
+import static org.sonar.server.ws.WsUtils.writeProtobuf;
 
 public class ShowAction implements HotspotsWsAction {
 
@@ -39,10 +52,14 @@ public class ShowAction implements HotspotsWsAction {
 
   private final DbClient dbClient;
   private final UserSession userSession;
+  private final HotspotWsResponseFormatter responseFormatter;
+  private final TextRangeResponseFormatter textRangeFormatter;
 
-  public ShowAction(DbClient dbClient, UserSession userSession) {
+  public ShowAction(DbClient dbClient, UserSession userSession, HotspotWsResponseFormatter responseFormatter, TextRangeResponseFormatter textRangeFormatter) {
     this.dbClient = dbClient;
     this.userSession = userSession;
+    this.responseFormatter = responseFormatter;
+    this.textRangeFormatter = textRangeFormatter;
   }
 
   @Override
@@ -50,8 +67,9 @@ public class ShowAction implements HotspotsWsAction {
     WebService.NewAction action = controller
       .createAction("show")
       .setHandler(this)
-      .setDescription("Provides the details of a Security Hotpot.")
-      .setSince("8.1");
+      .setDescription("Provides the details of a Security Hotspot.")
+      .setSince("8.1")
+      .setInternal(true);
 
     action.createParam(PARAM_HOTSPOT_KEY)
       .setDescription("Key of the Security Hotspot")
@@ -66,12 +84,103 @@ public class ShowAction implements HotspotsWsAction {
     String hotspotKey = request.mandatoryParam(PARAM_HOTSPOT_KEY);
     try (DbSession dbSession = dbClient.openSession(false)) {
       IssueDto hotspot = dbClient.issueDao().selectByKey(dbSession, hotspotKey)
-        .orElseThrow(() -> new NotFoundException(format("Hotspot with key '%s' does not exist", hotspotKey)));
-      ComponentDto project = dbClient.componentDao().selectByUuid(dbSession, hotspot.getProjectUuid())
-        .orElseThrow(() -> new NotFoundException(format("Project with uuid '%s' does not exist", hotspot.getProjectUuid())));
-      userSession.checkComponentPermission(UserRole.USER, project);
+        .filter(t -> t.getType() == RuleType.SECURITY_HOTSPOT.getDbConstant())
+        .orElseThrow(() -> new NotFoundException(format("Hotspot '%s' does not exist", hotspotKey)));
 
+      Components components = loadComponents(dbSession, hotspot);
+      RuleDefinitionDto rule = loadRule(dbSession, hotspot);
 
+      ShowWsResponse.Builder responseBuilder = ShowWsResponse.newBuilder();
+      formatHotspot(responseBuilder, hotspot);
+      formatComponents(components, responseBuilder);
+      formatRule(responseBuilder, rule);
+      formatTextRange(hotspot, responseBuilder);
+
+      writeProtobuf(responseBuilder.build(), request, response);
     }
   }
+
+  private void formatHotspot(ShowWsResponse.Builder builder, IssueDto hotspot) {
+    builder.setKey(hotspot.getKey());
+    ofNullable(hotspot.getStatus()).ifPresent(builder::setStatus);
+    // FIXME resolution field will be added later
+    // ofNullable(hotspot.getResolution()).ifPresent(builder::setResolution);
+    ofNullable(hotspot.getLine()).ifPresent(builder::setLine);
+    builder.setMessage(nullToEmpty(hotspot.getMessage()));
+    ofNullable(hotspot.getAssigneeUuid()).ifPresent(builder::setAssignee);
+    // FIXME Filter author only if user is member of the organization (as done in issues/search WS)
+    // if (data.getUserOrganizationUuids().contains(component.getOrganizationUuid())) {
+    builder.setAuthor(nullToEmpty(hotspot.getAuthorLogin()));
+    // }
+    builder.setCreationDate(formatDateTime(hotspot.getIssueCreationDate()));
+    builder.setUpdateDate(formatDateTime(hotspot.getIssueUpdateDate()));
+  }
+
+  private void formatComponents(Components components, ShowWsResponse.Builder responseBuilder) {
+    responseBuilder
+      .setProject(responseFormatter.formatComponent(Hotspots.Component.newBuilder(), components.getProject()))
+      .setComponent(responseFormatter.formatComponent(Hotspots.Component.newBuilder(), components.getComponent()));
+  }
+
+  private void formatRule(ShowWsResponse.Builder responseBuilder, RuleDefinitionDto ruleDefinitionDto) {
+    SecurityStandards securityStandards = SecurityStandards.fromSecurityStandards(ruleDefinitionDto.getSecurityStandards());
+    SecurityStandards.SQCategory sqCategory = securityStandards.getSqCategory();
+    HotspotRuleDescription hotspotRuleDescription = HotspotRuleDescription.from(ruleDefinitionDto);
+    Hotspots.Rule.Builder ruleBuilder = Hotspots.Rule.newBuilder()
+      .setKey(ruleDefinitionDto.getKey().toString())
+      .setName(nullToEmpty(ruleDefinitionDto.getName()))
+      .setSecurityCategory(sqCategory.getKey())
+      .setVulnerabilityProbability(sqCategory.getVulnerability().name());
+    hotspotRuleDescription.getVulnerable().ifPresent(ruleBuilder::setVulnerabilityDescription);
+    hotspotRuleDescription.getRisk().ifPresent(ruleBuilder::setRiskDescription);
+    hotspotRuleDescription.getFixIt().ifPresent(ruleBuilder::setFixRecommendations);
+    responseBuilder.setRule(ruleBuilder.build());
+  }
+
+  private void formatTextRange(IssueDto hotspot, ShowWsResponse.Builder responseBuilder) {
+    textRangeFormatter.formatTextRange(hotspot, responseBuilder::setTextRange);
+  }
+
+  private RuleDefinitionDto loadRule(DbSession dbSession, IssueDto hotspot) {
+    RuleKey ruleKey = hotspot.getRuleKey();
+    return dbClient.ruleDao().selectDefinitionByKey(dbSession, ruleKey)
+      .orElseThrow(() -> new NotFoundException(format("Rule '%s' does not exist", ruleKey)));
+  }
+
+  private Components loadComponents(DbSession dbSession, IssueDto hotspot) {
+    String projectUuid = hotspot.getProjectUuid();
+    String componentUuid = hotspot.getComponentUuid();
+    checkArgument(projectUuid != null, "Hotspot '%s' has no project", hotspot.getKee());
+    checkArgument(componentUuid != null, "Hotspot '%s' has no component", hotspot.getKee());
+
+    ComponentDto project = dbClient.componentDao().selectByUuid(dbSession, projectUuid)
+      .orElseThrow(() -> new NotFoundException(format("Project with uuid '%s' does not exist", projectUuid)));
+    userSession.checkComponentPermission(UserRole.USER, project);
+
+    boolean hotspotOnProject = Objects.equals(projectUuid, componentUuid);
+    ComponentDto component = hotspotOnProject ? project
+      : dbClient.componentDao().selectByUuid(dbSession, componentUuid)
+        .orElseThrow(() -> new NotFoundException(format("Component with uuid '%s' does not exist", componentUuid)));
+
+    return new Components(project, component);
+  }
+
+  private static final class Components {
+    private final ComponentDto project;
+    private final ComponentDto component;
+
+    private Components(ComponentDto project, ComponentDto component) {
+      this.project = project;
+      this.component = component;
+    }
+
+    public ComponentDto getProject() {
+      return project;
+    }
+
+    public ComponentDto getComponent() {
+      return component;
+    }
+  }
+
 }
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/TextRangeResponseFormatter.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/TextRangeResponseFormatter.java
new file mode 100644 (file)
index 0000000..b1eacc1
--- /dev/null
@@ -0,0 +1,83 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 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 GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * 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 GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.server.issue;
+
+import java.util.Map;
+import java.util.function.Consumer;
+import org.sonar.db.component.ComponentDto;
+import org.sonar.db.issue.IssueDto;
+import org.sonar.db.protobuf.DbCommons;
+import org.sonar.db.protobuf.DbIssues;
+import org.sonarqube.ws.Common;
+
+import static java.util.Optional.ofNullable;
+
+public class TextRangeResponseFormatter {
+
+  public void formatTextRange(IssueDto dto, Consumer<Common.TextRange> rangeConsumer) {
+    DbIssues.Locations locations = dto.parseLocations();
+    if (locations == null) {
+      return;
+    }
+    formatTextRange(locations, rangeConsumer);
+  }
+
+  public void formatTextRange(DbIssues.Locations locations, Consumer<Common.TextRange> rangeConsumer) {
+    if (locations.hasTextRange()) {
+      DbCommons.TextRange textRange = locations.getTextRange();
+      rangeConsumer.accept(convertTextRange(textRange).build());
+    }
+  }
+
+  public Common.Location formatLocation(DbIssues.Location source, String issueComponent, Map<String, ComponentDto> componentsByUuid) {
+    Common.Location.Builder target = Common.Location.newBuilder();
+    if (source.hasMsg()) {
+      target.setMsg(source.getMsg());
+    }
+    if (source.hasTextRange()) {
+      DbCommons.TextRange sourceRange = source.getTextRange();
+      Common.TextRange.Builder targetRange = convertTextRange(sourceRange);
+      target.setTextRange(targetRange);
+    }
+    if (source.hasComponentId()) {
+      ofNullable(componentsByUuid.get(source.getComponentId())).ifPresent(c -> target.setComponent(c.getKey()));
+    } else {
+      target.setComponent(issueComponent);
+    }
+    return target.build();
+  }
+
+  private static Common.TextRange.Builder convertTextRange(DbCommons.TextRange sourceRange) {
+    Common.TextRange.Builder targetRange = Common.TextRange.newBuilder();
+    if (sourceRange.hasStartLine()) {
+      targetRange.setStartLine(sourceRange.getStartLine());
+    }
+    if (sourceRange.hasStartOffset()) {
+      targetRange.setStartOffset(sourceRange.getStartOffset());
+    }
+    if (sourceRange.hasEndLine()) {
+      targetRange.setEndLine(sourceRange.getEndLine());
+    }
+    if (sourceRange.hasEndOffset()) {
+      targetRange.setEndOffset(sourceRange.getEndOffset());
+    }
+    return targetRange;
+  }
+}
index b21e4728d6f40e776b705d215d73a3f5a3853dcb..1103eee6efafd0e8f960479230d9586bd49f8cd5 100644 (file)
@@ -21,6 +21,7 @@ package org.sonar.server.issue.ws;
 
 import org.sonar.core.platform.Module;
 import org.sonar.server.issue.AvatarResolverImpl;
+import org.sonar.server.issue.TextRangeResponseFormatter;
 import org.sonar.server.issue.IssueFieldsSetter;
 import org.sonar.server.issue.IssueFinder;
 import org.sonar.server.issue.TransitionService;
@@ -45,6 +46,7 @@ public class IssueWsModule extends Module {
       IssuesWs.class,
       AvatarResolverImpl.class,
       SearchResponseLoader.class,
+      TextRangeResponseFormatter.class,
       SearchResponseFormat.class,
       OperationResponseWriter.class,
       AddCommentAction.class,
index fad0639372c9f29c46a0f92d11a051b7d1af04b3..fff99e6c79c6a870e7dda764135cb7a96f489121 100644 (file)
@@ -82,6 +82,10 @@ public class SearchResponseData {
     return componentsByUuid.get(uuid);
   }
 
+  public Map<String, ComponentDto> getComponentsByUuid() {
+    return componentsByUuid;
+  }
+
   public List<UserDto> getUsers() {
     return new ArrayList<>(usersByUuid.values());
   }
index 362fe7f428dcc05bef220d93130017a7d5ce8f34..8b628b17185487f2429459c335a2bcd54450f43b 100644 (file)
@@ -37,14 +37,13 @@ import org.sonar.api.utils.Paging;
 import org.sonar.db.component.ComponentDto;
 import org.sonar.db.issue.IssueChangeDto;
 import org.sonar.db.issue.IssueDto;
-import org.sonar.db.protobuf.DbCommons;
 import org.sonar.db.protobuf.DbIssues;
-import org.sonar.db.protobuf.DbIssues.Location;
 import org.sonar.db.rule.RuleDefinitionDto;
 import org.sonar.db.user.UserDto;
 import org.sonar.markdown.Markdown;
 import org.sonar.server.es.Facets;
 import org.sonar.server.issue.AvatarResolver;
+import org.sonar.server.issue.TextRangeResponseFormatter;
 import org.sonar.server.issue.workflow.Transition;
 import org.sonarqube.ws.Common;
 import org.sonarqube.ws.Issues;
@@ -78,11 +77,14 @@ public class SearchResponseFormat {
   private final Durations durations;
   private final Languages languages;
   private final AvatarResolver avatarFactory;
+  private final TextRangeResponseFormatter textRangeFormatter;
 
-  public SearchResponseFormat(Durations durations, Languages languages, AvatarResolver avatarFactory) {
+  public SearchResponseFormat(Durations durations, Languages languages, AvatarResolver avatarFactory,
+    TextRangeResponseFormatter textRangeFormatter) {
     this.durations = durations;
     this.languages = languages;
     this.avatarFactory = avatarFactory;
+    this.textRangeFormatter = textRangeFormatter;
   }
 
   SearchWsResponse formatSearch(Set<SearchAdditionalField> fields, SearchResponseData data, Paging paging, Facets facets) {
@@ -219,57 +221,19 @@ public class SearchResponseFormat {
     return ruleKey.repository().replace(EXTERNAL_RULE_REPO_PREFIX, "");
   }
 
-  private static void completeIssueLocations(IssueDto dto, Issue.Builder issueBuilder, SearchResponseData data) {
+  private void completeIssueLocations(IssueDto dto, Issue.Builder issueBuilder, SearchResponseData data) {
     DbIssues.Locations locations = dto.parseLocations();
     if (locations == null) {
       return;
     }
-    if (locations.hasTextRange()) {
-      DbCommons.TextRange textRange = locations.getTextRange();
-      issueBuilder.setTextRange(convertTextRange(textRange));
-    }
+    textRangeFormatter.formatTextRange(locations, issueBuilder::setTextRange);
     for (DbIssues.Flow flow : locations.getFlowList()) {
       Common.Flow.Builder targetFlow = Common.Flow.newBuilder();
-      for (Location flowLocation : flow.getLocationList()) {
-        targetFlow.addLocations(convertLocation(issueBuilder, flowLocation, data));
+      for (DbIssues.Location flowLocation : flow.getLocationList()) {
+        targetFlow.addLocations(textRangeFormatter.formatLocation(flowLocation, issueBuilder.getComponent(), data.getComponentsByUuid()));
       }
-      issueBuilder.addFlows(targetFlow);
-    }
-  }
-
-  private static Common.Location convertLocation(Issue.Builder issueBuilder, Location source, SearchResponseData data) {
-    Common.Location.Builder target = Common.Location.newBuilder();
-    if (source.hasMsg()) {
-      target.setMsg(source.getMsg());
-    }
-    if (source.hasTextRange()) {
-      DbCommons.TextRange sourceRange = source.getTextRange();
-      Common.TextRange.Builder targetRange = convertTextRange(sourceRange);
-      target.setTextRange(targetRange);
-    }
-    if (source.hasComponentId()) {
-      ofNullable(data.getComponentByUuid(source.getComponentId())).ifPresent(c -> target.setComponent(c.getKey()));
-    } else {
-      target.setComponent(issueBuilder.getComponent());
-    }
-    return target.build();
-  }
-
-  private static Common.TextRange.Builder convertTextRange(DbCommons.TextRange sourceRange) {
-    Common.TextRange.Builder targetRange = Common.TextRange.newBuilder();
-    if (sourceRange.hasStartLine()) {
-      targetRange.setStartLine(sourceRange.getStartLine());
-    }
-    if (sourceRange.hasStartOffset()) {
-      targetRange.setStartOffset(sourceRange.getStartOffset());
-    }
-    if (sourceRange.hasEndLine()) {
-      targetRange.setEndLine(sourceRange.getEndLine());
-    }
-    if (sourceRange.hasEndOffset()) {
-      targetRange.setEndOffset(sourceRange.getEndOffset());
+      issueBuilder.addFlows(targetFlow.build());
     }
-    return targetRange;
   }
 
   private static void formatIssueTransitions(SearchResponseData data, Issue.Builder wsIssue, IssueDto dto) {
diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/hotspot/ws/HotspotRuleDescriptionTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/hotspot/ws/HotspotRuleDescriptionTest.java
new file mode 100644 (file)
index 0000000..1a35b06
--- /dev/null
@@ -0,0 +1,352 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 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 GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * 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 GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.server.hotspot.ws;
+
+import com.tngtech.java.junit.dataprovider.DataProvider;
+import com.tngtech.java.junit.dataprovider.DataProviderRunner;
+import com.tngtech.java.junit.dataprovider.UseDataProvider;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.sonar.db.rule.RuleDefinitionDto;
+import org.sonar.db.rule.RuleTesting;
+
+import static org.apache.commons.lang.RandomStringUtils.randomAlphabetic;
+import static org.assertj.core.api.Assertions.assertThat;
+
+@RunWith(DataProviderRunner.class)
+public class HotspotRuleDescriptionTest {
+  @Test
+  public void parse_returns_all_empty_fields_when_no_description() {
+    RuleDefinitionDto dto = RuleTesting.newRule().setDescription(null);
+
+    HotspotRuleDescription result = HotspotRuleDescription.from(dto);
+
+    assertThat(result.getRisk()).isEmpty();
+    assertThat(result.getVulnerable()).isEmpty();
+    assertThat(result.getFixIt()).isEmpty();
+  }
+
+  @Test
+  @UseDataProvider("noContentVariants")
+  public void parse_returns_all_empty_fields_when_empty_description(String noContent) {
+    RuleDefinitionDto dto = RuleTesting.newRule().setDescription("");
+
+    HotspotRuleDescription result = HotspotRuleDescription.from(dto);
+
+    assertThat(result.getRisk()).isEmpty();
+    assertThat(result.getVulnerable()).isEmpty();
+    assertThat(result.getFixIt()).isEmpty();
+  }
+
+  @Test
+  public void parse_ignores_titles_if_not_h2() {
+    RuleDefinitionDto dto = RuleTesting.newRule().setDescription(
+      "acme\" +" +
+        "<h1>Ask Yourself Whether</h1>\n" +
+        "bar\n" +
+        "<h1>Recommended Secure Coding Practices</h1>\n" +
+        "foo");
+
+    HotspotRuleDescription result = HotspotRuleDescription.from(dto);
+
+    assertThat(result.getRisk()).isEmpty();
+    assertThat(result.getVulnerable()).isEmpty();
+    assertThat(result.getFixIt()).isEmpty();
+  }
+
+  @Test
+  @UseDataProvider("whiteSpaceBeforeAndAfterCombinations")
+  public void parse_does_not_trim_content_of_h2_titles(String whiteSpaceBefore, String whiteSpaceAfter) {
+    RuleDefinitionDto dto = RuleTesting.newRule().setDescription(
+      "acme\" +" +
+        "<h2>" + whiteSpaceBefore + "Ask Yourself Whether" + whiteSpaceAfter + "</h2>\n" +
+        "bar\n" +
+        "<h2>" + whiteSpaceBefore + "Recommended Secure Coding Practices\" + whiteSpaceAfter + \"</h2>\n" +
+        "foo");
+
+    HotspotRuleDescription result = HotspotRuleDescription.from(dto);
+
+    assertThat(result.getRisk()).isEmpty();
+    assertThat(result.getVulnerable()).isEmpty();
+    assertThat(result.getFixIt()).isEmpty();
+  }
+
+  @DataProvider
+  public static Object[][] whiteSpaceBeforeAndAfterCombinations() {
+    String whiteSpace = " ";
+    String noWithSpace = "";
+    return new Object[][] {
+      {noWithSpace, whiteSpace},
+      {whiteSpace, noWithSpace},
+      {whiteSpace, whiteSpace}
+    };
+  }
+
+  @Test
+  @UseDataProvider("descriptionsWithoutTitles")
+  public void parse_return_null_fields_when_desc_contains_neither_title(String description) {
+    RuleDefinitionDto dto = RuleTesting.newRule().setDescription(description);
+
+    HotspotRuleDescription result = HotspotRuleDescription.from(dto);
+
+    assertThat(result.getRisk()).isEmpty();
+    assertThat(result.getVulnerable()).isEmpty();
+    assertThat(result.getFixIt()).isEmpty();
+  }
+
+  @DataProvider
+  public static Object[][] descriptionsWithoutTitles() {
+    return new Object[][] {
+      {""},
+      {randomAlphabetic(123)},
+      {"bar\n" +
+        "acme\n" +
+        "foo"}
+    };
+  }
+
+  @Test
+  public void parse_return_null_risk_when_desc_starts_with_ask_yourself_title() {
+    RuleDefinitionDto dto = RuleTesting.newRule().setDescription(
+      "<h2>Ask Yourself Whether</h2>\n" +
+        "bar\n" +
+        "<h2>Recommended Secure Coding Practices</h2>\n" +
+        "foo");
+
+    HotspotRuleDescription result = HotspotRuleDescription.from(dto);
+
+    assertThat(result.getRisk()).isEmpty();
+    assertThat(result.getVulnerable().get()).isEqualTo("<h2>Ask Yourself Whether</h2>\nbar");
+    assertThat(result.getFixIt().get()).isEqualTo("<h2>Recommended Secure Coding Practices</h2>\nfoo");
+  }
+
+  @Test
+  public void parse_return_null_vulnerable_when_no_ask_yourself_whether_title() {
+    RuleDefinitionDto dto = RuleTesting.newRule().setDescription(
+        "bar\n" +
+        "<h2>Recommended Secure Coding Practices</h2>\n" +
+        "foo");
+
+    HotspotRuleDescription result = HotspotRuleDescription.from(dto);
+
+    assertThat(result.getRisk().get()).isEqualTo("bar");
+    assertThat(result.getVulnerable()).isEmpty();
+    assertThat(result.getFixIt().get()).isEqualTo("<h2>Recommended Secure Coding Practices</h2>\nfoo");
+  }
+
+  @Test
+  @UseDataProvider("noContentVariants")
+  public void parse_returns_vulnerable_with_only_title_when_no_content_between_titles(String noContent) {
+    RuleDefinitionDto dto = RuleTesting.newRule().setDescription(
+      "bar\n" +
+        "<h2>Ask Yourself Whether</h2>\n" +
+        noContent +
+        "<h2>Recommended Secure Coding Practices</h2>\n" +
+        "foo");
+
+    HotspotRuleDescription result = HotspotRuleDescription.from(dto);
+
+    assertThat(result.getRisk().get()).isEqualTo("bar");
+    assertThat(result.getVulnerable().get()).isEqualTo("<h2>Ask Yourself Whether</h2>");
+    assertThat(result.getFixIt().get()).isEqualTo("<h2>Recommended Secure Coding Practices</h2>\nfoo");
+  }
+
+  @Test
+  @UseDataProvider("noContentVariants")
+  public void parse_returns_fixIt_with_only_title_when_no_content_after_Recommended_Secure_Coding_Practices_title(String noContent) {
+    RuleDefinitionDto dto = RuleTesting.newRule().setDescription(
+      "bar\n" +
+        "<h2>Ask Yourself Whether</h2>\n" +
+        "bar" +
+        "<h2>Recommended Secure Coding Practices</h2>\n" +
+        noContent);
+
+    HotspotRuleDescription result = HotspotRuleDescription.from(dto);
+
+    assertThat(result.getRisk().get()).isEqualTo("bar");
+    assertThat(result.getVulnerable().get()).isEqualTo("<h2>Ask Yourself Whether</h2>\nbar");
+    assertThat(result.getFixIt().get()).isEqualTo("<h2>Recommended Secure Coding Practices</h2>");
+  }
+
+  @DataProvider
+  public static Object[][] noContentVariants() {
+    return new Object[][] {
+      {""},
+      {"\n"},
+      {" \n "},
+      {"\t\n  \n"},
+    };
+  }
+
+  @Test
+  public void parse_return_null_fixIt_when_desc_has_no_Recommended_Secure_Coding_Practices_title() {
+    RuleDefinitionDto dto = RuleTesting.newRule().setDescription(
+      "bar\n" +
+        "<h2>Ask Yourself Whether</h2>\n" +
+        "foo");
+
+    HotspotRuleDescription result = HotspotRuleDescription.from(dto);
+
+    assertThat(result.getRisk().get()).isEqualTo("bar");
+    assertThat(result.getVulnerable().get()).isEqualTo("<h2>Ask Yourself Whether</h2>\nfoo");
+    assertThat(result.getFixIt()).isEmpty();
+  }
+
+  @Test
+  public void parse_returns_regular_description() {
+    RuleDefinitionDto dto = RuleTesting.newRule().setDescription(
+      "<p>Enabling Cross-Origin Resource Sharing (CORS) is security-sensitive. For example, it has led in the past to the following vulnerabilities:</p>\n" +
+        "<ul>\n" +
+        "  <li> <a href=\"http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2018-0269\">CVE-2018-0269</a> </li>\n" +
+        "  <li> <a href=\"http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2017-14460\">CVE-2017-14460</a> </li>\n" +
+        "</ul>\n" +
+        "<p>Applications that enable CORS will effectively relax the same-origin policy in browsers, which is in place to prevent AJAX requests to hosts other\n" +
+        "than the one showing in the browser address bar. Being too permissive, CORS can potentially allow an attacker to gain access to sensitive\n" +
+        "information.</p>\n" +
+        "<p>This rule flags code that enables CORS or specifies any HTTP response headers associated with CORS. The goal is to guide security code reviews.</p>\n" +
+        "<h2>Ask Yourself Whether</h2>\n" +
+        "<ul>\n" +
+        "  <li> Any URLs responding with <code>Access-Control-Allow-Origin: *</code> include sensitive content. </li>\n" +
+        "  <li> Any domains specified in <code>Access-Control-Allow-Origin</code> headers are checked against a whitelist. </li>\n" +
+        "</ul>\n" +
+        "<h2>Recommended Secure Coding Practices</h2>\n" +
+        "<ul>\n" +
+        "  <li> The <code>Access-Control-Allow-Origin</code> header should be set only on specific URLs that require access from other domains. Don't enable\n" +
+        "  the header on the entire domain. </li>\n" +
+        "  <li> Don't rely on the <code>Origin</code> header blindly without validation as it could be spoofed by an attacker. Use a whitelist to check that\n" +
+        "  the <code>Origin</code> domain (including protocol) is allowed before returning it back in the <code>Access-Control-Allow-Origin</code> header.\n" +
+        "  </li>\n" +
+        "  <li> Use <code>Access-Control-Allow-Origin: *</code> only if your application absolutely requires it, for example in the case of an open/public API.\n" +
+        "  For such endpoints, make sure that there is no sensitive content or information included in the response. </li>\n" +
+        "</ul>\n" +
+        "<h2>Sensitive Code Example</h2>\n" +
+        "<pre>\n" +
+        "// === Java Servlet ===\n" +
+        "@Override\n" +
+        "protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {\n" +
+        "  resp.setHeader(\"Content-Type\", \"text/plain; charset=utf-8\");\n" +
+        "  resp.setHeader(\"Access-Control-Allow-Origin\", \"http://localhost:8080\"); // Questionable\n" +
+        "  resp.setHeader(\"Access-Control-Allow-Credentials\", \"true\"); // Questionable\n" +
+        "  resp.setHeader(\"Access-Control-Allow-Methods\", \"GET\"); // Questionable\n" +
+        "  resp.getWriter().write(\"response\");\n" +
+        "}\n" +
+        "</pre>\n" +
+        "<pre>\n" +
+        "// === Spring MVC Controller annotation ===\n" +
+        "@CrossOrigin(origins = \"http://domain1.com\") // Questionable\n" +
+        "@RequestMapping(\"\")\n" +
+        "public class TestController {\n" +
+        "    public String home(ModelMap model) {\n" +
+        "        model.addAttribute(\"message\", \"ok \");\n" +
+        "        return \"view\";\n" +
+        "    }\n" +
+        "\n" +
+        "    @CrossOrigin(origins = \"http://domain2.com\") // Questionable\n" +
+        "    @RequestMapping(value = \"/test1\")\n" +
+        "    public ResponseEntity&lt;String&gt; test1() {\n" +
+        "        return ResponseEntity.ok().body(\"ok\");\n" +
+        "    }\n" +
+        "}\n" +
+        "</pre>\n" +
+        "<h2>See</h2>\n" +
+        "<ul>\n" +
+        "  <li> <a href=\"https://www.owasp.org/index.php/Top_10-2017_A6-Security_Misconfiguration\">OWASP Top 10 2017 Category A6</a> - Security\n" +
+        "  Misconfiguration </li>\n" +
+        "  <li> <a href=\"https://www.owasp.org/index.php/HTML5_Security_Cheat_Sheet#Cross_Origin_Resource_Sharing\">OWASP HTML5 Security Cheat Sheet</a> - Cross\n" +
+        "  Origin Resource Sharing </li>\n" +
+        "  <li> <a href=\"https://www.owasp.org/index.php/CORS_OriginHeaderScrutiny\">OWASP CORS OriginHeaderScrutiny</a> </li>\n" +
+        "  <li> <a href=\"https://www.owasp.org/index.php/CORS_RequestPreflighScrutiny\">OWASP CORS RequestPreflighScrutiny</a> </li>\n" +
+        "  <li> <a href=\"https://cwe.mitre.org/data/definitions/346.html\">MITRE, CWE-346</a> - Origin Validation Error </li>\n" +
+        "  <li> <a href=\"https://cwe.mitre.org/data/definitions/942.html\">MITRE, CWE-942</a> - Overly Permissive Cross-domain Whitelist </li>\n" +
+        "  <li> <a href=\"https://www.sans.org/top25-software-errors/#cat3\">SANS Top 25</a> - Porous Defenses </li>\n" +
+        "</ul>");
+
+    HotspotRuleDescription result = HotspotRuleDescription.from(dto);
+
+    assertThat(result.getRisk().get()).isEqualTo(
+      "<p>Enabling Cross-Origin Resource Sharing (CORS) is security-sensitive. For example, it has led in the past to the following vulnerabilities:</p>\n" +
+        "<ul>\n" +
+        "  <li> <a href=\"http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2018-0269\">CVE-2018-0269</a> </li>\n" +
+        "  <li> <a href=\"http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2017-14460\">CVE-2017-14460</a> </li>\n" +
+        "</ul>\n" +
+        "<p>Applications that enable CORS will effectively relax the same-origin policy in browsers, which is in place to prevent AJAX requests to hosts other\n" +
+        "than the one showing in the browser address bar. Being too permissive, CORS can potentially allow an attacker to gain access to sensitive\n" +
+        "information.</p>\n" +
+        "<p>This rule flags code that enables CORS or specifies any HTTP response headers associated with CORS. The goal is to guide security code reviews.</p>");
+    assertThat(result.getVulnerable().get()).isEqualTo(
+      "<h2>Ask Yourself Whether</h2>\n" +
+      "<ul>\n" +
+        "  <li> Any URLs responding with <code>Access-Control-Allow-Origin: *</code> include sensitive content. </li>\n" +
+        "  <li> Any domains specified in <code>Access-Control-Allow-Origin</code> headers are checked against a whitelist. </li>\n" +
+        "</ul>");
+    assertThat(result.getFixIt().get()).isEqualTo(
+      "<h2>Recommended Secure Coding Practices</h2>\n" +
+      "<ul>\n" +
+        "  <li> The <code>Access-Control-Allow-Origin</code> header should be set only on specific URLs that require access from other domains. Don't enable\n" +
+        "  the header on the entire domain. </li>\n" +
+        "  <li> Don't rely on the <code>Origin</code> header blindly without validation as it could be spoofed by an attacker. Use a whitelist to check that\n" +
+        "  the <code>Origin</code> domain (including protocol) is allowed before returning it back in the <code>Access-Control-Allow-Origin</code> header.\n" +
+        "  </li>\n" +
+        "  <li> Use <code>Access-Control-Allow-Origin: *</code> only if your application absolutely requires it, for example in the case of an open/public API.\n" +
+        "  For such endpoints, make sure that there is no sensitive content or information included in the response. </li>\n" +
+        "</ul>\n" +
+        "<h2>Sensitive Code Example</h2>\n" +
+        "<pre>\n" +
+        "// === Java Servlet ===\n" +
+        "@Override\n" +
+        "protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {\n" +
+        "  resp.setHeader(\"Content-Type\", \"text/plain; charset=utf-8\");\n" +
+        "  resp.setHeader(\"Access-Control-Allow-Origin\", \"http://localhost:8080\"); // Questionable\n" +
+        "  resp.setHeader(\"Access-Control-Allow-Credentials\", \"true\"); // Questionable\n" +
+        "  resp.setHeader(\"Access-Control-Allow-Methods\", \"GET\"); // Questionable\n" +
+        "  resp.getWriter().write(\"response\");\n" +
+        "}\n" +
+        "</pre>\n" +
+        "<pre>\n" +
+        "// === Spring MVC Controller annotation ===\n" +
+        "@CrossOrigin(origins = \"http://domain1.com\") // Questionable\n" +
+        "@RequestMapping(\"\")\n" +
+        "public class TestController {\n" +
+        "    public String home(ModelMap model) {\n" +
+        "        model.addAttribute(\"message\", \"ok \");\n" +
+        "        return \"view\";\n" +
+        "    }\n" +
+        "\n" +
+        "    @CrossOrigin(origins = \"http://domain2.com\") // Questionable\n" +
+        "    @RequestMapping(value = \"/test1\")\n" +
+        "    public ResponseEntity&lt;String&gt; test1() {\n" +
+        "        return ResponseEntity.ok().body(\"ok\");\n" +
+        "    }\n" +
+        "}\n" +
+        "</pre>\n" +
+        "<h2>See</h2>\n" +
+        "<ul>\n" +
+        "  <li> <a href=\"https://www.owasp.org/index.php/Top_10-2017_A6-Security_Misconfiguration\">OWASP Top 10 2017 Category A6</a> - Security\n" +
+        "  Misconfiguration </li>\n" +
+        "  <li> <a href=\"https://www.owasp.org/index.php/HTML5_Security_Cheat_Sheet#Cross_Origin_Resource_Sharing\">OWASP HTML5 Security Cheat Sheet</a> - Cross\n" +
+        "  Origin Resource Sharing </li>\n" +
+        "  <li> <a href=\"https://www.owasp.org/index.php/CORS_OriginHeaderScrutiny\">OWASP CORS OriginHeaderScrutiny</a> </li>\n" +
+        "  <li> <a href=\"https://www.owasp.org/index.php/CORS_RequestPreflighScrutiny\">OWASP CORS RequestPreflighScrutiny</a> </li>\n" +
+        "  <li> <a href=\"https://cwe.mitre.org/data/definitions/346.html\">MITRE, CWE-346</a> - Origin Validation Error </li>\n" +
+        "  <li> <a href=\"https://cwe.mitre.org/data/definitions/942.html\">MITRE, CWE-942</a> - Overly Permissive Cross-domain Whitelist </li>\n" +
+        "  <li> <a href=\"https://www.sans.org/top25-software-errors/#cat3\">SANS Top 25</a> - Porous Defenses </li>\n" +
+        "</ul>");
+  }
+}
index 197753a60a470c4978d21f64c6a68e7838063cd9..a9c839b56df98b94fddef54dba11187befe4586d 100644 (file)
@@ -30,7 +30,7 @@ public class HotspotsWsModuleTest {
   public void verify_count_of_added_components() {
     ComponentContainer container = new ComponentContainer();
     new HotspotsWsModule().configure(container);
-    assertThat(container.size()).isEqualTo(COMPONENTS_IN_EMPTY_COMPONENT_CONTAINER + 2);
+    assertThat(container.size()).isEqualTo(COMPONENTS_IN_EMPTY_COMPONENT_CONTAINER + 4);
   }
 
 }
index 2e173802409b547a82c9efda6d14be517f5f5dac..d1a1fc45ef9de7f94861684a9806ee6c4e30ff64 100644 (file)
@@ -97,8 +97,9 @@ public class SearchActionTest {
   private IssueIndex issueIndex = new IssueIndex(es.client(), System2.INSTANCE, userSessionRule, new WebAuthorizationTypeSupport(userSessionRule));
   private IssueIndexer issueIndexer = new IssueIndexer(es.client(), dbClient, new IssueIteratorFactory(dbClient));
   private StartupIndexer permissionIndexer = new PermissionIndexer(dbClient, es.client(), issueIndexer);
+  private HotspotWsResponseFormatter responseFormatter = new HotspotWsResponseFormatter(defaultOrganizationProvider);
 
-  private SearchAction underTest = new SearchAction(dbClient, userSessionRule, issueIndex, defaultOrganizationProvider);
+  private SearchAction underTest = new SearchAction(dbClient, userSessionRule, issueIndex, responseFormatter);
   private WsActionTester actionTester = new WsActionTester(underTest);
 
   @Test
@@ -200,7 +201,7 @@ public class SearchActionTest {
       .executeProtobuf(SearchWsResponse.class);
 
     assertThat(response.getHotspotsList())
-      .extracting(Hotspots.Hotspot::getKey)
+      .extracting(Hotspots.SearchWsResponse.Hotspot::getKey)
       .containsOnly(Arrays.stream(hotspots)
         .filter(t -> !t.getKey().equals(hotspotWithoutRule.getKey()))
         .map(IssueDto::getKey)
@@ -250,7 +251,7 @@ public class SearchActionTest {
       .executeProtobuf(SearchWsResponse.class);
 
     assertThat(response.getHotspotsList())
-      .extracting(Hotspots.Hotspot::getKey)
+      .extracting(SearchWsResponse.Hotspot::getKey)
       .containsOnly(Arrays.stream(hotspots).map(IssueDto::getKey).toArray(String[]::new));
     assertThat(response.getComponentsList())
       .extracting(Component::getKey)
@@ -274,7 +275,7 @@ public class SearchActionTest {
       .executeProtobuf(SearchWsResponse.class);
 
     assertThat(response.getHotspotsList())
-      .extracting(Hotspots.Hotspot::getKey)
+      .extracting(Hotspots.SearchWsResponse.Hotspot::getKey)
       .containsOnly(Arrays.stream(hotspots).map(IssueDto::getKey).toArray(String[]::new));
     assertThat(response.getComponentsList())
       .extracting(Component::getKey)
@@ -302,7 +303,7 @@ public class SearchActionTest {
       .executeProtobuf(SearchWsResponse.class);
 
     assertThat(responseProject1.getHotspotsList())
-      .extracting(Hotspots.Hotspot::getKey)
+      .extracting(SearchWsResponse.Hotspot::getKey)
       .doesNotContainAnyElementsOf(Arrays.stream(hotspots2).map(IssueDto::getKey).collect(Collectors.toList()));
     assertThat(responseProject1.getComponentsList())
       .extracting(Component::getKey)
@@ -312,7 +313,7 @@ public class SearchActionTest {
       .executeProtobuf(SearchWsResponse.class);
 
     assertThat(responseProject2.getHotspotsList())
-      .extracting(Hotspots.Hotspot::getKey)
+      .extracting(SearchWsResponse.Hotspot::getKey)
       .containsOnly(Arrays.stream(hotspots2).map(IssueDto::getKey).toArray(String[]::new));
     assertThat(responseProject2.getComponentsList())
       .extracting(Component::getKey)
@@ -339,7 +340,7 @@ public class SearchActionTest {
       .executeProtobuf(SearchWsResponse.class);
 
     assertThat(response.getHotspotsList())
-      .extracting(Hotspots.Hotspot::getKey)
+      .extracting(SearchWsResponse.Hotspot::getKey)
       .containsOnly(unresolvedHotspot.getKey(), badlyClosedHotspot.getKey());
   }
 
@@ -363,7 +364,7 @@ public class SearchActionTest {
       .executeProtobuf(SearchWsResponse.class);
 
     assertThat(response.getHotspotsList()).hasSize(1);
-    Hotspots.Hotspot actual = response.getHotspots(0);
+    SearchWsResponse.Hotspot actual = response.getHotspots(0);
     assertThat(actual.getComponent()).isEqualTo(file.getKey());
     assertThat(actual.getProject()).isEqualTo(project.getKey());
     assertThat(actual.getStatus()).isEqualTo(hotspot.getStatus());
@@ -392,7 +393,7 @@ public class SearchActionTest {
       .executeProtobuf(SearchWsResponse.class);
 
     assertThat(response.getHotspotsList()).hasSize(1);
-    Hotspots.Hotspot actual = response.getHotspots(0);
+    Hotspots.SearchWsResponse.Hotspot actual = response.getHotspots(0);
     assertThat(actual.getSecurityCategory()).isEqualTo(expected.getKey());
     assertThat(actual.getVulnerabilityProbability()).isEqualTo(expected.getVulnerability().name());
   }
@@ -434,7 +435,7 @@ public class SearchActionTest {
 
     assertThat(response.getHotspotsList())
       .hasSize(1);
-    Hotspots.Hotspot actual = response.getHotspots(0);
+    SearchWsResponse.Hotspot actual = response.getHotspots(0);
     assertThat(actual.hasStatus()).isFalse();
     // FIXME resolution field will be added later
     // assertThat(actual.hasResolution()).isFalse();
@@ -463,7 +464,7 @@ public class SearchActionTest {
       .executeProtobuf(SearchWsResponse.class);
 
     assertThat(response.getHotspotsList())
-      .extracting(Hotspots.Hotspot::getKey)
+      .extracting(SearchWsResponse.Hotspot::getKey)
       .containsOnly(fileHotspot.getKey(), dirHotspot.getKey(), projectHotspot.getKey());
     assertThat(response.getComponentsList()).hasSize(3);
     assertThat(response.getComponentsList())
@@ -523,7 +524,7 @@ public class SearchActionTest {
       .executeProtobuf(SearchWsResponse.class);
 
     assertThat(response.getHotspotsList())
-      .extracting(Hotspots.Hotspot::getKey)
+      .extracting(SearchWsResponse.Hotspot::getKey)
       .containsExactly(expectedHotspotKeys);
   }
 
@@ -556,7 +557,7 @@ public class SearchActionTest {
       .executeProtobuf(SearchWsResponse.class);
 
     assertThat(response.getHotspotsList())
-      .extracting(Hotspots.Hotspot::getKey)
+      .extracting(SearchWsResponse.Hotspot::getKey)
       .containsExactly(expectedHotspotKeys);
   }
 
@@ -579,7 +580,7 @@ public class SearchActionTest {
     SearchWsResponse response = request.executeProtobuf(SearchWsResponse.class);
 
     assertThat(response.getHotspotsList())
-      .extracting(Hotspots.Hotspot::getKey)
+      .extracting(SearchWsResponse.Hotspot::getKey)
       .containsExactly(hotspots.stream().limit(100).map(IssueDto::getKey).toArray(String[]::new));
     assertThat(response.getPaging().getTotal()).isEqualTo(hotspots.size());
     assertThat(response.getPaging().getPageIndex()).isEqualTo(1);
@@ -623,7 +624,7 @@ public class SearchActionTest {
       .executeProtobuf(SearchWsResponse.class);
 
     assertThat(response.getHotspotsList())
-      .extracting(Hotspots.Hotspot::getKey)
+      .extracting(SearchWsResponse.Hotspot::getKey)
       .containsExactly(hotspots.stream().skip(2 * pageSize).limit(pageSize).map(IssueDto::getKey).toArray(String[]::new));
     assertThat(response.getPaging().getTotal()).isEqualTo(hotspots.size());
     assertThat(response.getPaging().getPageIndex()).isEqualTo(3);
@@ -635,7 +636,7 @@ public class SearchActionTest {
       .executeProtobuf(SearchWsResponse.class);
 
     assertThat(response.getHotspotsList())
-      .extracting(Hotspots.Hotspot::getKey)
+      .extracting(SearchWsResponse.Hotspot::getKey)
       .containsExactly(hotspots.stream().skip(3 * pageSize).limit(pageSize).map(IssueDto::getKey).toArray(String[]::new));
     assertThat(response.getPaging().getTotal()).isEqualTo(hotspots.size());
     assertThat(response.getPaging().getPageIndex()).isEqualTo(4);
@@ -648,7 +649,7 @@ public class SearchActionTest {
       .executeProtobuf(SearchWsResponse.class);
 
     assertThat(response.getHotspotsList())
-      .extracting(Hotspots.Hotspot::getKey)
+      .extracting(SearchWsResponse.Hotspot::getKey)
       .isEmpty();
     assertThat(response.getPaging().getTotal()).isEqualTo(hotspots.size());
     assertThat(response.getPaging().getPageIndex()).isEqualTo(emptyPage);
diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/hotspot/ws/ShowActionTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/hotspot/ws/ShowActionTest.java
new file mode 100644 (file)
index 0000000..9f394b9
--- /dev/null
@@ -0,0 +1,364 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 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 GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * 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 GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.server.hotspot.ws;
+
+import com.google.common.collect.ImmutableSet;
+import com.tngtech.java.junit.dataprovider.DataProvider;
+import com.tngtech.java.junit.dataprovider.DataProviderRunner;
+import com.tngtech.java.junit.dataprovider.UseDataProvider;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Random;
+import java.util.Set;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.sonar.api.rules.RuleType;
+import org.sonar.api.utils.System2;
+import org.sonar.api.web.UserRole;
+import org.sonar.db.DbClient;
+import org.sonar.db.DbTester;
+import org.sonar.db.component.ComponentDto;
+import org.sonar.db.issue.IssueDto;
+import org.sonar.db.issue.IssueTesting;
+import org.sonar.db.protobuf.DbCommons;
+import org.sonar.db.protobuf.DbIssues;
+import org.sonar.db.rule.RuleDefinitionDto;
+import org.sonar.db.rule.RuleTesting;
+import org.sonar.server.issue.TextRangeResponseFormatter;
+import org.sonar.server.es.EsTester;
+import org.sonar.server.exceptions.ForbiddenException;
+import org.sonar.server.exceptions.NotFoundException;
+import org.sonar.server.organization.TestDefaultOrganizationProvider;
+import org.sonar.server.security.SecurityStandards;
+import org.sonar.server.security.SecurityStandards.SQCategory;
+import org.sonar.server.tester.UserSessionRule;
+import org.sonar.server.ws.TestRequest;
+import org.sonar.server.ws.WsActionTester;
+import org.sonarqube.ws.Common;
+import org.sonarqube.ws.Hotspots;
+
+import static org.apache.commons.lang.RandomStringUtils.randomAlphabetic;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.sonar.api.rules.RuleType.SECURITY_HOTSPOT;
+import static org.sonar.db.component.ComponentTesting.newFileDto;
+
+@RunWith(DataProviderRunner.class)
+public class ShowActionTest {
+  private static final Random RANDOM = new Random();
+
+  @Rule
+  public DbTester dbTester = DbTester.create(System2.INSTANCE);
+  @Rule
+  public EsTester es = EsTester.create();
+  @Rule
+  public UserSessionRule userSessionRule = UserSessionRule.standalone();
+
+  private DbClient dbClient = dbTester.getDbClient();
+  private TestDefaultOrganizationProvider defaultOrganizationProvider = TestDefaultOrganizationProvider.from(dbTester);
+
+  private TextRangeResponseFormatter commonFormatter = new TextRangeResponseFormatter();
+  private HotspotWsResponseFormatter responseFormatter = new HotspotWsResponseFormatter(defaultOrganizationProvider);
+
+  private ShowAction underTest = new ShowAction(dbClient, userSessionRule, responseFormatter, commonFormatter);
+  private WsActionTester actionTester = new WsActionTester(underTest);
+
+  @Test
+  public void ws_is_internal() {
+    assertThat(actionTester.getDef().isInternal()).isTrue();
+  }
+
+  @Test
+  public void fails_with_IAE_if_parameter_hotspot_is_missing() {
+    TestRequest request = actionTester.newRequest();
+
+    assertThatThrownBy(request::execute)
+      .isInstanceOf(IllegalArgumentException.class)
+      .hasMessage("The 'hotspot' parameter is missing");
+  }
+
+  @Test
+  public void fails_with_NotFoundException_if_hotspot_does_not_exist() {
+    String key = randomAlphabetic(12);
+    TestRequest request = actionTester.newRequest()
+      .setParam("hotspot", key);
+
+    assertThatThrownBy(request::execute)
+      .isInstanceOf(NotFoundException.class)
+      .hasMessage("Hotspot '%s' does not exist", key);
+  }
+
+  @Test
+  @UseDataProvider("ruleTypesButHotspot")
+  public void fails_with_NotFoundException_if_issue_is_not_a_hotspot(RuleType ruleType) {
+    ComponentDto project = dbTester.components().insertPublicProject();
+    ComponentDto file = dbTester.components().insertComponent(newFileDto(project));
+    RuleDefinitionDto rule = newRule(ruleType);
+    IssueDto notAHotspot = dbTester.issues().insertIssue(IssueTesting.newIssue(rule, project, file).setType(ruleType));
+    TestRequest request = newRequest(notAHotspot);
+
+    assertThatThrownBy(request::execute)
+      .isInstanceOf(NotFoundException.class)
+      .hasMessage("Hotspot '%s' does not exist", notAHotspot.getKey());
+  }
+
+  @DataProvider
+  public static Object[][] ruleTypesButHotspot() {
+    return Arrays.stream(RuleType.values())
+      .filter(t -> t != SECURITY_HOTSPOT)
+      .map(t -> new Object[] {t})
+      .toArray(Object[][]::new);
+  }
+
+  @Test
+  public void fails_with_ForbiddenException_if_project_is_private_and_not_allowed() {
+    ComponentDto project = dbTester.components().insertPrivateProject();
+    userSessionRule.registerComponents(project);
+    ComponentDto file = dbTester.components().insertComponent(newFileDto(project));
+    RuleDefinitionDto rule = newRule(SECURITY_HOTSPOT);
+    IssueDto hotspot = dbTester.issues().insertIssue(newHotspot(project, file, rule));
+    TestRequest request = newRequest(hotspot);
+
+    assertThatThrownBy(request::execute)
+      .isInstanceOf(ForbiddenException.class)
+      .hasMessage("Insufficient privileges");
+  }
+
+  @Test
+  public void succeeds_on_public_project() {
+    ComponentDto project = dbTester.components().insertPublicProject();
+    userSessionRule.registerComponents(project);
+    ComponentDto file = dbTester.components().insertComponent(newFileDto(project));
+    RuleDefinitionDto rule = newRule(SECURITY_HOTSPOT);
+    IssueDto hotspot = dbTester.issues().insertIssue(newHotspot(project, file, rule));
+
+    Hotspots.ShowWsResponse response = newRequest(hotspot)
+      .executeProtobuf(Hotspots.ShowWsResponse.class);
+
+    assertThat(response.getKey()).isEqualTo(hotspot.getKey());
+  }
+
+  @Test
+  public void succeeds_on_private_project_with_permission() {
+    ComponentDto project = dbTester.components().insertPrivateProject();
+    userSessionRule.registerComponents(project);
+    userSessionRule.logIn().addProjectPermission(UserRole.USER, project);
+    ComponentDto file = dbTester.components().insertComponent(newFileDto(project));
+    RuleDefinitionDto rule = newRule(SECURITY_HOTSPOT);
+    IssueDto hotspot = dbTester.issues().insertIssue(newHotspot(project, file, rule));
+
+    Hotspots.ShowWsResponse response = newRequest(hotspot)
+      .executeProtobuf(Hotspots.ShowWsResponse.class);
+
+    assertThat(response.getKey()).isEqualTo(hotspot.getKey());
+  }
+
+  @Test
+  public void returns_hotspot_component_and_rule() {
+    ComponentDto project = dbTester.components().insertPublicProject();
+    userSessionRule.registerComponents(project);
+    ComponentDto file = dbTester.components().insertComponent(newFileDto(project));
+    RuleDefinitionDto rule = newRule(SECURITY_HOTSPOT);
+    IssueDto hotspot = dbTester.issues().insertIssue(newHotspot(project, file, rule));
+
+    Hotspots.ShowWsResponse response = newRequest(hotspot)
+      .executeProtobuf(Hotspots.ShowWsResponse.class);
+
+    assertThat(response.getKey()).isEqualTo(hotspot.getKey());
+    verifyComponent(response.getComponent(), file);
+    verifyComponent(response.getProject(), project);
+    verifyRule(response.getRule(), rule);
+    assertThat(response.hasTextRange()).isFalse();
+  }
+
+  @Test
+  public void returns_no_textrange_when_locations_have_none() {
+    ComponentDto project = dbTester.components().insertPublicProject();
+    userSessionRule.registerComponents(project);
+    ComponentDto file = dbTester.components().insertComponent(newFileDto(project));
+    RuleDefinitionDto rule = newRule(SECURITY_HOTSPOT);
+    IssueDto hotspot = dbTester.issues().insertIssue(newHotspot(project, file, rule)
+      .setLocations(DbIssues.Locations.newBuilder().build()));
+
+    Hotspots.ShowWsResponse response = newRequest(hotspot)
+      .executeProtobuf(Hotspots.ShowWsResponse.class);
+
+    assertThat(response.hasTextRange()).isFalse();
+  }
+
+  @Test
+  @UseDataProvider("randomTextRangeValues")
+  public void returns_textRange(int startLine, int endLine, int startOffset, int endOffset) {
+    ComponentDto project = dbTester.components().insertPublicProject();
+    userSessionRule.registerComponents(project);
+    ComponentDto file = dbTester.components().insertComponent(newFileDto(project));
+    RuleDefinitionDto rule = newRule(SECURITY_HOTSPOT);
+    IssueDto hotspot = dbTester.issues().insertIssue(newHotspot(project, file, rule)
+      .setLocations(DbIssues.Locations.newBuilder()
+        .setTextRange(DbCommons.TextRange.newBuilder()
+          .setStartLine(startLine)
+          .setEndLine(endLine)
+          .setStartOffset(startOffset)
+          .setEndOffset(endOffset)
+          .build())
+        .build()));
+
+    Hotspots.ShowWsResponse response = newRequest(hotspot)
+      .executeProtobuf(Hotspots.ShowWsResponse.class);
+    assertThat(response.hasTextRange()).isTrue();
+    Common.TextRange textRange = response.getTextRange();
+    assertThat(textRange.getStartLine()).isEqualTo(startLine);
+    assertThat(textRange.getEndLine()).isEqualTo(endLine);
+    assertThat(textRange.getStartOffset()).isEqualTo(startOffset);
+    assertThat(textRange.getEndOffset()).isEqualTo(endOffset);
+  }
+
+  @DataProvider
+  public static Object[][] randomTextRangeValues() {
+    int startLine = RANDOM.nextInt(200);
+    int endLine = RANDOM.nextInt(200);
+    int startOffset = RANDOM.nextInt(200);
+    int endOffset = RANDOM.nextInt(200);
+    return new Object[][] {
+      {startLine, endLine, startOffset, endOffset}
+    };
+  }
+
+  @Test
+  public void returns_textRange_missing_fields() {
+    ComponentDto project = dbTester.components().insertPublicProject();
+    userSessionRule.registerComponents(project);
+    ComponentDto file = dbTester.components().insertComponent(newFileDto(project));
+    RuleDefinitionDto rule = newRule(SECURITY_HOTSPOT);
+    IssueDto hotspot = dbTester.issues().insertIssue(newHotspot(project, file, rule)
+      .setLocations(DbIssues.Locations.newBuilder()
+        .setTextRange(DbCommons.TextRange.newBuilder().build())
+        .build()));
+
+    Hotspots.ShowWsResponse response = newRequest(hotspot)
+      .executeProtobuf(Hotspots.ShowWsResponse.class);
+
+    assertThat(response.hasTextRange()).isTrue();
+    Common.TextRange textRange = response.getTextRange();
+    assertThat(textRange.hasStartLine()).isFalse();
+    assertThat(textRange.hasEndLine()).isFalse();
+    assertThat(textRange.hasStartOffset()).isFalse();
+    assertThat(textRange.hasEndOffset()).isFalse();
+  }
+
+  @Test
+  @UseDataProvider("allSQCategoryAndVulnerabilityProbability")
+  public void returns_securityCategory_and_vulnerabilityProbability_of_rule(Set<String> standards,
+    SQCategory expected) {
+    ComponentDto project = dbTester.components().insertPublicProject();
+    userSessionRule.registerComponents(project);
+    ComponentDto file = dbTester.components().insertComponent(newFileDto(project));
+    RuleDefinitionDto rule = newRule(SECURITY_HOTSPOT, t -> t.setSecurityStandards(standards));
+    IssueDto hotspot = dbTester.issues().insertIssue(newHotspot(project, file, rule)
+      .setLocations(DbIssues.Locations.newBuilder()
+        .setTextRange(DbCommons.TextRange.newBuilder().build())
+        .build()));
+
+    Hotspots.ShowWsResponse response = newRequest(hotspot)
+      .executeProtobuf(Hotspots.ShowWsResponse.class);
+    Hotspots.Rule wsRule = response.getRule();
+    assertThat(wsRule.getSecurityCategory()).isEqualTo(expected.getKey());
+    assertThat(wsRule.getVulnerabilityProbability()).isEqualTo(expected.getVulnerability().name());
+  }
+
+  @DataProvider
+  public static Object[][] allSQCategoryAndVulnerabilityProbability() {
+    Stream<Object[]> allButOthers = SecurityStandards.CWES_BY_SQ_CATEGORY
+      .entrySet()
+      .stream()
+      .map(t -> new Object[] {
+        t.getValue().stream().map(s -> "cwe:" + s).collect(Collectors.toSet()),
+        t.getKey()
+      });
+    Stream<Object[]> others = Stream.of(
+      new Object[] {Collections.emptySet(), SQCategory.OTHERS},
+      new Object[] {ImmutableSet.of("foo", "bar", "acme"), SQCategory.OTHERS});
+    return Stream.concat(allButOthers, others)
+      .toArray(Object[][]::new);
+  }
+
+  @Test
+  public void returns_project_twice_when_hotspot_on_project() {
+    ComponentDto project = dbTester.components().insertPublicProject();
+    userSessionRule.registerComponents(project);
+    RuleDefinitionDto rule = newRule(SECURITY_HOTSPOT);
+    IssueDto hotspot = dbTester.issues().insertIssue(newHotspot(project, project, rule)
+      .setLocations(DbIssues.Locations.newBuilder()
+        .setTextRange(DbCommons.TextRange.newBuilder().build())
+        .build()));
+
+    Hotspots.ShowWsResponse response = newRequest(hotspot)
+      .executeProtobuf(Hotspots.ShowWsResponse.class);
+
+    verifyComponent(response.getProject(), project);
+    verifyComponent(response.getComponent(), project);
+  }
+
+  public void verifyRule(Hotspots.Rule wsRule, RuleDefinitionDto dto) {
+    assertThat(wsRule.getKey()).isEqualTo(dto.getKey().toString());
+    assertThat(wsRule.getName()).isEqualTo(dto.getName());
+    assertThat(wsRule.getSecurityCategory()).isEqualTo(SQCategory.OTHERS.getKey());
+    assertThat(wsRule.getVulnerabilityProbability()).isEqualTo(SQCategory.OTHERS.getVulnerability().name());
+  }
+
+  private static void verifyComponent(Hotspots.Component wsComponent, ComponentDto dto) {
+    assertThat(wsComponent.getKey()).isEqualTo(dto.getKey());
+    if (dto.path() == null) {
+      assertThat(wsComponent.hasPath()).isFalse();
+    } else {
+      assertThat(wsComponent.getPath()).isEqualTo(dto.path());
+    }
+    assertThat(wsComponent.getQualifier()).isEqualTo(dto.qualifier());
+    assertThat(wsComponent.getName()).isEqualTo(dto.name());
+    assertThat(wsComponent.getLongName()).isEqualTo(dto.longName());
+  }
+
+  private static IssueDto newHotspot(ComponentDto project, ComponentDto file, RuleDefinitionDto rule) {
+    return IssueTesting.newIssue(rule, project, file).setType(SECURITY_HOTSPOT);
+  }
+
+  private TestRequest newRequest(IssueDto hotspot) {
+    return actionTester.newRequest()
+      .setParam("hotspot", hotspot.getKey());
+  }
+
+  private RuleDefinitionDto newRule(RuleType ruleType) {
+    return newRule(ruleType, t -> {
+    });
+  }
+
+  private RuleDefinitionDto newRule(RuleType ruleType, Consumer<RuleDefinitionDto> populate) {
+    RuleDefinitionDto ruleDefinition = RuleTesting.newRule()
+      .setType(ruleType);
+    populate.accept(ruleDefinition);
+    dbTester.rules().insert(ruleDefinition);
+    return ruleDefinition;
+  }
+
+}
index bb67997b4d03f24d5240de9bab5c8b57b23df318..4e41efcfd6fb061bb9f9fc1365fc0ecd5fb0de8c 100644 (file)
@@ -30,7 +30,7 @@ public class IssueWsModuleTest {
   public void verify_count_of_added_components() {
     ComponentContainer container = new ComponentContainer();
     new IssueWsModule().configure(container);
-    assertThat(container.size()).isEqualTo(COMPONENTS_IN_EMPTY_COMPONENT_CONTAINER + 28);
+    assertThat(container.size()).isEqualTo(COMPONENTS_IN_EMPTY_COMPONENT_CONTAINER + 29);
   }
 }
 
index f4e85478c725c8ae0897681e5d394ba291c9017b..9902e74ee9b29e3b07daefa848d6306075874758 100644 (file)
@@ -37,6 +37,7 @@ import org.sonar.db.component.ComponentDto;
 import org.sonar.db.issue.IssueDto;
 import org.sonar.db.organization.OrganizationDto;
 import org.sonar.db.rule.RuleDefinitionDto;
+import org.sonar.server.issue.TextRangeResponseFormatter;
 import org.sonar.server.es.EsTester;
 import org.sonar.server.issue.AvatarResolverImpl;
 import org.sonar.server.issue.IssueFieldsSetter;
@@ -105,7 +106,7 @@ public class SearchActionComponentsTest {
   private IssueWorkflow issueWorkflow = new IssueWorkflow(new FunctionExecutor(issueFieldsSetter), issueFieldsSetter);
   private SearchResponseLoader searchResponseLoader = new SearchResponseLoader(userSession, dbClient, new TransitionService(userSession, issueWorkflow));
   private Languages languages = new Languages();
-  private SearchResponseFormat searchResponseFormat = new SearchResponseFormat(new Durations(), languages, new AvatarResolverImpl());
+  private SearchResponseFormat searchResponseFormat = new SearchResponseFormat(new Durations(), languages, new AvatarResolverImpl(), new TextRangeResponseFormatter());
   private PermissionIndexerTester permissionIndexer = new PermissionIndexerTester(es, issueIndexer);
 
   private WsActionTester ws = new WsActionTester(new SearchAction(userSession, issueIndex, issueQueryFactory, searchResponseLoader, searchResponseFormat,
index 0981dccbb8468609eebe5c7cac0c9b45fcf0e657..4cc89cf1b10a66a1458b67d23512bf46c90fd785 100644 (file)
@@ -37,6 +37,7 @@ import org.sonar.db.component.ComponentDto;
 import org.sonar.db.organization.OrganizationDto;
 import org.sonar.db.rule.RuleDefinitionDto;
 import org.sonar.db.user.UserDto;
+import org.sonar.server.issue.TextRangeResponseFormatter;
 import org.sonar.server.es.EsTester;
 import org.sonar.server.es.StartupIndexer;
 import org.sonar.server.issue.AvatarResolverImpl;
@@ -87,7 +88,7 @@ public class SearchActionFacetsTest {
   private IssueQueryFactory issueQueryFactory = new IssueQueryFactory(db.getDbClient(), Clock.systemUTC(), userSession);
   private SearchResponseLoader searchResponseLoader = new SearchResponseLoader(userSession, db.getDbClient(), new TransitionService(userSession, null));
   private Languages languages = new Languages();
-  private SearchResponseFormat searchResponseFormat = new SearchResponseFormat(new Durations(), languages, new AvatarResolverImpl());
+  private SearchResponseFormat searchResponseFormat = new SearchResponseFormat(new Durations(), languages, new AvatarResolverImpl(), new TextRangeResponseFormatter());
 
   private WsActionTester ws = new WsActionTester(
     new SearchAction(userSession, issueIndex, issueQueryFactory, searchResponseLoader, searchResponseFormat,
index daa6ab4e4c2bfb25f1e274cfd0830712b13bfcaf..3d1b890861be4632af1007e4e9f3774a8abd31a0 100644 (file)
@@ -51,6 +51,7 @@ import org.sonar.db.rule.RuleDefinitionDto;
 import org.sonar.db.rule.RuleDto;
 import org.sonar.db.rule.RuleTesting;
 import org.sonar.db.user.UserDto;
+import org.sonar.server.issue.TextRangeResponseFormatter;
 import org.sonar.server.es.EsTester;
 import org.sonar.server.es.SearchOptions;
 import org.sonar.server.es.StartupIndexer;
@@ -120,7 +121,7 @@ public class SearchActionTest {
   private IssueWorkflow issueWorkflow = new IssueWorkflow(new FunctionExecutor(issueFieldsSetter), issueFieldsSetter);
   private SearchResponseLoader searchResponseLoader = new SearchResponseLoader(userSession, dbClient, new TransitionService(userSession, issueWorkflow));
   private Languages languages = new Languages();
-  private SearchResponseFormat searchResponseFormat = new SearchResponseFormat(new Durations(), languages, new AvatarResolverImpl());
+  private SearchResponseFormat searchResponseFormat = new SearchResponseFormat(new Durations(), languages, new AvatarResolverImpl(), new TextRangeResponseFormatter());
   private WsActionTester ws = new WsActionTester(new SearchAction(userSession, issueIndex, issueQueryFactory, searchResponseLoader, searchResponseFormat,
     new MapSettings().asConfig(), System2.INSTANCE, dbClient));
   private StartupIndexer permissionIndexer = new PermissionIndexer(dbClient, es.client(), issueIndexer);
index 9443753ccdb55505d2385632a044b9629326c7c5..8d71785e45066c391c7e58955eff9fbd27e229ad 100644 (file)
@@ -37,6 +37,7 @@ import org.sonar.db.component.ComponentDto;
 import org.sonar.db.organization.OrganizationDto;
 import org.sonar.db.rule.RuleDefinitionDto;
 import org.sonar.db.user.UserDto;
+import org.sonar.server.issue.TextRangeResponseFormatter;
 import org.sonar.server.es.EsTester;
 import org.sonar.server.issue.AvatarResolverImpl;
 import org.sonar.server.issue.IssueFieldsSetter;
@@ -77,7 +78,7 @@ public class SearchActionTestOnSonarCloud {
   private IssueWorkflow issueWorkflow = new IssueWorkflow(new FunctionExecutor(issueFieldsSetter), issueFieldsSetter);
   private SearchResponseLoader searchResponseLoader = new SearchResponseLoader(userSession, dbClient, new TransitionService(userSession, issueWorkflow));
   private Languages languages = new Languages();
-  private SearchResponseFormat searchResponseFormat = new SearchResponseFormat(new Durations(), languages, new AvatarResolverImpl());
+  private SearchResponseFormat searchResponseFormat = new SearchResponseFormat(new Durations(), languages, new AvatarResolverImpl(), new TextRangeResponseFormatter());
   private PermissionIndexerTester permissionIndexer = new PermissionIndexerTester(es, issueIndexer);
 
   private SearchAction underTest = new SearchAction(userSession, issueIndex, issueQueryFactory, searchResponseLoader, searchResponseFormat,
index e6650472d048d9305416607b066ef11bb76fec70..cef59629a8ec35cbcd076db4f0a23b8cade82470 100644 (file)
@@ -31,23 +31,42 @@ message SearchWsResponse {
   optional sonarqube.ws.commons.Paging paging = 1;
   repeated Hotspot hotspots = 2;
   repeated Component components = 3;
+
+  message Hotspot {
+    optional string key = 1;
+    optional string component = 2;
+    optional string project = 3;
+    optional string securityCategory = 4;
+    optional string vulnerabilityProbability = 5;
+    optional string status = 6;
+  //  FIXME resolution field will be added later
+  //  optional string resolution = 7;
+    optional int32 line = 8;
+    optional string message = 9;
+    optional string assignee = 10;
+    optional string author = 11;
+    optional string creationDate = 12;
+    optional string updateDate = 13;
+  }
 }
 
-message Hotspot {
+// Response of GET api/hotspots/show
+message ShowWsResponse {
   optional string key = 1;
-  optional string component = 2;
-  optional string project = 3;
-  optional string securityCategory = 4;
-  optional string vulnerabilityProbability = 5;
-  optional string status = 6;
+  optional Component component = 2;
+  optional Component project = 3;
+  optional Rule rule = 4;
+  optional string status = 5;
 //  FIXME resolution field will be added later
-//  optional string resolution = 7;
-  optional int32 line = 8;
-  optional string message = 9;
-  optional string assignee = 10;
-  optional string author = 11;
-  optional string creationDate = 12;
-  optional string updateDate = 13;
+//  optional string resolution = 6;
+  optional int32 line = 7;
+  optional string message = 8;
+  optional string assignee = 9;
+  // SCM login of the committer who introduced the issue
+  optional string author = 10;
+  optional string creationDate = 11;
+  optional string updateDate = 12;
+  optional sonarqube.ws.commons.TextRange textRange = 13;
 }
 
 message Component {
@@ -58,3 +77,13 @@ message Component {
   optional string longName = 5;
   optional string path = 6;
 }
+
+message Rule {
+  optional string key = 1;
+  optional string name = 2;
+  optional string securityCategory = 3;
+  optional string vulnerabilityProbability = 4;
+  optional string riskDescription = 5;
+  optional string vulnerabilityDescription = 6;
+  optional string fixRecommendations = 7;
+}