diff --git a/components/package.json b/components/package.json index f076490c127..98d06df0629 100644 --- a/components/package.json +++ b/components/package.json @@ -1,6 +1,6 @@ { "name": "defectdojo", - "version": "3.1.0", + "version": "3.2.0-dev", "license": "BSD-3-Clause", "private": true, "dependencies": { diff --git a/docs/content/en/open_source/upgrading/3.2.md b/docs/content/en/open_source/upgrading/3.2.md new file mode 100644 index 00000000000..461d394d336 --- /dev/null +++ b/docs/content/en/open_source/upgrading/3.2.md @@ -0,0 +1,7 @@ +--- +title: 'Upgrading to DefectDojo Version 3.2.x' +toc_hide: true +weight: -20260706 +description: No special instructions. +--- +There are no special instructions for upgrading to 3.2.x. Check the [Release Notes](https://github.com/DefectDojo/django-DefectDojo/releases/tag/3.2.0) for the contents of the release. diff --git a/dojo/__init__.py b/dojo/__init__.py index 238a44d13ce..5e975d608b1 100644 --- a/dojo/__init__.py +++ b/dojo/__init__.py @@ -4,6 +4,6 @@ # Django starts so that shared_task will use this app. from .celery import app as celery_app # noqa: F401 -__version__ = "3.1.0" +__version__ = "3.2.0-dev" __url__ = "https://github.com/DefectDojo/django-DefectDojo" __docs__ = "https://documentation.defectdojo.com" diff --git a/dojo/tools/api_sonarqube/importer.py b/dojo/tools/api_sonarqube/importer.py index d0ae635cfa0..168e8c73f16 100644 --- a/dojo/tools/api_sonarqube/importer.py +++ b/dojo/tools/api_sonarqube/importer.py @@ -194,12 +194,22 @@ def import_issues(self, test): sonarqube_permalink = "No permalink \n" rule_details = self.get_rule_details(rule) + mitigation = "No mitigation provided" # Default fallback + if rule_details: description = self.clean_rule_description_html( rule_details, ) cwe = self.clean_cwe(rule_details) references = sonarqube_permalink + self.get_references(rule_details) + + # Dynamic extraction for modern descriptionSections payloads + if "descriptionSections" in rule: + for section in rule.get("descriptionSections", []): + if isinstance(section, dict) and section.get("key") == "how_to_fix": + content = section.get("content", "") + if content: + mitigation = self.sanitize_rule_details(content) else: description = "" cwe = None @@ -234,7 +244,7 @@ def import_issues(self, test): duplicate=False, out_of_scope=False, mitigated=None, - mitigation="No mitigation provided", + mitigation=mitigation, # Dynamic assignment impact="No impact provided", static_finding=True, sonarqube_issue=sonarqube_issue, @@ -400,15 +410,40 @@ def clean_cwe(raw_html): @staticmethod def get_rule_details(rule): + # Path 1: Legacy HTML Support if html_desc := rule.get("htmlDesc"): return SonarQubeApiImporter.sanitize_rule_details(html_desc) - if not (md_desc := rule.get("mdDesc")): - return "" - # SonarQube 2025.x can return markdown-only rule descriptions, including - # inline HTML that should still be treated as markdown content. - return SonarQubeApiImporter.sanitize_rule_details( - markdown.markdown(md_desc, extensions=["extra"]), - ) + + # Path 2: Legacy Markdown Support + if md_desc := rule.get("mdDesc"): + return SonarQubeApiImporter.sanitize_rule_details( + markdown.markdown(md_desc, extensions=["extra"]), + ) + + # Path 3: Modern/Conditional Structured Sections Support (SonarQube 2025/2026+) + if "descriptionSections" in rule: + sections = rule["descriptionSections"] + if isinstance(sections, list): + sections_map = {} + for section in sections: + if isinstance(section, dict) and "key" in section and "content" in section: + sections_map[section["key"]] = section["content"] + + payload_elements = [] + if root_cause := sections_map.get("root_cause"): + payload_elements.append(f"
Raising generic exceptions reduces code readability.
" + }, + { + "key": "how_to_fix", + "content": "Raise specific built-in exceptions instead.
" + }, + { + "key": "resources", + "content": "See OWASP Top 10 or check CWE-95 for details.
" + } + ] +} diff --git a/unittests/tools/test_api_sonarqube_importer.py b/unittests/tools/test_api_sonarqube_importer.py index 8dd3b9eafa0..ed7e6521e68 100644 --- a/unittests/tools/test_api_sonarqube_importer.py +++ b/unittests/tools/test_api_sonarqube_importer.py @@ -62,6 +62,16 @@ def empty_list(self, *args, **kwargs): return [] +def dummy_rule_description_sections_only(self, *args, **kwargs): + with (get_unit_tests_scans_path("api_sonarqube") / "rule_description_sections_only.json").open(encoding="utf-8") as json_file: + return json.load(json_file) + + +def dummy_issues_single(self, *args, **kwargs): + with (get_unit_tests_scans_path("api_sonarqube") / "issues_single.json").open(encoding="utf-8") as json_file: + return json.load(json_file) + + class TestSonarqubeImporterNoSQToolConfig(DojoTestCase): # Testing case no 1. https://github.com/DefectDojo/django-DefectDojo/pull/4676 fixtures = [ @@ -377,6 +387,50 @@ def test_get_rule_details_sanitizes_html_desc(self): self.assertNotIn("javascript:", rule_details) +class TestSonarqubeImporterDescriptionSections(DojoTestCase): + + """ + Verify that the modern descriptionSections API path (SonarQube 2025/2026+) + correctly populates Finding.description and Finding.mitigation. + """ + + fixtures = [ + "unit_sonarqube_toolType.json", + "unit_sonarqube_toolConfig1.json", + "unit_sonarqube_toolConfig2.json", + "unit_sonarqube_product.json", + "unit_sonarqube_sqcWithKey.json", + ] + + def setUp(self): + product = Product.objects.get(name="product") + engagement = Engagement(product=product) + self.test = Test(engagement=engagement) + + @mock.patch("dojo.tools.api_sonarqube.api_client.SonarQubeAPI.get_project", dummy_product) + @mock.patch("dojo.tools.api_sonarqube.api_client.SonarQubeAPI.get_rule", dummy_rule_description_sections_only) + @mock.patch("dojo.tools.api_sonarqube.api_client.SonarQubeAPI.find_issues", dummy_issues_single) + @mock.patch("dojo.tools.api_sonarqube.api_client.SonarQubeAPI.get_hotspot_rule", dummy_hotspot_rule) + @mock.patch("dojo.tools.api_sonarqube.api_client.SonarQubeAPI.find_hotspots", empty_list) + def test_description_sections_populate_finding_fields(self): + parser = SonarQubeApiImporter() + findings = parser.get_findings(None, self.test) + + self.assertEqual(1, len(findings)) + finding = findings[0] + + # description should contain the root_cause prose but NOT the references section + # (clean_rule_description_html truncates at