diff --git a/.dryrunsecurity.yaml b/.dryrunsecurity.yaml index 1863e9a2027..dc341c0edd0 100644 --- a/.dryrunsecurity.yaml +++ b/.dryrunsecurity.yaml @@ -14,7 +14,8 @@ sensitiveCodepaths: - 'dojo/group/*.py' - 'dojo/importers/*.py' - 'dojo/importers/**/*.py' - - 'dojo/jira_link/*.py' + - 'dojo/jira/*.py' + - 'dojo/jira/**/*.py' - 'dojo/metrics/*.py' - 'dojo/note_type/*.py' - 'dojo/notes/*.py' diff --git a/dojo/api_v2/serializers.py b/dojo/api_v2/serializers.py index 7c9bbd6ae79..0dbd15bf436 100644 --- a/dojo/api_v2/serializers.py +++ b/dojo/api_v2/serializers.py @@ -24,7 +24,6 @@ from rest_framework.fields import DictField, MultipleChoiceField import dojo.finding.helper as finding_helper -import dojo.jira_link.helper as jira_helper import dojo.risk_acceptance.helper as ra_helper from dojo.authorization.authorization import user_has_permission from dojo.authorization.roles_permissions import Permissions @@ -40,6 +39,7 @@ from dojo.importers.base_importer import BaseImporter from dojo.importers.default_importer import DefaultImporter from dojo.importers.default_reimporter import DefaultReImporter +from dojo.jira import services as jira_services from dojo.location.models import Location, LocationFindingReference from dojo.models import ( DEFAULT_NOTIFICATION, @@ -75,9 +75,6 @@ Finding_Template, General_Survey, Global_Role, - JIRA_Instance, - JIRA_Issue, - JIRA_Project, Language_Type, Languages, Network_Locations, @@ -1376,79 +1373,11 @@ class Meta: fields = "__all__" -class JIRAIssueSerializer(serializers.ModelSerializer): - url = serializers.SerializerMethodField(read_only=True) - - class Meta: - model = JIRA_Issue - fields = "__all__" - - def get_url(self, obj) -> str: - return jira_helper.get_jira_issue_url(obj) - - def validate(self, data): - if self.context["request"].method == "PATCH": - engagement = data.get("engagement", self.instance.engagement) - finding = data.get("finding", self.instance.finding) - finding_group = data.get( - "finding_group", self.instance.finding_group, - ) - else: - engagement = data.get("engagement", None) - finding = data.get("finding", None) - finding_group = data.get("finding_group", None) - - if ( - (engagement and not finding and not finding_group) - or (finding and not engagement and not finding_group) - or (finding_group and not engagement and not finding) - ): - pass - else: - msg = "Either engagement or finding or finding_group has to be set." - raise serializers.ValidationError(msg) - - if finding: - if (linked_finding := jira_helper.jira_already_linked(finding, data.get("jira_key"), data.get("jira_id"))) is not None: - msg = "JIRA issue " + data.get("jira_key") + " already linked to " + reverse("view_finding", args=(linked_finding.id,)) - raise serializers.ValidationError(msg) - - return data - - -class JIRAInstanceSerializer(serializers.ModelSerializer): - class Meta: - model = JIRA_Instance - fields = "__all__" - extra_kwargs = { - "password": {"write_only": True}, - } - - -class JIRAProjectSerializer(serializers.ModelSerializer): - class Meta: - model = JIRA_Project - fields = "__all__" - - def validate(self, data): - if self.context["request"].method == "PATCH": - engagement = data.get("engagement", self.instance.engagement) - product = data.get("product", self.instance.product) - else: - engagement = data.get("engagement", None) - product = data.get("product", None) - - if (engagement and product) or (not engagement and not product): - msg = "Either engagement or product has to be set." - raise serializers.ValidationError(msg) - - if "custom_fields" in data and isinstance(data["custom_fields"], str): - try: - data["custom_fields"] = json.loads(data["custom_fields"]) - except json.JSONDecodeError as e: - raise serializers.ValidationError({"custom_fields": f"Invalid JSON: {e}"}) from e - - return data +from dojo.jira.api.serializers import ( # noqa: E402, F401 backward compat + JIRAInstanceSerializer, + JIRAIssueSerializer, + JIRAProjectSerializer, +) class SonarqubeIssueSerializer(serializers.ModelSerializer): @@ -1770,7 +1699,7 @@ def get_test(self, obj): @extend_schema_field(JIRAIssueSerializer) def get_jira(self, obj): - issue = jira_helper.get_jira_issue(obj) + issue = jira_services.get_issue(obj) if issue is None: return None return JIRAIssueSerializer(read_only=True).to_representation(issue) @@ -1844,11 +1773,11 @@ def get_accepted_risks(self, obj): @extend_schema_field(serializers.DateTimeField()) def get_jira_creation(self, obj): - return jira_helper.get_jira_creation(obj) + return jira_services.get_creation(obj) @extend_schema_field(serializers.DateTimeField()) def get_jira_change(self, obj): - return jira_helper.get_jira_change(obj) + return jira_services.get_change(obj) @extend_schema_field(FindingRelatedFieldsSerializer) def get_related_fields(self, obj): @@ -1924,9 +1853,9 @@ def update(self, instance, validated_data): for location_ref in locations: location_ref.location.associate_with_finding(instance) - if push_to_jira or finding_helper.is_keep_in_sync_with_jira(instance): + if push_to_jira or jira_services.is_keep_in_sync(instance): # Push synchronously so that we can see jira errors in real time - success, message = jira_helper.push_to_jira(instance, sync=True) + success, message = jira_services.push(instance, sync=True) if not success: raise serializers.ValidationError(message) @@ -2083,7 +2012,7 @@ def create(self, validated_data): save_vulnerability_ids(new_finding, parsed_vulnerability_ids) if push_to_jira: - jira_helper.push_to_jira(new_finding) + jira_services.push(new_finding) # Create a notification create_notification( @@ -3081,9 +3010,9 @@ class ReportGenerateSerializer(serializers.Serializer): ) -class EngagementUpdateJiraEpicSerializer(serializers.Serializer): - epic_name = serializers.CharField(required=False, max_length=200) - epic_priority = serializers.CharField(required=False, allow_null=True) +from dojo.jira.api.serializers import ( # noqa: E402, F401 backward compat + EngagementUpdateJiraEpicSerializer, +) class TagSerializer(serializers.Serializer): diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index 85627178af9..4da5a02885c 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -37,7 +37,6 @@ from rest_framework.response import Response import dojo.finding.helper as finding_helper -import dojo.jira_link.helper as jira_helper from dojo.api_v2 import ( mixins as dojo_mixins, ) @@ -88,10 +87,7 @@ get_authorized_groups, ) from dojo.importers.auto_create_context import AutoCreateContextManager -from dojo.jira_link.queries import ( - get_authorized_jira_issues, - get_authorized_jira_projects, -) +from dojo.jira import services as jira_services from dojo.labels import get_labels from dojo.models import ( Announcement, @@ -117,9 +113,6 @@ Finding_Template, General_Survey, Global_Role, - JIRA_Instance, - JIRA_Issue, - JIRA_Project, Language_Type, Languages, Network_Locations, @@ -720,15 +713,18 @@ def download_file(self, request, file_id, pk=None): def update_jira_epic(self, request, pk=None): engagement = self.get_object() try: - if engagement.has_jira_issue: - dojo_dispatch_task(jira_helper.update_epic, engagement.id, **request.data) + task = jira_services.get_epic_task("update_epic") + if task: + dojo_dispatch_task(task, engagement.id, **request.data) response = Response( {"info": "Jira Epic update query sent"}, status=status.HTTP_200_OK, ) else: - dojo_dispatch_task(jira_helper.add_epic, engagement.id, **request.data) + task = jira_services.get_epic_task("add_epic") + if task: + dojo_dispatch_task(task, engagement.id, **request.data) response = Response( {"info": "Jira Epic create query sent"}, status=status.HTTP_200_OK, @@ -1088,7 +1084,7 @@ class FindingViewSet( def perform_update(self, serializer): # IF JIRA is enabled and this product has a JIRA configuration push_to_jira = serializer.validated_data.get("push_to_jira") - jira_project = jira_helper.get_jira_project(serializer.instance) + jira_project = jira_services.get_project(serializer.instance) if get_system_setting("enable_jira") and jira_project: push_to_jira = push_to_jira or jira_project.push_all_issues @@ -1361,9 +1357,9 @@ def notes(self, request, pk=None): ) if finding.has_jira_issue: - jira_helper.add_comment(finding, note) + jira_services.add_comment(finding, note) elif finding.has_jira_group_issue: - jira_helper.add_comment(finding.finding_group, note) + jira_services.add_comment(finding.finding_group, note) serialized_note = serializers.NoteSerializer( {"author": author, "entry": entry, "private": private}, @@ -1769,74 +1765,11 @@ def metadata(self, request, pk=None): # Authorization: configuration -class JiraInstanceViewSet( - DojoModelViewSet, -): - serializer_class = serializers.JIRAInstanceSerializer - queryset = JIRA_Instance.objects.none() - filter_backends = (DjangoFilterBackend,) - filterset_fields = ["id", "url"] - permission_classes = (permissions.UserHasConfigurationPermissionSuperuser,) - - def get_queryset(self): - return JIRA_Instance.objects.all().order_by("id") - - -# Authorization: object-based -# @extend_schema_view(**schema_with_prefetch()) -# Nested models with prefetch make the response schema too long for Swagger UI -class JiraIssuesViewSet( - PrefetchDojoModelViewSet, -): - serializer_class = serializers.JIRAIssueSerializer - queryset = JIRA_Issue.objects.none() - filter_backends = (DjangoFilterBackend,) - filterset_fields = [ - "id", - "jira_id", - "jira_key", - "finding", - "engagement", - "finding_group", - ] - - permission_classes = ( - IsAuthenticated, - permissions.UserHasJiraIssuePermission, - ) - - def get_queryset(self): - return get_authorized_jira_issues(Permissions.Product_View) - - -# Authorization: object-based -@extend_schema_view(**schema_with_prefetch()) -class JiraProjectViewSet( - PrefetchDojoModelViewSet, -): - serializer_class = serializers.JIRAProjectSerializer - queryset = JIRA_Project.objects.none() - filter_backends = (DjangoFilterBackend,) - filterset_fields = [ - "id", - "jira_instance", - "product", - "engagement", - "enabled", - "component", - "project_key", - "push_all_issues", - "enable_engagement_epic_mapping", - "push_notes", - ] - - permission_classes = ( - IsAuthenticated, - permissions.UserHasJiraProductPermission, - ) - - def get_queryset(self): - return get_authorized_jira_projects(Permissions.Product_View) +from dojo.jira.api.views import ( # noqa: E402, F401 backward compat + JiraInstanceViewSet, + JiraIssuesViewSet, + JiraProjectViewSet, +) # Authorization: superuser @@ -2871,7 +2804,7 @@ def perform_create(self, serializer): push_to_jira = serializer.validated_data.get("push_to_jira") if get_system_setting("enable_jira"): jira_driver = engagement or (product or None) - if jira_project := (jira_helper.get_jira_project(jira_driver) if jira_driver else None): + if jira_project := (jira_services.get_project(jira_driver) if jira_driver else None): push_to_jira = push_to_jira or jira_project.push_all_issues # Add pghistory context for audit trail (adds to existing middleware context). @@ -3029,7 +2962,7 @@ def perform_create(self, serializer): push_to_jira = serializer.validated_data.get("push_to_jira") if get_system_setting("enable_jira"): jira_driver = test or (engagement or (product or None)) - if jira_project := (jira_helper.get_jira_project(jira_driver) if jira_driver else None): + if jira_project := (jira_services.get_project(jira_driver) if jira_driver else None): push_to_jira = push_to_jira or jira_project.push_all_issues logger.debug("push_to_jira: %s", push_to_jira) # Add pghistory context for audit trail (adds to existing middleware context) diff --git a/dojo/engagement/services.py b/dojo/engagement/services.py index 42a7c1c05e4..b78844fc6bc 100644 --- a/dojo/engagement/services.py +++ b/dojo/engagement/services.py @@ -4,8 +4,8 @@ from django.db.models.signals import pre_save from django.dispatch import receiver -import dojo.jira_link.helper as jira_helper from dojo.celery_dispatch import dojo_dispatch_task +from dojo.jira import services as jira_services from dojo.models import Engagement logger = logging.getLogger(__name__) @@ -16,8 +16,10 @@ def close_engagement(eng): eng.status = "Completed" eng.save() - if jira_helper.get_jira_project(eng): - dojo_dispatch_task(jira_helper.close_epic, eng.id, push_to_jira=True) + if jira_services.get_project(eng): + task = jira_services.get_epic_task("close_epic") + if task: + dojo_dispatch_task(task, eng.id, push_to_jira=True) def reopen_engagement(eng): diff --git a/dojo/engagement/views.py b/dojo/engagement/views.py index ca36d81c0dd..34c516bcc7b 100644 --- a/dojo/engagement/views.py +++ b/dojo/engagement/views.py @@ -32,7 +32,6 @@ from openpyxl import Workbook from openpyxl.styles import Font -import dojo.jira_link.helper as jira_helper import dojo.risk_acceptance.helper as ra_helper from dojo.authorization.authorization import user_has_permission_or_403 from dojo.authorization.authorization_decorators import user_is_authorized @@ -74,6 +73,7 @@ ) from dojo.importers.base_importer import BaseImporter from dojo.importers.default_importer import DefaultImporter +from dojo.jira import services as jira_services from dojo.location.models import Location from dojo.location.utils import save_locations_to_add from dojo.models import ( @@ -281,7 +281,7 @@ def edit_engagement(request, eid): if request.method == "POST": form = EngForm(request.POST, instance=engagement, cicd=is_ci_cd, product=engagement.product, user=request.user) - jira_project = jira_helper.get_jira_project(engagement, use_inheritance=False) + jira_project = jira_services.get_project(engagement, use_inheritance=False) if form.is_valid(): # first save engagement details @@ -307,10 +307,10 @@ def edit_engagement(request, eid): "Engagement updated successfully.", extra_tags="alert-success") - success, jira_project_form = jira_helper.process_jira_project_form(request, instance=jira_project, target="engagement", engagement=engagement, product=engagement.product) + success, jira_project_form = jira_services.process_project_form(request, instance=jira_project, target="engagement", engagement=engagement, product=engagement.product) error = not success - success, jira_epic_form = jira_helper.process_jira_epic_form(request, engagement=engagement) + success, jira_epic_form = jira_services.process_epic_form(request, engagement=engagement) error = error or not success if not error: @@ -327,7 +327,7 @@ def edit_engagement(request, eid): jira_epic_form = None if get_system_setting("enable_jira"): - jira_project = jira_helper.get_jira_project(engagement, use_inheritance=False) + jira_project = jira_services.get_project(engagement, use_inheritance=False) jira_project_form = JIRAProjectForm(instance=jira_project, target="engagement", product=engagement.product) logger.debug("showing jira-epic-form") jira_epic_form = JIRAEngagementForm(instance=engagement) @@ -471,8 +471,8 @@ def get(self, request, eid, *args, **kwargs): network = eng.preset.network_locations.all() system_settings = System_Settings.objects.get() - jissue = jira_helper.get_jira_issue(eng) - jira_project = jira_helper.get_jira_project(eng) + jissue = jira_services.get_issue(eng) + jira_project = jira_services.get_project(eng) try: check = Check_List.objects.get(engagement=eng) @@ -540,8 +540,8 @@ def post(self, request, eid, *args, **kwargs): network = eng.preset.network_locations.all() system_settings = System_Settings.objects.get() - jissue = jira_helper.get_jira_issue(eng) - jira_project = jira_helper.get_jira_project(eng) + jissue = jira_services.get_issue(eng) + jira_project = jira_services.get_project(eng) try: check = Check_List.objects.get(engagement=eng) @@ -802,9 +802,9 @@ def get_jira_form( jira_form = None push_all_jira_issues = False # Determine if jira issues should be pushed automatically - push_all_jira_issues = jira_helper.is_push_all_issues(engagement_or_product) + push_all_jira_issues = jira_services.is_push_all_issues(engagement_or_product) # Only return the form if the jira is enabled on this engagement or product - if jira_helper.get_jira_project(engagement_or_product): + if jira_services.get_project(engagement_or_product): if request.method == "POST": jira_form = JIRAImportScanForm( request.POST, @@ -1201,7 +1201,7 @@ def unlink_jira(request, eid): logger.info("trying to unlink a linked jira epic from engagement %d:%s", eng.id, eng.name) if eng.has_jira_issue: try: - jira_helper.unlink_jira(request, eng) + jira_services.unlink(request, eng) messages.add_message( request, messages.SUCCESS, diff --git a/dojo/finding/helper.py b/dojo/finding/helper.py index ba11567869b..7ec3ba477ea 100644 --- a/dojo/finding/helper.py +++ b/dojo/finding/helper.py @@ -15,7 +15,6 @@ from django.utils.timezone import is_naive, make_aware, now from fieldsignals import pre_save_changed -import dojo.jira_link.helper as jira_helper import dojo.risk_acceptance.helper as ra_helper from dojo.celery import app from dojo.endpoint.utils import endpoint_get_or_create, save_endpoints_to_add @@ -27,7 +26,7 @@ do_false_positive_history_batch, get_finding_models_for_deduplication, ) -from dojo.jira_link.helper import is_keep_in_sync_with_jira +from dojo.jira import services as jira_services from dojo.location.models import Location from dojo.location.status import FindingLocationStatus from dojo.location.utils import save_locations_to_add @@ -241,10 +240,10 @@ def add_to_finding_group(finding_group, finds): finding_group.findings.add(*available_findings) # Now update the JIRA to add the finding to the finding group - jira_instance = jira_helper.get_jira_instance(finding_group) + jira_instance = jira_services.get_instance(finding_group) if finding_group.has_jira_issue and jira_instance and jira_instance.finding_jira_sync: logger.debug("pushing to jira from finding.finding_bulk_update_all()") - jira_helper.push_to_jira(finding_group) + jira_services.push(finding_group) added = len(available_findings) skipped = len(finds) - added @@ -269,10 +268,10 @@ def remove_from_finding_group(finds): # Now update the JIRA to remove the finding from the finding group for group in affected_groups: - jira_instance = jira_helper.get_jira_instance(group) + jira_instance = jira_services.get_instance(group) if group.has_jira_issue and jira_instance and jira_instance.finding_jira_sync: logger.debug("pushing to jira from finding.finding_bulk_update_all()") - jira_helper.push_to_jira(group) + jira_services.push(group) return affected_groups, removed, skipped @@ -347,10 +346,10 @@ def group_findings_by(finds, finding_group_by_option): # Now update the JIRA to add the finding to the finding group for group in affected_groups: - jira_instance = jira_helper.get_jira_instance(group) + jira_instance = jira_services.get_instance(group) if group.has_jira_issue and jira_instance and jira_instance.finding_jira_sync: logger.debug("pushing to jira from finding.finding_bulk_update_all()") - jira_helper.push_to_jira(group) + jira_services.push(group) return affected_groups, grouped, skipped, groups_created @@ -452,9 +451,9 @@ def post_process_finding_save_internal(finding, dedupe_option=True, rules_option # based on feedback we could introduct another push_group_to_jira boolean everywhere # but what about the push_all boolean? Let's see how this works for now and get some feedback. if finding.has_jira_issue or not finding.finding_group: - jira_helper.push_to_jira(finding) + jira_services.push(finding) elif finding.finding_group: - jira_helper.push_to_jira(finding.finding_group) + jira_services.push(finding.finding_group) @app.task @@ -524,8 +523,8 @@ def post_process_findings_batch( for finding in findings: object_to_push = finding if finding.has_jira_issue or not finding.finding_group else finding.finding_group # Check the push_to_jira flag again to potentially shorty circuit without checking for existing findings - if push_to_jira or is_keep_in_sync_with_jira(object_to_push, prefetched_jira_instance=jira_instance): - jira_helper.push_to_jira(object_to_push) + if push_to_jira or jira_services.is_keep_in_sync(object_to_push, prefetched_jira_instance=jira_instance): + jira_services.push(object_to_push) else: logger.debug("push_to_jira is False, not pushing to JIRA") @@ -1158,20 +1157,20 @@ def _save_finding_with_jira_sync(finding, *, new_note=None): jira_issue_exists = finding.has_jira_issue or ( finding.finding_group and finding.finding_group.has_jira_issue ) - jira_instance = jira_helper.get_jira_instance(finding) - jira_project = jira_helper.get_jira_project(finding) + jira_instance = jira_services.get_instance(finding) + jira_project = jira_services.get_project(finding) if jira_issue_exists: push_to_jira = ( - jira_helper.is_push_all_issues(finding) + jira_services.is_push_all_issues(finding) or (jira_instance and jira_instance.finding_jira_sync) ) if new_note and (getattr(jira_project, "push_notes", False) or push_to_jira) and not finding_in_group: - jira_helper.add_comment(finding, new_note, force_push=True) + jira_services.add_comment(finding, new_note, force_push=True) finding.save(push_to_jira=(push_to_jira and not finding_in_group)) if push_to_jira and finding_in_group: - jira_helper.push_to_jira(finding.finding_group) + jira_services.push(finding.finding_group) def close_finding( diff --git a/dojo/finding/views.py b/dojo/finding/views.py index a4173348b4b..e64bea6a29f 100644 --- a/dojo/finding/views.py +++ b/dojo/finding/views.py @@ -30,7 +30,6 @@ from imagekit.processors import ResizeToFill import dojo.finding.helper as finding_helper -import dojo.jira_link.helper as jira_helper import dojo.risk_acceptance.helper as ra_helper from dojo.authorization.authorization import user_has_global_permission_or_403, user_has_permission_or_403 from dojo.authorization.authorization_decorators import ( @@ -79,6 +78,7 @@ StubFindingForm, TypedNoteForm, ) +from dojo.jira import services as jira_services from dojo.location.status import FindingLocationStatus from dojo.models import ( IMPORT_UNTOUCHED_FINDING, @@ -329,7 +329,7 @@ def get_initial_context(self, request: HttpRequest): user_has_permission_or_403(request.user, product, Permissions.Product_View) context["show_product_column"] = False context["product_tab"] = Product_Tab(product, title="Findings", tab="findings") - context["jira_project"] = jira_helper.get_jira_project(product) + context["jira_project"] = jira_services.get_project(product) if github_config := GITHUB_PKey.objects.filter(product=product).first(): context["github_config"] = github_config.git_conf_id elif engagement_id := self.get_engagement_id(): @@ -337,7 +337,7 @@ def get_initial_context(self, request: HttpRequest): user_has_permission_or_403(request.user, engagement, Permissions.Engagement_View) context["show_product_column"] = False context["product_tab"] = Product_Tab(engagement.product, title=engagement.name, tab="engagements") - context["jira_project"] = jira_helper.get_jira_project(engagement) + context["jira_project"] = jira_services.get_project(engagement) if github_config := GITHUB_PKey.objects.filter(product__engagement=engagement).first(): context["github_config"] = github_config.git_conf_id @@ -592,7 +592,7 @@ def get_jira_data(self, finding: Finding): can_be_pushed_to_jira, can_be_pushed_to_jira_error, error_code, - ) = jira_helper.can_be_pushed_to_jira(finding) + ) = jira_services.can_be_pushed(finding) # Check the error code if error_code: logger.debug(error_code) @@ -647,9 +647,9 @@ def process_form(self, request: HttpRequest, finding: Finding, context: dict): finding.save() # Determine if the note should be sent to jira if finding.has_jira_issue: - jira_helper.add_comment(finding, new_note) + jira_services.add_comment(finding, new_note) elif finding.has_jira_group_issue: - jira_helper.add_comment(finding.finding_group, new_note) + jira_services.add_comment(finding.finding_group, new_note) # Send the notification of the note being added url = request.build_absolute_uri( reverse("view_finding", args=(finding.id,)), @@ -765,9 +765,9 @@ def get_finding_form(self, request: HttpRequest, finding: Finding): def get_jira_form(self, request: HttpRequest, finding: Finding, finding_form: FindingForm = None): # Determine if jira should be used - if (jira_project := jira_helper.get_jira_project(finding)) is not None: + if (jira_project := jira_services.get_project(finding)) is not None: # Determine if push all findings is enabled - push_all_findings = jira_helper.is_push_all_issues(finding) + push_all_findings = jira_services.is_push_all_issues(finding) # Set up the args for the form args = [request.POST] if request.method == "POST" else [] # Set the initial form args @@ -987,8 +987,8 @@ def process_jira_form(self, request: HttpRequest, finding: Finding, context: dic logger.debug(JFORM_PUSH_TO_JIRA_MESSAGE, context["jform"].cleaned_data.get("push_to_jira")) # can't use helper as when push_all_jira_issues is True, the checkbox gets disabled and is always false push_to_jira_checkbox = context["jform"].cleaned_data.get("push_to_jira") - push_all_jira_issues = jira_helper.is_push_all_issues(finding) - push_to_jira = push_all_jira_issues or push_to_jira_checkbox or jira_helper.is_keep_in_sync_with_jira(finding) + push_all_jira_issues = jira_services.is_push_all_issues(finding) + push_to_jira = push_all_jira_issues or push_to_jira_checkbox or jira_services.is_keep_in_sync(finding) logger.debug("push_to_jira: %s", push_to_jira) logger.debug("push_all_jira_issues: %s", push_all_jira_issues) logger.debug("has_jira_group_issue: %s", finding.has_jira_group_issue) @@ -1005,14 +1005,14 @@ def process_jira_form(self, request: HttpRequest, finding: Finding, context: dic which is already checked in the validation of the form """ if not new_jira_issue_key: - jira_helper.finding_unlink_jira(request, finding) + jira_services.unlink_finding(request, finding) jira_message = "Link to JIRA issue removed successfully." elif new_jira_issue_key != finding.jira_issue.jira_key: - jira_helper.finding_unlink_jira(request, finding) - jira_helper.finding_link_jira(request, finding, new_jira_issue_key) + jira_services.unlink_finding(request, finding) + jira_services.link_finding(request, finding, new_jira_issue_key) jira_message = "Changed JIRA link successfully." elif new_jira_issue_key: - jira_helper.finding_link_jira(request, finding, new_jira_issue_key) + jira_services.link_finding(request, finding, new_jira_issue_key) jira_message = "Linked a JIRA issue successfully." # any existing finding should be updated # Determine if a message should be added @@ -1069,7 +1069,7 @@ def process_forms(self, request: HttpRequest, finding: Finding, context: dict): # we only push the group after storing the finding to make sure # the updated data of the finding is pushed as part of the group if push_to_jira and finding.finding_group: - jira_helper.push_to_jira(finding.finding_group) + jira_services.push(finding.finding_group) return request, all_forms_valid @@ -1337,8 +1337,8 @@ def defect_finding_review(request, fid): # Only push if the finding is not in a group if jira_issue_exists: # Determine if any automatic sync should occur - jira_instance = jira_helper.get_jira_instance(finding) - push_to_jira = jira_helper.is_push_all_issues(finding) \ + jira_instance = jira_services.get_instance(finding) + push_to_jira = jira_services.is_push_all_issues(finding) \ or (jira_instance and jira_instance.finding_jira_sync) # Add the closing note if push_to_jira and not finding_in_group: @@ -1346,14 +1346,14 @@ def defect_finding_review(request, fid): new_note.entry += "\nJira issue set to resolved." else: new_note.entry += "\nJira issue re-opened." - jira_helper.add_comment(finding, new_note, force_push=True) + jira_services.add_comment(finding, new_note, force_push=True) # Save the finding finding.save(push_to_jira=(push_to_jira and not finding_in_group)) # we only push the group after saving the finding to make sure # the updated data of the finding is pushed as part of the group if push_to_jira and finding_in_group: - jira_helper.push_to_jira(finding.finding_group) + jira_services.push(finding.finding_group) messages.add_message( request, messages.SUCCESS, "Defect Reviewed", extra_tags="alert-success", @@ -1407,8 +1407,8 @@ def reopen_finding(request, fid): # Clear the risk acceptance, if present ra_helper.risk_unaccept(request.user, finding) finding.save(dedupe_option=False, push_to_jira=False) - if jira_helper.is_push_all_issues(finding) or jira_helper.is_keep_in_sync_with_jira(finding): - jira_helper.push_to_jira(finding) + if jira_services.is_push_all_issues(finding) or jira_services.is_keep_in_sync(finding): + jira_services.push(finding) reopen_external_issue(finding.id, "re-opened by defectdojo", "github") @@ -1609,19 +1609,19 @@ def request_finding_review(request, fid): # Only push if the finding is not in a group if jira_issue_exists: # Determine if any automatic sync should occur - jira_instance = jira_helper.get_jira_instance(finding) - push_to_jira = jira_helper.is_push_all_issues(finding) \ + jira_instance = jira_services.get_instance(finding) + push_to_jira = jira_services.is_push_all_issues(finding) \ or (jira_instance and jira_instance.finding_jira_sync) # Add the closing note if push_to_jira and not finding_in_group: - jira_helper.add_comment(finding, new_note, force_push=True) + jira_services.add_comment(finding, new_note, force_push=True) # Save the finding finding.save(push_to_jira=(push_to_jira and not finding_in_group)) # we only push the group after saving the finding to make sure # the updated data of the finding is pushed as part of the group if push_to_jira and finding_in_group: - jira_helper.push_to_jira(finding.finding_group) + jira_services.push(finding.finding_group) reviewers = Dojo_User.objects.filter(id__in=form.cleaned_data["reviewers"]) reviewers_string = ", ".join([f"{user} ({user.id})" for user in reviewers]) @@ -1704,19 +1704,19 @@ def clear_finding_review(request, fid): # Only push if the finding is not in a group if jira_issue_exists: # Determine if any automatic sync should occur - jira_instance = jira_helper.get_jira_instance(finding) - push_to_jira = jira_helper.is_push_all_issues(finding) \ + jira_instance = jira_services.get_instance(finding) + push_to_jira = jira_services.is_push_all_issues(finding) \ or (jira_instance and jira_instance.finding_jira_sync) # Add the closing note if push_to_jira and not finding_in_group: - jira_helper.add_comment(finding, new_note, force_push=True) + jira_services.add_comment(finding, new_note, force_push=True) # Save the finding finding.save(push_to_jira=(push_to_jira and not finding_in_group)) # we only push the group after saving the finding to make sure # the updated data of the finding is pushed as part of the group if push_to_jira and finding_in_group: - jira_helper.push_to_jira(finding.finding_group) + jira_services.push(finding.finding_group) messages.add_message( request, @@ -2099,9 +2099,9 @@ def promote_to_finding(request, fid): finding = get_object_or_404(Stub_Finding, id=fid) test = finding.test form_error = False - push_all_jira_issues = jira_helper.is_push_all_issues(finding) + push_all_jira_issues = jira_services.is_push_all_issues(finding) jform = None - use_jira = jira_helper.get_jira_project(finding) is not None + use_jira = jira_services.get_project(finding) is not None product_tab = Product_Tab( finding.test.engagement.product, title="Promote Finding", tab="findings", ) @@ -2114,7 +2114,7 @@ def promote_to_finding(request, fid): instance=finding, prefix="jiraform", push_all=push_all_jira_issues, - jira_project=jira_helper.get_jira_project(finding), + jira_project=jira_services.get_project(finding), ) if form.is_valid() and (jform is None or jform.is_valid()): @@ -2166,11 +2166,11 @@ def promote_to_finding(request, fid): """ if not new_jira_issue_key: - jira_helper.finding_unlink_jira(request, new_finding) + jira_services.unlink_finding(request, new_finding) elif new_jira_issue_key != new_finding.jira_issue.jira_key: - jira_helper.finding_unlink_jira(request, new_finding) - jira_helper.finding_link_jira( + jira_services.unlink_finding(request, new_finding) + jira_services.link_finding( request, new_finding, new_jira_issue_key, ) else: @@ -2178,7 +2178,7 @@ def promote_to_finding(request, fid): if new_jira_issue_key: logger.debug( "finding has no jira issue yet, but jira issue specified in request. trying to link.") - jira_helper.finding_link_jira( + jira_services.link_finding( request, new_finding, new_jira_issue_key, ) @@ -2231,8 +2231,8 @@ def promote_to_finding(request, fid): if use_jira: jform = JIRAFindingForm( prefix="jiraform", - push_all=jira_helper.is_push_all_issues(test), - jira_project=jira_helper.get_jira_project(test), + push_all=jira_services.is_push_all_issues(test), + jira_project=jira_services.get_project(test), ) return render( @@ -3066,8 +3066,8 @@ def _bulk_push_to_jira(finds, form, note): for finding in finds if finding.has_finding_group and ( - jira_helper.is_push_all_issues(finding) - or jira_helper.is_keep_in_sync_with_jira(finding) + jira_services.is_push_all_issues(finding) + or jira_services.is_keep_in_sync(finding) or form.cleaned_data.get("push_to_jira") ) ) @@ -3075,22 +3075,22 @@ def _bulk_push_to_jira(finds, form, note): for group in finding_groups: if ( form.cleaned_data.get("push_to_jira") - or jira_helper.is_push_all_issues(group) - or jira_helper.is_keep_in_sync_with_jira(group) + or jira_services.is_push_all_issues(group) + or jira_services.is_keep_in_sync(group) ): ( can_be_pushed_to_jira, error_message, _error_code, - ) = jira_helper.can_be_pushed_to_jira(group) + ) = jira_services.can_be_pushed(group) if not can_be_pushed_to_jira: error_counts[error_message] += 1 - jira_helper.log_jira_cannot_be_pushed_reason(error_message, group) + jira_services.log_cannot_be_pushed_reason(error_message, group) else: logger.debug( "pushing to jira from finding.finding_bulk_update_all()", ) - jira_helper.push_to_jira(group) + jira_services.push(group) success_count += 1 for error_message, error_count in error_counts.items(): @@ -3110,40 +3110,40 @@ def _bulk_push_to_jira(finds, form, note): # not sure yet if we want to support bulk unlink, so leave as commented out for now # if form.cleaned_data['unlink_from_jira']: # if finding.has_jira_issue: - # jira_helper.finding_unlink_jira(request, finding) + # jira_services.unlink_finding(request, finding) # Because we never call finding.save() in a bulk update, we need to actually # push the JIRA stuff here, rather than in finding.save() # can't use helper as when push_all_jira_issues is True, # the checkbox gets disabled and is always false - # push_to_jira = jira_helper.is_push_to_jira(new_finding, + # push_to_jira = jira_services.is_push_to_jira(new_finding, # form.cleaned_data.get('push_to_jira')) if ( form.cleaned_data.get("push_to_jira") - or jira_helper.is_push_all_issues(finding) - or jira_helper.is_keep_in_sync_with_jira(finding) + or jira_services.is_push_all_issues(finding) + or jira_services.is_keep_in_sync(finding) ) and not finding.has_finding_group: ( can_be_pushed_to_jira, error_message, _error_code, - ) = jira_helper.can_be_pushed_to_jira(finding) + ) = jira_services.can_be_pushed(finding) if finding.has_jira_group_issue and not finding.has_jira_issue: error_message = ( "finding already pushed as part of Finding Group" ) error_counts[error_message] += 1 - jira_helper.log_jira_cannot_be_pushed_reason(error_message, finding) + jira_services.log_cannot_be_pushed_reason(error_message, finding) elif not can_be_pushed_to_jira: error_counts[error_message] += 1 - jira_helper.log_jira_cannot_be_pushed_reason(error_message, finding) + jira_services.log_cannot_be_pushed_reason(error_message, finding) else: logger.debug( "pushing to jira from finding.finding_bulk_update_all()", ) - jira_helper.push_to_jira(finding) + jira_services.push(finding) if note is not None and isinstance(note, Notes): - jira_helper.add_comment(finding, note) + jira_services.add_comment(finding, note) success_count += 1 for error_message, error_count in error_counts.items(): @@ -3500,7 +3500,7 @@ def unlink_jira(request, fid): ) if finding.has_jira_issue: try: - jira_helper.finding_unlink_jira(request, finding) + jira_services.unlink_finding(request, finding) messages.add_message( request, @@ -3543,7 +3543,7 @@ def push_to_jira(request, fid): # but cant't change too much now without having a test suite, # so leave as is for now with the addition warning message # to check alerts for background errors. - if jira_helper.push_to_jira(finding): + if jira_services.push(finding): messages.add_message( request, messages.SUCCESS, diff --git a/dojo/finding_group/views.py b/dojo/finding_group/views.py index 451d4dcd720..afdb897e98a 100644 --- a/dojo/finding_group/views.py +++ b/dojo/finding_group/views.py @@ -12,7 +12,6 @@ from django.views import View from django.views.decorators.http import require_POST -import dojo.jira_link.helper as jira_helper from dojo.authorization.authorization import user_has_permission_or_403 from dojo.authorization.authorization_decorators import user_is_authorized from dojo.authorization.roles_permissions import Permissions @@ -23,6 +22,7 @@ ) from dojo.finding.queries import prefetch_for_findings from dojo.forms import DeleteFindingGroupForm, EditFindingGroupForm, FindingBulkUpdateForm +from dojo.jira import services as jira_services from dojo.models import Engagement, Finding, Finding_Group, GITHUB_PKey, Global_Role, Product from dojo.product.queries import get_authorized_products from dojo.utils import Product_Tab, add_breadcrumb, get_page_items, get_setting, get_system_setting, get_words_for_field @@ -48,7 +48,7 @@ def view_finding_group(request, fgid): product = get_object_or_404(Product, id=pid) user_has_permission_or_403(request.user, product, Permissions.Product_View) product_tab = Product_Tab(product, title="Findings", tab="findings") - jira_project = jira_helper.get_jira_project(product) + jira_project = jira_services.get_project(product) github_config = GITHUB_PKey.objects.filter(product=pid).first() findings_filter = finding_filter_class(request.GET, findings, user=request.user, pid=pid) elif finding_group.test.engagement.id: @@ -56,7 +56,7 @@ def view_finding_group(request, fgid): engagement = get_object_or_404(Engagement, id=eid) user_has_permission_or_403(request.user, engagement, Permissions.Engagement_View) product_tab = Product_Tab(engagement.product, title=engagement.name, tab="engagements") - jira_project = jira_helper.get_jira_project(engagement) + jira_project = jira_services.get_project(engagement) github_config = GITHUB_PKey.objects.filter(product__engagement=eid).first() findings_filter = finding_filter_class(request.GET, findings, user=request.user, eid=eid) @@ -82,7 +82,7 @@ def view_finding_group(request, fgid): if jira_issue: # See if the submitted issue was a issue key or the full URL - jira_project = jira_helper.get_jira_project(finding_group) + jira_project = jira_services.get_project(finding_group) if not jira_project or not jira_project.jira_instance: messages.add_message( request, @@ -94,13 +94,13 @@ def view_finding_group(request, fgid): jira_instance = jira_project.jira_instance jira_issue = jira_issue.removeprefix(jira_instance.url + "/browse/") - if finding_group.has_jira_issue and jira_issue != jira_helper.get_jira_key(finding_group): - jira_helper.unlink_jira(request, finding_group) - jira_helper.finding_group_link_jira(request, finding_group, jira_issue) + if finding_group.has_jira_issue and jira_issue != jira_services.get_key(finding_group): + jira_services.unlink(request, finding_group) + jira_services.link_finding_group(request, finding_group, jira_issue) elif not finding_group.has_jira_issue: - jira_helper.finding_group_link_jira(request, finding_group, jira_issue) + jira_services.link_finding_group(request, finding_group, jira_issue) elif push_to_jira: - jira_helper.push_to_jira(finding_group, sync=True) + jira_services.push(finding_group, sync=True) finding_group.save() return HttpResponseRedirect(reverse("view_test", args=(finding_group.test.id,))) @@ -162,7 +162,7 @@ def unlink_jira(request, fgid): logger.info("trying to unlink a linked jira issue from %d:%s", group.id, group.name) if group.has_jira_issue: try: - jira_helper.unlink_jira(request, group) + jira_services.unlink(request, group) messages.add_message( request, @@ -200,7 +200,7 @@ def push_to_jira(request, fgid): # it may look like success here, but the push_to_jira are swallowing exceptions # but cant't change too much now without having a test suite, so leave as is for now with the addition warning message to check alerts for background errors. - if jira_helper.push_to_jira(group, sync=True): + if jira_services.push(group, sync=True): messages.add_message( request, messages.SUCCESS, diff --git a/dojo/forms.py b/dojo/forms.py index f8c1b5f760a..211314915b3 100644 --- a/dojo/forms.py +++ b/dojo/forms.py @@ -1,5 +1,4 @@ import logging -import os import pickle import re import warnings @@ -16,13 +15,11 @@ from django.conf import settings from django.contrib.auth.models import Permission from django.contrib.auth.password_validation import validate_password -from django.core import validators from django.core.exceptions import ValidationError from django.core.validators import URLValidator from django.db.models import Count, Q from django.forms import modelformset_factory from django.forms.widgets import Select, Widget -from django.urls import reverse from django.utils import timezone from django.utils.dates import MONTHS from django.utils.safestring import mark_safe @@ -30,13 +27,26 @@ from polymorphic.base import ManagerInheritanceWarning from tagulous.forms import TagField -import dojo.jira_link.helper as jira_helper from dojo.authorization.authorization import user_has_configuration_permission, user_is_superuser_or_global_owner from dojo.authorization.roles_permissions import Permissions from dojo.endpoint.utils import endpoint_filter, endpoint_get_or_create, validate_endpoints_to_add from dojo.engagement.queries import get_authorized_engagements from dojo.finding.queries import get_authorized_findings from dojo.group.queries import get_authorized_groups, get_group_member_roles +from dojo.jira import services as jira_services +from dojo.jira.forms import ( # noqa: F401 backward compat + JIRA_TEMPLATE_CHOICES, + AdvancedJIRAForm, + BaseJiraForm, + DeleteJIRAInstanceForm, + JIRA_IssueForm, + JIRAEngagementForm, + JIRAFindingForm, + JIRAForm, + JIRAImportScanForm, + JIRAProjectForm, + get_jira_issue_template_dir_choices, +) from dojo.labels import get_labels from dojo.location.models import Location from dojo.location.utils import validate_locations_to_add @@ -73,9 +83,6 @@ GITHUB_Issue, GITHUB_PKey, Global_Role, - JIRA_Instance, - JIRA_Issue, - JIRA_Project, Note_Type, Notes, Notification_Webhooks, @@ -419,7 +426,7 @@ def __init__(self, *args, **kwargs): self.fields["push_to_jira"].label = "Push to JIRA" if hasattr(self.instance, "has_jira_issue") and self.instance.has_jira_issue: - jira_url = jira_helper.get_jira_url(self.instance) + jira_url = jira_services.get_url(self.instance) self.fields["jira_issue"].initial = jira_url self.fields["push_to_jira"].widget.attrs["checked"] = "checked" @@ -2830,89 +2837,6 @@ class Meta: "high_mapping_severity", "critical_mapping_severity", "finding_text"] -def get_jira_issue_template_dir_choices(): - template_root = settings.JIRA_TEMPLATE_ROOT - template_dir_list = [("", "---")] - for base_dir, dirnames, _filenames in os.walk(template_root): - # for filename in filenames: - # if base_dir.startswith(settings.TEMPLATE_DIR_PREFIX): - # base_dir = base_dir[len(settings.TEMPLATE_DIR_PREFIX):] - # template_list.append((os.path.join(base_dir, filename), filename)) - - for dirname in dirnames: - clean_base_dir = base_dir.removeprefix(settings.TEMPLATE_DIR_PREFIX) - template_dir_list.append((str(Path(clean_base_dir) / dirname), dirname)) - - logger.debug("templates: %s", template_dir_list) - return template_dir_list - - -JIRA_TEMPLATE_CHOICES = sorted(get_jira_issue_template_dir_choices()) - - -class JIRA_IssueForm(forms.ModelForm): - - class Meta: - model = JIRA_Issue - exclude = ["product"] - - -class BaseJiraForm(forms.ModelForm): - password = forms.CharField(widget=forms.PasswordInput, required=True, help_text=JIRA_Instance._meta.get_field("password").help_text, label=JIRA_Instance._meta.get_field("password").verbose_name) - - def test_jira_connection(self): - try: - # Attempt to validate the credentials before moving forward - jira_helper.get_jira_connection_raw(self.cleaned_data["url"], - self.cleaned_data["username"], - self.cleaned_data["password"]) - logger.debug("valid JIRA config!") - except Exception as e: - # form only used by admins, so we can show full error message using str(e) which can help debug any problems - message = "Unable to authenticate to JIRA. Please check the URL, username, password, captcha challenge, Network connection. Details in alert on top right. " + str( - e) - self.add_error("username", message) - self.add_error("password", message) - - def clean(self): - self.test_jira_connection() - return self.cleaned_data - - -class AdvancedJIRAForm(BaseJiraForm): - issue_template_dir = forms.ChoiceField(required=False, - choices=JIRA_TEMPLATE_CHOICES, - help_text="Choose the folder containing the Django templates used to render the JIRA issue description. These are stored in dojo/templates/issue-trackers. Leave empty to use the default jira_full templates.") - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if self.instance: - self.fields["password"].required = False - - def clean(self): - if self.instance and not self.cleaned_data["password"]: - self.cleaned_data["password"] = self.instance.password - return super().clean() - - class Meta: - model = JIRA_Instance - exclude = [""] - - -class JIRAForm(BaseJiraForm): - issue_key = forms.CharField(required=True, help_text="A valid issue ID is required to gather the necessary information.") - issue_template_dir = forms.ChoiceField(required=False, - choices=JIRA_TEMPLATE_CHOICES, - help_text="Choose the folder containing the Django templates used to render the JIRA issue description. These are stored in dojo/templates/issue-trackers. Leave empty to use the default jira_full templates.") - - class Meta: - model = JIRA_Instance - exclude = ["product", "epic_name_id", "open_status_key", - "close_status_key", "info_mapping_severity", - "low_mapping_severity", "medium_mapping_severity", - "high_mapping_severity", "critical_mapping_severity", "finding_text"] - - class Benchmark_Product_SummaryForm(forms.ModelForm): class Meta: @@ -2953,15 +2877,6 @@ class Meta: fields = ["id"] -class DeleteJIRAInstanceForm(forms.ModelForm): - id = forms.IntegerField(required=True, - widget=forms.widgets.HiddenInput()) - - class Meta: - model = JIRA_Instance - fields = ["id"] - - class ToolTypeForm(forms.ModelForm): class Meta: model = Tool_Type @@ -3315,138 +3230,6 @@ class Meta: exclude = ["product"] -class JIRAProjectForm(forms.ModelForm): - inherit_from_product = forms.BooleanField(label="inherit JIRA settings from product", required=False) - jira_instance = forms.ModelChoiceField(queryset=JIRA_Instance.objects.all(), label="JIRA Instance", required=False) - issue_template_dir = forms.ChoiceField(required=False, - choices=JIRA_TEMPLATE_CHOICES, - help_text="Choose the folder containing the Django templates used to render the JIRA issue description. These are stored in dojo/templates/issue-trackers. Leave empty to use the default jira_full templates.") - - prefix = "jira-project-form" - - class Meta: - model = JIRA_Project - exclude = ["product", "engagement"] - fields = ["inherit_from_product", "jira_instance", "project_key", "issue_template_dir", "epic_issue_type_name", "component", "custom_fields", "jira_labels", "default_assignee", "enabled", "add_vulnerability_id_to_jira_label", "push_all_issues", "enable_engagement_epic_mapping", "push_notes", "product_jira_sla_notification", "risk_acceptance_expiration_notification"] - - def __init__(self, *args, **kwargs): - # if the form is shown for an engagement, we set a placeholder text around inherited settings from product - self.target = kwargs.pop("target", "product") - self.product = kwargs.pop("product", None) - self.engagement = kwargs.pop("engagement", None) - super().__init__(*args, **kwargs) - - logger.debug("self.target: %s, self.product: %s, self.instance: %s", self.target, self.product, self.instance) - logger.debug("data: %s", self.data) - if self.target == "engagement": - product_name = self.product.name if self.product else self.engagement.product.name if self.engagement.product else "" - - self.fields["project_key"].widget = forms.TextInput(attrs={"placeholder": f"JIRA settings inherited from product '{product_name}'"}) - self.fields["project_key"].help_text = f"JIRA settings are inherited from product '{product_name}', unless configured differently here." - self.fields["jira_instance"].help_text = f"JIRA settings are inherited from product '{product_name}' , unless configured differently here." - - # if we don't have an instance, django will insert a blank empty one :-( - # so we have to check for id to make sure we only trigger this when there is a real instance from db - if self.instance.id: - logger.debug("jira project instance found for engagement, unchecking inherit checkbox") - self.fields["jira_instance"].required = True - self.fields["project_key"].required = True - self.initial["inherit_from_product"] = False - # once a jira project config is attached to an engagement, we can't go back to inheriting - # because the config needs to remain in place for the existing jira issues - self.fields["inherit_from_product"].disabled = True - self.fields["inherit_from_product"].help_text = "Once an engagement has a JIRA Project stored, you cannot switch back to inheritance to avoid breaking existing JIRA issues" - self.fields["jira_instance"].disabled = False - self.fields["project_key"].disabled = False - self.fields["issue_template_dir"].disabled = False - self.fields["epic_issue_type_name"].disabled = False - self.fields["component"].disabled = False - self.fields["custom_fields"].disabled = False - self.fields["default_assignee"].disabled = False - self.fields["jira_labels"].disabled = False - self.fields["enabled"].disabled = False - self.fields["add_vulnerability_id_to_jira_label"].disabled = False - self.fields["push_all_issues"].disabled = False - self.fields["enable_engagement_epic_mapping"].disabled = False - self.fields["push_notes"].disabled = False - self.fields["product_jira_sla_notification"].disabled = False - self.fields["risk_acceptance_expiration_notification"].disabled = False - - elif self.product: - logger.debug("setting jira project fields from product1") - self.initial["inherit_from_product"] = True - jira_project_product = jira_helper.get_jira_project(self.product) - # we have to check that we are not in a POST request where jira project config data is posted - # this is because initial values will overwrite the actual values entered by the user - # makes no sense, but seems to be accepted behaviour: https://code.djangoproject.com/ticket/30407 - if jira_project_product and (self.prefix + "-jira_instance") not in self.data: - logger.debug("setting jira project fields from product2") - self.initial["jira_instance"] = jira_project_product.jira_instance.id if jira_project_product.jira_instance else None - self.initial["project_key"] = jira_project_product.project_key - self.initial["issue_template_dir"] = jira_project_product.issue_template_dir - self.initial["epic_issue_type_name"] = jira_project_product.epic_issue_type_name - self.initial["component"] = jira_project_product.component - self.initial["custom_fields"] = jira_project_product.custom_fields - self.initial["default_assignee"] = jira_project_product.default_assignee - self.initial["jira_labels"] = jira_project_product.jira_labels - self.initial["enabled"] = jira_project_product.enabled - self.initial["add_vulnerability_id_to_jira_label"] = jira_project_product.add_vulnerability_id_to_jira_label - self.initial["push_all_issues"] = jira_project_product.push_all_issues - self.initial["enable_engagement_epic_mapping"] = jira_project_product.enable_engagement_epic_mapping - self.initial["push_notes"] = jira_project_product.push_notes - self.initial["product_jira_sla_notification"] = jira_project_product.product_jira_sla_notification - self.initial["risk_acceptance_expiration_notification"] = jira_project_product.risk_acceptance_expiration_notification - - self.fields["jira_instance"].disabled = True - self.fields["project_key"].disabled = True - self.fields["issue_template_dir"].disabled = True - self.fields["epic_issue_type_name"].disabled = True - self.fields["component"].disabled = True - self.fields["custom_fields"].disabled = True - self.fields["default_assignee"].disabled = True - self.fields["jira_labels"].disabled = True - self.fields["enabled"].disabled = True - self.fields["add_vulnerability_id_to_jira_label"].disabled = True - self.fields["push_all_issues"].disabled = True - self.fields["enable_engagement_epic_mapping"].disabled = True - self.fields["push_notes"].disabled = True - self.fields["product_jira_sla_notification"].disabled = True - self.fields["risk_acceptance_expiration_notification"].disabled = True - - else: - del self.fields["inherit_from_product"] - - # if we don't have an instance, django will insert a blank empty one :-( - # so we have to check for id to make sure we only trigger this when there is a real instance from db - if self.instance.id: - self.fields["jira_instance"].required = True - self.fields["project_key"].required = True - self.fields["epic_issue_type_name"].required = True - - def clean(self): - logger.debug("validating jira project form") - cleaned_data = super().clean() - - logger.debug("clean: inherit: %s", self.cleaned_data.get("inherit_from_product", False)) - if not self.cleaned_data.get("inherit_from_product", False): - jira_instance = self.cleaned_data.get("jira_instance") - project_key = self.cleaned_data.get("project_key") - epic_issue_type_name = self.cleaned_data.get("epic_issue_type_name") - - if project_key and jira_instance and epic_issue_type_name: - return cleaned_data - - if not project_key and not jira_instance and not epic_issue_type_name: - return cleaned_data - - if self.target == "engagement": - msg = "JIRA Project needs a JIRA Instance, JIRA Project Key, and Epic issue type name, or choose to inherit settings from product" - raise ValidationError(msg) - msg = "JIRA Project needs a JIRA Instance, JIRA Project Key, and Epic issue type name, leave empty to have no JIRA integration setup" - raise ValidationError(msg) - return None - - class GITHUBFindingForm(forms.Form): def __init__(self, *args, **kwargs): self.enabled = kwargs.pop("enabled") @@ -3458,164 +3241,6 @@ def __init__(self, *args, **kwargs): push_to_github = forms.BooleanField(required=False) -class JIRAFindingForm(forms.Form): - def __init__(self, *args, **kwargs): - self.push_all = kwargs.pop("push_all", False) - self.instance = kwargs.pop("instance", None) - self.jira_project = kwargs.pop("jira_project", None) - # we provide the finding_form from the same page so we can add validation errors - # if the finding doesn't satisfy the rules to be pushed to JIRA - self.finding_form = kwargs.pop("finding_form", None) - - if self.instance is None and self.jira_project is None: - msg = "either and finding instance or jira_project is needed" - raise ValueError(msg) - - super().__init__(*args, **kwargs) - self.fields["push_to_jira"] = forms.BooleanField() - self.fields["push_to_jira"].required = False - if is_finding_groups_enabled(): - self.fields["push_to_jira"].help_text = "Checking this will overwrite content of your JIRA issue, or create one. If this finding is part of a Finding Group, the group will pushed instead of the finding." - else: - self.fields["push_to_jira"].help_text = "Checking this will overwrite content of your JIRA issue, or create one." - - self.fields["push_to_jira"].label = "Push to JIRA" - if self.push_all: - # This will show the checkbox as checked and greyed out, this way the user is aware - # that issues will be pushed to JIRA, given their product-level settings. - self.fields["push_to_jira"].help_text = ( - "Push all issues is enabled on this product. If you do not wish to push all issues" - " to JIRA, please disable Push all issues on this product." - ) - self.fields["push_to_jira"].widget.attrs["checked"] = "checked" - self.fields["push_to_jira"].disabled = True - - if self.instance: - if hasattr(self.instance, "has_jira_issue") and self.instance.has_jira_issue: - self.initial["jira_issue"] = self.instance.jira_issue.jira_key - self.fields["push_to_jira"].widget.attrs["checked"] = "checked" - if is_finding_groups_enabled(): - self.fields["jira_issue"].widget = forms.TextInput(attrs={"placeholder": "Leave empty and check push to jira to create a new JIRA issue for this finding, or the group this finding is in."}) - else: - self.fields["jira_issue"].widget = forms.TextInput(attrs={"placeholder": "Leave empty and check push to jira to create a new JIRA issue for this finding."}) - - if self.instance and hasattr(self.instance, "has_jira_group_issue") and self.instance.has_jira_group_issue: - self.fields["push_to_jira"].widget.attrs["checked"] = "checked" - self.fields["jira_issue"].help_text = "Changing the linked JIRA issue for finding groups is not (yet) supported." - self.initial["jira_issue"] = self.instance.finding_group.jira_issue.jira_key - self.fields["jira_issue"].disabled = True - - def clean(self): - logger.debug("jform clean") - super().clean() - jira_issue_key_new = self.cleaned_data.get("jira_issue") - finding = self.instance - jira_project = self.jira_project - - logger.debug("self.cleaned_data.push_to_jira: %s", self.cleaned_data.get("push_to_jira", None)) - - if self.cleaned_data.get("push_to_jira", None) and finding and finding.has_jira_group_issue: - can_be_pushed_to_jira, error_message, error_code = jira_helper.can_be_pushed_to_jira(finding.finding_group, self.finding_form) - if not can_be_pushed_to_jira: - self.add_error("push_to_jira", ValidationError(error_message, code=error_code)) - # for field in error_fields: - # self.finding_form.add_error(field, error) - - elif self.cleaned_data.get("push_to_jira", None) and finding: - can_be_pushed_to_jira, error_message, error_code = jira_helper.can_be_pushed_to_jira(finding, self.finding_form) - if not can_be_pushed_to_jira: - self.add_error("push_to_jira", ValidationError(error_message, code=error_code)) - # for field in error_fields: - # self.finding_form.add_error(field, error) - elif self.cleaned_data.get("push_to_jira", None): - active = self.finding_form["active"].value() - verified = self.finding_form["verified"].value() - if not active or (not verified and (get_system_setting("enforce_verified_status", True) or get_system_setting("enforce_verified_status_jira", True))): - logger.debug("Findings must be active and verified to be pushed to JIRA") - error_message = "Findings must be active and verified to be pushed to JIRA" - self.add_error("push_to_jira", ValidationError(error_message, code="not_active_or_verified")) - - if jira_issue_key_new and (not finding or not finding.has_jira_group_issue): - # when there is a group jira issue, we skip all the linking/unlinking as this is not supported (yet) - if finding: - # in theory there can multiple jira instances that have similar projects - # so checking by only the jira issue key can lead to false positives - # so we check also the jira internal id of the jira issue - # if the key and id are equal, it is probably the same jira instance and the same issue - # the database model is lacking some relations to also include the jira config name or url here - # and I don't want to change too much now. this should cover most usecases. - - jira_issue_need_to_exist = False - # changing jira link on finding - if finding.has_jira_issue and jira_issue_key_new != finding.jira_issue.jira_key: - jira_issue_need_to_exist = True - - # adding existing jira issue to finding without jira link - if not finding.has_jira_issue: - jira_issue_need_to_exist = True - - else: - jira_issue_need_to_exist = True - - if jira_issue_need_to_exist: - jira_issue_new = jira_helper.jira_get_issue(jira_project, jira_issue_key_new) - if not jira_issue_new: - raise ValidationError("JIRA issue " + jira_issue_key_new + " does not exist or cannot be retrieved") - - logger.debug("checking if provided jira issue id already is linked to another finding") - jira_issues = JIRA_Issue.objects.filter(jira_id=jira_issue_new.id, jira_key=jira_issue_key_new).exclude(engagement__isnull=False) - - if self.instance: - # just be sure we exclude the finding that is being edited - jira_issues = jira_issues.exclude(finding=finding) - - if len(jira_issues) > 0: - raise ValidationError("JIRA issue " + jira_issue_key_new + " already linked to " + reverse("view_finding", args=(jira_issues[0].finding_id,))) - - jira_issue = forms.CharField(required=False, label="Linked JIRA Issue", - validators=[validators.RegexValidator( - regex=r"^[A-Z][A-Z_0-9]+-\d+$", - message="JIRA issue key must be in XXXX-nnnn format ([A-Z][A-Z_0-9]+-\\d+)")]) - push_to_jira = forms.BooleanField(required=False, label="Push to JIRA") - - -class JIRAImportScanForm(forms.Form): - def __init__(self, *args, **kwargs): - self.push_all = kwargs.pop("push_all", False) - - super().__init__(*args, **kwargs) - if self.push_all: - # This will show the checkbox as checked and greyed out, this way the user is aware - # that issues will be pushed to JIRA, given their product-level settings. - self.fields["push_to_jira"].help_text = ( - "Push all issues is enabled on this product. If you do not wish to push all issues" - " to JIRA, please disable Push all issues on this product." - ) - self.fields["push_to_jira"].widget.attrs["checked"] = "checked" - self.fields["push_to_jira"].disabled = True - - push_to_jira = forms.BooleanField(required=False, label="Push to JIRA", help_text="Checking this will create a new jira issue for each new finding.") - - -class JIRAEngagementForm(forms.Form): - prefix = "jira-epic-form" - - def __init__(self, *args, **kwargs): - self.instance = kwargs.pop("instance", None) - - super().__init__(*args, **kwargs) - - if self.instance: - if self.instance.has_jira_issue: - self.fields["push_to_jira"].widget.attrs["checked"] = "checked" - self.fields["push_to_jira"].label = "Update JIRA Epic" - self.fields["push_to_jira"].help_text = "Checking this will update the existing EPIC in JIRA." - - push_to_jira = forms.BooleanField(required=False, label="Create EPIC", help_text="Checking this will create an EPIC in JIRA for this engagement.") - epic_name = forms.CharField(max_length=200, required=False, help_text="EPIC name in JIRA. If not specified, it defaults to the engagement name") - epic_priority = forms.CharField(max_length=200, required=False, help_text="EPIC priority. If not specified, the JIRA default priority will be used") - - class LoginBanner(forms.Form): banner_enable = forms.BooleanField( label="Enable login banner", diff --git a/dojo/importers/base_importer.py b/dojo/importers/base_importer.py index ff04a5698de..e42eae0f4a2 100644 --- a/dojo/importers/base_importer.py +++ b/dojo/importers/base_importer.py @@ -14,7 +14,7 @@ import dojo.finding.helper as finding_helper import dojo.risk_acceptance.helper as ra_helper from dojo.importers.options import ImporterOptions -from dojo.jira_link.helper import is_keep_in_sync_with_jira +from dojo.jira.services import is_keep_in_sync from dojo.location.models import Location from dojo.models import ( # Import History States @@ -875,7 +875,7 @@ def mitigate_finding( # don't try to dedupe findings that we are closing finding.save(dedupe_option=False, product_grading_option=product_grading_option) else: - finding.save(dedupe_option=False, push_to_jira=(self.push_to_jira or is_keep_in_sync_with_jira(finding, prefetched_jira_instance=self.jira_instance)), product_grading_option=product_grading_option) + finding.save(dedupe_option=False, push_to_jira=(self.push_to_jira or is_keep_in_sync(finding, prefetched_jira_instance=self.jira_instance)), product_grading_option=product_grading_option) def notify_scan_added( self, diff --git a/dojo/importers/default_importer.py b/dojo/importers/default_importer.py index 8fb4cdc185a..ff8078f1b80 100644 --- a/dojo/importers/default_importer.py +++ b/dojo/importers/default_importer.py @@ -5,13 +5,12 @@ from django.db.models.query_utils import Q from django.urls import reverse -import dojo.jira_link.helper as jira_helper from dojo.celery_dispatch import dojo_dispatch_task from dojo.finding import helper as finding_helper from dojo.importers.base_importer import BaseImporter, Parser from dojo.importers.base_location_manager import LocationHandler from dojo.importers.options import ImporterOptions -from dojo.jira_link.helper import is_keep_in_sync_with_jira +from dojo.jira import services as jira_services from dojo.models import ( Engagement, Finding, @@ -294,9 +293,9 @@ def process_findings( ) if self.push_to_jira: if findings[0].finding_group is not None: - jira_helper.push_to_jira(findings[0].finding_group) + jira_services.push(findings[0].finding_group) else: - jira_helper.push_to_jira(findings[0]) + jira_services.push(findings[0]) else: logger.debug("push_to_jira is False, not pushing to JIRA") @@ -402,8 +401,8 @@ def close_old_findings( if self.findings_groups_enabled and (self.push_to_jira or getattr(self.jira_instance, "finding_jira_sync", False)): for finding_group in {finding.finding_group for finding in old_findings if finding.finding_group is not None}: # Check the push_to_jira flag again to potentially shorty circuit without checking for existing findings - if self.push_to_jira or is_keep_in_sync_with_jira(finding_group, prefetched_jira_instance=self.jira_instance): - jira_helper.push_to_jira(finding_group) + if self.push_to_jira or jira_services.is_keep_in_sync(finding_group, prefetched_jira_instance=self.jira_instance): + jira_services.push(finding_group) # Calculate grade once after all findings have been closed if old_findings: diff --git a/dojo/importers/default_reimporter.py b/dojo/importers/default_reimporter.py index b63b7134701..06f06ca368f 100644 --- a/dojo/importers/default_reimporter.py +++ b/dojo/importers/default_reimporter.py @@ -6,7 +6,6 @@ from django.db.models.query_utils import Q import dojo.finding.helper as finding_helper -import dojo.jira_link.helper as jira_helper from dojo.celery_dispatch import dojo_dispatch_task from dojo.finding.deduplication import ( find_candidates_for_deduplication_hash, @@ -17,7 +16,7 @@ from dojo.importers.base_importer import BaseImporter, Parser from dojo.importers.base_location_manager import LocationHandler from dojo.importers.options import ImporterOptions -from dojo.jira_link.helper import is_keep_in_sync_with_jira +from dojo.jira import services as jira_services from dojo.models import ( Development_Environment, Finding, @@ -540,8 +539,8 @@ def close_old_findings( if self.findings_groups_enabled and (self.push_to_jira or getattr(self.jira_instance, "finding_jira_sync", False)): for finding_group in {finding.finding_group for finding in findings if finding.finding_group is not None}: # Check the push_to_jira flag again to potentially shorty circuit without checking for existing findings - if self.push_to_jira or is_keep_in_sync_with_jira(finding_group, prefetched_jira_instance=self.jira_instance): - jira_helper.push_to_jira(finding_group) + if self.push_to_jira or jira_services.is_keep_in_sync(finding_group, prefetched_jira_instance=self.jira_instance): + jira_services.push(finding_group) # Calculate grade once after all findings have been closed if mitigated_findings: perform_product_grading(self.test.engagement.product) @@ -1023,8 +1022,8 @@ def process_groups_for_all_findings( if self.push_to_jira or getattr(self.jira_instance, "finding_jira_sync", False): object_to_push = findings[0].finding_group if findings[0].finding_group is not None else findings[0] # Check the push_to_jira flag again to potentially shorty circuit without checking for existing findings - if self.push_to_jira or is_keep_in_sync_with_jira(object_to_push, prefetched_jira_instance=self.jira_instance): - jira_helper.push_to_jira(object_to_push) + if self.push_to_jira or jira_services.is_keep_in_sync(object_to_push, prefetched_jira_instance=self.jira_instance): + jira_services.push(object_to_push) # We dont check if the finding jira sync is applicable quite yet until we can get in the loop # but this is a way to at least make it that far if self.findings_groups_enabled and (self.push_to_jira or getattr(self.jira_instance, "finding_jira_sync", False)): @@ -1034,8 +1033,8 @@ def process_groups_for_all_findings( if finding.finding_group is not None and not finding.is_mitigated }: # Check the push_to_jira flag again to potentially shorty circuit without checking for existing findings - if self.push_to_jira or is_keep_in_sync_with_jira(finding_group, prefetched_jira_instance=self.jira_instance): - jira_helper.push_to_jira(finding_group) + if self.push_to_jira or jira_services.is_keep_in_sync(finding_group, prefetched_jira_instance=self.jira_instance): + jira_services.push(finding_group) def calculate_unsaved_finding_hash_code( self, diff --git a/dojo/importers/options.py b/dojo/importers/options.py index 5b9576615c8..02cecff1113 100644 --- a/dojo/importers/options.py +++ b/dojo/importers/options.py @@ -10,7 +10,7 @@ from django.utils import timezone from django.utils.functional import SimpleLazyObject -from dojo.jira_link.helper import get_jira_instance +from dojo.jira.services import get_instance as get_jira_instance from dojo.models import ( Development_Environment, Dojo_User, diff --git a/dojo/jira_link/__init__.py b/dojo/jira/__init__.py similarity index 100% rename from dojo/jira_link/__init__.py rename to dojo/jira/__init__.py diff --git a/dojo/jira/api/__init__.py b/dojo/jira/api/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/jira/api/serializers.py b/dojo/jira/api/serializers.py new file mode 100644 index 00000000000..a4c6ae412d2 --- /dev/null +++ b/dojo/jira/api/serializers.py @@ -0,0 +1,91 @@ +import json + +from django.urls import reverse +from rest_framework import serializers + +from dojo.jira import services as jira_services +from dojo.models import ( + JIRA_Instance, + JIRA_Issue, + JIRA_Project, +) + + +class JIRAIssueSerializer(serializers.ModelSerializer): + url = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = JIRA_Issue + fields = "__all__" + + def get_url(self, obj) -> str: + return jira_services.get_issue_url(obj) + + def validate(self, data): + if self.context["request"].method == "PATCH": + engagement = data.get("engagement", self.instance.engagement) + finding = data.get("finding", self.instance.finding) + finding_group = data.get( + "finding_group", self.instance.finding_group, + ) + else: + engagement = data.get("engagement", None) + finding = data.get("finding", None) + finding_group = data.get("finding_group", None) + + if ( + (engagement and not finding and not finding_group) + or (finding and not engagement and not finding_group) + or (finding_group and not engagement and not finding) + ): + pass + else: + msg = "Either engagement or finding or finding_group has to be set." + raise serializers.ValidationError(msg) + + if finding: + if (linked_finding := jira_services.already_linked(finding, data.get("jira_key"), data.get("jira_id"))) is not None: + msg = "JIRA issue " + data.get("jira_key") + " already linked to " + reverse("view_finding", args=(linked_finding.id,)) + raise serializers.ValidationError(msg) + + return data + + +class JIRAInstanceSerializer(serializers.ModelSerializer): + class Meta: + model = JIRA_Instance + fields = "__all__" + extra_kwargs = { + "password": {"write_only": True}, + } + + +class JIRAProjectSerializer(serializers.ModelSerializer): + class Meta: + model = JIRA_Project + fields = "__all__" + + def validate(self, data): + if self.context["request"].method == "PATCH": + engagement = data.get("engagement", self.instance.engagement) + product = data.get("product", self.instance.product) + else: + engagement = data.get("engagement", None) + product = data.get("product", None) + + if (engagement and product) or (not engagement and not product): + msg = "Either engagement or product has to be set." + raise serializers.ValidationError(msg) + + if "custom_fields" in data and isinstance(data["custom_fields"], str): + try: + data["custom_fields"] = json.loads(data["custom_fields"]) + except json.JSONDecodeError as e: + raise serializers.ValidationError({"custom_fields": f"Invalid JSON: {e}"}) from e + + return data + + +class EngagementUpdateJiraEpicSerializer(serializers.Serializer): + epic_name = serializers.CharField(required=False, max_length=200) + epic_priority = serializers.CharField(required=False, allow_null=True) diff --git a/dojo/jira/api/views.py b/dojo/jira/api/views.py new file mode 100644 index 00000000000..71fa5ee6393 --- /dev/null +++ b/dojo/jira/api/views.py @@ -0,0 +1,91 @@ +from django_filters.rest_framework import DjangoFilterBackend +from drf_spectacular.utils import extend_schema_view +from rest_framework.permissions import IsAuthenticated + +from dojo.api_v2 import permissions +from dojo.api_v2.views import DojoModelViewSet, PrefetchDojoModelViewSet, schema_with_prefetch +from dojo.authorization.roles_permissions import Permissions +from dojo.jira.api.serializers import ( + JIRAInstanceSerializer, + JIRAIssueSerializer, + JIRAProjectSerializer, +) +from dojo.jira.queries import ( + get_authorized_jira_issues, + get_authorized_jira_projects, +) +from dojo.models import ( + JIRA_Instance, + JIRA_Issue, + JIRA_Project, +) + + +class JiraInstanceViewSet( + DojoModelViewSet, +): + serializer_class = JIRAInstanceSerializer + queryset = JIRA_Instance.objects.none() + filter_backends = (DjangoFilterBackend,) + filterset_fields = ["id", "url"] + permission_classes = (permissions.UserHasConfigurationPermissionSuperuser,) + + def get_queryset(self): + return JIRA_Instance.objects.all().order_by("id") + + +# Authorization: object-based +# @extend_schema_view(**schema_with_prefetch()) +# Nested models with prefetch make the response schema too long for Swagger UI +class JiraIssuesViewSet( + PrefetchDojoModelViewSet, +): + serializer_class = JIRAIssueSerializer + queryset = JIRA_Issue.objects.none() + filter_backends = (DjangoFilterBackend,) + filterset_fields = [ + "id", + "jira_id", + "jira_key", + "finding", + "engagement", + "finding_group", + ] + + permission_classes = ( + IsAuthenticated, + permissions.UserHasJiraIssuePermission, + ) + + def get_queryset(self): + return get_authorized_jira_issues(Permissions.Product_View) + + +# Authorization: object-based +@extend_schema_view(**schema_with_prefetch()) +class JiraProjectViewSet( + PrefetchDojoModelViewSet, +): + serializer_class = JIRAProjectSerializer + queryset = JIRA_Project.objects.none() + filter_backends = (DjangoFilterBackend,) + filterset_fields = [ + "id", + "jira_instance", + "product", + "engagement", + "enabled", + "component", + "project_key", + "push_all_issues", + "enable_engagement_epic_mapping", + "push_notes", + ] + + permission_classes = ( + IsAuthenticated, + permissions.UserHasJiraProductPermission, + ) + + def get_queryset(self): + return get_authorized_jira_projects(Permissions.Product_View) diff --git a/dojo/jira/forms.py b/dojo/jira/forms.py new file mode 100644 index 00000000000..3ec7005d0fc --- /dev/null +++ b/dojo/jira/forms.py @@ -0,0 +1,404 @@ +import logging +import os +from pathlib import Path + +from django import forms +from django.conf import settings +from django.core import validators +from django.core.exceptions import ValidationError +from django.urls import reverse + +from dojo.jira import services as jira_services +from dojo.models import ( + JIRA_Instance, + JIRA_Issue, + JIRA_Project, +) +from dojo.utils import ( + get_system_setting, + is_finding_groups_enabled, +) + +logger = logging.getLogger(__name__) + + +def get_jira_issue_template_dir_choices(): + template_root = settings.JIRA_TEMPLATE_ROOT + template_dir_list = [("", "---")] + for base_dir, dirnames, _filenames in os.walk(template_root): + # for filename in filenames: + # if base_dir.startswith(settings.TEMPLATE_DIR_PREFIX): + # base_dir = base_dir[len(settings.TEMPLATE_DIR_PREFIX):] + # template_list.append((os.path.join(base_dir, filename), filename)) + + for dirname in dirnames: + clean_base_dir = base_dir.removeprefix(settings.TEMPLATE_DIR_PREFIX) + template_dir_list.append((str(Path(clean_base_dir) / dirname), dirname)) + + logger.debug("templates: %s", template_dir_list) + return template_dir_list + + +JIRA_TEMPLATE_CHOICES = sorted(get_jira_issue_template_dir_choices()) + + +class JIRA_IssueForm(forms.ModelForm): + + class Meta: + model = JIRA_Issue + exclude = ["product"] + + +class BaseJiraForm(forms.ModelForm): + password = forms.CharField(widget=forms.PasswordInput, required=True, help_text=JIRA_Instance._meta.get_field("password").help_text, label=JIRA_Instance._meta.get_field("password").verbose_name) + + def test_jira_connection(self): + try: + # Attempt to validate the credentials before moving forward + jira_services.get_connection_raw(self.cleaned_data["url"], + self.cleaned_data["username"], + self.cleaned_data["password"]) + logger.debug("valid JIRA config!") + except Exception as e: + # form only used by admins, so we can show full error message using str(e) which can help debug any problems + message = "Unable to authenticate to JIRA. Please check the URL, username, password, captcha challenge, Network connection. Details in alert on top right. " + str( + e) + self.add_error("username", message) + self.add_error("password", message) + + def clean(self): + self.test_jira_connection() + return self.cleaned_data + + +class AdvancedJIRAForm(BaseJiraForm): + issue_template_dir = forms.ChoiceField(required=False, + choices=JIRA_TEMPLATE_CHOICES, + help_text="Choose the folder containing the Django templates used to render the JIRA issue description. These are stored in dojo/templates/issue-trackers. Leave empty to use the default jira_full templates.") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.instance: + self.fields["password"].required = False + + def clean(self): + if self.instance and not self.cleaned_data["password"]: + self.cleaned_data["password"] = self.instance.password + return super().clean() + + class Meta: + model = JIRA_Instance + exclude = [""] + + +class JIRAForm(BaseJiraForm): + issue_key = forms.CharField(required=True, help_text="A valid issue ID is required to gather the necessary information.") + issue_template_dir = forms.ChoiceField(required=False, + choices=JIRA_TEMPLATE_CHOICES, + help_text="Choose the folder containing the Django templates used to render the JIRA issue description. These are stored in dojo/templates/issue-trackers. Leave empty to use the default jira_full templates.") + + class Meta: + model = JIRA_Instance + exclude = ["product", "epic_name_id", "open_status_key", + "close_status_key", "info_mapping_severity", + "low_mapping_severity", "medium_mapping_severity", + "high_mapping_severity", "critical_mapping_severity", "finding_text"] + + +class DeleteJIRAInstanceForm(forms.ModelForm): + id = forms.IntegerField(required=True, + widget=forms.widgets.HiddenInput()) + + class Meta: + model = JIRA_Instance + fields = ["id"] + + +class JIRAProjectForm(forms.ModelForm): + inherit_from_product = forms.BooleanField(label="inherit JIRA settings from product", required=False) + jira_instance = forms.ModelChoiceField(queryset=JIRA_Instance.objects.all(), label="JIRA Instance", required=False) + issue_template_dir = forms.ChoiceField(required=False, + choices=JIRA_TEMPLATE_CHOICES, + help_text="Choose the folder containing the Django templates used to render the JIRA issue description. These are stored in dojo/templates/issue-trackers. Leave empty to use the default jira_full templates.") + + prefix = "jira-project-form" + + class Meta: + model = JIRA_Project + exclude = ["product", "engagement"] + fields = ["inherit_from_product", "jira_instance", "project_key", "issue_template_dir", "epic_issue_type_name", "component", "custom_fields", "jira_labels", "default_assignee", "enabled", "add_vulnerability_id_to_jira_label", "push_all_issues", "enable_engagement_epic_mapping", "push_notes", "product_jira_sla_notification", "risk_acceptance_expiration_notification"] + + def __init__(self, *args, **kwargs): + # if the form is shown for an engagement, we set a placeholder text around inherited settings from product + self.target = kwargs.pop("target", "product") + self.product = kwargs.pop("product", None) + self.engagement = kwargs.pop("engagement", None) + super().__init__(*args, **kwargs) + + logger.debug("self.target: %s, self.product: %s, self.instance: %s", self.target, self.product, self.instance) + logger.debug("data: %s", self.data) + if self.target == "engagement": + product_name = self.product.name if self.product else self.engagement.product.name if self.engagement.product else "" + + self.fields["project_key"].widget = forms.TextInput(attrs={"placeholder": f"JIRA settings inherited from product '{product_name}'"}) + self.fields["project_key"].help_text = f"JIRA settings are inherited from product '{product_name}', unless configured differently here." + self.fields["jira_instance"].help_text = f"JIRA settings are inherited from product '{product_name}' , unless configured differently here." + + # if we don't have an instance, django will insert a blank empty one :-( + # so we have to check for id to make sure we only trigger this when there is a real instance from db + if self.instance.id: + logger.debug("jira project instance found for engagement, unchecking inherit checkbox") + self.fields["jira_instance"].required = True + self.fields["project_key"].required = True + self.initial["inherit_from_product"] = False + # once a jira project config is attached to an engagement, we can't go back to inheriting + # because the config needs to remain in place for the existing jira issues + self.fields["inherit_from_product"].disabled = True + self.fields["inherit_from_product"].help_text = "Once an engagement has a JIRA Project stored, you cannot switch back to inheritance to avoid breaking existing JIRA issues" + self.fields["jira_instance"].disabled = False + self.fields["project_key"].disabled = False + self.fields["issue_template_dir"].disabled = False + self.fields["epic_issue_type_name"].disabled = False + self.fields["component"].disabled = False + self.fields["custom_fields"].disabled = False + self.fields["default_assignee"].disabled = False + self.fields["jira_labels"].disabled = False + self.fields["enabled"].disabled = False + self.fields["add_vulnerability_id_to_jira_label"].disabled = False + self.fields["push_all_issues"].disabled = False + self.fields["enable_engagement_epic_mapping"].disabled = False + self.fields["push_notes"].disabled = False + self.fields["product_jira_sla_notification"].disabled = False + self.fields["risk_acceptance_expiration_notification"].disabled = False + + elif self.product: + logger.debug("setting jira project fields from product1") + self.initial["inherit_from_product"] = True + jira_project_product = jira_services.get_project(self.product) + # we have to check that we are not in a POST request where jira project config data is posted + # this is because initial values will overwrite the actual values entered by the user + # makes no sense, but seems to be accepted behaviour: https://code.djangoproject.com/ticket/30407 + if jira_project_product and (self.prefix + "-jira_instance") not in self.data: + logger.debug("setting jira project fields from product2") + self.initial["jira_instance"] = jira_project_product.jira_instance.id if jira_project_product.jira_instance else None + self.initial["project_key"] = jira_project_product.project_key + self.initial["issue_template_dir"] = jira_project_product.issue_template_dir + self.initial["epic_issue_type_name"] = jira_project_product.epic_issue_type_name + self.initial["component"] = jira_project_product.component + self.initial["custom_fields"] = jira_project_product.custom_fields + self.initial["default_assignee"] = jira_project_product.default_assignee + self.initial["jira_labels"] = jira_project_product.jira_labels + self.initial["enabled"] = jira_project_product.enabled + self.initial["add_vulnerability_id_to_jira_label"] = jira_project_product.add_vulnerability_id_to_jira_label + self.initial["push_all_issues"] = jira_project_product.push_all_issues + self.initial["enable_engagement_epic_mapping"] = jira_project_product.enable_engagement_epic_mapping + self.initial["push_notes"] = jira_project_product.push_notes + self.initial["product_jira_sla_notification"] = jira_project_product.product_jira_sla_notification + self.initial["risk_acceptance_expiration_notification"] = jira_project_product.risk_acceptance_expiration_notification + + self.fields["jira_instance"].disabled = True + self.fields["project_key"].disabled = True + self.fields["issue_template_dir"].disabled = True + self.fields["epic_issue_type_name"].disabled = True + self.fields["component"].disabled = True + self.fields["custom_fields"].disabled = True + self.fields["default_assignee"].disabled = True + self.fields["jira_labels"].disabled = True + self.fields["enabled"].disabled = True + self.fields["add_vulnerability_id_to_jira_label"].disabled = True + self.fields["push_all_issues"].disabled = True + self.fields["enable_engagement_epic_mapping"].disabled = True + self.fields["push_notes"].disabled = True + self.fields["product_jira_sla_notification"].disabled = True + self.fields["risk_acceptance_expiration_notification"].disabled = True + + else: + del self.fields["inherit_from_product"] + + # if we don't have an instance, django will insert a blank empty one :-( + # so we have to check for id to make sure we only trigger this when there is a real instance from db + if self.instance.id: + self.fields["jira_instance"].required = True + self.fields["project_key"].required = True + self.fields["epic_issue_type_name"].required = True + + def clean(self): + logger.debug("validating jira project form") + cleaned_data = super().clean() + + logger.debug("clean: inherit: %s", self.cleaned_data.get("inherit_from_product", False)) + if not self.cleaned_data.get("inherit_from_product", False): + jira_instance = self.cleaned_data.get("jira_instance") + project_key = self.cleaned_data.get("project_key") + epic_issue_type_name = self.cleaned_data.get("epic_issue_type_name") + + if project_key and jira_instance and epic_issue_type_name: + return cleaned_data + + if not project_key and not jira_instance and not epic_issue_type_name: + return cleaned_data + + if self.target == "engagement": + msg = "JIRA Project needs a JIRA Instance, JIRA Project Key, and Epic issue type name, or choose to inherit settings from product" + raise ValidationError(msg) + msg = "JIRA Project needs a JIRA Instance, JIRA Project Key, and Epic issue type name, leave empty to have no JIRA integration setup" + raise ValidationError(msg) + return None + + +class JIRAFindingForm(forms.Form): + def __init__(self, *args, **kwargs): + self.push_all = kwargs.pop("push_all", False) + self.instance = kwargs.pop("instance", None) + self.jira_project = kwargs.pop("jira_project", None) + # we provide the finding_form from the same page so we can add validation errors + # if the finding doesn't satisfy the rules to be pushed to JIRA + self.finding_form = kwargs.pop("finding_form", None) + + if self.instance is None and self.jira_project is None: + msg = "either and finding instance or jira_project is needed" + raise ValueError(msg) + + super().__init__(*args, **kwargs) + self.fields["push_to_jira"] = forms.BooleanField() + self.fields["push_to_jira"].required = False + if is_finding_groups_enabled(): + self.fields["push_to_jira"].help_text = "Checking this will overwrite content of your JIRA issue, or create one. If this finding is part of a Finding Group, the group will pushed instead of the finding." + else: + self.fields["push_to_jira"].help_text = "Checking this will overwrite content of your JIRA issue, or create one." + + self.fields["push_to_jira"].label = "Push to JIRA" + if self.push_all: + # This will show the checkbox as checked and greyed out, this way the user is aware + # that issues will be pushed to JIRA, given their product-level settings. + self.fields["push_to_jira"].help_text = ( + "Push all issues is enabled on this product. If you do not wish to push all issues" + " to JIRA, please disable Push all issues on this product." + ) + self.fields["push_to_jira"].widget.attrs["checked"] = "checked" + self.fields["push_to_jira"].disabled = True + + if self.instance: + if hasattr(self.instance, "has_jira_issue") and self.instance.has_jira_issue: + self.initial["jira_issue"] = self.instance.jira_issue.jira_key + self.fields["push_to_jira"].widget.attrs["checked"] = "checked" + if is_finding_groups_enabled(): + self.fields["jira_issue"].widget = forms.TextInput(attrs={"placeholder": "Leave empty and check push to jira to create a new JIRA issue for this finding, or the group this finding is in."}) + else: + self.fields["jira_issue"].widget = forms.TextInput(attrs={"placeholder": "Leave empty and check push to jira to create a new JIRA issue for this finding."}) + + if self.instance and hasattr(self.instance, "has_jira_group_issue") and self.instance.has_jira_group_issue: + self.fields["push_to_jira"].widget.attrs["checked"] = "checked" + self.fields["jira_issue"].help_text = "Changing the linked JIRA issue for finding groups is not (yet) supported." + self.initial["jira_issue"] = self.instance.finding_group.jira_issue.jira_key + self.fields["jira_issue"].disabled = True + + def clean(self): + logger.debug("jform clean") + super().clean() + jira_issue_key_new = self.cleaned_data.get("jira_issue") + finding = self.instance + jira_project = self.jira_project + + logger.debug("self.cleaned_data.push_to_jira: %s", self.cleaned_data.get("push_to_jira", None)) + + if self.cleaned_data.get("push_to_jira", None) and finding and finding.has_jira_group_issue: + can_be_pushed_to_jira, error_message, error_code = jira_services.can_be_pushed(finding.finding_group, self.finding_form) + if not can_be_pushed_to_jira: + self.add_error("push_to_jira", ValidationError(error_message, code=error_code)) + # for field in error_fields: + # self.finding_form.add_error(field, error) + + elif self.cleaned_data.get("push_to_jira", None) and finding: + can_be_pushed_to_jira, error_message, error_code = jira_services.can_be_pushed(finding, self.finding_form) + if not can_be_pushed_to_jira: + self.add_error("push_to_jira", ValidationError(error_message, code=error_code)) + # for field in error_fields: + # self.finding_form.add_error(field, error) + elif self.cleaned_data.get("push_to_jira", None): + active = self.finding_form["active"].value() + verified = self.finding_form["verified"].value() + if not active or (not verified and (get_system_setting("enforce_verified_status", True) or get_system_setting("enforce_verified_status_jira", True))): + logger.debug("Findings must be active and verified to be pushed to JIRA") + error_message = "Findings must be active and verified to be pushed to JIRA" + self.add_error("push_to_jira", ValidationError(error_message, code="not_active_or_verified")) + + if jira_issue_key_new and (not finding or not finding.has_jira_group_issue): + # when there is a group jira issue, we skip all the linking/unlinking as this is not supported (yet) + if finding: + # in theory there can multiple jira instances that have similar projects + # so checking by only the jira issue key can lead to false positives + # so we check also the jira internal id of the jira issue + # if the key and id are equal, it is probably the same jira instance and the same issue + # the database model is lacking some relations to also include the jira config name or url here + # and I don't want to change too much now. this should cover most usecases. + + jira_issue_need_to_exist = False + # changing jira link on finding + if finding.has_jira_issue and jira_issue_key_new != finding.jira_issue.jira_key: + jira_issue_need_to_exist = True + + # adding existing jira issue to finding without jira link + if not finding.has_jira_issue: + jira_issue_need_to_exist = True + + else: + jira_issue_need_to_exist = True + + if jira_issue_need_to_exist: + jira_issue_new = jira_services.jira_get_issue(jira_project, jira_issue_key_new) + if not jira_issue_new: + raise ValidationError("JIRA issue " + jira_issue_key_new + " does not exist or cannot be retrieved") + + logger.debug("checking if provided jira issue id already is linked to another finding") + jira_issues = JIRA_Issue.objects.filter(jira_id=jira_issue_new.id, jira_key=jira_issue_key_new).exclude(engagement__isnull=False) + + if self.instance: + # just be sure we exclude the finding that is being edited + jira_issues = jira_issues.exclude(finding=finding) + + if len(jira_issues) > 0: + raise ValidationError("JIRA issue " + jira_issue_key_new + " already linked to " + reverse("view_finding", args=(jira_issues[0].finding_id,))) + + jira_issue = forms.CharField(required=False, label="Linked JIRA Issue", + validators=[validators.RegexValidator( + regex=r"^[A-Z][A-Z_0-9]+-\d+$", + message="JIRA issue key must be in XXXX-nnnn format ([A-Z][A-Z_0-9]+-\\d+)")]) + push_to_jira = forms.BooleanField(required=False, label="Push to JIRA") + + +class JIRAImportScanForm(forms.Form): + def __init__(self, *args, **kwargs): + self.push_all = kwargs.pop("push_all", False) + + super().__init__(*args, **kwargs) + if self.push_all: + # This will show the checkbox as checked and greyed out, this way the user is aware + # that issues will be pushed to JIRA, given their product-level settings. + self.fields["push_to_jira"].help_text = ( + "Push all issues is enabled on this product. If you do not wish to push all issues" + " to JIRA, please disable Push all issues on this product." + ) + self.fields["push_to_jira"].widget.attrs["checked"] = "checked" + self.fields["push_to_jira"].disabled = True + + push_to_jira = forms.BooleanField(required=False, label="Push to JIRA", help_text="Checking this will create a new jira issue for each new finding.") + + +class JIRAEngagementForm(forms.Form): + prefix = "jira-epic-form" + + def __init__(self, *args, **kwargs): + self.instance = kwargs.pop("instance", None) + + super().__init__(*args, **kwargs) + + if self.instance: + if self.instance.has_jira_issue: + self.fields["push_to_jira"].widget.attrs["checked"] = "checked" + self.fields["push_to_jira"].label = "Update JIRA Epic" + self.fields["push_to_jira"].help_text = "Checking this will update the existing EPIC in JIRA." + + push_to_jira = forms.BooleanField(required=False, label="Create EPIC", help_text="Checking this will create an EPIC in JIRA for this engagement.") + epic_name = forms.CharField(max_length=200, required=False, help_text="EPIC name in JIRA. If not specified, it defaults to the engagement name") + epic_priority = forms.CharField(max_length=200, required=False, help_text="EPIC priority. If not specified, the JIRA default priority will be used") diff --git a/dojo/jira_link/helper.py b/dojo/jira/helper.py similarity index 100% rename from dojo/jira_link/helper.py rename to dojo/jira/helper.py diff --git a/dojo/jira/models.py b/dojo/jira/models.py new file mode 100644 index 00000000000..fe70c6ab3d0 --- /dev/null +++ b/dojo/jira/models.py @@ -0,0 +1,221 @@ +from django import forms +from django.conf import settings +from django.contrib import admin +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext as _ + + +class JIRA_Instance(models.Model): + configuration_name = models.CharField(max_length=2000, help_text=_("Enter a name to give to this configuration"), default="") + url = models.URLField(max_length=2000, verbose_name=_("JIRA URL"), help_text=_("For more information how to configure Jira, read the DefectDojo documentation.")) + username = models.CharField(max_length=2000, verbose_name=_("Username/Email"), help_text=_("Username or Email Address, see DefectDojo documentation for more information.")) + password = models.CharField(max_length=2000, verbose_name=_("Password/Token"), help_text=_("Password or API Token, see DefectDojo documentation for more information.")) + + if hasattr(settings, "JIRA_ISSUE_TYPE_CHOICES_CONFIG"): + default_issue_type_choices = settings.JIRA_ISSUE_TYPE_CHOICES_CONFIG + else: + default_issue_type_choices = ( + ("Task", "Task"), + ("Story", "Story"), + ("Epic", "Epic"), + ("Spike", "Spike"), + ("Bug", "Bug"), + ("Security", "Security"), + ) + default_issue_type = models.CharField(max_length=255, + choices=default_issue_type_choices, + default="Bug", + help_text=_("You can define extra issue types in settings.py")) + issue_template_dir = models.CharField(max_length=255, + null=True, + blank=True, + help_text=_("Choose the folder containing the Django templates used to render the JIRA issue description. These are stored in dojo/templates/issue-trackers. Leave empty to use the default jira_full templates.")) + epic_name_id = models.IntegerField(help_text=_("To obtain the 'Epic name id' visit https:///rest/api/2/field and search for Epic Name. Copy the number out of cf[number] and paste it here.")) + open_status_key = models.IntegerField(verbose_name=_("Reopen Transition ID"), help_text=_("Transition ID to Re-Open JIRA issues, visit https:///rest/api/latest/issue//transitions?expand=transitions.fields to find the ID for your JIRA instance")) + close_status_key = models.IntegerField(verbose_name=_("Close Transition ID"), help_text=_("Transition ID to Close JIRA issues, visit https:///rest/api/latest/issue//transitions?expand=transitions.fields to find the ID for your JIRA instance")) + info_mapping_severity = models.CharField(max_length=200, help_text=_("Maps to the 'Priority' field in Jira. For example: Info")) + low_mapping_severity = models.CharField(max_length=200, help_text=_("Maps to the 'Priority' field in Jira. For example: Low")) + medium_mapping_severity = models.CharField(max_length=200, help_text=_("Maps to the 'Priority' field in Jira. For example: Medium")) + high_mapping_severity = models.CharField(max_length=200, help_text=_("Maps to the 'Priority' field in Jira. For example: High")) + critical_mapping_severity = models.CharField(max_length=200, help_text=_("Maps to the 'Priority' field in Jira. For example: Critical")) + finding_text = models.TextField(null=True, blank=True, help_text=_("Additional text that will be added to the finding in Jira. For example including how the finding was created or who to contact for more information.")) + accepted_mapping_resolution = models.CharField(null=True, blank=True, max_length=300, verbose_name="Risk Accepted resolution mapping", help_text=_('JIRA issues that are closed in JIRA with one of these resolutions will result in the Finding becoming Risk Accepted in Defect Dojo. JIRA issues that are closed in JIRA with one of these resolutions will result in the Finding becoming Risk Accepted in Defect Dojo. The expiration time for this Risk Acceptance will be determined by the "Risk acceptance form default days" in "System Settings". This mapping is not used when Findings are pushed to JIRA. In that case the Risk Accepted Findings are closed in JIRA and JIRA sets the default resolution.')) + false_positive_mapping_resolution = models.CharField(null=True, blank=True, verbose_name="False Positive resolution mapping", max_length=300, help_text=_("JIRA issues that are closed in JIRA with one of these resolutions will result in the Finding being marked as False Positive Defect Dojo. This mapping is not used when Findings are pushed to JIRA. In that case the Finding is closed in JIRA and JIRA sets the default resolution.")) + global_jira_sla_notification = models.BooleanField(default=True, blank=False, verbose_name=_("Globally send SLA notifications as comment?"), help_text=_("This setting can be overidden at the Product level")) + finding_jira_sync = models.BooleanField(default=False, blank=False, verbose_name=_("Automatically sync Findings with JIRA?"), help_text=_("If enabled, this will sync changes to a Finding automatically to JIRA")) + + class Meta: + app_label = "dojo" + + def __str__(self): + return self.configuration_name + " | " + self.url + " | " + self.username + + @property + def accepted_resolutions(self): + return [m.strip() for m in (self.accepted_mapping_resolution or "").split(",")] + + @property + def false_positive_resolutions(self): + return [m.strip() for m in (self.false_positive_mapping_resolution or "").split(",")] + + def get_priority(self, status): + if status == "Info": + return self.info_mapping_severity + if status == "Low": + return self.low_mapping_severity + if status == "Medium": + return self.medium_mapping_severity + if status == "High": + return self.high_mapping_severity + if status == "Critical": + return self.critical_mapping_severity + return "N/A" + + +# declare form here as we can't import forms.py due to circular imports not even locally +class JIRAForm_Admin(forms.ModelForm): + password = forms.CharField(widget=forms.PasswordInput, required=True) + + # django doesn't seem to have an easy way to handle password fields as PasswordInput requires reentry of passwords + password_from_db = None + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.instance: + # keep password from db to use if the user entered no password + self.password_from_db = self.instance.password + self.fields["password"].required = False + + def clean(self): + cleaned_data = super().clean() + if not cleaned_data["password"]: + cleaned_data["password"] = self.password_from_db + + return cleaned_data + + +class JIRA_Instance_Admin(admin.ModelAdmin): + form = JIRAForm_Admin + + +class JIRA_Project(models.Model): + jira_instance = models.ForeignKey(JIRA_Instance, verbose_name=_("JIRA Instance"), + null=True, blank=True, on_delete=models.PROTECT) + project_key = models.CharField(max_length=200, blank=True) + product = models.ForeignKey("dojo.Product", on_delete=models.CASCADE, null=True) + issue_template_dir = models.CharField(max_length=255, + null=True, + blank=True, + help_text=_("Choose the folder containing the Django templates used to render the JIRA issue description. These are stored in dojo/templates/issue-trackers. Leave empty to use the default jira_full templates.")) + engagement = models.OneToOneField("dojo.Engagement", on_delete=models.CASCADE, null=True, blank=True) + component = models.CharField(max_length=200, blank=True) + custom_fields = models.JSONField(max_length=200, blank=True, null=True, + help_text=_('JIRA custom field JSON mapping of Id to value, e.g. {"customfield_10122": [{"name": "8.0.1"}]}')) + default_assignee = models.CharField(max_length=200, blank=True, null=True, + help_text=_("JIRA default assignee (name). If left blank then it defaults to whatever is configured in JIRA.")) + jira_labels = models.CharField(max_length=200, blank=True, null=True, + help_text=_("JIRA issue labels space seperated")) + add_vulnerability_id_to_jira_label = models.BooleanField(default=False, + verbose_name=_("Add vulnerability Id as a JIRA label"), + blank=False) + push_all_issues = models.BooleanField(default=False, blank=True, + help_text=_("Automatically create JIRA tickets for verified findings, assuming enforce_verified_status is True, or for all findings otherwise. Once linked, the JIRA ticket will continue to sync, regardless of status in DefectDojo.")) + enable_engagement_epic_mapping = models.BooleanField(default=False, + blank=True) + epic_issue_type_name = models.CharField(max_length=64, blank=True, default="Epic", help_text=_("The name of the of structure that represents an Epic")) + push_notes = models.BooleanField(default=False, blank=True) + product_jira_sla_notification = models.BooleanField(default=False, blank=True, verbose_name=_("Send SLA notifications as comment?")) + risk_acceptance_expiration_notification = models.BooleanField(default=False, blank=True, verbose_name=_("Send Risk Acceptance expiration notifications as comment?")) + enabled = models.BooleanField( + verbose_name=_("Enable Connection With Jira Project"), + help_text=_("When disabled, Findings will no longer be pushed to Jira, even if they have already been pushed previously."), + default=True, + blank=True) + + class Meta: + app_label = "dojo" + + def __str__(self): + value = f"{self.id}: {self.project_key} ({self.jira_instance.url if self.jira_instance else 'None'})" + if not self.enabled: + value += " - Not Connected" + return value + + def clean(self): + if not self.jira_instance: + msg = "Cannot save JIRA Project Configuration without JIRA Instance" + raise ValidationError(msg) + + +# declare form here as we can't import forms.py due to circular imports not even locally +class JIRAForm_Admin(forms.ModelForm): + password = forms.CharField(widget=forms.PasswordInput, required=True) + + # django doesn't seem to have an easy way to handle password fields as PasswordInput requires reentry of passwords + password_from_db = None + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.instance: + # keep password from db to use if the user entered no password + self.password_from_db = self.instance.password + self.fields["password"].required = False + + def clean(self): + cleaned_data = super().clean() + if not cleaned_data["password"]: + cleaned_data["password"] = self.password_from_db + + return cleaned_data + + +class JIRA_Conf_Admin(admin.ModelAdmin): + form = JIRAForm_Admin + + +class JIRA_Issue(models.Model): + jira_project = models.ForeignKey(JIRA_Project, on_delete=models.CASCADE, null=True) + jira_id = models.CharField(max_length=200) + jira_key = models.CharField(max_length=200) + finding = models.OneToOneField("dojo.Finding", null=True, blank=True, on_delete=models.CASCADE) + engagement = models.OneToOneField("dojo.Engagement", null=True, blank=True, on_delete=models.CASCADE) + finding_group = models.OneToOneField("dojo.Finding_Group", null=True, blank=True, on_delete=models.CASCADE) + + jira_creation = models.DateTimeField(editable=True, + null=True, + verbose_name=_("Jira creation"), + help_text=_("The date a Jira issue was created from this finding.")) + jira_change = models.DateTimeField(editable=True, + null=True, + verbose_name=_("Jira last update"), + help_text=_("The date the linked Jira issue was last modified.")) + + class Meta: + app_label = "dojo" + + def __str__(self): + text = "" + if self.finding: + text = self.finding.test.engagement.product.name + " | Finding: " + self.finding.title + ", ID: " + str(self.finding.id) + elif self.engagement: + text = self.engagement.product.name + " | Engagement: " + self.engagement.name + ", ID: " + str(self.engagement.id) + return text + " | Jira Key: " + str(self.jira_key) + + def set_obj(self, obj): + from dojo.models import Engagement, Finding, Finding_Group # noqa: PLC0415 circular import + if isinstance(obj, Finding): + self.finding = obj + elif isinstance(obj, Finding_Group): + self.finding_group = obj + elif isinstance(obj, Engagement): + self.engagement = obj + else: + from dojo.utils import to_str_typed # noqa: PLC0415 + msg = f"unknown object type while creating JIRA_Issue: {to_str_typed(obj)}" + raise TypeError(msg) + + +admin.site.register(JIRA_Issue) +admin.site.register(JIRA_Instance, JIRA_Instance_Admin) +admin.site.register(JIRA_Project) diff --git a/dojo/jira_link/queries.py b/dojo/jira/queries.py similarity index 100% rename from dojo/jira_link/queries.py rename to dojo/jira/queries.py diff --git a/dojo/jira/services.py b/dojo/jira/services.py new file mode 100644 index 00000000000..0a6a2aa9149 --- /dev/null +++ b/dojo/jira/services.py @@ -0,0 +1,477 @@ +""" +Service layer for Jira integration. + +Core code imports from here instead of from dojo.jira.helper directly so that +the helper module (which imports dojo.forms/models/utils) can be loaded +lazily, breaking the import cycle. +""" + +import logging + +logger = logging.getLogger(__name__) + + +def _get_helper(): + from dojo.jira import helper # noqa: PLC0415 — lazy to break import cycle with dojo.forms + return helper + + +# --------------------------------------------------------------------------- +# Mutation wrappers — delegate to dojo.jira.helper +# --------------------------------------------------------------------------- + +def push(obj, *args, **kwargs): + """ + Push a finding, finding group, or engagement to Jira. + + Wraps: jira_helper.push_to_jira + """ + return _get_helper().push_to_jira(obj, *args, **kwargs) + + +def add_comment(obj, note, *, force_push=False, **kwargs): + """ + Add a comment to a Jira issue. + + Wraps: jira_helper.add_comment + """ + return _get_helper().add_comment(obj, note, force_push=force_push, **kwargs) + + +def add_simple_comment(jira_instance, jira_issue, comment): + """ + Add a simple text comment to a Jira issue. + + Wraps: jira_helper.add_simple_jira_comment + """ + return _get_helper().add_simple_jira_comment(jira_instance, jira_issue, comment) + + +def add_comment_internal(jira_issue_id, note_id, *, force_push=False, **kwargs): + """ + Internal add comment by IDs. + + Wraps: jira_helper.add_comment_internal + """ + return _get_helper().add_comment_internal(jira_issue_id, note_id, force_push=force_push, **kwargs) + + +def get_epic_task(task_name): + """ + Return the raw Celery task for epic operations. + + Use with dojo_dispatch_task() when you need Celery task semantics. + """ + return getattr(_get_helper(), task_name, None) + + +def add_epic(engagement_id, **kwargs): + """ + Create a Jira epic for an engagement. + + Wraps: jira_helper.add_epic + """ + return _get_helper().add_epic(engagement_id, **kwargs) + + +def update_epic(engagement_id, **kwargs): + """ + Update a Jira epic for an engagement. + + Wraps: jira_helper.update_epic + """ + return _get_helper().update_epic(engagement_id, **kwargs) + + +def close_epic(engagement_id, push_to_jira, **kwargs): + """ + Close a Jira epic for an engagement. + + Wraps: jira_helper.close_epic + """ + return _get_helper().close_epic(engagement_id, push_to_jira, **kwargs) + + +def link_finding(request, finding, new_jira_issue_key): + """ + Link a finding to an existing Jira issue. + + Wraps: jira_helper.finding_link_jira + """ + return _get_helper().finding_link_jira(request, finding, new_jira_issue_key) + + +def unlink_finding(request, finding): + """ + Unlink a finding from its Jira issue. + + Wraps: jira_helper.finding_unlink_jira + """ + return _get_helper().finding_unlink_jira(request, finding) + + +def link_finding_group(request, finding_group, new_jira_issue_key): + """ + Link a finding group to an existing Jira issue. + + Wraps: jira_helper.finding_group_link_jira + """ + return _get_helper().finding_group_link_jira(request, finding_group, new_jira_issue_key) + + +def unlink(request, obj): + """ + Unlink an object from its Jira issue. + + Wraps: jira_helper.unlink_jira + """ + return _get_helper().unlink_jira(request, obj) + + +def push_status(obj, jira_instance, jira, issue, *, save=False): + """ + Push finding status to Jira. + + Wraps: jira_helper.push_status_to_jira + """ + return _get_helper().push_status_to_jira(obj, jira_instance, jira, issue, save=save) + + +def update_issue(obj, *args, **kwargs): + """ + Update a Jira issue. + + Wraps: jira_helper.update_jira_issue + """ + return _get_helper().update_jira_issue(obj, *args, **kwargs) + + +def process_project_form(request, instance=None, target=None, product=None, engagement=None): + """ + Process a Jira project configuration form. + + Wraps: jira_helper.process_jira_project_form + """ + return _get_helper().process_jira_project_form(request, instance=instance, target=target, + product=product, engagement=engagement) + + +def process_epic_form(request, engagement=None): + """ + Process a Jira epic form. + + Wraps: jira_helper.process_jira_epic_form + """ + return _get_helper().process_jira_epic_form(request, engagement=engagement) + + +def process_resolution_from_jira(finding, resolution_id, resolution_name, + assignee_name, jira_now, jira_issue, + finding_group=None): + """ + Process a resolution change from Jira webhook. + + Wraps: jira_helper.process_resolution_from_jira + """ + return _get_helper().process_resolution_from_jira( + finding, resolution_id, resolution_name, + assignee_name, jira_now, jira_issue, + finding_group=finding_group, + ) + + +# --------------------------------------------------------------------------- +# Query wrappers +# --------------------------------------------------------------------------- + +def is_enabled(): + """ + Check if Jira integration is enabled globally. + + Wraps: jira_helper.is_jira_enabled + """ + return _get_helper().is_jira_enabled() + + +def is_configured_and_enabled(obj): + """ + Check if Jira is configured and enabled for the given object. + + Wraps: jira_helper.is_jira_configured_and_enabled + """ + return _get_helper().is_jira_configured_and_enabled(obj) + + +def has_issue(obj): + """ + Check if the object has a linked Jira issue. + + Wraps: jira_helper.has_jira_issue + """ + return _get_helper().has_jira_issue(obj) + + +def has_configured(obj): + """ + Check if Jira is configured for the given object. + + Wraps: jira_helper.has_jira_configured + """ + return _get_helper().has_jira_configured(obj) + + +def get_project(obj, *, use_inheritance=True, jira_enabled=False): + """ + Get the Jira project configuration for an object. + + Wraps: jira_helper.get_jira_project + """ + return _get_helper().get_jira_project(obj, use_inheritance=use_inheritance, jira_enabled=jira_enabled) + + +def get_instance(obj, *, jira_enabled=False): + """ + Get the Jira instance for an object. + + Wraps: jira_helper.get_jira_instance + """ + return _get_helper().get_jira_instance(obj, jira_enabled=jira_enabled) + + +def get_issue(obj): + """ + Get the local JIRA_Issue record for an object. + + Wraps: jira_helper.get_jira_issue + """ + return _get_helper().get_jira_issue(obj) + + +def get_url(obj): + """ + Get the Jira URL for an object. + + Wraps: jira_helper.get_jira_url + """ + return _get_helper().get_jira_url(obj) + + +def get_issue_url(issue): + """ + Get the URL for a specific Jira issue. + + Wraps: jira_helper.get_jira_issue_url + """ + return _get_helper().get_jira_issue_url(issue) + + +def get_project_url(obj): + """ + Get the Jira project URL for an object. + + Wraps: jira_helper.get_jira_project_url + """ + return _get_helper().get_jira_project_url(obj) + + +def get_key(obj): + """ + Get the Jira issue key for an object. + + Wraps: jira_helper.get_jira_key + """ + return _get_helper().get_jira_key(obj) + + +def get_issue_key(obj): + """ + Get the Jira issue key. + + Wraps: jira_helper.get_jira_issue_key + """ + return _get_helper().get_jira_issue_key(obj) + + +def get_project_key(obj): + """ + Get the Jira project key. + + Wraps: jira_helper.get_jira_project_key + """ + return _get_helper().get_jira_project_key(obj) + + +def get_creation(obj): + """ + Get the Jira issue creation datetime. + + Wraps: jira_helper.get_jira_creation + """ + return _get_helper().get_jira_creation(obj) + + +def get_change(obj): + """ + Get the Jira issue last-changed datetime. + + Wraps: jira_helper.get_jira_change + """ + return _get_helper().get_jira_change(obj) + + +def is_push_all_issues(instance): + """ + Check if push_all_issues is enabled. + + Wraps: jira_helper.is_push_all_issues + """ + return _get_helper().is_push_all_issues(instance) + + +def is_keep_in_sync(obj, prefetched_jira_instance=None): + """ + Check if object should be kept in sync with Jira. + + Wraps: jira_helper.is_keep_in_sync_with_jira + """ + return _get_helper().is_keep_in_sync_with_jira(obj, prefetched_jira_instance=prefetched_jira_instance) + + +def is_push(instance, push_to_jira_parameter=None): + """ + Check if Jira push should happen. + + Wraps: jira_helper.is_push_to_jira + """ + return _get_helper().is_push_to_jira(instance, push_to_jira_parameter=push_to_jira_parameter) + + +def can_be_pushed(obj, form=None): + """ + Check if an object can be pushed to Jira. + + Returns (can_push, reason, error_code). + Wraps: jira_helper.can_be_pushed_to_jira + """ + return _get_helper().can_be_pushed_to_jira(obj, form=form) + + +def escape_text(text): + """ + Escape text for Jira formatting. + + Wraps: jira_helper.escape_for_jira + """ + return _get_helper().escape_for_jira(text) + + +def already_linked(finding, jira_issue_key, jira_id): + """ + Check if a finding is already linked to a Jira issue. + + Wraps: jira_helper.jira_already_linked + """ + return _get_helper().jira_already_linked(finding, jira_issue_key, jira_id) + + +def get_qualified_findings(finding_group): + """ + Get findings in a group that qualify for Jira. + + Wraps: jira_helper.get_qualified_findings + """ + return _get_helper().get_qualified_findings(finding_group) + + +def get_non_qualified_findings(finding_group): + """ + Get findings in a group that don't qualify for Jira. + + Wraps: jira_helper.get_non_qualified_findings + """ + return _get_helper().get_non_qualified_findings(finding_group) + + +def get_sla_deadline(obj): + """ + Get the SLA deadline for a Jira issue. + + Wraps: jira_helper.get_sla_deadline + """ + return _get_helper().get_sla_deadline(obj) + + +def get_severity(findings): + """ + Get the severity for a set of findings (Jira context). + + Wraps: jira_helper.get_severity + """ + return _get_helper().get_severity(findings) + + +def get_connection(obj): + """ + Get a Jira connection for the given object. + + Wraps: jira_helper.get_jira_connection + """ + return _get_helper().get_jira_connection(obj) + + +def get_connection_raw(jira_server, jira_username, jira_password): + """ + Get a raw Jira connection. + + Wraps: jira_helper.get_jira_connection_raw + """ + return _get_helper().get_jira_connection_raw(jira_server, jira_username, jira_password) + + +def get_issue_from_jira(find): + """ + Fetch a Jira issue from the Jira server. + + Wraps: jira_helper.get_jira_issue_from_jira + """ + return _get_helper().get_jira_issue_from_jira(find) + + +def issue_from_jira_is_active(issue_from_jira): + """ + Check if a Jira issue is in an active state. + + Wraps: jira_helper.issue_from_jira_is_active + """ + return _get_helper().issue_from_jira_is_active(issue_from_jira) + + +def jira_get_issue(jira_project, issue_key): + """ + Get a Jira issue by project and key. + + Wraps: jira_helper.jira_get_issue + """ + return _get_helper().jira_get_issue(jira_project, issue_key) + + +# --------------------------------------------------------------------------- +# Logging wrappers +# --------------------------------------------------------------------------- + +def log_cannot_be_pushed_reason(error, obj): + """ + Log the reason an object cannot be pushed to Jira. + + Wraps: jira_helper.log_jira_cannot_be_pushed_reason + """ + _get_helper().log_jira_cannot_be_pushed_reason(error, obj) + + +def log_message(text, finding): + """ + Log a Jira-related message. + + Wraps: jira_helper.log_jira_message + """ + _get_helper().log_jira_message(text, finding) diff --git a/dojo/jira_link/urls.py b/dojo/jira/urls.py similarity index 100% rename from dojo/jira_link/urls.py rename to dojo/jira/urls.py diff --git a/dojo/jira_link/views.py b/dojo/jira/views.py similarity index 99% rename from dojo/jira_link/views.py rename to dojo/jira/views.py index 061ad83d83c..908393d10ca 100644 --- a/dojo/jira_link/views.py +++ b/dojo/jira/views.py @@ -21,7 +21,7 @@ from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST -import dojo.jira_link.helper as jira_helper +import dojo.jira.helper as jira_helper from dojo.authorization.authorization import user_has_configuration_permission # Local application/library imports diff --git a/dojo/management/commands/jira_async_updates.py b/dojo/management/commands/jira_async_updates.py index 222671daa75..072635f86ca 100644 --- a/dojo/management/commands/jira_async_updates.py +++ b/dojo/management/commands/jira_async_updates.py @@ -3,7 +3,7 @@ from django.core.management.base import BaseCommand from jira.exceptions import JIRAError -import dojo.jira_link.helper as jira_helper +import dojo.jira.helper as jira_helper from dojo.models import Dojo_User, Finding, Notes, User from dojo.utils import get_system_setting, timezone diff --git a/dojo/management/commands/jira_refactor_data_migration.py b/dojo/management/commands/jira_refactor_data_migration.py index 6160882ec98..94db476d98b 100644 --- a/dojo/management/commands/jira_refactor_data_migration.py +++ b/dojo/management/commands/jira_refactor_data_migration.py @@ -2,7 +2,7 @@ from django.core.management.base import BaseCommand -import dojo.jira_link.helper as jira_helper +import dojo.jira.helper as jira_helper from dojo.models import JIRA_Instance, JIRA_Issue logger = logging.getLogger(__name__) diff --git a/dojo/management/commands/jira_status_reconciliation.py b/dojo/management/commands/jira_status_reconciliation.py index 2b8a649ed9f..57a7e79d43d 100644 --- a/dojo/management/commands/jira_status_reconciliation.py +++ b/dojo/management/commands/jira_status_reconciliation.py @@ -8,7 +8,7 @@ from django.utils import timezone from django.utils.dateparse import parse_datetime -import dojo.jira_link.helper as jira_helper +import dojo.jira.helper as jira_helper from dojo.models import Engagement, Finding, Finding_Group, Product logger = logging.getLogger(__name__) @@ -327,7 +327,7 @@ def _reconcile_finding_groups(mode, product_obj, engagement_obj, timestamp, dryr if action == "import_status_from_jira": # Import status from JIRA to all findings in the group - # Same pattern as the JIRA webhook handler in dojo/jira_link/views.py + # Same pattern as the JIRA webhook handler in dojo/jira/views.py any_status_changed = False for find in group_findings: if not dryrun: diff --git a/dojo/management/commands/push_to_jira_update.py b/dojo/management/commands/push_to_jira_update.py index babd2438625..18a7a75c030 100644 --- a/dojo/management/commands/push_to_jira_update.py +++ b/dojo/management/commands/push_to_jira_update.py @@ -2,7 +2,7 @@ from django.core.management.base import BaseCommand -import dojo.jira_link.helper as jira_helper +import dojo.jira.helper as jira_helper from dojo.models import Finding from dojo.utils import get_system_setting diff --git a/dojo/models.py b/dojo/models.py index bcfb39180a4..e80e22aa099 100644 --- a/dojo/models.py +++ b/dojo/models.py @@ -1349,8 +1349,8 @@ def open_findings_list(self): @property def has_jira_configured(self): - import dojo.jira_link.helper as jira_helper # noqa: PLC0415 circular import - return jira_helper.has_jira_configured(self) + from dojo.jira import services as jira_services # noqa: PLC0415 circular import + return jira_services.has_configured(self) def violates_sla(self): findings = Finding.objects.filter(test__engagement__product=self, @@ -1637,8 +1637,8 @@ def accept_risks(self, accepted_risks): @property def has_jira_issue(self): - import dojo.jira_link.helper as jira_helper # noqa: PLC0415 circular import - return jira_helper.has_jira_issue(self) + from dojo.jira import services as jira_services # noqa: PLC0415 circular import + return jira_services.has_issue(self) @property def is_ci_cd(self): @@ -3360,8 +3360,8 @@ def github_conf_new(self): @property def has_jira_issue(self): - import dojo.jira_link.helper as jira_helper # noqa: PLC0415 circular import - return jira_helper.has_jira_issue(self) + from dojo.jira import services as jira_services # noqa: PLC0415 circular import + return jira_services.has_issue(self) @cached_property def finding_group(self): @@ -3373,13 +3373,13 @@ def has_jira_group_issue(self): if not self.has_finding_group: return False - import dojo.jira_link.helper as jira_helper # noqa: PLC0415 circular import - return jira_helper.has_jira_issue(self.finding_group) + from dojo.jira import services as jira_services # noqa: PLC0415 circular import + return jira_services.has_issue(self.finding_group) @property def has_jira_configured(self): - import dojo.jira_link.helper as jira_helper # noqa: PLC0415 circular import - return jira_helper.has_jira_configured(self) + from dojo.jira import services as jira_services # noqa: PLC0415 circular import + return jira_services.has_configured(self) @cached_property def has_finding_group(self): @@ -3701,8 +3701,8 @@ def __str__(self): @property def has_jira_issue(self): - import dojo.jira_link.helper as jira_helper # noqa: PLC0415 circular import - return jira_helper.has_jira_issue(self) + from dojo.jira import services as jira_services # noqa: PLC0415 circular import + return jira_services.has_issue(self) @cached_property def severity(self): @@ -4125,204 +4125,12 @@ def __str__(self): return self.product.name + " | " + self.git_project -class JIRA_Instance(models.Model): - configuration_name = models.CharField(max_length=2000, help_text=_("Enter a name to give to this configuration"), default="") - url = models.URLField(max_length=2000, verbose_name=_("JIRA URL"), help_text=_("For more information how to configure Jira, read the DefectDojo documentation.")) - username = models.CharField(max_length=2000, verbose_name=_("Username/Email"), help_text=_("Username or Email Address, see DefectDojo documentation for more information.")) - password = models.CharField(max_length=2000, verbose_name=_("Password/Token"), help_text=_("Password or API Token, see DefectDojo documentation for more information.")) - - if hasattr(settings, "JIRA_ISSUE_TYPE_CHOICES_CONFIG"): - default_issue_type_choices = settings.JIRA_ISSUE_TYPE_CHOICES_CONFIG - else: - default_issue_type_choices = ( - ("Task", "Task"), - ("Story", "Story"), - ("Epic", "Epic"), - ("Spike", "Spike"), - ("Bug", "Bug"), - ("Security", "Security"), - ) - default_issue_type = models.CharField(max_length=255, - choices=default_issue_type_choices, - default="Bug", - help_text=_("You can define extra issue types in settings.py")) - issue_template_dir = models.CharField(max_length=255, - null=True, - blank=True, - help_text=_("Choose the folder containing the Django templates used to render the JIRA issue description. These are stored in dojo/templates/issue-trackers. Leave empty to use the default jira_full templates.")) - epic_name_id = models.IntegerField(help_text=_("To obtain the 'Epic name id' visit https:///rest/api/2/field and search for Epic Name. Copy the number out of cf[number] and paste it here.")) - open_status_key = models.IntegerField(verbose_name=_("Reopen Transition ID"), help_text=_("Transition ID to Re-Open JIRA issues, visit https:///rest/api/latest/issue//transitions?expand=transitions.fields to find the ID for your JIRA instance")) - close_status_key = models.IntegerField(verbose_name=_("Close Transition ID"), help_text=_("Transition ID to Close JIRA issues, visit https:///rest/api/latest/issue//transitions?expand=transitions.fields to find the ID for your JIRA instance")) - info_mapping_severity = models.CharField(max_length=200, help_text=_("Maps to the 'Priority' field in Jira. For example: Info")) - low_mapping_severity = models.CharField(max_length=200, help_text=_("Maps to the 'Priority' field in Jira. For example: Low")) - medium_mapping_severity = models.CharField(max_length=200, help_text=_("Maps to the 'Priority' field in Jira. For example: Medium")) - high_mapping_severity = models.CharField(max_length=200, help_text=_("Maps to the 'Priority' field in Jira. For example: High")) - critical_mapping_severity = models.CharField(max_length=200, help_text=_("Maps to the 'Priority' field in Jira. For example: Critical")) - finding_text = models.TextField(null=True, blank=True, help_text=_("Additional text that will be added to the finding in Jira. For example including how the finding was created or who to contact for more information.")) - accepted_mapping_resolution = models.CharField(null=True, blank=True, max_length=300, verbose_name="Risk Accepted resolution mapping", help_text=_('JIRA issues that are closed in JIRA with one of these resolutions will result in the Finding becoming Risk Accepted in Defect Dojo. JIRA issues that are closed in JIRA with one of these resolutions will result in the Finding becoming Risk Accepted in Defect Dojo. The expiration time for this Risk Acceptance will be determined by the "Risk acceptance form default days" in "System Settings". This mapping is not used when Findings are pushed to JIRA. In that case the Risk Accepted Findings are closed in JIRA and JIRA sets the default resolution.')) - false_positive_mapping_resolution = models.CharField(null=True, blank=True, verbose_name="False Positive resolution mapping", max_length=300, help_text=_("JIRA issues that are closed in JIRA with one of these resolutions will result in the Finding being marked as False Positive Defect Dojo. This mapping is not used when Findings are pushed to JIRA. In that case the Finding is closed in JIRA and JIRA sets the default resolution.")) - global_jira_sla_notification = models.BooleanField(default=True, blank=False, verbose_name=_("Globally send SLA notifications as comment?"), help_text=_("This setting can be overidden at the Product level")) - finding_jira_sync = models.BooleanField(default=False, blank=False, verbose_name=_("Automatically sync Findings with JIRA?"), help_text=_("If enabled, this will sync changes to a Finding automatically to JIRA")) - - def __str__(self): - return self.configuration_name + " | " + self.url + " | " + self.username - - @property - def accepted_resolutions(self): - return [m.strip() for m in (self.accepted_mapping_resolution or "").split(",")] - - @property - def false_positive_resolutions(self): - return [m.strip() for m in (self.false_positive_mapping_resolution or "").split(",")] - - def get_priority(self, status): - if status == "Info": - return self.info_mapping_severity - if status == "Low": - return self.low_mapping_severity - if status == "Medium": - return self.medium_mapping_severity - if status == "High": - return self.high_mapping_severity - if status == "Critical": - return self.critical_mapping_severity - return "N/A" - - -# declare form here as we can't import forms.py due to circular imports not even locally -class JIRAForm_Admin(forms.ModelForm): - password = forms.CharField(widget=forms.PasswordInput, required=True) - - # django doesn't seem to have an easy way to handle password fields as PasswordInput requires reentry of passwords - password_from_db = None - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if self.instance: - # keep password from db to use if the user entered no password - self.password_from_db = self.instance.password - self.fields["password"].required = False - - def clean(self): - cleaned_data = super().clean() - if not cleaned_data["password"]: - cleaned_data["password"] = self.password_from_db - - return cleaned_data - - -class JIRA_Instance_Admin(admin.ModelAdmin): - form = JIRAForm_Admin - - -class JIRA_Project(models.Model): - jira_instance = models.ForeignKey(JIRA_Instance, verbose_name=_("JIRA Instance"), - null=True, blank=True, on_delete=models.PROTECT) - project_key = models.CharField(max_length=200, blank=True) - product = models.ForeignKey(Product, on_delete=models.CASCADE, null=True) - issue_template_dir = models.CharField(max_length=255, - null=True, - blank=True, - help_text=_("Choose the folder containing the Django templates used to render the JIRA issue description. These are stored in dojo/templates/issue-trackers. Leave empty to use the default jira_full templates.")) - engagement = models.OneToOneField(Engagement, on_delete=models.CASCADE, null=True, blank=True) - component = models.CharField(max_length=200, blank=True) - custom_fields = models.JSONField(max_length=200, blank=True, null=True, - help_text=_('JIRA custom field JSON mapping of Id to value, e.g. {"customfield_10122": [{"name": "8.0.1"}]}')) - default_assignee = models.CharField(max_length=200, blank=True, null=True, - help_text=_("JIRA default assignee (name). If left blank then it defaults to whatever is configured in JIRA.")) - jira_labels = models.CharField(max_length=200, blank=True, null=True, - help_text=_("JIRA issue labels space seperated")) - add_vulnerability_id_to_jira_label = models.BooleanField(default=False, - verbose_name=_("Add vulnerability Id as a JIRA label"), - blank=False) - push_all_issues = models.BooleanField(default=False, blank=True, - help_text=_("Automatically create JIRA tickets for verified findings, assuming enforce_verified_status is True, or for all findings otherwise. Once linked, the JIRA ticket will continue to sync, regardless of status in DefectDojo.")) - enable_engagement_epic_mapping = models.BooleanField(default=False, - blank=True) - epic_issue_type_name = models.CharField(max_length=64, blank=True, default="Epic", help_text=_("The name of the of structure that represents an Epic")) - push_notes = models.BooleanField(default=False, blank=True) - product_jira_sla_notification = models.BooleanField(default=False, blank=True, verbose_name=_("Send SLA notifications as comment?")) - risk_acceptance_expiration_notification = models.BooleanField(default=False, blank=True, verbose_name=_("Send Risk Acceptance expiration notifications as comment?")) - enabled = models.BooleanField( - verbose_name=_("Enable Connection With Jira Project"), - help_text=_("When disabled, Findings will no longer be pushed to Jira, even if they have already been pushed previously."), - default=True, - blank=True) - - def __str__(self): - value = f"{self.id}: {self.project_key} ({self.jira_instance.url if self.jira_instance else 'None'})" - if not self.enabled: - value += " - Not Connected" - return value - - def clean(self): - if not self.jira_instance: - msg = "Cannot save JIRA Project Configuration without JIRA Instance" - raise ValidationError(msg) - - -# declare form here as we can't import forms.py due to circular imports not even locally -class JIRAForm_Admin(forms.ModelForm): - password = forms.CharField(widget=forms.PasswordInput, required=True) - - # django doesn't seem to have an easy way to handle password fields as PasswordInput requires reentry of passwords - password_from_db = None - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if self.instance: - # keep password from db to use if the user entered no password - self.password_from_db = self.instance.password - self.fields["password"].required = False - - def clean(self): - cleaned_data = super().clean() - if not cleaned_data["password"]: - cleaned_data["password"] = self.password_from_db - - return cleaned_data - - -class JIRA_Conf_Admin(admin.ModelAdmin): - form = JIRAForm_Admin - - -class JIRA_Issue(models.Model): - jira_project = models.ForeignKey(JIRA_Project, on_delete=models.CASCADE, null=True) - jira_id = models.CharField(max_length=200) - jira_key = models.CharField(max_length=200) - finding = models.OneToOneField(Finding, null=True, blank=True, on_delete=models.CASCADE) - engagement = models.OneToOneField(Engagement, null=True, blank=True, on_delete=models.CASCADE) - finding_group = models.OneToOneField(Finding_Group, null=True, blank=True, on_delete=models.CASCADE) - - jira_creation = models.DateTimeField(editable=True, - null=True, - verbose_name=_("Jira creation"), - help_text=_("The date a Jira issue was created from this finding.")) - jira_change = models.DateTimeField(editable=True, - null=True, - verbose_name=_("Jira last update"), - help_text=_("The date the linked Jira issue was last modified.")) - - def __str__(self): - text = "" - if self.finding: - text = self.finding.test.engagement.product.name + " | Finding: " + self.finding.title + ", ID: " + str(self.finding.id) - elif self.engagement: - text = self.engagement.product.name + " | Engagement: " + self.engagement.name + ", ID: " + str(self.engagement.id) - return text + " | Jira Key: " + str(self.jira_key) - - def set_obj(self, obj): - if isinstance(obj, Finding): - self.finding = obj - elif isinstance(obj, Finding_Group): - self.finding_group = obj - elif isinstance(obj, Engagement): - self.engagement = obj - else: - msg = f"unknown object type while creating JIRA_Issue: {to_str_typed(obj)}" - raise TypeError(msg) - +from dojo.jira.models import ( # noqa: E402,F401 backward compat + JIRA_Instance, + JIRA_Instance_Admin, + JIRA_Issue, + JIRA_Project, +) NOTIFICATION_CHOICE_SLACK = ("slack", "slack") NOTIFICATION_CHOICE_MSTEAMS = ("msteams", "msteams") @@ -4917,7 +4725,6 @@ def __str__(self): from dojo.utils import ( # noqa: E402 # there is issue due to a circular import parse_cvss_data, - to_str_typed, ) tagulous.admin.register(Product.tags) @@ -4969,9 +4776,6 @@ def __str__(self): admin.site.register(Notes) admin.site.register(Note_Type) admin.site.register(Alerts) -admin.site.register(JIRA_Issue) -admin.site.register(JIRA_Instance, JIRA_Instance_Admin) -admin.site.register(JIRA_Project) admin.site.register(GITHUB_Conf) admin.site.register(GITHUB_Issue) admin.site.register(GITHUB_Clone) diff --git a/dojo/product/views.py b/dojo/product/views.py index 082b1ee4719..8f74c74c6ae 100644 --- a/dojo/product/views.py +++ b/dojo/product/views.py @@ -26,7 +26,6 @@ from github import Github import dojo.finding.helper as finding_helper -import dojo.jira_link.helper as jira_helper from dojo.authorization.authorization import user_has_permission, user_has_permission_or_403 from dojo.authorization.authorization_decorators import user_is_authorized from dojo.authorization.roles_permissions import Permissions @@ -70,6 +69,7 @@ ProductNotificationsForm, SLA_Configuration, ) +from dojo.jira import services as jira_services from dojo.labels import get_labels from dojo.models import ( App_Analysis, @@ -956,7 +956,7 @@ def new_product(request, ptid=None): messages.SUCCESS, labels.ASSET_CREATE_SUCCESS_MESSAGE, extra_tags="alert-success") - success, jira_project_form = jira_helper.process_jira_project_form(request, product=product) + success, jira_project_form = jira_services.process_project_form(request, product=product) error = not success if get_system_setting("enable_github"): @@ -1027,7 +1027,7 @@ def edit_product(request, pid): if request.method == "POST": form = ProductForm(request.POST, instance=product) - jira_project = jira_helper.get_jira_project(product) + jira_project = jira_services.get_project(product) if form.is_valid(): initial_sla_config = Product.objects.get(pk=form.instance.id).sla_configuration form.save() @@ -1040,7 +1040,7 @@ def edit_product(request, pid): msg, extra_tags="alert-success") - success, jform = jira_helper.process_jira_project_form(request, instance=jira_project, product=product) + success, jform = jira_services.process_project_form(request, instance=jira_project, product=product) error = not success if get_system_setting("enable_github") and github_inst: @@ -1064,7 +1064,7 @@ def edit_product(request, pid): form = ProductForm(instance=product) if jira_enabled: - jira_project = jira_helper.get_jira_project(product) + jira_project = jira_services.get_project(product) jform = JIRAProjectForm(instance=jira_project) else: jform = None @@ -1168,13 +1168,13 @@ def new_eng_for_app(request, pid, *, cicd=False): logger.debug("new_eng_for_app: process jira coming") # new engagement, so do not provide jira_project - success, jira_project_form = jira_helper.process_jira_project_form(request, instance=None, + success, jira_project_form = jira_services.process_project_form(request, instance=None, engagement=engagement) error = not success logger.debug("new_eng_for_app: process jira epic coming") - success, jira_epic_form = jira_helper.process_jira_epic_form(request, engagement=engagement) + success, jira_epic_form = jira_services.process_epic_form(request, engagement=engagement) error = error or not success messages.add_message(request, @@ -1375,12 +1375,12 @@ def get_finding_form(self, request: HttpRequest, product: Product): def get_jira_form(self, request: HttpRequest, test: Test, finding_form: AdHocFindingForm = None): # Determine if jira should be used - if (jira_project := jira_helper.get_jira_project(test)) is not None: + if (jira_project := jira_services.get_project(test)) is not None: # Set up the args for the form args = [request.POST] if request.method == "POST" else [] # Set the initial form args kwargs = { - "push_all": jira_helper.is_push_all_issues(test), + "push_all": jira_services.is_push_all_issues(test), "prefix": "jiraform", "jira_project": jira_project, "finding_form": finding_form, @@ -1399,7 +1399,7 @@ def get_github_form(self, request: HttpRequest, test: Test): args = [request.POST] if request.method == "POST" else [] # Set the initial form args kwargs = { - "enabled": jira_helper.is_push_all_issues(test), + "enabled": jira_services.is_push_all_issues(test), "prefix": "githubform", } @@ -1462,7 +1462,7 @@ def process_jira_form(self, request: HttpRequest, finding: Finding, context: dic if context["jform"] and context["jform"].is_valid(): # Push to Jira? logger.debug("jira form valid") - push_to_jira = jira_helper.is_push_all_issues(finding) or context["jform"].cleaned_data.get("push_to_jira") + push_to_jira = jira_services.is_push_all_issues(finding) or context["jform"].cleaned_data.get("push_to_jira") jira_message = None # if the jira issue key was changed, update database new_jira_issue_key = context["jform"].cleaned_data.get("jira_issue") @@ -1472,19 +1472,19 @@ def process_jira_form(self, request: HttpRequest, finding: Finding, context: dic # I have no idea why, but it means we have to retrieve the issue from JIRA to get the internal JIRA id. # we can assume the issue exist, which is already checked in the validation of the jform if not new_jira_issue_key: - jira_helper.finding_unlink_jira(request, finding) + jira_services.unlink_finding(request, finding) jira_message = "Link to JIRA issue removed successfully." elif new_jira_issue_key != finding.jira_issue.jira_key: - jira_helper.finding_unlink_jira(request, finding) - jira_helper.finding_link_jira(request, finding, new_jira_issue_key) + jira_services.unlink_finding(request, finding) + jira_services.link_finding(request, finding, new_jira_issue_key) jira_message = "Changed JIRA link successfully." else: logger.debug("finding has no jira issue yet") if new_jira_issue_key: logger.debug( "finding has no jira issue yet, but jira issue specified in request. trying to link.") - jira_helper.finding_link_jira(request, finding, new_jira_issue_key) + jira_services.link_finding(request, finding, new_jira_issue_key) jira_message = "Linked a JIRA issue successfully." # Determine if a message should be added if jira_message: diff --git a/dojo/risk_acceptance/helper.py b/dojo/risk_acceptance/helper.py index 1ce170deabb..b1c21512f42 100644 --- a/dojo/risk_acceptance/helper.py +++ b/dojo/risk_acceptance/helper.py @@ -7,9 +7,8 @@ from django.urls import reverse from django.utils import timezone -import dojo.jira_link.helper as jira_helper from dojo.celery import app -from dojo.jira_link.helper import escape_for_jira +from dojo.jira import services as jira_services from dojo.models import Dojo_User, Finding, Notes, Risk_Acceptance, System_Settings from dojo.notifications.helper import create_notification from dojo.utils import get_full_url, get_system_setting @@ -35,9 +34,9 @@ def expire_now(risk_acceptance): finding.sla_start_date = timezone.now().date() # this method both saves and pushed to JIRA (no other post processing) finding.save(dedupe_option=False) - if jira_helper.is_push_all_issues(finding) or jira_helper.is_keep_in_sync_with_jira(finding): + if jira_services.is_push_all_issues(finding) or jira_services.is_keep_in_sync(finding): logger.info("pushing finding to JIRA after expiration of risk acceptance") - jira_helper.push_to_jira(finding) + jira_services.push(finding) reactivated_findings.append(finding) else: @@ -77,9 +76,9 @@ def reinstate(risk_acceptance, old_expiration_date): update_endpoint_statuses(finding, accept_risk=True) # this method both saves and pushed to JIRA (no other post processing) finding.save(dedupe_option=False) - if jira_helper.is_push_all_issues(finding) or jira_helper.is_keep_in_sync_with_jira(finding): + if jira_services.is_push_all_issues(finding) or jira_services.is_keep_in_sync(finding): logger.info("pushing finding to JIRA after reinstating risk acceptance") - jira_helper.push_to_jira(finding) + jira_services.push(finding) reinstated_findings.append(finding) else: logger.debug("%i:%s: already inactive, not making any changes", finding.id, finding) @@ -121,9 +120,9 @@ def remove_finding_from_risk_acceptance(user: Dojo_User, risk_acceptance: Risk_A update_endpoint_statuses(finding, accept_risk=False) # this method both saves and pushed to JIRA (no other post processing) finding.save(dedupe_option=False) - if jira_helper.is_push_all_issues(finding) or jira_helper.is_keep_in_sync_with_jira(finding): + if jira_services.is_push_all_issues(finding) or jira_services.is_keep_in_sync(finding): logger.info("pushing finding to JIRA after removal from risk acceptance") - jira_helper.push_to_jira(finding) + jira_services.push(finding) # best effort jira integration, no status changes post_jira_comments(risk_acceptance, [finding], unaccepted_message_creator) @@ -149,9 +148,9 @@ def add_findings_to_risk_acceptance(user: Dojo_User, risk_acceptance: Risk_Accep risk_acceptance.accepted_findings.add(finding) - if jira_helper.is_push_all_issues(finding) or jira_helper.is_keep_in_sync_with_jira(finding): + if jira_services.is_push_all_issues(finding) or jira_services.is_keep_in_sync(finding): logger.info("pushing finding to JIRA after adding to risk acceptance") - jira_helper.push_to_jira(finding) + jira_services.push(finding) # Add a note to reflect that the finding was removed from the risk acceptance if user is not None: @@ -226,21 +225,21 @@ def get_view_risk_acceptance(risk_acceptance: Risk_Acceptance) -> str: def expiration_message_creator(risk_acceptance, heads_up_days=0): return "Risk acceptance [({})|{}] with {} findings has expired".format( - escape_for_jira(risk_acceptance.name), + jira_services.escape_text(risk_acceptance.name), get_full_url(reverse("view_risk_acceptance", args=(risk_acceptance.engagement.id, risk_acceptance.id))), len(risk_acceptance.accepted_findings.all())) def expiration_warning_message_creator(risk_acceptance, heads_up_days=0): return "Risk acceptance [({})|{}] with {} findings will expire in {} days".format( - escape_for_jira(risk_acceptance.name), + jira_services.escape_text(risk_acceptance.name), get_full_url(reverse("view_risk_acceptance", args=(risk_acceptance.engagement.id, risk_acceptance.id))), len(risk_acceptance.accepted_findings.all()), heads_up_days) def reinstation_message_creator(risk_acceptance, heads_up_days=0): return "Risk acceptance [({})|{}] with {} findings has been reinstated (expires on {})".format( - escape_for_jira(risk_acceptance.name), + jira_services.escape_text(risk_acceptance.name), get_full_url(reverse("view_risk_acceptance", args=(risk_acceptance.engagement.id, risk_acceptance.id))), len(risk_acceptance.accepted_findings.all()), timezone.localtime(risk_acceptance.expiration_date).strftime("%b %d, %Y")) @@ -248,7 +247,7 @@ def reinstation_message_creator(risk_acceptance, heads_up_days=0): def accepted_message_creator(risk_acceptance, heads_up_days=0): if risk_acceptance: return "Finding has been added to risk acceptance [({})|{}] with {} findings (expires on {})".format( - escape_for_jira(risk_acceptance.name), + jira_services.escape_text(risk_acceptance.name), get_full_url(reverse("view_risk_acceptance", args=(risk_acceptance.engagement.id, risk_acceptance.id))), len(risk_acceptance.accepted_findings.all()), timezone.localtime(risk_acceptance.expiration_date).strftime("%b %d, %Y")) return "Finding has been risk accepted" @@ -256,7 +255,7 @@ def accepted_message_creator(risk_acceptance, heads_up_days=0): def unaccepted_message_creator(risk_acceptance, heads_up_days=0): if risk_acceptance: - return "finding was unaccepted/deleted from risk acceptance [({})|{}]".format(escape_for_jira(risk_acceptance.name), + return "finding was unaccepted/deleted from risk acceptance [({})|{}]".format(jira_services.escape_text(risk_acceptance.name), get_full_url(reverse("view_risk_acceptance", args=(risk_acceptance.engagement.id, risk_acceptance.id)))) return "Finding is no longer risk accepted" @@ -264,10 +263,10 @@ def unaccepted_message_creator(risk_acceptance, heads_up_days=0): def post_jira_comment(finding, message_factory, heads_up_days=0): if not finding or (not finding.has_jira_issue and not finding.has_jira_group_issue): return - jira_project = jira_helper.get_jira_project(finding) + jira_project = jira_services.get_project(finding) if jira_project and jira_project.risk_acceptance_expiration_notification: - jira_instance = jira_helper.get_jira_instance(finding) + jira_instance = jira_services.get_instance(finding) if jira_instance: jira_comment = message_factory(None, heads_up_days) @@ -277,17 +276,17 @@ def post_jira_comment(finding, message_factory, heads_up_days=0): jira_issue = finding.jira_issue elif finding.has_jira_group_issue: jira_issue = finding.finding_group.jira_issue - jira_helper.add_simple_jira_comment(jira_instance, jira_issue, jira_comment) + jira_services.add_simple_comment(jira_instance, jira_issue, jira_comment) def post_jira_comments(risk_acceptance, findings, message_factory, heads_up_days=0): if not risk_acceptance: return - jira_project = jira_helper.get_jira_project(risk_acceptance.engagement) + jira_project = jira_services.get_project(risk_acceptance.engagement) if jira_project and jira_project.risk_acceptance_expiration_notification: - jira_instance = jira_helper.get_jira_instance(risk_acceptance.engagement) + jira_instance = jira_services.get_instance(risk_acceptance.engagement) if jira_instance: jira_comment = message_factory(risk_acceptance, heads_up_days) @@ -299,7 +298,7 @@ def post_jira_comments(risk_acceptance, findings, message_factory, heads_up_days jira_issue = finding.finding_group.jira_issue if jira_issue: - jira_helper.add_simple_jira_comment(jira_instance, jira_issue, jira_comment) + jira_services.add_simple_comment(jira_instance, jira_issue, jira_comment) def get_expired_risk_acceptances_to_handle(): @@ -335,8 +334,8 @@ def simple_risk_accept(user: Dojo_User, finding: Finding, *, perform_save=True) finding.save(dedupe_option=False) # post_jira_comment might reload from database so see unaccepted finding. but the comment # only contains some text so that's ok - if jira_helper.is_push_all_issues(finding) or jira_helper.is_keep_in_sync_with_jira(finding): - jira_helper.push_to_jira(finding) + if jira_services.is_push_all_issues(finding) or jira_services.is_keep_in_sync(finding): + jira_services.push(finding) post_jira_comment(finding, accepted_message_creator) # Add a note to reflect that the finding was removed from the risk acceptance @@ -368,8 +367,8 @@ def risk_unaccept(user: Dojo_User, finding: Finding, *, perform_save=True, post_ post_jira_comment(finding, unaccepted_message_creator) # Update the JIRA obect for this finding - if jira_helper.is_push_all_issues(finding) or jira_helper.is_keep_in_sync_with_jira(finding): - jira_helper.push_to_jira(finding) + if jira_services.is_push_all_issues(finding) or jira_services.is_keep_in_sync(finding): + jira_services.push(finding) # Add a note to reflect that the finding was removed from the risk acceptance if user is not None: diff --git a/dojo/tasks.py b/dojo/tasks.py index d1f90275dc2..dbc2135e560 100644 --- a/dojo/tasks.py +++ b/dojo/tasks.py @@ -202,6 +202,9 @@ def async_sla_compute_and_notify_task(*args, **kwargs): @app.task def jira_status_reconciliation_task(*args, **kwargs): + if jira_status_reconciliation is None: + logger.warning("Jira status reconciliation is not available") + return None # Wrap with pghistory context for audit trail with pghistory.context( source="jira_reconciliation", diff --git a/dojo/templatetags/display_tags.py b/dojo/templatetags/display_tags.py index be102783d03..855bd74066e 100644 --- a/dojo/templatetags/display_tags.py +++ b/dojo/templatetags/display_tags.py @@ -24,9 +24,9 @@ from django.utils.safestring import mark_safe from django.utils.translation import gettext as _ -import dojo.jira_link.helper as jira_helper import dojo.utils from dojo import __docs__, __version__ +from dojo.jira import services as jira_services from dojo.models import Benchmark_Product, Check_List, Dojo_User, FileAccessToken, Finding, Product, System_Settings from dojo.utils import calculate_grade, get_file_images, get_full_url, get_system_setting, prepare_for_view @@ -909,52 +909,52 @@ def jiraencode_component(value): @register.filter def jira_project(obj, *, use_inheritance=True): - return jira_helper.get_jira_project(obj, use_inheritance=use_inheritance) + return jira_services.get_project(obj, use_inheritance=use_inheritance) @register.filter def jira_issue_url(obj): - return jira_helper.get_jira_url(obj) + return jira_services.get_url(obj) @register.filter def jira_project_url(obj): - return jira_helper.get_jira_project_url(obj) + return jira_services.get_project_url(obj) @register.filter def jira_key(obj): - return jira_helper.get_jira_key(obj) + return jira_services.get_key(obj) @register.filter def jira_creation(obj): - return jira_helper.get_jira_creation(obj) + return jira_services.get_creation(obj) @register.filter def jira_change(obj): - return jira_helper.get_jira_change(obj) + return jira_services.get_change(obj) @register.filter def jira_qualified_findings(finding_group): - return jira_helper.get_qualified_findings(finding_group) + return jira_services.get_qualified_findings(finding_group) @register.filter def jira_non_qualified_findings(finding_group): - return jira_helper.get_non_qualified_findings(finding_group) + return jira_services.get_non_qualified_findings(finding_group) @register.filter def jira_sla_deadline(obj): - return jira_helper.get_sla_deadline(obj) + return jira_services.get_sla_deadline(obj) @register.filter def jira_severity(findings): - return jira_helper.get_severity(findings) + return jira_services.get_severity(findings) @register.filter @@ -1012,7 +1012,7 @@ def jira_project_tag(product_or_engagement, *, autoescape=True): def esc(x): return x - jira_project = jira_helper.get_jira_project(product_or_engagement) + jira_project = jira_services.get_project(product_or_engagement) if not jira_project: return "" @@ -1028,7 +1028,7 @@ def esc(x): Push Notes: %s"> """ - jira_project_no_inheritance = jira_helper.get_jira_project(product_or_engagement, use_inheritance=False) + jira_project_no_inheritance = jira_services.get_project(product_or_engagement, use_inheritance=False) inherited = bool(not jira_project_no_inheritance) icon = "fa-bug" diff --git a/dojo/test/views.py b/dojo/test/views.py index f3510147290..936a43b54aa 100644 --- a/dojo/test/views.py +++ b/dojo/test/views.py @@ -23,7 +23,6 @@ from django.views.decorators.vary import vary_on_cookie import dojo.finding.helper as finding_helper -import dojo.jira_link.helper as jira_helper from dojo.authorization.authorization import user_has_permission_or_403 from dojo.authorization.authorization_decorators import user_is_authorized from dojo.authorization.roles_permissions import Permissions @@ -46,6 +45,7 @@ ) from dojo.importers.base_importer import BaseImporter from dojo.importers.default_reimporter import DefaultReImporter +from dojo.jira import services as jira_services from dojo.location.models import Location from dojo.models import ( BurpRawRequestResponse, @@ -183,7 +183,7 @@ def get_initial_context(self, request: HttpRequest, test: Test): "show_re_upload": any(test.test_type.name in code for code in get_choices_sorted()), "creds": Cred_Mapping.objects.filter(engagement=test.engagement).select_related("cred_id").order_by("cred_id"), "cred_test": Cred_Mapping.objects.filter(test=test).select_related("cred_id").order_by("cred_id"), - "jira_project": jira_helper.get_jira_project(test), + "jira_project": jira_services.get_project(test), "bulk_edit_form": FindingBulkUpdateForm(request.GET), "enable_table_filtering": get_system_setting("enable_ui_table_based_searching"), "finding_groups": test.finding_group_set.all().prefetch_related("findings", "jira_issue", "creator", "findings__vulnerability_id_set"), @@ -478,12 +478,12 @@ def get_finding_form(self, request: HttpRequest, test: Test): def get_jira_form(self, request: HttpRequest, test: Test, finding_form: AddFindingForm = None): # Determine if jira should be used - if (jira_project := jira_helper.get_jira_project(test)) is not None: + if (jira_project := jira_services.get_project(test)) is not None: # Set up the args for the form args = [request.POST] if request.method == "POST" else [] # Set the initial form args kwargs = { - "push_all": jira_helper.is_push_all_issues(test), + "push_all": jira_services.is_push_all_issues(test), "prefix": "jiraform", "jira_project": jira_project, "finding_form": finding_form, @@ -545,8 +545,8 @@ def process_jira_form(self, request: HttpRequest, finding: Finding, context: dic if context["jform"] and context["jform"].is_valid(): # can't use helper as when push_all_jira_issues is True, the checkbox gets disabled and is always false - # push_to_jira = jira_helper.is_push_to_jira(finding, jform.cleaned_data.get('push_to_jira')) - push_to_jira = jira_helper.is_push_all_issues(finding) or context["jform"].cleaned_data.get("push_to_jira") + # push_to_jira = jira_services.is_push_to_jira(finding, jform.cleaned_data.get('push_to_jira')) + push_to_jira = jira_services.is_push_all_issues(finding) or context["jform"].cleaned_data.get("push_to_jira") jira_message = None # if the jira issue key was changed, update database new_jira_issue_key = context["jform"].cleaned_data.get("jira_issue") @@ -556,18 +556,18 @@ def process_jira_form(self, request: HttpRequest, finding: Finding, context: dic # I have no idea why, but it means we have to retrieve the issue from JIRA to get the internal JIRA id. # we can assume the issue exist, which is already checked in the validation of the jform if not new_jira_issue_key: - jira_helper.finding_unlink_jira(request, finding) + jira_services.unlink_finding(request, finding) jira_message = "Link to JIRA issue removed successfully." elif new_jira_issue_key != finding.jira_issue.jira_key: - jira_helper.finding_unlink_jira(request, finding) - jira_helper.finding_link_jira(request, finding, new_jira_issue_key) + jira_services.unlink_finding(request, finding) + jira_services.link_finding(request, finding, new_jira_issue_key) jira_message = "Changed JIRA link successfully." else: logger.debug("finding has no jira issue yet") if new_jira_issue_key: logger.debug("finding has no jira issue yet, but jira issue specified in request. trying to link.") - jira_helper.finding_link_jira(request, finding, new_jira_issue_key) + jira_services.link_finding(request, finding, new_jira_issue_key) jira_message = "Linked a JIRA issue successfully." # Determine if a message should be added if jira_message: @@ -667,13 +667,13 @@ def add_finding_from_template(request, tid, fid): test = get_object_or_404(Test, id=tid) template = get_object_or_404(Finding_Template, id=fid) findings = Finding_Template.objects.all() - push_all_jira_issues = jira_helper.is_push_all_issues(template) + push_all_jira_issues = jira_services.is_push_all_issues(template) if request.method == "POST": form = AddFindingForm(request.POST, req_resp=None, product=test.engagement.product) - if jira_helper.get_jira_project(test): - jform = JIRAFindingForm(push_all=jira_helper.is_push_all_issues(test), prefix="jiraform", jira_project=jira_helper.get_jira_project(test), finding_form=form) + if jira_services.get_project(test): + jform = JIRAFindingForm(push_all=jira_services.is_push_all_issues(test), prefix="jiraform", jira_project=jira_services.get_project(test), finding_form=form) logger.debug(f"jform valid: {jform.is_valid()}") if (form["active"].value() is False or form["false_p"].value()) and form["duplicate"].value() is False: @@ -724,10 +724,10 @@ def add_finding_from_template(request, tid, fid): new_finding.save() if "jiraform-push_to_jira" in request.POST: - jform = JIRAFindingForm(request.POST, prefix="jiraform", instance=new_finding, push_all=push_all_jira_issues, jira_project=jira_helper.get_jira_project(test), finding_form=form) + jform = JIRAFindingForm(request.POST, prefix="jiraform", instance=new_finding, push_all=push_all_jira_issues, jira_project=jira_services.get_project(test), finding_form=form) if jform.is_valid(): if jform.cleaned_data.get("push_to_jira"): - jira_helper.push_to_jira(new_finding) + jira_services.push(new_finding) else: add_error_message_to_response(f"jira form validation failed: {jform.errors}") if "request" in form.cleaned_data or "response" in form.cleaned_data: @@ -809,8 +809,8 @@ def add_finding_from_template(request, tid, fid): form = AddFindingForm(req_resp=None, product=test.engagement.product, initial=initial_data) - if jira_helper.get_jira_project(test): - jform = JIRAFindingForm(push_all=jira_helper.is_push_all_issues(test), prefix="jiraform", jira_project=jira_helper.get_jira_project(test), finding_form=form) + if jira_services.get_project(test): + jform = JIRAFindingForm(push_all=jira_services.is_push_all_issues(test), prefix="jiraform", jira_project=jira_services.get_project(test), finding_form=form) product_tab = Product_Tab(test.engagement.product, title=_("Add Finding"), tab="engagements") product_tab.setEngagement(test.engagement) @@ -872,9 +872,9 @@ def get_jira_form( # Decide if we need to present the Push to JIRA form if get_system_setting("enable_jira"): # Determine if jira issues should be pushed automatically - push_all_jira_issues = jira_helper.is_push_all_issues(test) + push_all_jira_issues = jira_services.is_push_all_issues(test) # Only return the form if the jira is enabled on this engagement or product - if jira_helper.get_jira_project(test): + if jira_services.get_project(test): if request.method == "POST": jira_form = JIRAImportScanForm( request.POST, diff --git a/dojo/urls.py b/dojo/urls.py index 289500fc882..87ad7d85c36 100644 --- a/dojo/urls.py +++ b/dojo/urls.py @@ -89,7 +89,7 @@ from dojo.github_issue_link.urls import urlpatterns as github_urls from dojo.group.urls import urlpatterns as group_urls from dojo.home.urls import urlpatterns as home_urls -from dojo.jira_link.urls import urlpatterns as jira_urls +from dojo.jira.urls import urlpatterns as jira_urls from dojo.location.api.endpoint_compat import V3EndpointCompatibleViewSet, V3EndpointStatusCompatibleViewSet from dojo.location.api.urls import add_locations_urls from dojo.metrics.urls import urlpatterns as metrics_urls diff --git a/dojo/utils.py b/dojo/utils.py index 0e528b67d5d..c4e9c3f15d7 100644 --- a/dojo/utils.py +++ b/dojo/utils.py @@ -1517,7 +1517,7 @@ def sla_compute_and_notify(*args, **kwargs): Notifications are managed the usual way, so you'd have to opt-in. Exception is for JIRA issues, which would get a comment anyways. """ - import dojo.jira_link.helper as jira_helper # noqa: PLC0415 circular import + from dojo.jira import services as jira_services # noqa: PLC0415 circular import class NotificationEntry: def __init__(self, finding=None, jira_issue=None, *, do_jira_sla_comment=False): @@ -1587,7 +1587,7 @@ def _create_notifications(): if n.do_jira_sla_comment: logger.info("Creating JIRA comment to notify of SLA breach information.") - jira_helper.add_simple_jira_comment(jira_instance, n.jira_issue, title) + jira_services.add_simple_comment(jira_instance, n.jira_issue, title) findings_list.append(n.finding) @@ -1676,13 +1676,13 @@ def _create_notifications(): if jira_issue: jira_count += 1 - jira_instance = jira_helper.get_jira_instance(finding) + jira_instance = jira_services.get_instance(finding) if jira_instance is not None: logger.debug("JIRA config for finding is %s", jira_instance) # global config or product config set, product level takes precedence try: # TODO: see new property from #2649 to then replace, somehow not working with prefetching though. - product_jira_sla_comment_enabled = jira_helper.get_jira_project(finding).product_jira_sla_notification + product_jira_sla_comment_enabled = jira_services.get_project(finding).product_jira_sla_notification except Exception as e: logger.error("The product is not linked to a JIRA configuration! Something is weird here.") logger.error("Error is: %s", e) diff --git a/unittests/dojo_test_case.py b/unittests/dojo_test_case.py index 098aa77376d..13cc0a20341 100644 --- a/unittests/dojo_test_case.py +++ b/unittests/dojo_test_case.py @@ -17,8 +17,8 @@ from vcr_unittest import VCRTestCase from dojo.importers.location_manager import LocationManager -from dojo.jira_link import helper as jira_helper -from dojo.jira_link.views import get_custom_field +from dojo.jira import helper as jira_helper +from dojo.jira.views import get_custom_field from dojo.location.models import Location, LocationFindingReference from dojo.location.status import FindingLocationStatus from dojo.middleware import DojoSytemSettingsMiddleware diff --git a/unittests/test_importers_importer.py b/unittests/test_importers_importer.py index 6d5b4024a8c..aa14ace8beb 100644 --- a/unittests/test_importers_importer.py +++ b/unittests/test_importers_importer.py @@ -461,7 +461,7 @@ def test_import_by_product_name_not_exists_engagement_name(self): self.import_scan_with_params(NPM_AUDIT_NO_VULN_FILENAME, scan_type=NPM_AUDIT_SCAN_TYPE, product_name=PRODUCT_NAME_NEW, engagement=None, engagement_name=ENGAGEMENT_NAME_NEW, expected_http_status_code=400) - @patch("dojo.jira_link.helper.get_jira_project") + @patch("dojo.jira.helper.get_jira_project") def test_import_by_product_name_not_exists_engagement_name_auto_create(self, mock): with assertImportModelsCreated(self, tests=1, engagements=1, products=1, product_types=0, endpoints=0): import0 = self.import_scan_with_params(NPM_AUDIT_NO_VULN_FILENAME, scan_type=NPM_AUDIT_SCAN_TYPE, product_name=PRODUCT_NAME_NEW, @@ -474,7 +474,7 @@ def test_import_by_product_name_not_exists_engagement_name_auto_create(self, moc mock.assert_not_called() - @patch("dojo.jira_link.helper.get_jira_project") + @patch("dojo.jira.helper.get_jira_project") def test_import_by_product_type_name_not_exists_product_name_not_exists_engagement_name_auto_create(self, mock): with assertImportModelsCreated(self, tests=1, engagements=1, products=1, product_types=1, endpoints=0): import0 = self.import_scan_with_params(NPM_AUDIT_NO_VULN_FILENAME, scan_type=NPM_AUDIT_SCAN_TYPE, product_name=PRODUCT_NAME_NEW, @@ -666,7 +666,7 @@ def test_reimport_by_product_name_not_exists_engagement_name(self): self.reimport_scan_with_params(None, NPM_AUDIT_NO_VULN_FILENAME, scan_type=NPM_AUDIT_SCAN_TYPE, product_name=PRODUCT_NAME_NEW, engagement=None, engagement_name=ENGAGEMENT_NAME_NEW, expected_http_status_code=400) - @patch("dojo.jira_link.helper.get_jira_project") + @patch("dojo.jira.helper.get_jira_project") def test_reimport_by_product_name_not_exists_engagement_name_auto_create(self, mock): with assertImportModelsCreated(self, tests=1, engagements=1, products=1, product_types=0, endpoints=0): import0 = self.reimport_scan_with_params(None, NPM_AUDIT_NO_VULN_FILENAME, scan_type=NPM_AUDIT_SCAN_TYPE, product_name=PRODUCT_NAME_NEW, @@ -679,7 +679,7 @@ def test_reimport_by_product_name_not_exists_engagement_name_auto_create(self, m mock.assert_not_called() - @patch("dojo.jira_link.helper.get_jira_project") + @patch("dojo.jira.helper.get_jira_project") def test_reimport_by_product_type_not_exists_product_name_not_exists_engagement_name_auto_create(self, mock): with assertImportModelsCreated(self, tests=1, engagements=1, products=1, product_types=1, endpoints=0): import0 = self.reimport_scan_with_params(None, NPM_AUDIT_NO_VULN_FILENAME, scan_type=NPM_AUDIT_SCAN_TYPE, product_name=PRODUCT_NAME_NEW, diff --git a/unittests/test_jira_config_engagement.py b/unittests/test_jira_config_engagement.py index 7d0e413ddd2..aaabfdddc5e 100644 --- a/unittests/test_jira_config_engagement.py +++ b/unittests/test_jira_config_engagement.py @@ -8,7 +8,7 @@ # from dojo.models import JIRA_Project from django.utils.http import urlencode -from dojo.jira_link import helper as jira_helper +from dojo.jira import helper as jira_helper from dojo.models import Engagement, Product from unittests.dojo_test_case import DojoTestCase, versioned_fixtures @@ -260,7 +260,7 @@ def setUp(self): product = Product.objects.get(id=self.product_id) self.assertIsNone(jira_helper.get_jira_project(product)) - @patch("dojo.jira_link.views.jira_helper.is_jira_project_valid") + @patch("dojo.jira.views.jira_helper.is_jira_project_valid") def test_add_jira_project_to_engagement_without_jira_project(self, jira_mock): jira_mock.return_value = True # cannot set return_value in decorated AND have the mock into the method # TODO: add engagement also via API, but let's focus on JIRA here @@ -268,7 +268,7 @@ def test_add_jira_project_to_engagement_without_jira_project(self, jira_mock): self.edit_jira_project_for_engagement(engagement, expected_delta_jira_project_db=1) self.assertEqual(jira_mock.call_count, 1) - @patch("dojo.jira_link.views.jira_helper.is_jira_project_valid") + @patch("dojo.jira.views.jira_helper.is_jira_project_valid") def test_add_empty_jira_project_to_engagement_without_jira_project(self, jira_mock): jira_mock.return_value = True # cannot set return_value in decorated AND have the mock into the method # Prevent the exception from being raised here so that the test can be ran in parallel @@ -277,14 +277,14 @@ def test_add_empty_jira_project_to_engagement_without_jira_project(self, jira_mo self.empty_jira_project_for_engagement(engagement, expected_delta_jira_project_db=0) self.assertEqual(jira_mock.call_count, 0) - @patch("dojo.jira_link.views.jira_helper.is_jira_project_valid") + @patch("dojo.jira.views.jira_helper.is_jira_project_valid") def test_edit_jira_project_to_engagement_with_jira_project(self, jira_mock): jira_mock.return_value = True # cannot set return_value in decorated AND have the mock into the method engagement = self.add_engagement_with_jira_project(expected_delta_jira_project_db=1) self.edit_jira_project_for_engagement2(engagement, expected_delta_jira_project_db=0) self.assertEqual(jira_mock.call_count, 2) - @patch("dojo.jira_link.views.jira_helper.is_jira_project_valid") + @patch("dojo.jira.views.jira_helper.is_jira_project_valid") def test_edit_empty_jira_project_to_engagement_with_jira_project(self, jira_mock): jira_mock.return_value = True # cannot set return_value in decorated AND have the mock into the method # Prevent the exception from being raised here so that the test can be ran in parallel @@ -299,14 +299,14 @@ def test_edit_empty_jira_project_to_engagement_with_jira_project(self, jira_mock self.empty_jira_project_for_engagement(engagement, expected_delta_jira_project_db=0, expect_error=True) self.assertEqual(jira_mock.call_count, 1) - @patch("dojo.jira_link.views.jira_helper.is_jira_project_valid") + @patch("dojo.jira.views.jira_helper.is_jira_project_valid") def test_add_jira_project_to_engagement_without_jira_project_invalid_project(self, jira_mock): jira_mock.return_value = False # cannot set return_value in decorated AND have the mock into the method # errors means it won't redirect to view_engagement, but returns a 200 and redisplays the edit engagement page self.edit_jira_project_for_engagement(Engagement.objects.get(id=3), expected_delta_jira_project_db=0, expect_200=True) self.assertEqual(jira_mock.call_count, 1) - @patch("dojo.jira_link.views.jira_helper.is_jira_project_valid") + @patch("dojo.jira.views.jira_helper.is_jira_project_valid") def test_edit_jira_project_to_engagement_with_jira_project_invalid_project(self, jira_mock): jira_mock.return_value = True # cannot set return_value in decorated AND have the mock into the method engagement = self.add_engagement_with_jira_project(expected_delta_jira_project_db=1) @@ -315,14 +315,14 @@ def test_edit_jira_project_to_engagement_with_jira_project_invalid_project(self, self.edit_jira_project_for_engagement2(engagement, expected_delta_jira_project_db=0, expect_200=True) self.assertEqual(jira_mock.call_count, 2) - @patch("dojo.jira_link.views.jira_helper.is_jira_project_valid") + @patch("dojo.jira.views.jira_helper.is_jira_project_valid") def test_add_engagement_with_jira_project(self, jira_mock): jira_mock.return_value = True # cannot set return_value in decorated AND have the mock into the method engagement = self.add_engagement_with_jira_project(expected_delta_jira_project_db=1) self.assertIsNotNone(engagement) self.assertEqual(jira_mock.call_count, 1) - @patch("dojo.jira_link.views.jira_helper.is_jira_project_valid") + @patch("dojo.jira.views.jira_helper.is_jira_project_valid") def test_add_engagement_with_jira_project_invalid_jira_project(self, jira_mock): jira_mock.return_value = False # cannot set return_value in decorated AND have the mock into the method engagement = self.add_engagement_with_jira_project(expected_delta_jira_project_db=0, expect_redirect_to="/engagement/%i/edit") @@ -330,7 +330,7 @@ def test_add_engagement_with_jira_project_invalid_jira_project(self, jira_mock): self.assertIsNotNone(engagement) self.assertEqual(jira_mock.call_count, 1) - @patch("dojo.jira_link.views.jira_helper.is_jira_project_valid") + @patch("dojo.jira.views.jira_helper.is_jira_project_valid") def test_add_engagement_without_jira_project(self, jira_mock): jira_mock.return_value = True # cannot set return_value in decorated AND have the mock into the method engagement = self.add_engagement_without_jira_project(expected_delta_jira_project_db=0) @@ -362,7 +362,7 @@ class JIRAConfigEngagementTest_Inheritance(JIRAConfigEngagementTest): def __init__(self, *args, **kwargs): JIRAConfigEngagementTest.__init__(self, *args, **kwargs) - @patch("dojo.jira_link.views.jira_helper.is_jira_project_valid") + @patch("dojo.jira.views.jira_helper.is_jira_project_valid") def setUp(self, jira_mock, *args, **kwargs): jira_mock.return_value = True JIRAConfigEngagementTest.setUp(self, *args, **kwargs) diff --git a/unittests/test_jira_config_product.py b/unittests/test_jira_config_product.py index 44cf4ba8f9c..c4992caf049 100644 --- a/unittests/test_jira_config_product.py +++ b/unittests/test_jira_config_product.py @@ -7,7 +7,7 @@ from django.utils.http import urlencode from jira.exceptions import JIRAError -import dojo.jira_link.helper as jira_helper +import dojo.jira.helper as jira_helper from dojo.models import JIRA_Instance, Product from unittests.dojo_test_case import DojoTestCase, versioned_fixtures @@ -47,7 +47,7 @@ def setUp(self): self.system_settings(enable_jira=True) self.client.force_login(self.get_test_admin()) - @patch("dojo.jira_link.views.jira_helper.get_jira_connection_raw") + @patch("dojo.jira.views.jira_helper.get_jira_connection_raw") def add_jira_instance(self, data, jira_mock): response = self.client.post(reverse("add_jira_advanced"), urlencode(data), content_type="application/x-www-form-urlencoded") # check that storing a new config triggers a login call to JIRA @@ -91,7 +91,7 @@ def test_add_jira_instance_unknown_host(self): with self.assertRaises(requests.exceptions.RequestException): jira_helper.get_jira_connection_raw(data["url"], data["username"], data["password"]) - @patch("dojo.jira_link.views.jira_helper.get_jira_connection_raw") + @patch("dojo.jira.views.jira_helper.get_jira_connection_raw") def test_add_jira_instance_invalid_credentials(self, jira_mock): jira_mock.side_effect = JIRAError(status_code=401, text="Login failed") data = self.data_jira_instance @@ -108,7 +108,7 @@ def test_add_jira_instance_invalid_credentials(self, jira_mock): self.assertIn("Login failed", content) self.assertIn("Unable to authenticate to JIRA", content) - @patch("dojo.jira_link.views.jira_helper.is_jira_project_valid") + @patch("dojo.jira.views.jira_helper.is_jira_project_valid") def test_add_jira_project_to_product_without_jira_project(self, jira_mock): jira_mock.return_value = True # cannot set return_value in decorated AND have the mock into the method # TODO: add product also via API, but let's focus on JIRA here @@ -116,21 +116,21 @@ def test_add_jira_project_to_product_without_jira_project(self, jira_mock): self.edit_jira_project_for_product(product, expected_delta_jira_project_db=1) self.assertEqual(jira_mock.call_count, 1) - @patch("dojo.jira_link.views.jira_helper.is_jira_project_valid") + @patch("dojo.jira.views.jira_helper.is_jira_project_valid") def test_add_empty_jira_project_to_product_without_jira_project(self, jira_mock): jira_mock.return_value = True # cannot set return_value in decorater AND have the mock into the method product = self.add_product_without_jira_project(expected_delta_jira_project_db=0) self.empty_jira_project_for_product(product, expected_delta_jira_project_db=0) self.assertEqual(jira_mock.call_count, 0) - @patch("dojo.jira_link.views.jira_helper.is_jira_project_valid") + @patch("dojo.jira.views.jira_helper.is_jira_project_valid") def test_edit_jira_project_to_product_with_jira_project(self, jira_mock): jira_mock.return_value = True # cannot set return_value in decorated AND have the mock into the method product = self.add_product_with_jira_project(expected_delta_jira_project_db=1) self.edit_jira_project_for_product2(product, expected_delta_jira_project_db=0) self.assertEqual(jira_mock.call_count, 2) - @patch("dojo.jira_link.views.jira_helper.is_jira_project_valid") + @patch("dojo.jira.views.jira_helper.is_jira_project_valid") def test_edit_empty_jira_project_to_product_with_jira_project(self, jira_mock): jira_mock.return_value = True # cannot set return_value in decorated AND have the mock into the method product = self.add_product_with_jira_project(expected_delta_jira_project_db=1) @@ -143,14 +143,14 @@ def test_edit_empty_jira_project_to_product_with_jira_project(self, jira_mock): self.empty_jira_project_for_product(product, expected_delta_jira_project_db=0, expect_200=True) self.assertEqual(jira_mock.call_count, 1) - @patch("dojo.jira_link.views.jira_helper.is_jira_project_valid") + @patch("dojo.jira.views.jira_helper.is_jira_project_valid") def test_add_jira_project_to_product_without_jira_project_invalid_project(self, jira_mock): jira_mock.return_value = False # cannot set return_value in decorated AND have the mock into the method # errors means it won't redirect to view_product, but returns a 200 and redisplays the edit product page self.edit_jira_project_for_product(Product.objects.get(id=3), expected_delta_jira_project_db=0, expect_200=True) self.assertEqual(jira_mock.call_count, 1) - @patch("dojo.jira_link.views.jira_helper.is_jira_project_valid") + @patch("dojo.jira.views.jira_helper.is_jira_project_valid") def test_edit_jira_project_to_product_with_jira_project_invalid_project(self, jira_mock): jira_mock.return_value = True # cannot set return_value in decorated AND have the mock into the method product = self.add_product_with_jira_project(expected_delta_jira_project_db=1) @@ -159,14 +159,14 @@ def test_edit_jira_project_to_product_with_jira_project_invalid_project(self, ji self.edit_jira_project_for_product2(product, expected_delta_jira_project_db=0, expect_200=True) self.assertEqual(jira_mock.call_count, 2) - @patch("dojo.jira_link.views.jira_helper.is_jira_project_valid") + @patch("dojo.jira.views.jira_helper.is_jira_project_valid") def test_add_product_with_jira_project(self, jira_mock): jira_mock.return_value = True # cannot set return_value in decorated AND have the mock into the method product = self.add_product_with_jira_project(expected_delta_jira_project_db=1) self.assertIsNotNone(product) self.assertEqual(jira_mock.call_count, 1) - @patch("dojo.jira_link.views.jira_helper.is_jira_project_valid") + @patch("dojo.jira.views.jira_helper.is_jira_project_valid") def test_add_product_with_jira_project_invalid_jira_project(self, jira_mock): jira_mock.return_value = False # cannot set return_value in decorated AND have the mock into the method product = self.add_product_with_jira_project(expected_delta_jira_project_db=0, expect_redirect_to="/product/%i/edit") @@ -174,7 +174,7 @@ def test_add_product_with_jira_project_invalid_jira_project(self, jira_mock): self.assertIsNotNone(product) self.assertEqual(jira_mock.call_count, 1) - @patch("dojo.jira_link.views.jira_helper.is_jira_project_valid") + @patch("dojo.jira.views.jira_helper.is_jira_project_valid") def test_add_product_without_jira_project(self, jira_mock): jira_mock.return_value = True # cannot set return_value in decorated AND have the mock into the method product = self.add_product_without_jira_project(expected_delta_jira_project_db=0) diff --git a/unittests/test_jira_import_and_pushing_api.py b/unittests/test_jira_import_and_pushing_api.py index d7caf5404c4..eb3f0692dbc 100644 --- a/unittests/test_jira_import_and_pushing_api.py +++ b/unittests/test_jira_import_and_pushing_api.py @@ -10,7 +10,7 @@ from vcr import VCR import dojo.risk_acceptance.helper as ra_helper -from dojo.jira_link import helper as jira_helper +from dojo.jira import helper as jira_helper from dojo.models import Finding, Finding_Group, JIRA_Instance, JIRA_Project, Risk_Acceptance, Test, User from unittests.dojo_test_case import ( DojoVCRAPITestCase, @@ -981,9 +981,9 @@ def test_engagement_epic_mapping_disabled_no_epic_and_push_findings(self): self.assert_cassette_played() - @patch("dojo.jira_link.helper.can_be_pushed_to_jira", return_value=(True, None, None)) - @patch("dojo.jira_link.helper.is_push_all_issues", return_value=False) - @patch("dojo.jira_link.helper.push_to_jira", return_value=None) + @patch("dojo.jira.helper.can_be_pushed_to_jira", return_value=(True, None, None)) + @patch("dojo.jira.helper.is_push_all_issues", return_value=False) + @patch("dojo.jira.helper.push_to_jira", return_value=None) @patch("dojo.notifications.helper.send_webhooks_notification") def test_bulk_edit_mixed_findings_and_groups_jira_push_bug(self, mock_webhooks, mock_push_to_jira, mock_is_push_all_issues, mock_can_be_pushed): """ @@ -1130,9 +1130,9 @@ def _bulk_edit_finding_groups_without_checkbox(self): self.client.post("/finding/bulk", post_data) - @patch("dojo.jira_link.helper.can_be_pushed_to_jira", return_value=(True, None, None)) - @patch("dojo.jira_link.helper.is_push_all_issues", return_value=True) - @patch("dojo.jira_link.helper.push_to_jira", return_value=None) + @patch("dojo.jira.helper.can_be_pushed_to_jira", return_value=(True, None, None)) + @patch("dojo.jira.helper.is_push_all_issues", return_value=True) + @patch("dojo.jira.helper.push_to_jira", return_value=None) @patch("dojo.notifications.helper.WebhookNotificationManger.send_webhooks_notification") def test_bulk_edit_push_all_issues_pushes_finding_groups(self, mock_webhooks, mock_push_to_jira, mock_is_push_all_issues, mock_can_be_pushed): """ @@ -1150,10 +1150,10 @@ def test_bulk_edit_push_all_issues_pushes_finding_groups(self, mock_webhooks, mo self.assertEqual(len(group_calls), 2, "Expected 2 finding groups to be pushed") self.assertEqual(len(individual_calls), 2, "Expected 2 individual findings to be pushed") - @patch("dojo.jira_link.helper.can_be_pushed_to_jira", return_value=(True, None, None)) - @patch("dojo.jira_link.helper.is_keep_in_sync_with_jira", return_value=True) - @patch("dojo.jira_link.helper.is_push_all_issues", return_value=False) - @patch("dojo.jira_link.helper.push_to_jira", return_value=None) + @patch("dojo.jira.helper.can_be_pushed_to_jira", return_value=(True, None, None)) + @patch("dojo.jira.helper.is_keep_in_sync_with_jira", return_value=True) + @patch("dojo.jira.helper.is_push_all_issues", return_value=False) + @patch("dojo.jira.helper.push_to_jira", return_value=None) @patch("dojo.notifications.helper.WebhookNotificationManger.send_webhooks_notification") def test_bulk_edit_keep_in_sync_pushes_finding_groups(self, mock_webhooks, mock_push_to_jira, mock_is_push_all_issues, mock_is_keep_in_sync, mock_can_be_pushed): """ diff --git a/unittests/test_jira_template.py b/unittests/test_jira_template.py index bc22d722e8c..d4b27d8119b 100644 --- a/unittests/test_jira_template.py +++ b/unittests/test_jira_template.py @@ -1,7 +1,7 @@ # from unittest import skip import logging -from dojo.jira_link import helper as jira_helper +from dojo.jira import helper as jira_helper from dojo.models import Product from unittests.dojo_test_case import DojoTestCase, versioned_fixtures diff --git a/unittests/test_jira_webhook.py b/unittests/test_jira_webhook.py index 8d99a60dac9..098eada180b 100644 --- a/unittests/test_jira_webhook.py +++ b/unittests/test_jira_webhook.py @@ -5,7 +5,7 @@ from django.urls import reverse -import dojo.jira_link.helper as jira_helper +import dojo.jira.helper as jira_helper from dojo.models import JIRA_Issue from unittests.dojo_test_case import DojoTestCase, versioned_fixtures diff --git a/unittests/test_rest_framework.py b/unittests/test_rest_framework.py index 7199e08c126..084857fa5d2 100644 --- a/unittests/test_rest_framework.py +++ b/unittests/test_rest_framework.py @@ -1002,8 +1002,8 @@ def test_close_finding_rejects_unauthorized_mitigated_by(self): def test_close_finding_pushes_note_to_jira_when_configured(self): finding = Finding.objects.get(id=7) - with patch("dojo.jira_link.helper.add_comment") as add_comment_mock, \ - patch("dojo.jira_link.helper.is_push_all_issues", return_value=True), \ + with patch("dojo.jira.helper.add_comment") as add_comment_mock, \ + patch("dojo.jira.helper.is_push_all_issues", return_value=True), \ patch.object(Finding, "has_jira_issue", new_callable=PropertyMock, return_value=True): payload = { "is_mitigated": True,