From ec2602675a0e4e16bb032d7eeb327459777cf00f Mon Sep 17 00:00:00 2001 From: Frank Niessink Date: Wed, 10 May 2023 19:29:02 +0200 Subject: [PATCH] When using SonarQube security hotspots as source for the security warnings metric, allow for filtering hotspots by hotspot status ('to review', 'acknowledged', 'fixed', 'safe'). Closes #5956. --- .../sonarqube/security_warnings.py | 34 +++++++++++++------ .../tests/source_collectors/sonarqube/base.py | 3 ++ .../sonarqube/test_security_warnings.py | 18 ++++++++++ .../shared_data_model/sources/sonarqube.py | 23 ++++++++++--- docs/src/changelog.md | 10 ++++++ docs/styles/Vocab/Base/accept.txt | 2 +- 6 files changed, 74 insertions(+), 16 deletions(-) diff --git a/components/collector/src/source_collectors/sonarqube/security_warnings.py b/components/collector/src/source_collectors/sonarqube/security_warnings.py index 6fe07659e9..41dc1053a8 100644 --- a/components/collector/src/source_collectors/sonarqube/security_warnings.py +++ b/components/collector/src/source_collectors/sonarqube/security_warnings.py @@ -45,17 +45,12 @@ async def _get_source_responses(self, *urls: URL, **kwargs) -> SourceResponses: ) ) if "security_hotspot" in security_types: - # Note: SonarQube is a bit inconsistent. For issue search, the SonarQube status parameter is called - # "statuses", but for hotspots it's called "status". - api_urls.append( - URL(f"{base_url}/api/hotspots/search?projectKey={component}&branch={branch}&status=TO_REVIEW&ps=500") - ) + api_urls.append(URL(f"{base_url}/api/hotspots/search?projectKey={component}&branch={branch}&ps=500")) return await super()._get_source_responses(*api_urls, **kwargs) async def _parse_source_responses(self, responses: SourceResponses) -> SourceMeasurement: """Override to parse the selected security types.""" security_types = self._parameter(self.types_parameter) - review_priorities = [priority.upper() for priority in self._parameter("review_priorities")] vulnerabilities = ( await super()._parse_source_responses(SourceResponses(responses=[responses[0]])) if "vulnerability" in security_types @@ -64,9 +59,9 @@ async def _parse_source_responses(self, responses: SourceResponses) -> SourceMea if "security_hotspot" in security_types: json = await responses[-1].json() hotspots = [ - await self.__entity(hotspot) + await self.__hotspot_entity(hotspot) for hotspot in json.get("hotspots", []) - if hotspot["vulnerabilityProbability"] in review_priorities + if self.__include_hotspot(hotspot) ] else: hotspots = [] @@ -75,8 +70,16 @@ async def _parse_source_responses(self, responses: SourceResponses) -> SourceMea entities=vulnerabilities.entities + hotspots, ) - async def __entity(self, hotspot) -> Entity: - """Create the security warning entity.""" + def __include_hotspot(self, hotspot) -> bool: + """Return whether to include the hotspot.""" + review_priorities = self._parameter("review_priorities") + review_priority = hotspot["vulnerabilityProbability"].lower() + statuses = self._parameter("hotspot_statuses") + status = self.__hotspot_status(hotspot) + return review_priority in review_priorities and status in statuses + + async def __hotspot_entity(self, hotspot) -> Entity: + """Create the security warning entity for the hotspot.""" return Entity( key=hotspot["key"], component=hotspot["component"], @@ -86,8 +89,19 @@ async def __entity(self, hotspot) -> Entity: review_priority=hotspot["vulnerabilityProbability"].lower(), creation_date=hotspot["creationDate"], update_date=hotspot["updateDate"], + hotspot_status=self.__hotspot_status(hotspot), ) + @staticmethod + def __hotspot_status(hotspot: dict[str, str]) -> str: + """Return the hotspot status.""" + # The SonarQube documentation describes the hotspot lifecycle as having four statuses (see + # https://docs.sonarqube.org/latest/user-guide/security-hotspots/#lifecycle): 'to review', 'acknowledged', + # 'fixed', and 'safe'. However, in the API the status is either 'to review' or 'reviewed' and the other + # statuses ('acknowledged', 'fixed', and 'safe') are called resolutions. So to determine the status as defined + # by the docs, check both the hotspot status and the hotspot resolution: + return "to review" if hotspot["status"] == "TO_REVIEW" else hotspot["resolution"].lower() + async def __hotspot_landing_url(self, hotspot_key: str) -> URL: """Generate a landing url for the hotspot.""" url = await SonarQubeCollector._landing_url(self, SourceResponses()) # pylint: disable=protected-access diff --git a/components/collector/tests/source_collectors/sonarqube/base.py b/components/collector/tests/source_collectors/sonarqube/base.py index b2e128127c..a15fa1db5c 100644 --- a/components/collector/tests/source_collectors/sonarqube/base.py +++ b/components/collector/tests/source_collectors/sonarqube/base.py @@ -29,6 +29,7 @@ def entity( # pylint: disable=too-many-arguments review_priority: str = None, creation_date: str = None, update_date: str = None, + hotspot_status: str = None, ) -> Entity: """Create an entity.""" url = ( @@ -53,4 +54,6 @@ def entity( # pylint: disable=too-many-arguments entity["rationale"] = rationale if review_priority is not None: entity["review_priority"] = review_priority + if hotspot_status is not None: + entity["hotspot_status"] = hotspot_status return entity diff --git a/components/collector/tests/source_collectors/sonarqube/test_security_warnings.py b/components/collector/tests/source_collectors/sonarqube/test_security_warnings.py index f34c3f4d14..809b7164b9 100644 --- a/components/collector/tests/source_collectors/sonarqube/test_security_warnings.py +++ b/components/collector/tests/source_collectors/sonarqube/test_security_warnings.py @@ -44,6 +44,7 @@ def setUp(self): vulnerabilityProbability="MEDIUM", creationDate="2010-12-13T10:37:07+0000", updateDate="2019-08-26T09:02:49+0000", + status="TO_REVIEW", ), dict( key="hotspot2", @@ -52,6 +53,8 @@ def setUp(self): vulnerabilityProbability="LOW", creationDate="2011-10-26T13:34:12+0000", updateDate="2020-08-31T08:19:00+0000", + status="REVIEWED", + resolution="FIXED", ), ], ) @@ -64,6 +67,7 @@ def setUp(self): review_priority="medium", creation_date="2010-12-13T10:37:07+0000", update_date="2019-08-26T09:02:49+0000", + hotspot_status="to review", ), self.entity( key="hotspot2", @@ -73,6 +77,7 @@ def setUp(self): review_priority="low", creation_date="2011-10-26T13:34:12+0000", update_date="2020-08-31T08:19:00+0000", + hotspot_status="fixed", ), ] self.vulnerability_entities = [ @@ -133,3 +138,16 @@ async def test_security_warnings_vulnerabilities_only(self): entities=self.vulnerability_entities, landing_url="https://sonarqube/project/issues?id=id&branch=master&resolved=false&types=VULNERABILITY", ) + + async def test_filter_security_warnings_hotspots_by_status(self): + """Test that the security hotspots can be filtered by status.""" + self.set_source_parameter("security_types", ["security_hotspot"]) + self.set_source_parameter("hotspot_statuses", ["fixed"]) + response = await self.collect(get_request_json_return_value=self.hotspots_json) + self.assert_measurement( + response, + value="1", + total="100", + entities=self.hotspot_entities[1:], + landing_url="https://sonarqube/project/security_hotspots?id=id&branch=master", + ) diff --git a/components/shared_data_model/src/shared_data_model/sources/sonarqube.py b/components/shared_data_model/src/shared_data_model/sources/sonarqube.py index 0863ac8020..a9b6e54eb1 100644 --- a/components/shared_data_model/src/shared_data_model/sources/sonarqube.py +++ b/components/shared_data_model/src/shared_data_model/sources/sonarqube.py @@ -23,7 +23,9 @@ class Lines(str, Enum): CODE = "lines with code" -def violation_entity_attributes(include_review_priority=False, include_resolution=False, include_rationale=False): +def violation_entity_attributes( + include_review_priority=False, include_resolution=False, include_rationale=False, include_status=False +): """Return the violation entity attributes.""" attributes = [ dict(name="Message"), @@ -31,7 +33,9 @@ def violation_entity_attributes(include_review_priority=False, include_resolutio ] if include_review_priority: attributes.append(dict(name="Review priority", color=dict(high=Color.NEGATIVE, medium=Color.WARNING))) - attributes.append(dict(name="Type")) + attributes.append(dict(name="Warning type", key="type")) + if include_status: + attributes.append(dict(name="Hotspot status")) if include_resolution: attributes.append(dict(name="Resolution")) if include_rationale: @@ -245,6 +249,14 @@ def violation_entity_attributes(include_review_priority=False, include_resolutio values=["info", "minor", "major", "critical", "blocker"], metrics=["security_warnings", "suppressed_violations", "violations"], ), + hotspot_statuses=MultipleChoiceParameter( + name="Security hotspot statuses", + short_name="hotspot statuses", + help_url="https://docs.sonarqube.org/latest/user-guide/security-hotspots/", + placeholder="all hotspot statuses", + values=["to review", "acknowledged", "safe", "fixed"], + metrics=["security_warnings"], + ), review_priorities=MultipleChoiceParameter( name="Security hotspot review priorities", short_name="review priorities", @@ -280,10 +292,10 @@ def violation_entity_attributes(include_review_priority=False, include_resolutio security_types=MultipleChoiceParameter( name="Security issue types (measuring security hotspots requires SonarQube 8.2 or newer)", short_name="types", - placeholder="all security issue types", + placeholder="vulnerability", help_url="https://docs.sonarqube.org/latest/user-guide/rules/", default_value=["vulnerability"], - values=["vulnerability", "security_hotspot"], + values=["security_hotspot", "vulnerability"], metrics=["security_warnings"], ), ), @@ -309,7 +321,8 @@ def violation_entity_attributes(include_review_priority=False, include_resolutio ], ), security_warnings=dict( - name="security warning", attributes=violation_entity_attributes(include_review_priority=True) + name="security warning", + attributes=violation_entity_attributes(include_review_priority=True, include_status=True), ), suppressed_violations=dict( name="violation", diff --git a/docs/src/changelog.md b/docs/src/changelog.md index 3a7328c5ee..e5e5802ccc 100644 --- a/docs/src/changelog.md +++ b/docs/src/changelog.md @@ -8,6 +8,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) +## [Unreleased] + +### Deployment notes + +If your currently installed *Quality-time* version is v4.0.0 or older, please read the v4.0.0 deployment notes. + +### Added + +- When using SonarQube security hotspots as source for the security warnings metric, allow for filtering hotspots by hotspot status ('to review', 'acknowledged', 'fixed', 'safe'). Closes [#5956](https://github.com/ICTU/quality-time/issues/5956). + ## v4.10.0 - 2023-04-26 ### Deployment notes diff --git a/docs/styles/Vocab/Base/accept.txt b/docs/styles/Vocab/Base/accept.txt index c1aca9616c..c7821dcda1 100644 --- a/docs/styles/Vocab/Base/accept.txt +++ b/docs/styles/Vocab/Base/accept.txt @@ -34,7 +34,7 @@ donut errored favicon hostnames? -hotspots +hotspots? misconfigured mypy namespace