diff --git a/docs/content/supported_tools/parsers/file/fortify.md b/docs/content/supported_tools/parsers/file/fortify.md index 9fede87689d..794aff2407c 100644 --- a/docs/content/supported_tools/parsers/file/fortify.md +++ b/docs/content/supported_tools/parsers/file/fortify.md @@ -5,6 +5,9 @@ toc_hide: true You can either import the findings in .xml or in .fpr file format.
If you import a .fpr file, the parser will look for the file 'audit.fvdl' and analyze it. An extracted example can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/fortify/audit.fvdl). The optional `audit.xml` is also parsed. All vulnerabilities marked with `suppressed="true"` will be marked as false positive. +### Fortify Scan v2 +The `Fortify Scan v2` scan type behaves identically to `Fortify Scan` except for .fpr imports: findings store the line number Fortify reports for the vulnerability (the FVDL `SourceLocation` line) instead of the first line of the surrounding code snippet, which includes up to 3 leading context lines. Use `Fortify Scan v2` when deduplicating on file path + line number, especially across tools. The two scan types produce different hashcodes for .fpr findings, so keep using `Fortify Scan` on existing products if you want to preserve deduplication history. + ### Sample Scan Data Sample Fortify scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/fortify). diff --git a/dojo/tools/fortify/fpr_parser.py b/dojo/tools/fortify/fpr_parser.py index b13689e565e..90d649fae04 100644 --- a/dojo/tools/fortify/fpr_parser.py +++ b/dojo/tools/fortify/fpr_parser.py @@ -333,3 +333,20 @@ def compute_line(self, vulnerability, snippet) -> str: if snippet and snippet.start_line: return snippet.start_line return vulnerability.source_location_line + + +class FortifyFPRParserV2(FortifyFPRParser): + + """ + FPR parser for the "Fortify Scan v2" scan type. + + Stores the line Fortify reports for the vulnerability itself (the SourceLocation + "line" attribute) instead of the snippet StartLine used by the v1 parser, which + includes leading context lines and does not point at the finding. Kept as a + separate scan type so hashcodes of existing "Fortify Scan" findings are unaffected. + """ + + def compute_line(self, vulnerability, snippet) -> str: + if vulnerability.source_location_line: + return vulnerability.source_location_line + return super().compute_line(vulnerability, snippet) diff --git a/dojo/tools/fortify/parser.py b/dojo/tools/fortify/parser.py index 7d2b15c0e25..322a6a40a05 100644 --- a/dojo/tools/fortify/parser.py +++ b/dojo/tools/fortify/parser.py @@ -1,4 +1,4 @@ -from dojo.tools.fortify.fpr_parser import FortifyFPRParser +from dojo.tools.fortify.fpr_parser import FortifyFPRParser, FortifyFPRParserV2 from dojo.tools.fortify.xml_parser import FortifyXMLParser @@ -19,3 +19,22 @@ def get_findings(self, filename, test): return FortifyFPRParser().parse_fpr(filename, test) msg = "Filename extension not recognized. Use .xml or .fpr" raise ValueError(msg) + + +class FortifyParserV2: + def get_scan_types(self): + return ["Fortify Scan v2"] + + def get_label_for_scan_types(self, scan_type): + return scan_type # no custom label for now + + def get_description_for_scan_types(self, scan_type): + return "Import Findings in FPR or XML file format. Unlike Fortify Scan, FPR findings use the line reported by Fortify rather than the start of the code snippet." + + def get_findings(self, filename, test): + if str(filename.name).endswith(".xml"): + return FortifyXMLParser().parse_xml(filename, test) + if str(filename.name).endswith(".fpr"): + return FortifyFPRParserV2().parse_fpr(filename, test) + msg = "Filename extension not recognized. Use .xml or .fpr" + raise ValueError(msg) diff --git a/unittests/tools/test_fortify_parser.py b/unittests/tools/test_fortify_parser.py index cef5b56b1bd..c5cc9b12eca 100644 --- a/unittests/tools/test_fortify_parser.py +++ b/unittests/tools/test_fortify_parser.py @@ -1,6 +1,6 @@ from dojo.models import Test -from dojo.tools.fortify.parser import FortifyParser +from dojo.tools.fortify.parser import FortifyParser, FortifyParserV2 from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path @@ -189,3 +189,65 @@ def test_fortify_hello_world_fpr_rule_without_metainfo(self): self.assertEqual("D3166922519EDD92D132761602EB71B4", finding.unique_id_from_tool) self.assertEqual("src/main/java/hello/HelloWorld.java", finding.file_path) self.assertEqual(13, finding.line) + + +class TestFortifyV2Parser(DojoTestCase): + + """ + Regression: the v1 FPR parser stores the snippet context StartLine (Fortify's + reported line minus up to 3 context lines) instead of the reported SourceLocation + line, which breaks file_path + line deduplication against other tools. The + "Fortify Scan v2" scan type stores the reported line; v1 is intentionally left + unchanged so existing hashcodes are unaffected. + """ + + def test_fortify_v2_scan_type(self): + self.assertEqual(["Fortify Scan v2"], FortifyParserV2().get_scan_types()) + + def test_fortify_v2_fpr_uses_true_source_location_line(self): + with (get_unit_tests_scans_path("fortify") / "many_findings.fpr").open(encoding="utf-8") as testfile: + parser = FortifyParserV2() + findings = parser.get_findings(testfile, Test()) + self.assertEqual(61, len(findings)) + self.validate_locations(findings) + finding = findings[0] + # control: the description records Fortify's reported line verbatim + self.assertIn("**SourceLocationLine:** 222", finding.description) + # the stored line must match the reported line, not the snippet start (219) + self.assertEqual( + 222, finding.line, + msg=f"expected line=222 (SourceLocationLine), stored line={finding.line}", + ) + self.assertEqual("Cross-Site Request Forgery - category.html: 222 (114E5A67-3446-4DD5-B578-D0E6FDBB304E)", finding.title) + + def test_fortify_v2_fpr_clamped_snippet(self): + with (get_unit_tests_scans_path("fortify") / "hello_world.fpr").open(encoding="utf-8") as testfile: + parser = FortifyParserV2() + findings = parser.get_findings(testfile, Test()) + self.assertEqual(4, len(findings)) + with self.subTest(i=0): + finding = findings[0] + self.assertIn("**SourceLocationLine:** 8", finding.description) + self.assertEqual( + 8, finding.line, + msg=f"expected line=8 (SourceLocationLine), stored line={finding.line}", + ) + with self.subTest(i=2): + # near the top of a file the snippet start clamps to 1, so the v1 + # offset is not even constant: reported line 3, snippet start 1 + finding = findings[2] + self.assertIn("**SourceLocationLine:** 3", finding.description) + self.assertEqual( + 3, finding.line, + msg=f"expected line=3 (SourceLocationLine), stored line={finding.line}", + ) + + def test_fortify_v2_xml_unchanged_from_v1(self): + # the XML report path already uses the reported line in v1; v2 must match + with (get_unit_tests_scans_path("fortify") / "fortify_many_findings.xml").open(encoding="utf-8") as testfile: + parser = FortifyParserV2() + findings = parser.get_findings(testfile, Test()) + self.assertEqual(324, len(findings)) + finding = findings[0] + self.assertEqual("src/main/java/org/joychou/controller/XXE.java", finding.file_path) + self.assertEqual(81, finding.line)