From 42d07bc28569e2728e241364998c20f5f3da9b47 Mon Sep 17 00:00:00 2001 From: Paul Osinski Date: Mon, 13 Jul 2026 11:55:37 -0400 Subject: [PATCH 1/2] change fortify parser to prefer true line --- dojo/tools/fortify/fpr_parser.py | 6 ++- unittests/tools/test_fortify_parser.py | 55 +++++++++++++++++++------- 2 files changed, 46 insertions(+), 15 deletions(-) diff --git a/dojo/tools/fortify/fpr_parser.py b/dojo/tools/fortify/fpr_parser.py index f406782bfb0..d49003476ab 100644 --- a/dojo/tools/fortify/fpr_parser.py +++ b/dojo/tools/fortify/fpr_parser.py @@ -334,6 +334,10 @@ def compute_status(self, related_data, vulnerability) -> tuple[bool, bool]: return True, False def compute_line(self, vulnerability, snippet) -> str: + # Prefer the line Fortify reports for the vulnerability itself; the snippet + # StartLine includes leading context lines and does not point at the finding. + if vulnerability.source_location_line: + return vulnerability.source_location_line if snippet and snippet.start_line: return snippet.start_line - return vulnerability.source_location_line + return None diff --git a/unittests/tools/test_fortify_parser.py b/unittests/tools/test_fortify_parser.py index cef5b56b1bd..c969ddc0ea6 100644 --- a/unittests/tools/test_fortify_parser.py +++ b/unittests/tools/test_fortify_parser.py @@ -93,18 +93,18 @@ def test_fortify_many_fdr_findings(self): with self.subTest(i=0): finding = findings[0] - self.assertEqual("Cross-Site Request Forgery - category.html: 219 (114E5A67-3446-4DD5-B578-D0E6FDBB304E)", finding.title) + self.assertEqual("Cross-Site Request Forgery - category.html: 222 (114E5A67-3446-4DD5-B578-D0E6FDBB304E)", finding.title) self.assertEqual("Low", finding.severity) self.assertEqual("1B87289954501EF8FD3861819DD98C27", finding.unique_id_from_tool) self.assertEqual("public/category.html", finding.file_path) - self.assertEqual(219, finding.line) + self.assertEqual(222, finding.line) with self.subTest(i=1): finding = findings[50] - self.assertEqual("Insecure Transport - footer.html: 104 (C72A3E77-8324-4FF9-B958-74FCDDF39D17)", finding.title) + self.assertEqual("Insecure Transport - footer.html: 107 (C72A3E77-8324-4FF9-B958-74FCDDF39D17)", finding.title) self.assertEqual("Medium", finding.severity) self.assertEqual("D739B2E51B127BDFA4FE07B5A7662A45", finding.unique_id_from_tool) self.assertEqual("public/footer.html", finding.file_path) - self.assertEqual(104, finding.line) + self.assertEqual(107, finding.line) def test_fortify_hello_world_fpr_findings(self): with (get_unit_tests_scans_path("fortify") / "hello_world.fpr").open(encoding="utf-8") as testfile: @@ -117,18 +117,45 @@ def test_fortify_hello_world_fpr_findings(self): with self.subTest(i=0): finding = findings[0] - self.assertEqual("Password Management - HelloWorld.java: 5 (720E3A66-55AC-4D2D-8DB9-DC30E120A52F)", finding.title) + self.assertEqual("Password Management - HelloWorld.java: 8 (720E3A66-55AC-4D2D-8DB9-DC30E120A52F)", finding.title) self.assertEqual("Low", finding.severity) self.assertEqual("A5338E223E737FF81F8A806C50A05969", finding.unique_id_from_tool) self.assertEqual("src/main/java/hello/HelloWorld.java", finding.file_path) - self.assertEqual(5, finding.line) + self.assertEqual(8, finding.line) with self.subTest(i=1): finding = findings[1] - self.assertEqual("Password Management - HelloWorld.java: 13 (9C5BD1B5-C296-48d4-B5F5-5D2958661BC4)", finding.title) + self.assertEqual("Password Management - HelloWorld.java: 16 (9C5BD1B5-C296-48d4-B5F5-5D2958661BC4)", finding.title) self.assertEqual("High", finding.severity) self.assertEqual("D3166922519EDD92D132761602EB71B4", finding.unique_id_from_tool) self.assertEqual("src/main/java/hello/HelloWorld.java", finding.file_path) - self.assertEqual(13, finding.line) + self.assertEqual(16, finding.line) + + # Regression: FPR parser stored the snippet context StartLine (reported line - 3) + # instead of Fortify's reported SourceLocation line, breaking file_path+line dedup. + def test_fortify_fpr_uses_true_source_location_line(self): + with (get_unit_tests_scans_path("fortify") / "many_findings.fpr").open(encoding="utf-8") as testfile: + parser = FortifyParser() + findings = parser.get_findings(testfile, Test()) + 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) + with (get_unit_tests_scans_path("fortify") / "hello_world.fpr").open(encoding="utf-8") as testfile: + parser = FortifyParser() + findings = parser.get_findings(testfile, Test()) + # near the top of a file the snippet start clamps to 1, so the 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_webinspect_4_2_many_findings(self): with (get_unit_tests_scans_path("fortify") / "webinspect_4_2_many_findings.xml").open(encoding="utf-8") as testfile: @@ -152,14 +179,14 @@ def test_fortify_fpr_suppressed_finding(self): with self.subTest(i=0): finding = findings[0] - self.assertEqual("Password Management - HelloWorld.java: 5 (720E3A66-55AC-4D2D-8DB9-DC30E120A52F)", finding.title) + self.assertEqual("Password Management - HelloWorld.java: 8 (720E3A66-55AC-4D2D-8DB9-DC30E120A52F)", finding.title) self.assertEqual("A5338E223E737FF81F8A806C50A05969", finding.unique_id_from_tool) self.assertTrue(finding.active) self.assertFalse(finding.false_p) self.assertEqual("", finding.impact) with self.subTest(i=1): finding = findings[2] - self.assertEqual("Build Misconfiguration - pom.xml: 1 (FF57412F-DD28-44DE-8F4F-0AD39620768C)", finding.title) + self.assertEqual("Build Misconfiguration - pom.xml: 3 (FF57412F-DD28-44DE-8F4F-0AD39620768C)", finding.title) self.assertEqual("87E3EC5CC8154C006783CC461A6DDEEB", finding.unique_id_from_tool) self.assertFalse(finding.active) self.assertTrue(finding.false_p) @@ -176,16 +203,16 @@ def test_fortify_hello_world_fpr_rule_without_metainfo(self): with self.subTest(i=0): finding = findings[0] - self.assertEqual("Password Management - HelloWorld.java: 5 (720E3A66-55AC-4D2D-8DB9-DC30E120A52F)", finding.title) + self.assertEqual("Password Management - HelloWorld.java: 8 (720E3A66-55AC-4D2D-8DB9-DC30E120A52F)", finding.title) # Info as rule has no metainfo/impact self.assertEqual("Info", finding.severity) self.assertEqual("A5338E223E737FF81F8A806C50A05969", finding.unique_id_from_tool) self.assertEqual("src/main/java/hello/HelloWorld.java", finding.file_path) - self.assertEqual(5, finding.line) + self.assertEqual(8, finding.line) with self.subTest(i=1): finding = findings[1] - self.assertEqual("Password Management - HelloWorld.java: 13 (9C5BD1B5-C296-48d4-B5F5-5D2958661BC4)", finding.title) + self.assertEqual("Password Management - HelloWorld.java: 16 (9C5BD1B5-C296-48d4-B5F5-5D2958661BC4)", finding.title) self.assertEqual("High", finding.severity) self.assertEqual("D3166922519EDD92D132761602EB71B4", finding.unique_id_from_tool) self.assertEqual("src/main/java/hello/HelloWorld.java", finding.file_path) - self.assertEqual(13, finding.line) + self.assertEqual(16, finding.line) From 5a467f8fb3b92d1299937037cfd002d41fe2e7cf Mon Sep 17 00:00:00 2001 From: Paul Osinski Date: Tue, 14 Jul 2026 10:15:11 -0400 Subject: [PATCH 2/2] create v2 scan --- .../supported_tools/parsers/file/fortify.md | 3 + dojo/tools/fortify/fpr_parser.py | 23 +++- dojo/tools/fortify/parser.py | 21 +++- unittests/tools/test_fortify_parser.py | 119 +++++++++++------- 4 files changed, 118 insertions(+), 48 deletions(-) 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 d49003476ab..03e9135819c 100644 --- a/dojo/tools/fortify/fpr_parser.py +++ b/dojo/tools/fortify/fpr_parser.py @@ -334,10 +334,23 @@ def compute_status(self, related_data, vulnerability) -> tuple[bool, bool]: return True, False def compute_line(self, vulnerability, snippet) -> str: - # Prefer the line Fortify reports for the vulnerability itself; the snippet - # StartLine includes leading context lines and does not point at the finding. - if vulnerability.source_location_line: - return vulnerability.source_location_line if snippet and snippet.start_line: return snippet.start_line - return None + 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 c969ddc0ea6..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 @@ -93,18 +93,18 @@ def test_fortify_many_fdr_findings(self): with self.subTest(i=0): finding = findings[0] - self.assertEqual("Cross-Site Request Forgery - category.html: 222 (114E5A67-3446-4DD5-B578-D0E6FDBB304E)", finding.title) + self.assertEqual("Cross-Site Request Forgery - category.html: 219 (114E5A67-3446-4DD5-B578-D0E6FDBB304E)", finding.title) self.assertEqual("Low", finding.severity) self.assertEqual("1B87289954501EF8FD3861819DD98C27", finding.unique_id_from_tool) self.assertEqual("public/category.html", finding.file_path) - self.assertEqual(222, finding.line) + self.assertEqual(219, finding.line) with self.subTest(i=1): finding = findings[50] - self.assertEqual("Insecure Transport - footer.html: 107 (C72A3E77-8324-4FF9-B958-74FCDDF39D17)", finding.title) + self.assertEqual("Insecure Transport - footer.html: 104 (C72A3E77-8324-4FF9-B958-74FCDDF39D17)", finding.title) self.assertEqual("Medium", finding.severity) self.assertEqual("D739B2E51B127BDFA4FE07B5A7662A45", finding.unique_id_from_tool) self.assertEqual("public/footer.html", finding.file_path) - self.assertEqual(107, finding.line) + self.assertEqual(104, finding.line) def test_fortify_hello_world_fpr_findings(self): with (get_unit_tests_scans_path("fortify") / "hello_world.fpr").open(encoding="utf-8") as testfile: @@ -117,45 +117,18 @@ def test_fortify_hello_world_fpr_findings(self): with self.subTest(i=0): finding = findings[0] - self.assertEqual("Password Management - HelloWorld.java: 8 (720E3A66-55AC-4D2D-8DB9-DC30E120A52F)", finding.title) + self.assertEqual("Password Management - HelloWorld.java: 5 (720E3A66-55AC-4D2D-8DB9-DC30E120A52F)", finding.title) self.assertEqual("Low", finding.severity) self.assertEqual("A5338E223E737FF81F8A806C50A05969", finding.unique_id_from_tool) self.assertEqual("src/main/java/hello/HelloWorld.java", finding.file_path) - self.assertEqual(8, finding.line) + self.assertEqual(5, finding.line) with self.subTest(i=1): finding = findings[1] - self.assertEqual("Password Management - HelloWorld.java: 16 (9C5BD1B5-C296-48d4-B5F5-5D2958661BC4)", finding.title) + self.assertEqual("Password Management - HelloWorld.java: 13 (9C5BD1B5-C296-48d4-B5F5-5D2958661BC4)", finding.title) self.assertEqual("High", finding.severity) self.assertEqual("D3166922519EDD92D132761602EB71B4", finding.unique_id_from_tool) self.assertEqual("src/main/java/hello/HelloWorld.java", finding.file_path) - self.assertEqual(16, finding.line) - - # Regression: FPR parser stored the snippet context StartLine (reported line - 3) - # instead of Fortify's reported SourceLocation line, breaking file_path+line dedup. - def test_fortify_fpr_uses_true_source_location_line(self): - with (get_unit_tests_scans_path("fortify") / "many_findings.fpr").open(encoding="utf-8") as testfile: - parser = FortifyParser() - findings = parser.get_findings(testfile, Test()) - 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) - with (get_unit_tests_scans_path("fortify") / "hello_world.fpr").open(encoding="utf-8") as testfile: - parser = FortifyParser() - findings = parser.get_findings(testfile, Test()) - # near the top of a file the snippet start clamps to 1, so the 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}", - ) + self.assertEqual(13, finding.line) def test_fortify_webinspect_4_2_many_findings(self): with (get_unit_tests_scans_path("fortify") / "webinspect_4_2_many_findings.xml").open(encoding="utf-8") as testfile: @@ -179,14 +152,14 @@ def test_fortify_fpr_suppressed_finding(self): with self.subTest(i=0): finding = findings[0] - self.assertEqual("Password Management - HelloWorld.java: 8 (720E3A66-55AC-4D2D-8DB9-DC30E120A52F)", finding.title) + self.assertEqual("Password Management - HelloWorld.java: 5 (720E3A66-55AC-4D2D-8DB9-DC30E120A52F)", finding.title) self.assertEqual("A5338E223E737FF81F8A806C50A05969", finding.unique_id_from_tool) self.assertTrue(finding.active) self.assertFalse(finding.false_p) self.assertEqual("", finding.impact) with self.subTest(i=1): finding = findings[2] - self.assertEqual("Build Misconfiguration - pom.xml: 3 (FF57412F-DD28-44DE-8F4F-0AD39620768C)", finding.title) + self.assertEqual("Build Misconfiguration - pom.xml: 1 (FF57412F-DD28-44DE-8F4F-0AD39620768C)", finding.title) self.assertEqual("87E3EC5CC8154C006783CC461A6DDEEB", finding.unique_id_from_tool) self.assertFalse(finding.active) self.assertTrue(finding.false_p) @@ -203,16 +176,78 @@ def test_fortify_hello_world_fpr_rule_without_metainfo(self): with self.subTest(i=0): finding = findings[0] - self.assertEqual("Password Management - HelloWorld.java: 8 (720E3A66-55AC-4D2D-8DB9-DC30E120A52F)", finding.title) + self.assertEqual("Password Management - HelloWorld.java: 5 (720E3A66-55AC-4D2D-8DB9-DC30E120A52F)", finding.title) # Info as rule has no metainfo/impact self.assertEqual("Info", finding.severity) self.assertEqual("A5338E223E737FF81F8A806C50A05969", finding.unique_id_from_tool) self.assertEqual("src/main/java/hello/HelloWorld.java", finding.file_path) - self.assertEqual(8, finding.line) + self.assertEqual(5, finding.line) with self.subTest(i=1): finding = findings[1] - self.assertEqual("Password Management - HelloWorld.java: 16 (9C5BD1B5-C296-48d4-B5F5-5D2958661BC4)", finding.title) + self.assertEqual("Password Management - HelloWorld.java: 13 (9C5BD1B5-C296-48d4-B5F5-5D2958661BC4)", finding.title) self.assertEqual("High", finding.severity) self.assertEqual("D3166922519EDD92D132761602EB71B4", finding.unique_id_from_tool) self.assertEqual("src/main/java/hello/HelloWorld.java", finding.file_path) - self.assertEqual(16, finding.line) + 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)