Skip to content

Commit 5a467f8

Browse files
committed
create v2 scan
1 parent 42d07bc commit 5a467f8

4 files changed

Lines changed: 118 additions & 48 deletions

File tree

docs/content/supported_tools/parsers/file/fortify.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ toc_hide: true
55
You can either import the findings in .xml or in .fpr file format. </br>
66
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.
77

8+
### Fortify Scan v2
9+
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.
10+
811
### Sample Scan Data
912
Sample Fortify scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/fortify).
1013

dojo/tools/fortify/fpr_parser.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -334,10 +334,23 @@ def compute_status(self, related_data, vulnerability) -> tuple[bool, bool]:
334334
return True, False
335335

336336
def compute_line(self, vulnerability, snippet) -> str:
337-
# Prefer the line Fortify reports for the vulnerability itself; the snippet
338-
# StartLine includes leading context lines and does not point at the finding.
339-
if vulnerability.source_location_line:
340-
return vulnerability.source_location_line
341337
if snippet and snippet.start_line:
342338
return snippet.start_line
343-
return None
339+
return vulnerability.source_location_line
340+
341+
342+
class FortifyFPRParserV2(FortifyFPRParser):
343+
344+
"""
345+
FPR parser for the "Fortify Scan v2" scan type.
346+
347+
Stores the line Fortify reports for the vulnerability itself (the SourceLocation
348+
"line" attribute) instead of the snippet StartLine used by the v1 parser, which
349+
includes leading context lines and does not point at the finding. Kept as a
350+
separate scan type so hashcodes of existing "Fortify Scan" findings are unaffected.
351+
"""
352+
353+
def compute_line(self, vulnerability, snippet) -> str:
354+
if vulnerability.source_location_line:
355+
return vulnerability.source_location_line
356+
return super().compute_line(vulnerability, snippet)

dojo/tools/fortify/parser.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from dojo.tools.fortify.fpr_parser import FortifyFPRParser
1+
from dojo.tools.fortify.fpr_parser import FortifyFPRParser, FortifyFPRParserV2
22
from dojo.tools.fortify.xml_parser import FortifyXMLParser
33

44

@@ -19,3 +19,22 @@ def get_findings(self, filename, test):
1919
return FortifyFPRParser().parse_fpr(filename, test)
2020
msg = "Filename extension not recognized. Use .xml or .fpr"
2121
raise ValueError(msg)
22+
23+
24+
class FortifyParserV2:
25+
def get_scan_types(self):
26+
return ["Fortify Scan v2"]
27+
28+
def get_label_for_scan_types(self, scan_type):
29+
return scan_type # no custom label for now
30+
31+
def get_description_for_scan_types(self, scan_type):
32+
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."
33+
34+
def get_findings(self, filename, test):
35+
if str(filename.name).endswith(".xml"):
36+
return FortifyXMLParser().parse_xml(filename, test)
37+
if str(filename.name).endswith(".fpr"):
38+
return FortifyFPRParserV2().parse_fpr(filename, test)
39+
msg = "Filename extension not recognized. Use .xml or .fpr"
40+
raise ValueError(msg)

unittests/tools/test_fortify_parser.py

Lines changed: 77 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11

22
from dojo.models import Test
3-
from dojo.tools.fortify.parser import FortifyParser
3+
from dojo.tools.fortify.parser import FortifyParser, FortifyParserV2
44
from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path
55

66

@@ -93,18 +93,18 @@ def test_fortify_many_fdr_findings(self):
9393

9494
with self.subTest(i=0):
9595
finding = findings[0]
96-
self.assertEqual("Cross-Site Request Forgery - category.html: 222 (114E5A67-3446-4DD5-B578-D0E6FDBB304E)", finding.title)
96+
self.assertEqual("Cross-Site Request Forgery - category.html: 219 (114E5A67-3446-4DD5-B578-D0E6FDBB304E)", finding.title)
9797
self.assertEqual("Low", finding.severity)
9898
self.assertEqual("1B87289954501EF8FD3861819DD98C27", finding.unique_id_from_tool)
9999
self.assertEqual("public/category.html", finding.file_path)
100-
self.assertEqual(222, finding.line)
100+
self.assertEqual(219, finding.line)
101101
with self.subTest(i=1):
102102
finding = findings[50]
103-
self.assertEqual("Insecure Transport - footer.html: 107 (C72A3E77-8324-4FF9-B958-74FCDDF39D17)", finding.title)
103+
self.assertEqual("Insecure Transport - footer.html: 104 (C72A3E77-8324-4FF9-B958-74FCDDF39D17)", finding.title)
104104
self.assertEqual("Medium", finding.severity)
105105
self.assertEqual("D739B2E51B127BDFA4FE07B5A7662A45", finding.unique_id_from_tool)
106106
self.assertEqual("public/footer.html", finding.file_path)
107-
self.assertEqual(107, finding.line)
107+
self.assertEqual(104, finding.line)
108108

109109
def test_fortify_hello_world_fpr_findings(self):
110110
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):
117117

118118
with self.subTest(i=0):
119119
finding = findings[0]
120-
self.assertEqual("Password Management - HelloWorld.java: 8 (720E3A66-55AC-4D2D-8DB9-DC30E120A52F)", finding.title)
120+
self.assertEqual("Password Management - HelloWorld.java: 5 (720E3A66-55AC-4D2D-8DB9-DC30E120A52F)", finding.title)
121121
self.assertEqual("Low", finding.severity)
122122
self.assertEqual("A5338E223E737FF81F8A806C50A05969", finding.unique_id_from_tool)
123123
self.assertEqual("src/main/java/hello/HelloWorld.java", finding.file_path)
124-
self.assertEqual(8, finding.line)
124+
self.assertEqual(5, finding.line)
125125
with self.subTest(i=1):
126126
finding = findings[1]
127-
self.assertEqual("Password Management - HelloWorld.java: 16 (9C5BD1B5-C296-48d4-B5F5-5D2958661BC4)", finding.title)
127+
self.assertEqual("Password Management - HelloWorld.java: 13 (9C5BD1B5-C296-48d4-B5F5-5D2958661BC4)", finding.title)
128128
self.assertEqual("High", finding.severity)
129129
self.assertEqual("D3166922519EDD92D132761602EB71B4", finding.unique_id_from_tool)
130130
self.assertEqual("src/main/java/hello/HelloWorld.java", finding.file_path)
131-
self.assertEqual(16, finding.line)
132-
133-
# Regression: FPR parser stored the snippet context StartLine (reported line - 3)
134-
# instead of Fortify's reported SourceLocation line, breaking file_path+line dedup.
135-
def test_fortify_fpr_uses_true_source_location_line(self):
136-
with (get_unit_tests_scans_path("fortify") / "many_findings.fpr").open(encoding="utf-8") as testfile:
137-
parser = FortifyParser()
138-
findings = parser.get_findings(testfile, Test())
139-
finding = findings[0]
140-
# control: the description records Fortify's reported line verbatim
141-
self.assertIn("**SourceLocationLine:** 222", finding.description)
142-
# the stored line must match the reported line, not the snippet start (219)
143-
self.assertEqual(
144-
222, finding.line,
145-
msg=f"expected line=222 (SourceLocationLine), stored line={finding.line}",
146-
)
147-
self.assertEqual("Cross-Site Request Forgery - category.html: 222 (114E5A67-3446-4DD5-B578-D0E6FDBB304E)", finding.title)
148-
with (get_unit_tests_scans_path("fortify") / "hello_world.fpr").open(encoding="utf-8") as testfile:
149-
parser = FortifyParser()
150-
findings = parser.get_findings(testfile, Test())
151-
# near the top of a file the snippet start clamps to 1, so the offset
152-
# is not even constant: reported line 3, snippet start 1
153-
finding = findings[2]
154-
self.assertIn("**SourceLocationLine:** 3", finding.description)
155-
self.assertEqual(
156-
3, finding.line,
157-
msg=f"expected line=3 (SourceLocationLine), stored line={finding.line}",
158-
)
131+
self.assertEqual(13, finding.line)
159132

160133
def test_fortify_webinspect_4_2_many_findings(self):
161134
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):
179152

180153
with self.subTest(i=0):
181154
finding = findings[0]
182-
self.assertEqual("Password Management - HelloWorld.java: 8 (720E3A66-55AC-4D2D-8DB9-DC30E120A52F)", finding.title)
155+
self.assertEqual("Password Management - HelloWorld.java: 5 (720E3A66-55AC-4D2D-8DB9-DC30E120A52F)", finding.title)
183156
self.assertEqual("A5338E223E737FF81F8A806C50A05969", finding.unique_id_from_tool)
184157
self.assertTrue(finding.active)
185158
self.assertFalse(finding.false_p)
186159
self.assertEqual("", finding.impact)
187160
with self.subTest(i=1):
188161
finding = findings[2]
189-
self.assertEqual("Build Misconfiguration - pom.xml: 3 (FF57412F-DD28-44DE-8F4F-0AD39620768C)", finding.title)
162+
self.assertEqual("Build Misconfiguration - pom.xml: 1 (FF57412F-DD28-44DE-8F4F-0AD39620768C)", finding.title)
190163
self.assertEqual("87E3EC5CC8154C006783CC461A6DDEEB", finding.unique_id_from_tool)
191164
self.assertFalse(finding.active)
192165
self.assertTrue(finding.false_p)
@@ -203,16 +176,78 @@ def test_fortify_hello_world_fpr_rule_without_metainfo(self):
203176

204177
with self.subTest(i=0):
205178
finding = findings[0]
206-
self.assertEqual("Password Management - HelloWorld.java: 8 (720E3A66-55AC-4D2D-8DB9-DC30E120A52F)", finding.title)
179+
self.assertEqual("Password Management - HelloWorld.java: 5 (720E3A66-55AC-4D2D-8DB9-DC30E120A52F)", finding.title)
207180
# Info as rule has no metainfo/impact
208181
self.assertEqual("Info", finding.severity)
209182
self.assertEqual("A5338E223E737FF81F8A806C50A05969", finding.unique_id_from_tool)
210183
self.assertEqual("src/main/java/hello/HelloWorld.java", finding.file_path)
211-
self.assertEqual(8, finding.line)
184+
self.assertEqual(5, finding.line)
212185
with self.subTest(i=1):
213186
finding = findings[1]
214-
self.assertEqual("Password Management - HelloWorld.java: 16 (9C5BD1B5-C296-48d4-B5F5-5D2958661BC4)", finding.title)
187+
self.assertEqual("Password Management - HelloWorld.java: 13 (9C5BD1B5-C296-48d4-B5F5-5D2958661BC4)", finding.title)
215188
self.assertEqual("High", finding.severity)
216189
self.assertEqual("D3166922519EDD92D132761602EB71B4", finding.unique_id_from_tool)
217190
self.assertEqual("src/main/java/hello/HelloWorld.java", finding.file_path)
218-
self.assertEqual(16, finding.line)
191+
self.assertEqual(13, finding.line)
192+
193+
194+
class TestFortifyV2Parser(DojoTestCase):
195+
196+
"""
197+
Regression: the v1 FPR parser stores the snippet context StartLine (Fortify's
198+
reported line minus up to 3 context lines) instead of the reported SourceLocation
199+
line, which breaks file_path + line deduplication against other tools. The
200+
"Fortify Scan v2" scan type stores the reported line; v1 is intentionally left
201+
unchanged so existing hashcodes are unaffected.
202+
"""
203+
204+
def test_fortify_v2_scan_type(self):
205+
self.assertEqual(["Fortify Scan v2"], FortifyParserV2().get_scan_types())
206+
207+
def test_fortify_v2_fpr_uses_true_source_location_line(self):
208+
with (get_unit_tests_scans_path("fortify") / "many_findings.fpr").open(encoding="utf-8") as testfile:
209+
parser = FortifyParserV2()
210+
findings = parser.get_findings(testfile, Test())
211+
self.assertEqual(61, len(findings))
212+
self.validate_locations(findings)
213+
finding = findings[0]
214+
# control: the description records Fortify's reported line verbatim
215+
self.assertIn("**SourceLocationLine:** 222", finding.description)
216+
# the stored line must match the reported line, not the snippet start (219)
217+
self.assertEqual(
218+
222, finding.line,
219+
msg=f"expected line=222 (SourceLocationLine), stored line={finding.line}",
220+
)
221+
self.assertEqual("Cross-Site Request Forgery - category.html: 222 (114E5A67-3446-4DD5-B578-D0E6FDBB304E)", finding.title)
222+
223+
def test_fortify_v2_fpr_clamped_snippet(self):
224+
with (get_unit_tests_scans_path("fortify") / "hello_world.fpr").open(encoding="utf-8") as testfile:
225+
parser = FortifyParserV2()
226+
findings = parser.get_findings(testfile, Test())
227+
self.assertEqual(4, len(findings))
228+
with self.subTest(i=0):
229+
finding = findings[0]
230+
self.assertIn("**SourceLocationLine:** 8", finding.description)
231+
self.assertEqual(
232+
8, finding.line,
233+
msg=f"expected line=8 (SourceLocationLine), stored line={finding.line}",
234+
)
235+
with self.subTest(i=2):
236+
# near the top of a file the snippet start clamps to 1, so the v1
237+
# offset is not even constant: reported line 3, snippet start 1
238+
finding = findings[2]
239+
self.assertIn("**SourceLocationLine:** 3", finding.description)
240+
self.assertEqual(
241+
3, finding.line,
242+
msg=f"expected line=3 (SourceLocationLine), stored line={finding.line}",
243+
)
244+
245+
def test_fortify_v2_xml_unchanged_from_v1(self):
246+
# the XML report path already uses the reported line in v1; v2 must match
247+
with (get_unit_tests_scans_path("fortify") / "fortify_many_findings.xml").open(encoding="utf-8") as testfile:
248+
parser = FortifyParserV2()
249+
findings = parser.get_findings(testfile, Test())
250+
self.assertEqual(324, len(findings))
251+
finding = findings[0]
252+
self.assertEqual("src/main/java/org/joychou/controller/XXE.java", finding.file_path)
253+
self.assertEqual(81, finding.line)

0 commit comments

Comments
 (0)