Skip to content

Commit 8d194cf

Browse files
make sonarqube hotspots sync work (#13206)
* sonarqube hotspots sync implementation * params fix * ruff fixes * ruff fixes
1 parent 982ff32 commit 8d194cf

3 files changed

Lines changed: 279 additions & 84 deletions

File tree

dojo/tools/api_sonarqube/api_client.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,31 @@ def get_hotspot_rule(self, rule_id):
334334
self.rules_cache.update({rule_id: rule})
335335
return rule
336336

337+
def get_hotspot(self, rule_id):
338+
"""
339+
Get detailed information about a hotspot
340+
:param rule_id:
341+
:return:
342+
"""
343+
rule = self.rules_cache.get(rule_id)
344+
if not rule:
345+
response = self.session.get(
346+
url=f"{self.sonar_api_url}/hotspots/search",
347+
params={"hotspots": rule_id},
348+
headers=self.default_headers,
349+
timeout=settings.REQUESTS_TIMEOUT,
350+
)
351+
if not response.ok:
352+
msg = (
353+
f"Unable to get the hotspot rule {rule_id} "
354+
f"due to {response.status_code} - {response.content}"
355+
)
356+
raise Exception(msg)
357+
358+
rule = response.json()["hotspots"][0]
359+
self.rules_cache.update({rule_id: rule})
360+
return rule
361+
337362
def transition_issue(self, issue_key, transition):
338363
"""
339364
Do workflow transition on an issue. Requires authentication and Browse permission on project.
@@ -375,6 +400,46 @@ def transition_issue(self, issue_key, transition):
375400
)
376401
raise Exception(msg)
377402

403+
def transition_hotspot(self, issue_key, status, resolution=None):
404+
"""
405+
Do workflow transition on an issue. Requires authentication and Browse permission on project.
406+
The transitions 'wontfix' and 'falsepositive' require the permission 'Administer Issues'.
407+
The transitions involving security hotspots (except 'requestreview') require
408+
the permission 'Administer Security Hotspot'.
409+
410+
Possible resolution values:
411+
- FIXED
412+
- SAFE
413+
- ACKNOWLEDGED
414+
415+
Possible status values:
416+
- TO_REVIEW
417+
- REVIEWED
418+
419+
:param issue_key:
420+
:param status:
421+
:param resolution:
422+
:return:
423+
"""
424+
data = {"hotspot": issue_key, "status": status}
425+
426+
if resolution:
427+
data["resolution"] = resolution
428+
429+
response = self.session.post(
430+
url=f"{self.sonar_api_url}/hotspots/change_status",
431+
data=data,
432+
headers=self.default_headers,
433+
timeout=settings.REQUESTS_TIMEOUT,
434+
)
435+
436+
if not response.ok:
437+
msg = (
438+
f"Unable to change status {status} / resolution {resolution} the issue {issue_key} "
439+
f'due to {response.status_code} - {response.content.decode("utf-8")}'
440+
)
441+
raise Exception(msg)
442+
378443
def add_comment(self, issue_key, text):
379444
"""
380445
Add a comment.

dojo/tools/api_sonarqube/updater.py

Lines changed: 123 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,45 @@ class SonarQubeApiUpdater:
5252
},
5353
]
5454

55+
MAPPING_SONARQUBE_HOTSPOT_STATUS_TRANSITION = [
56+
{
57+
"from": ["TO_REVIEW"],
58+
"to": "RESOLVED / FALSE-POSITIVE",
59+
"transition": "REVIEWED",
60+
"resolution": "SAFE",
61+
},
62+
{
63+
"from": ["TO_REVIEW"],
64+
"to": "RESOLVED / FIXED",
65+
"transition": "REVIEWED",
66+
"resolution": "FIXED",
67+
},
68+
{
69+
"from": ["TO_REVIEW"],
70+
"to": "RESOLVED / WONTFIX",
71+
"transition": "REVIEWED",
72+
"resolution": "ACKNOWLEDGED",
73+
},
74+
{
75+
"from": ["REVIEWED"],
76+
"to": "OPEN",
77+
"transition": "TO_REVIEW",
78+
"resolution": None,
79+
},
80+
{
81+
"from": ["REVIEWED"],
82+
"to": "REOPENED",
83+
"transition": "TO_REVIEW",
84+
"resolution": None,
85+
},
86+
{
87+
"from": ["REVIEWED"],
88+
"to": "CONFIRMED",
89+
"transition": "TO_REVIEW",
90+
"resolution": None,
91+
},
92+
]
93+
5594
@staticmethod
5695
def get_sonarqube_status_for(finding):
5796
target_status = None
@@ -66,16 +105,22 @@ def get_sonarqube_status_for(finding):
66105
return target_status
67106

68107
def get_sonarqube_required_transitions_for(
69-
self, current_status, target_status,
70-
):
108+
self, current_status, target_status, is_hotspot):
71109
# If current and target is the same... do nothing
72110
if current_status == target_status:
73111
return None
74112

113+
# Select the appropriate mapping based on issue type
114+
mapping = (
115+
self.MAPPING_SONARQUBE_HOTSPOT_STATUS_TRANSITION
116+
if is_hotspot
117+
else self.MAPPING_SONARQUBE_STATUS_TRANSITION
118+
)
119+
75120
# Check if there is at least one transition from current_status...
76121
if not [
77122
x
78-
for x in self.MAPPING_SONARQUBE_STATUS_TRANSITION
123+
for x in mapping
79124
if current_status in x.get("from")
80125
]:
81126
return None
@@ -84,34 +129,46 @@ def get_sonarqube_required_transitions_for(
84129
# can transition to target_status
85130
transitions = [
86131
x
87-
for x in self.MAPPING_SONARQUBE_STATUS_TRANSITION
132+
for x in mapping
88133
if target_status == x.get("to")
89134
]
90135
if transitions:
91136
for transition in transitions:
92137
# There is a direct transition from current status...
93138
if current_status in transition.get("from"):
94139
t = transition.get("transition")
140+
if is_hotspot:
141+
return [{"status": t, "resolution": transition.get("resolution")}] if t else None
95142
return [t] if t else None
96143

97-
# We have the last transition to get to our target status but there
98-
# is no direct transition
99-
transitions_result = deque()
100-
transitions_result.appendleft(transitions[0].get("transition"))
144+
# Handle complex transitions for regular issues
145+
if not is_hotspot:
146+
# We have the last transition to get to our target status but there
147+
# is no direct transition
148+
transitions_result = deque()
149+
transitions_result.appendleft(transitions[0].get("transition"))
101150

102-
# Find out previous transitions that would finish in any FROM of a
103-
# previous to use as target
104-
for transition in transitions:
105-
for t_from in transition.get("from"):
106-
possible_transition = (
107-
self.get_sonarqube_required_transitions_for(
108-
current_status, t_from,
151+
# Find out previous transitions that would finish in any FROM of a
152+
# previous to use as target
153+
for transition in transitions:
154+
for t_from in transition.get("from"):
155+
possible_transition = (
156+
self.get_sonarqube_required_transitions_for(
157+
current_status, t_from, is_hotspot,
158+
)
109159
)
110-
)
111-
if possible_transition:
112-
transitions_result.extendleft(possible_transition)
113-
return list(transitions_result)
114-
return None
160+
if possible_transition:
161+
transitions_result.extendleft(possible_transition)
162+
return list(transitions_result)
163+
else:
164+
# SQ code is too complicated for ISSUES, there is no such thing for HOTSPOTS,
165+
# there are only 2 states: TO_REVIEW and REVIEWED
166+
transitions_result = deque()
167+
transitions_result.appendleft(
168+
{"status": transitions[0].get("transition"),
169+
"resolution": transitions[0].get("resolution")},
170+
)
171+
return list(transitions_result)
115172
return None
116173

117174
def update_sonarqube_finding(self, finding):
@@ -128,44 +185,56 @@ def update_sonarqube_finding(self, finding):
128185
# during import
129186

130187
target_status = self.get_sonarqube_status_for(finding)
188+
is_hotspot = sonarqube_issue.type == "SECURITY_HOTSPOT"
131189

132-
issue = client.get_issue(sonarqube_issue.key)
133-
if (
134-
issue
135-
): # Issue could have disappeared in SQ because a previous scan has resolved the issue as fixed
136-
if issue.get("resolution"):
137-
current_status = "{} / {}".format(
138-
issue.get("status"), issue.get("resolution"),
139-
)
140-
else:
141-
current_status = issue.get("status")
190+
issue = client.get_hotspot(sonarqube_issue.key) if is_hotspot else client.get_issue(sonarqube_issue.key)
191+
192+
# Issue does not exist (could have disappeared in SQ because a previous scan resolved it)
193+
if not issue:
194+
return
195+
196+
if is_hotspot:
197+
current_status = issue.get("status")
198+
elif issue.get("resolution"):
199+
current_status = "{} / {}".format(issue.get("status"), issue.get("resolution"))
200+
else:
201+
current_status = issue.get("status")
142202

203+
# Get required transitions
204+
transitions = self.get_sonarqube_required_transitions_for(
205+
current_status, target_status, is_hotspot=is_hotspot,
206+
)
207+
208+
if not transitions:
143209
logger.debug(
144-
f"--> SQ Current status: {current_status}. Current target status: {target_status}",
210+
f"There are no transitions between {current_status} and {target_status} for finding '{finding}' in SonarQube",
145211
)
212+
return
146213

147-
transitions = self.get_sonarqube_required_transitions_for(
148-
current_status, target_status,
214+
logger.debug(
215+
f"Updating finding '{finding}' transition {current_status} -> {target_status} in SonarQube",
149216
)
150-
if transitions:
151-
logger.info(
152-
f"Updating finding '{finding}' in SonarQube",
153-
)
154217

155-
for transition in transitions:
156-
client.transition_issue(sonarqube_issue.key, transition)
157-
158-
# Track Defect Dojo has updated the SonarQube issue
159-
Sonarqube_Issue_Transition.objects.create(
160-
sonarqube_issue=finding.sonarqube_issue,
161-
# not sure if this is needed, but looks like the original author decided to send display status
162-
# to sonarqube we changed Accepted into Risk Accepted, but we change it back to be sure we don't
163-
# break the integration
164-
finding_status=finding.status().replace(
165-
"Risk Accepted", "Accepted",
166-
)
167-
if finding.status()
168-
else finding.status(),
169-
sonarqube_status=current_status,
170-
transitions=",".join(transitions),
171-
)
218+
# Apply transitions
219+
for transition in transitions:
220+
if is_hotspot:
221+
client.transition_hotspot(sonarqube_issue.key,
222+
status=transition["status"],
223+
resolution=transition["resolution"])
224+
else:
225+
client.transition_issue(sonarqube_issue.key, transition)
226+
227+
# Track that Defect Dojo has updated the SonarQube issue
228+
Sonarqube_Issue_Transition.objects.create(
229+
sonarqube_issue=finding.sonarqube_issue,
230+
# not sure if this is needed, but looks like the original author decided to send display status
231+
# to sonarqube we changed Accepted into Risk Accepted, but we change it back to be sure we don't
232+
# break the integration
233+
finding_status=finding.status().replace(
234+
"Risk Accepted", "Accepted",
235+
)
236+
if finding.status()
237+
else finding.status(),
238+
sonarqube_status=current_status,
239+
transitions=",".join(transition["status"] if is_hotspot else transition for transition in transitions),
240+
)

0 commit comments

Comments
 (0)