Skip to content

Commit 76c63d3

Browse files
Fortify: Handle suppressed findings as false positives (#12293)
* fortify: handle suppressed findings * ruff * fortify: handle suppressed findings docs * fortify: handle suppressed findings docs
1 parent b413437 commit 76c63d3

File tree

4 files changed

+95
-19
lines changed

4 files changed

+95
-19
lines changed

docs/content/en/connecting_your_tools/parsers/file/fortify.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,22 @@ title: "Fortify"
33
toc_hide: true
44
---
55
You can either import the findings in .xml or in .fpr file format. </br>
6-
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).
6+
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

88
### Sample Scan Data
99
Sample Fortify scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/fortify).
1010

1111
### Fortify Webinspect report formats.
12-
Fortify Webinspect released in version 24.2 a new xml report format. This parser is able to handle both report formats. See [this issue](https://github.com/DefectDojo/django-DefectDojo/issues/12065) for further information.
12+
Fortify Webinspect released in version 24.2 a new xml report format. This parser is able to handle both report formats. See [this issue](https://github.com/DefectDojo/django-DefectDojo/issues/12065) for further information.
1313

1414
#### Generate XML Output from Foritfy
15-
This section describes how to import XML generated from a Fortify FPR. It assumes you
15+
This section describes how to import XML generated from a Fortify FPR. It assumes you
1616
already have, or know how to acquire, an FPR file. Once you have the FPR file you will need
1717
use Fortify's ReportGenerator tool (located in the bin directory of your fortify install).
1818
```FORTIFY_INSTALL_ROOT/bin/ReportGenerator```
1919

2020
By default, the Report Generator tool does _not_ display all issues, it will only display one
21-
per category. To get all issues, copy the [DefaultReportDefinitionAllIssues.xml](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/fortify/DefaultReportDefinitionAllIssues.xml) to:
21+
per category. To get all issues, copy the [DefaultReportDefinitionAllIssues.xml](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/fortify/DefaultReportDefinitionAllIssues.xml) to:
2222
```FORTIFY_INSTALL_ROOT/Core/config/reports```
2323

2424
Once this is complete, you can run the following command on your .fpr file to generate the

dojo/tools/fortify/fpr_parser.py

Lines changed: 67 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ def __init__(self):
1717
self.snippets: dict[str, SnippetData] = {}
1818
self.rules: dict[str, RuleData] = {}
1919
self.vulnerabilities: list[VulnerabilityData] = []
20+
self.suppressed: dict[str, bool] = {}
21+
self.threaded_comments: dict[str, list[str]] = {}
2022

2123
def parse_fpr(self, filename, test):
2224
if str(filename.__class__) == "<class '_io.TextIOWrapper'>":
@@ -26,31 +28,35 @@ def parse_fpr(self, filename, test):
2628
# Read each file from the zip artifact into a dict with the format of
2729
# filename: file_content
2830
zip_data = {name: input_zip.read(name) for name in input_zip.namelist()}
29-
root = self.identify_root(zip_data)
30-
return self.convert_vulnerabilities_to_findings(root, test)
31+
root, self.namespaces = self.identify_root(zip_data, "audit.fvdl", "No audit.fvdl file found in the zip")
32+
audit_log, self.namespaces_audit_log = self.identify_root(zip_data, "audit.xml")
33+
return self.convert_vulnerabilities_to_findings(root, audit_log, test)
3134

32-
def identify_root(self, zip_data: dict) -> Element:
33-
"""Iterate through the zip data to determine which file in the zip could be the XMl to be parsed."""
35+
def identify_root(self, zip_data: dict, filename_suffix: str, msg_if_not_found: str | None = None) -> tuple[Element, dict[str, str]]:
36+
"""Iterate through the zip data to determine which file in the zip could be the XML to be parsed."""
3437
# Determine where the "audit.fvdl" could be
3538
audit_file = None
3639
for file_name in zip_data:
37-
if file_name.endswith("audit.fvdl"):
40+
if file_name.endswith(filename_suffix):
3841
audit_file = file_name
3942
break
4043
# Make sure we have an audit file
41-
if audit_file is None:
42-
msg = 'A search for an "audit.fvdl" file was not successful. '
43-
raise ValueError(msg)
44-
# Parser the XML file and determine the name space, if present
44+
if audit_file is None and msg_if_not_found:
45+
raise ValueError(msg_if_not_found)
46+
47+
if not audit_file:
48+
return None, None
49+
50+
# Parse the XML file and determine the namespace, if present
4551
root = ElementTree.fromstring(zip_data.get(audit_file).decode("utf-8"))
46-
self.identify_namespace(root)
47-
return root
52+
namespaces = self.identify_namespace(root)
53+
return root, namespaces
4854

49-
def identify_namespace(self, root: Element) -> None:
55+
def identify_namespace(self, root: Element) -> dict[str, str]:
5056
"""Determine what the namespace could be, and then set the value in a class var labeled `namespaces`"""
5157
regex = r"{(.*)}"
5258
matches = re.match(regex, root.tag)
53-
self.namespaces = {"": matches.group(1)}
59+
return {"": matches.group(1)}
5460

5561
def parse_related_data(self, root: Element, test: Test) -> None:
5662
"""Parse the XML and generate a list of findings."""
@@ -72,11 +78,37 @@ def parse_related_data(self, root: Element, test: Test) -> None:
7278
if rule_id:
7379
self.rules[rule_id] = self.parse_rule_information(rule.find("MetaInfo", self.namespaces))
7480

75-
def convert_vulnerabilities_to_findings(self, root: Element, test: Test) -> list[Finding]:
81+
def parse_audit_log(self, audit_log: Element) -> None:
82+
logger.debug("Parse audit log")
83+
if audit_log is None:
84+
return
85+
86+
for issue in audit_log.find("IssueList", self.namespaces_audit_log).findall("Issue", self.namespaces_audit_log):
87+
instance_id = issue.attrib.get("instanceId")
88+
if instance_id:
89+
suppressed_string = issue.attrib.get("suppressed")
90+
suppressed = suppressed_string.lower() == "true" if suppressed_string else False
91+
logger.debug(f"Issue: {instance_id} - Suppressed: {suppressed}")
92+
self.suppressed[instance_id] = suppressed
93+
94+
threaded_comments = issue.find("ThreadedComments", self.namespaces_audit_log)
95+
logger.debug(f"ThreadedComments: {threaded_comments}")
96+
if threaded_comments is not None:
97+
self.threaded_comments[instance_id] = [self.get_comment_text(comment) for comment in threaded_comments.findall("Comment", self.namespaces_audit_log)]
98+
99+
def get_comment_text(self, comment: Element) -> str:
100+
content = comment.findtext("Content", "", self.namespaces_audit_log)
101+
username = comment.findtext("Username", "", self.namespaces_audit_log)
102+
timestamp = comment.findtext("Timestamp", "", self.namespaces_audit_log)
103+
104+
return f"{timestamp} - ({username}): {content}"
105+
106+
def convert_vulnerabilities_to_findings(self, root: Element, audit_log: Element, test: Test) -> list[Finding]:
76107
"""Convert the list of vulnerabilities to a list of findings."""
77108
"""Try to mimic the logic from the xml parser"""
78109
"""Future Improvement: share code between xml and fpr parser (it was split up earlier)"""
79110
self.parse_related_data(root, test)
111+
self.parse_audit_log(audit_log)
80112

81113
findings = []
82114
for vuln in root.find("Vulnerabilities", self.namespaces):
@@ -91,10 +123,12 @@ def convert_vulnerabilities_to_findings(self, root: Element, test: Test) -> list
91123

92124
finding = Finding(test=test, static_finding=True)
93125

126+
finding.active, finding.false_p = self.compute_status(vuln_data)
94127
finding.title = self.format_title(vuln_data, snippet, description, rule)
95128
finding.description = self.format_description(vuln_data, snippet, description, rule)
96129
finding.mitigation = self.format_mitigation(vuln_data, snippet, description, rule)
97130
finding.severity = self.compute_severity(vuln_data, snippet, description, rule)
131+
finding.impact = self.format_impact(vuln_data)
98132

99133
finding.file_path = vuln_data.source_location_path
100134
finding.line = int(self.compute_line(vuln_data, snippet, description, rule))
@@ -268,6 +302,25 @@ def compute_severity(self, vulnerability, snippet, description, rule) -> str:
268302

269303
return "Informational"
270304

305+
def format_impact(self, vuln_data) -> str:
306+
"""Format the impact of the vulnerability based on the threaded comments."""
307+
logger.debug(f"Threaded comments: {self.threaded_comments}")
308+
threaded_comments = self.threaded_comments.get(vuln_data.instance_id)
309+
if not threaded_comments:
310+
return ""
311+
312+
impact = "Threaded Comments:\n"
313+
for comment in self.threaded_comments[vuln_data.instance_id]:
314+
impact += f"{comment}\n"
315+
316+
return impact
317+
318+
def compute_status(self, vulnerability) -> tuple[bool, bool]:
319+
"""Compute the status of the vulnerability based on the instance ID. Return active, false_p"""
320+
if vulnerability.instance_id in self.suppressed:
321+
return False, True
322+
return True, False
323+
271324
def compute_line(self, vulnerability, snippet, description, rule) -> str:
272325
if snippet and snippet.start_line:
273326
return snippet.start_line
Binary file not shown.

unittests/tools/test_fortify_parser.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ def test_fortify_many_fdr_findings(self):
9999
self.assertEqual("public/footer.html", finding.file_path)
100100
self.assertEqual(104, finding.line)
101101

102-
def test_fortify_hello_world_fdr_findings(self):
102+
def test_fortify_hello_world_fpr_findings(self):
103103
with open(get_unit_tests_scans_path("fortify") / "hello_world.fpr", encoding="utf-8") as testfile:
104104
parser = FortifyParser()
105105
findings = parser.get_findings(testfile, Test())
@@ -131,3 +131,26 @@ def test_fortify_webinspect_4_2_many_findings(self):
131131
finding = findings[0]
132132
self.assertEqual("Cookie Security: Cookie not Sent Over SSL", finding.title)
133133
self.assertEqual("Medium", finding.severity)
134+
135+
def test_fortify_fpr_suppressed_finding(self):
136+
with open(get_unit_tests_scans_path("fortify") / "fortify_suppressed_with_comments.fpr", encoding="utf-8") as testfile:
137+
parser = FortifyParser()
138+
findings = parser.get_findings(testfile, Test())
139+
self.assertEqual(4, len(findings))
140+
# for i in range(len(findings)):
141+
# print(f"{i}: {findings[i]}: {findings[i].active}")
142+
143+
with self.subTest(i=0):
144+
finding = findings[0]
145+
self.assertEqual("Password Management - HelloWorld.java: 5 (720E3A66-55AC-4D2D-8DB9-DC30E120A52F)", finding.title)
146+
self.assertEqual("A5338E223E737FF81F8A806C50A05969", finding.unique_id_from_tool)
147+
self.assertTrue(finding.active)
148+
self.assertFalse(finding.false_p)
149+
self.assertEqual("", finding.impact)
150+
with self.subTest(i=1):
151+
finding = findings[2]
152+
self.assertEqual("Build Misconfiguration - pom.xml: 1 (FF57412F-DD28-44DE-8F4F-0AD39620768C)", finding.title)
153+
self.assertEqual("87E3EC5CC8154C006783CC461A6DDEEB", finding.unique_id_from_tool)
154+
self.assertFalse(finding.active)
155+
self.assertTrue(finding.false_p)
156+
self.assertEqual("Threaded Comments:\n2025-03-10T20:52:28.964+05:30 - (testuser): Not an issue. Handled in server config to refer to internal Artifactory\n", finding.impact)

0 commit comments

Comments
 (0)