|
7 | 7 | from django.contrib.admin.utils import NestedObjects
|
8 | 8 | from django.urls import reverse
|
9 | 9 | from django.db import DEFAULT_DB_ALIAS
|
10 |
| -from django.http import HttpResponseRedirect, HttpResponse, Http404, HttpResponseBadRequest |
| 10 | +from django.http import HttpResponseRedirect, HttpResponse, Http404 |
11 | 11 | from django.shortcuts import render, get_object_or_404
|
12 | 12 | from django.utils import timezone
|
13 | 13 | from django.utils.dateparse import parse_datetime
|
14 | 14 | from django.views.decorators.csrf import csrf_exempt
|
15 | 15 | from django.core.exceptions import PermissionDenied
|
16 | 16 | # Local application/library imports
|
17 | 17 | from dojo.forms import JIRAForm, DeleteJIRAInstanceForm, ExpressJIRAForm
|
18 |
| -from dojo.models import User, JIRA_Instance, JIRA_Issue, Notes |
19 |
| -from dojo.utils import add_breadcrumb, add_error_message_to_response, get_system_setting |
| 18 | +from dojo.models import System_Settings, User, JIRA_Instance, JIRA_Issue, Notes |
| 19 | +from dojo.utils import add_breadcrumb, add_error_message_to_response |
20 | 20 | from dojo.notifications.helper import create_notification
|
21 | 21 | from django.views.decorators.http import require_POST
|
22 | 22 | import dojo.jira_link.helper as jira_helper
|
|
26 | 26 | logger = logging.getLogger(__name__)
|
27 | 27 |
|
28 | 28 |
|
29 |
| -# for examples of incoming json, see the unit tests for the webhook: https://github.com/DefectDojo/django-DefectDojo/blob/master/unittests/test_jira_webhook.py |
30 |
| -# or the officials docs (which are not always clear): https://developer.atlassian.com/server/jira/platform/webhooks/ |
| 29 | +def webhook_responser_handler( |
| 30 | + log_level: str, |
| 31 | + message: str, |
| 32 | +) -> HttpResponse: |
| 33 | + # These represent an error and will be sent to the debugger |
| 34 | + # for development purposes |
| 35 | + if log_level == "info": |
| 36 | + logger.info(message) |
| 37 | + # These are more common in misconfigurations and have a better |
| 38 | + # chance of being seen by a user |
| 39 | + elif log_level == "debug": |
| 40 | + logger.debug(message) |
| 41 | + # Return the response with the code |
| 42 | + return HttpResponse(message, status=200) |
| 43 | + |
| 44 | + |
31 | 45 | @csrf_exempt
|
32 | 46 | @require_POST
|
33 | 47 | def webhook(request, secret=None):
|
34 |
| - if not get_system_setting('enable_jira'): |
35 |
| - logger.debug('ignoring incoming webhook as JIRA is disabled.') |
36 |
| - raise Http404('JIRA disabled') |
37 |
| - elif not get_system_setting('enable_jira_web_hook'): |
38 |
| - logger.debug('ignoring incoming webhook as JIRA Webhook is disabled.') |
39 |
| - raise Http404('JIRA Webhook disabled') |
40 |
| - elif not get_system_setting('disable_jira_webhook_secret'): |
41 |
| - if not get_system_setting('jira_webhook_secret'): |
42 |
| - logger.warning('ignoring incoming webhook as JIRA Webhook secret is empty in Defect Dojo system settings.') |
43 |
| - raise PermissionDenied('JIRA Webhook secret cannot be empty') |
44 |
| - if secret != get_system_setting('jira_webhook_secret'): |
45 |
| - logger.warning('invalid secret provided to JIRA Webhook') |
46 |
| - raise PermissionDenied('invalid or no secret provided to JIRA Webhook') |
| 48 | + """ |
| 49 | + for examples of incoming json, see the unit tests for the webhook: |
| 50 | + https://github.com/DefectDojo/django-DefectDojo/blob/master/unittests/test_jira_webhook.py |
| 51 | + or the officials docs (which are not always clear): |
| 52 | + https://developer.atlassian.com/server/jira/platform/webhooks/ |
47 | 53 |
|
| 54 | + All responses here will return a 201 so that we may have control over the |
| 55 | + logging level |
| 56 | + """ |
| 57 | + # Make sure the request is a POST, otherwise, we reject |
| 58 | + if request.method != "POST": |
| 59 | + return webhook_responser_handler("debug", "Only POST requests are supported") |
| 60 | + # Determine if th webhook is in use or not |
| 61 | + system_settings = System_Settings.objects.get() |
| 62 | + # If the jira integration is not enabled, then return a 404 |
| 63 | + if not system_settings.enable_jira: |
| 64 | + return webhook_responser_handler("info", "Ignoring incoming webhook as JIRA is disabled.") |
| 65 | + # If the webhook is not enabled, then return a 404 |
| 66 | + elif not system_settings.enable_jira_web_hook: |
| 67 | + return webhook_responser_handler("info", "Ignoring incoming webhook as JIRA Webhook is disabled.") |
| 68 | + # Determine if the request should be "authenticated" |
| 69 | + elif not system_settings.disable_jira_webhook_secret: |
| 70 | + # Make sure there is a value for the webhook secret before making a comparison |
| 71 | + if not system_settings.jira_webhook_secret: |
| 72 | + return webhook_responser_handler("info", "Ignoring incoming webhook as JIRA Webhook secret is empty in Defect Dojo system settings.") |
| 73 | + # Make sure the secret supplied in the path of the webhook request matches the |
| 74 | + # secret supplied in the system settings |
| 75 | + if secret != system_settings.jira_webhook_secret: |
| 76 | + return webhook_responser_handler("info", "Invalid or no secret provided to JIRA Webhook") |
48 | 77 | # if webhook secret is disabled in system_settings, we ignore the incoming secret, even if it doesn't match
|
49 |
| - |
50 | 78 | # example json bodies at the end of this file
|
51 |
| - |
52 |
| - if request.content_type != 'application/json': |
53 |
| - return HttpResponseBadRequest("only application/json supported") |
54 |
| - |
55 |
| - if request.method == 'POST': |
56 |
| - try: |
57 |
| - parsed = json.loads(request.body.decode('utf-8')) |
58 |
| - if parsed.get('webhookEvent') == 'jira:issue_updated': |
59 |
| - # xml examples at the end of file |
60 |
| - jid = parsed['issue']['id'] |
61 |
| - jissue = get_object_or_404(JIRA_Issue, jira_id=jid) |
62 |
| - |
63 |
| - findings = None |
64 |
| - if jissue.finding: |
65 |
| - logging.info(f"Received issue update for {jissue.jira_key} for finding {jissue.finding.id}") |
66 |
| - findings = [jissue.finding] |
67 |
| - elif jissue.finding_group: |
68 |
| - logging.info(f"Received issue update for {jissue.jira_key} for finding group {jissue.finding_group}") |
69 |
| - findings = jissue.finding_group.findings.all() |
70 |
| - elif jissue.engagement: |
71 |
| - # if parsed['issue']['fields']['resolution'] != None: |
72 |
| - # eng.active = False |
73 |
| - # eng.status = 'Completed' |
74 |
| - # eng.save() |
75 |
| - return HttpResponse('Update for engagement ignored') |
76 |
| - else: |
77 |
| - logging.info(f"Received issue update for {jissue.jira_key} for unknown object") |
78 |
| - raise Http404(f'No finding, finding_group or engagement found for JIRA issue {jissue.jira_key}') |
79 |
| - |
80 |
| - assignee = parsed['issue']['fields'].get('assignee') |
81 |
| - assignee_name = 'Jira User' |
82 |
| - if assignee is not None: |
83 |
| - # First look for the 'name' field. If not present, try 'displayName'. Else put None |
84 |
| - assignee_name = assignee.get('name', assignee.get('displayName')) |
85 |
| - |
86 |
| - resolution = parsed['issue']['fields']['resolution'] |
87 |
| - |
88 |
| - # "resolution":{ |
89 |
| - # "self":"http://www.testjira.com/rest/api/2/resolution/11", |
90 |
| - # "id":"11", |
91 |
| - # "description":"Cancelled by the customer.", |
92 |
| - # "name":"Cancelled" |
93 |
| - # }, |
94 |
| - |
95 |
| - # or |
96 |
| - # "resolution": null |
97 |
| - |
98 |
| - # or |
99 |
| - # "resolution": "None" |
100 |
| - |
101 |
| - resolution = resolution if resolution and resolution != "None" else None |
102 |
| - resolution_id = resolution['id'] if resolution else None |
103 |
| - resolution_name = resolution['name'] if resolution else None |
104 |
| - jira_now = parse_datetime(parsed['issue']['fields']['updated']) |
105 |
| - |
106 |
| - if findings: |
107 |
| - for finding in findings: |
108 |
| - jira_helper.process_resolution_from_jira(finding, resolution_id, resolution_name, assignee_name, jira_now, jissue) |
109 |
| - # Check for any comment that could have come along with the resolution |
110 |
| - if (error_response := check_for_and_create_comment(parsed)) is not None: |
111 |
| - return error_response |
112 |
| - |
113 |
| - if parsed.get('webhookEvent') == 'comment_created': |
114 |
| - if (error_response := check_for_and_create_comment(parsed)) is not None: |
115 |
| - return error_response |
116 |
| - |
117 |
| - if parsed.get('webhookEvent') not in ['comment_created', 'jira:issue_updated']: |
118 |
| - logger.info(f"Unrecognized JIRA webhook event received: {parsed.get('webhookEvent')}") |
119 |
| - |
120 |
| - except Exception as e: |
121 |
| - if isinstance(e, Http404): |
122 |
| - logger.warning('404 error processing JIRA webhook') |
123 |
| - logger.warning(str(e)) |
124 |
| - else: |
125 |
| - logger.exception(e) |
126 |
| - |
| 79 | + if request.content_type != "application/json": |
| 80 | + return webhook_responser_handler("debug", "only application/json supported") |
| 81 | + # Time to process the request |
| 82 | + try: |
| 83 | + parsed = json.loads(request.body.decode("utf-8")) |
| 84 | + # Check if the events supplied are supported |
| 85 | + if parsed.get('webhookEvent') not in ['comment_created', 'jira:issue_updated']: |
| 86 | + return webhook_responser_handler("info", f"Unrecognized JIRA webhook event received: {parsed.get('webhookEvent')}") |
| 87 | + |
| 88 | + if parsed.get('webhookEvent') == 'jira:issue_updated': |
| 89 | + # xml examples at the end of file |
| 90 | + jid = parsed['issue']['id'] |
| 91 | + # This may raise a 404, but it will be handled in the exception response |
127 | 92 | try:
|
128 |
| - logger.debug('jira_webhook_body_parsed:') |
129 |
| - logger.debug(json.dumps(parsed, indent=4)) |
130 |
| - except Exception: |
131 |
| - logger.debug('jira_webhook_body:') |
132 |
| - logger.debug(request.body.decode('utf-8')) |
| 93 | + jissue = JIRA_Issue.objects.get(jira_id=jid) |
| 94 | + except JIRA_Instance.DoesNotExist: |
| 95 | + return webhook_responser_handler("info", f"JIRA issue {jid} is not linked to a DefectDojo Finding") |
| 96 | + findings = None |
| 97 | + # Determine what type of object we will be working with |
| 98 | + if jissue.finding: |
| 99 | + logging.debug(f"Received issue update for {jissue.jira_key} for finding {jissue.finding.id}") |
| 100 | + findings = [jissue.finding] |
| 101 | + elif jissue.finding_group: |
| 102 | + logging.debug(f"Received issue update for {jissue.jira_key} for finding group {jissue.finding_group}") |
| 103 | + findings = jissue.finding_group.findings.all() |
| 104 | + elif jissue.engagement: |
| 105 | + return webhook_responser_handler("debug", "Update for engagement ignored") |
| 106 | + else: |
| 107 | + return webhook_responser_handler("info", f"Received issue update for {jissue.jira_key} for unknown object") |
| 108 | + # Process the assignee if present |
| 109 | + assignee = parsed['issue']['fields'].get('assignee') |
| 110 | + assignee_name = 'Jira User' |
| 111 | + if assignee is not None: |
| 112 | + # First look for the 'name' field. If not present, try 'displayName'. Else put None |
| 113 | + assignee_name = assignee.get('name', assignee.get('displayName')) |
| 114 | + |
| 115 | + # "resolution":{ |
| 116 | + # "self":"http://www.testjira.com/rest/api/2/resolution/11", |
| 117 | + # "id":"11", |
| 118 | + # "description":"Cancelled by the customer.", |
| 119 | + # "name":"Cancelled" |
| 120 | + # }, |
| 121 | + |
| 122 | + # or |
| 123 | + # "resolution": null |
| 124 | + |
| 125 | + # or |
| 126 | + # "resolution": "None" |
| 127 | + |
| 128 | + resolution = parsed['issue']['fields']['resolution'] |
| 129 | + resolution = resolution if resolution and resolution != "None" else None |
| 130 | + resolution_id = resolution['id'] if resolution else None |
| 131 | + resolution_name = resolution['name'] if resolution else None |
| 132 | + jira_now = parse_datetime(parsed['issue']['fields']['updated']) |
| 133 | + |
| 134 | + if findings: |
| 135 | + for finding in findings: |
| 136 | + jira_helper.process_resolution_from_jira(finding, resolution_id, resolution_name, assignee_name, jira_now, jissue) |
| 137 | + # Check for any comment that could have come along with the resolution |
| 138 | + if (error_response := check_for_and_create_comment(parsed)) is not None: |
| 139 | + return error_response |
| 140 | + |
| 141 | + if parsed.get('webhookEvent') == 'comment_created': |
| 142 | + if (error_response := check_for_and_create_comment(parsed)) is not None: |
| 143 | + return error_response |
| 144 | + |
| 145 | + except Exception as e: |
| 146 | + # Check if the issue is originally a 404 |
| 147 | + if isinstance(e, Http404): |
| 148 | + return webhook_responser_handler("debug", str(e)) |
| 149 | + # Try to get a little more information on the exact exception |
| 150 | + try: |
| 151 | + message = ( |
| 152 | + f"Original Exception: {e}\n" |
| 153 | + f"jira webhook body parsed:\n{json.dumps(parsed, indent=4)}" |
| 154 | + ) |
| 155 | + except Exception: |
| 156 | + message = ( |
| 157 | + f"Original Exception: {e}\n" |
| 158 | + f"jira webhook body :\n{request.body.decode('utf-8')}" |
| 159 | + ) |
| 160 | + return webhook_responser_handler("debug", message) |
133 | 161 |
|
134 |
| - # reraise to make sure we don't silently swallow things |
135 |
| - raise |
136 |
| - return HttpResponse('') |
| 162 | + return webhook_responser_handler("No logging here", "Success!") |
137 | 163 |
|
138 | 164 |
|
139 | 165 | def check_for_and_create_comment(parsed_json):
|
@@ -194,31 +220,30 @@ def check_for_and_create_comment(parsed_json):
|
194 | 220 | commenter_display_name = comment.get('updateAuthor', {}).get('displayName')
|
195 | 221 | # example: body['comment']['self'] = "http://www.testjira.com/jira_under_a_path/rest/api/2/issue/666/comment/456843"
|
196 | 222 | jid = comment.get('self', '').split('/')[-3]
|
197 |
| - jissue = get_object_or_404(JIRA_Issue, jira_id=jid) |
198 |
| - logging.info(f"Received issue comment for {jissue.jira_key}") |
| 223 | + try: |
| 224 | + jissue = JIRA_Issue.objects.get(jira_id=jid) |
| 225 | + except JIRA_Instance.DoesNotExist: |
| 226 | + return webhook_responser_handler("info", f"JIRA issue {jid} is not linked to a DefectDojo Finding") |
| 227 | + logging.debug(f"Received issue comment for {jissue.jira_key}") |
199 | 228 | logger.debug('jissue: %s', vars(jissue))
|
200 | 229 |
|
201 | 230 | jira_usernames = JIRA_Instance.objects.values_list('username', flat=True)
|
202 | 231 | for jira_user_id in jira_usernames:
|
203 | 232 | # logger.debug('incoming username: %s jira config username: %s', commenter.lower(), jira_user_id.lower())
|
204 | 233 | if jira_user_id.lower() == commenter.lower():
|
205 |
| - logger.debug('skipping incoming JIRA comment as the user id of the comment in JIRA (%s) matches the JIRA username in DefectDojo (%s)', commenter.lower(), jira_user_id.lower()) |
206 |
| - return HttpResponse('') |
| 234 | + return webhook_responser_handler("debug", f"skipping incoming JIRA comment as the user id of the comment in JIRA {commenter.lower()} matches the JIRA username in DefectDojo {jira_user_id.lower()}") |
207 | 235 |
|
208 | 236 | findings = None
|
209 | 237 | if jissue.finding:
|
210 | 238 | findings = [jissue.finding]
|
211 | 239 | create_notification(event='other', title=f'JIRA incoming comment - {jissue.finding}', finding=jissue.finding, url=reverse("view_finding", args=(jissue.finding.id,)), icon='check')
|
212 |
| - |
213 | 240 | elif jissue.finding_group:
|
214 | 241 | findings = [jissue.finding_group.findings.all()]
|
215 | 242 | create_notification(event='other', title=f'JIRA incoming comment - {jissue.finding}', finding=jissue.finding, url=reverse("view_finding_group", args=(jissue.finding_group.id,)), icon='check')
|
216 |
| - |
217 | 243 | elif jissue.engagement:
|
218 |
| - return HttpResponse('Comment for engagement ignored') |
| 244 | + return webhook_responser_handler("debug", "Comment for engagement ignored") |
219 | 245 | else:
|
220 |
| - raise Http404(f'No finding or engagement found for JIRA issue {jissue.jira_key}') |
221 |
| - |
| 246 | + return webhook_responser_handler("info", f"Received issue update for {jissue.jira_key} for unknown object") |
222 | 247 | # Set the fields for the notes
|
223 | 248 | author, _ = User.objects.get_or_create(username='JIRA')
|
224 | 249 | entry = f'({commenter_display_name} ({commenter})): {comment_text}'
|
|
0 commit comments