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/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml
index b8ed603820d..43bec588257 100644
--- a/.github/workflows/gh-pages.yml
+++ b/.github/workflows/gh-pages.yml
@@ -22,7 +22,7 @@ jobs:
extended: true
- name: Setup Node
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24.14.1' # TODO: Renovate helper might not be needed here - needs to be fully tested
diff --git a/.github/workflows/k8s-tests.yml b/.github/workflows/k8s-tests.yml
index 89efe5c95ab..c4191d6f338 100644
--- a/.github/workflows/k8s-tests.yml
+++ b/.github/workflows/k8s-tests.yml
@@ -16,9 +16,9 @@ jobs:
# databases, broker and k8s are independent, so we don't need to test each combination
# lastest k8s version (https://kubernetes.io/releases/) and the oldest officially supported version
# are tested (https://kubernetes.io/releases/)
- - k8s: 'v1.35.3' # renovate: datasource=github-releases depName=kubernetes/kubernetes versioning=loose
+ - k8s: 'v1.35.4' # renovate: datasource=github-releases depName=kubernetes/kubernetes versioning=loose
os: debian
- - k8s: '1.33.10' # renovate: datasource=custom.endoflife-oldest-maintained depName=kubernetes
+ - k8s: '1.33.11' # renovate: datasource=custom.endoflife-oldest-maintained depName=kubernetes
os: debian
steps:
- name: Checkout
diff --git a/.github/workflows/renovate.yaml b/.github/workflows/renovate.yaml
index 2610e79b2ea..8f71e0abfc0 100644
--- a/.github/workflows/renovate.yaml
+++ b/.github/workflows/renovate.yaml
@@ -21,4 +21,4 @@ jobs:
uses: suzuki-shunsuke/github-action-renovate-config-validator@ee9f69e1f683ed0d08225086482b34fc9abe9300 # v2.1.0
with:
strict: "true"
- validator_version: 43.112.1 # renovate: datasource=github-releases depName=renovatebot/renovate
+ validator_version: 43.139.4 # renovate: datasource=github-releases depName=renovatebot/renovate
diff --git a/.github/workflows/validate_docs_build.yml b/.github/workflows/validate_docs_build.yml
index c6ddb2f02f4..5d4cfb1e6ed 100644
--- a/.github/workflows/validate_docs_build.yml
+++ b/.github/workflows/validate_docs_build.yml
@@ -17,7 +17,7 @@ jobs:
extended: true
- name: Setup Node
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24.14.1' # TODO: Renovate helper might not be needed here - needs to be fully tested
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 00000000000..64279977619
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,249 @@
+# DefectDojo Development Guide
+
+## Project Overview
+
+DefectDojo is a Django application (`dojo` app) for vulnerability management. The codebase is undergoing a modular reorganization to move from monolithic files toward self-contained domain modules.
+
+## Module Reorganization
+
+### Reference Pattern: `dojo/url/`
+
+All domain modules should match the structure of `dojo/url/`. This is the canonical example of a fully reorganized module.
+
+```
+dojo/{module}/
+├── __init__.py # import dojo.{module}.admin # noqa: F401
+├── models.py # Domain models, constants, factory methods
+├── admin.py # @admin.register() for the module's models
+├── services.py # Business logic (no HTTP concerns)
+├── queries.py # Complex DB aggregations/annotations
+├── signals.py # Django signal handlers
+├── [manager.py] # Custom QuerySet/Manager if needed
+├── [validators.py] # Field-level validators if needed
+├── [helpers.py] # Async task wrappers, tag propagation, etc.
+├── ui/
+│ ├── __init__.py # Empty
+│ ├── forms.py # Django ModelForms
+│ ├── filters.py # UI-layer django-filter classes
+│ ├── views.py # Thin view functions — delegates to services.py
+│ └── urls.py # URL routing
+└── api/
+ ├── __init__.py # path = "{module}"
+ ├── serializer.py # DRF serializers
+ ├── views.py # API ViewSets — delegates to services.py
+ ├── filters.py # API-layer filters
+ └── urls.py # add_{module}_urls(router) registration
+```
+
+### Architecture Principles
+
+
+**services.py is the critical layer**: Both `ui/views.py` and `api/views.py` call `services.py` for business logic. Services accept domain objects and primitives — never request/response objects, forms, or serializers.
+
+**Backward-compatible re-exports**: When moving code out of monolithic files (`dojo/models.py`, `dojo/forms.py`, `dojo/filters.py`, `dojo/api_v2/serializers.py`, `dojo/api_v2/views.py`), always leave a re-export at the original location:
+```python
+from dojo.{module}.models import {Model} # noqa: F401 -- backward compat
+```
+Never remove re-exports until all consumers are updated in a dedicated cleanup pass.
+
+### Current State
+
+Modules in various stages of reorganization:
+
+| Module | models.py | services.py | ui/ | api/ | Status |
+|--------|-----------|-------------|-----|------|--------|
+| **url** | In module | N/A | Done | Done | **Complete** |
+| **location** | In module | N/A | N/A | Done | **Complete** |
+| **product_type** | In dojo/models.py | Missing | Partial (views at root) | In dojo/api_v2/ | Needs work |
+| **test** | In dojo/models.py | Missing | Partial (views at root) | In dojo/api_v2/ | Needs work |
+| **engagement** | In dojo/models.py | Partial (32 lines) | Partial (views at root) | In dojo/api_v2/ | Needs work |
+| **product** | In dojo/models.py | Missing | Partial (views at root) | In dojo/api_v2/ | Needs work |
+| **finding** | In dojo/models.py | Missing | Partial (views at root) | In dojo/api_v2/ | Needs work |
+
+### Monolithic Files Being Decomposed
+
+These files still contain code for multiple modules. Extract code to the target module's subdirectory and leave a re-export stub.
+
+- `dojo/models.py` (4,973 lines) — All model definitions
+- `dojo/forms.py` (4,127 lines) — All Django forms
+- `dojo/filters.py` (4,016 lines) — All UI and API filter classes
+- `dojo/api_v2/serializers.py` (3,387 lines) — All DRF serializers
+- `dojo/api_v2/views.py` (3,519 lines) — All API viewsets
+
+---
+
+## Reorganization Playbook
+
+When asked to reorganize a module, follow these phases in order. Each phase should be independently verifiable.
+
+### Phase 0: Pre-Flight (Read-Only)
+
+Before any changes, identify all code to extract:
+
+```bash
+# 1. Model classes and line ranges in dojo/models.py
+grep -n "class {Model}" dojo/models.py
+
+# 2. Form classes in dojo/forms.py
+grep -n "class.*{Module}" dojo/forms.py
+grep -n "model = {Model}" dojo/forms.py
+
+# 3. Filter classes in dojo/filters.py
+grep -n "class.*{Module}\|class.*{Model}" dojo/filters.py
+
+# 4. Serializer classes
+grep -n "class.*{Model}" dojo/api_v2/serializers.py
+
+# 5. ViewSet classes
+grep -n "class.*{Model}\|class.*{Module}" dojo/api_v2/views.py
+
+# 6. Admin registrations
+grep -n "admin.site.register({Model}" dojo/models.py
+
+# 7. All import sites (to verify backward compat)
+grep -rn "from dojo.models import.*{Model}" dojo/ unittests/
+
+# 8. Business logic in current views
+# Scan dojo/{module}/views.py for: .save(), .delete(), create_notification(),
+# jira_helper.*, dojo_dispatch_task(), multi-model workflows
+```
+
+### Phase 1: Extract Models
+
+1. Create `dojo/{module}/models.py` with the model class(es) and associated constants
+2. Create `dojo/{module}/admin.py` with `admin.site.register()` calls (remove from `dojo/models.py`)
+3. Update `dojo/{module}/__init__.py` to `import dojo.{module}.admin # noqa: F401`
+4. Add re-exports in `dojo/models.py`
+5. Remove original model code (keep re-export line)
+
+**Import rules for models.py:**
+- Upward FKs (e.g., Test -> Engagement): import from `dojo.models` if not yet extracted, or `dojo.{module}.models` if already extracted
+- Downward references (e.g., Product_Type querying Finding): use lazy imports inside method bodies
+- Shared utilities (`copy_model_util`, `_manage_inherited_tags`, `get_current_date`, etc.): import from `dojo.models`
+- Do NOT set `app_label` in Meta — all models inherit `dojo` app_label automatically
+
+**Verify:**
+```bash
+python manage.py check
+python manage.py makemigrations --check
+python -c "from dojo.{module}.models import {Model}"
+python -c "from dojo.models import {Model}"
+```
+
+### Phase 2: Extract Services
+
+Create `dojo/{module}/services.py` with business logic extracted from UI views.
+
+**What belongs in services.py:**
+- State transitions (close, reopen, status changes)
+- Multi-step creation/update workflows
+- External integration calls (JIRA, GitHub)
+- Notification dispatching
+- Copy/clone operations
+- Bulk operations
+- Merge operations
+
+**What stays in views:**
+- HTTP request/response handling
+- Form instantiation and validation
+- Serialization/deserialization
+- Authorization checks (`@user_is_authorized`, `user_has_permission_or_403`)
+- Template rendering, redirects
+- Pagination, breadcrumbs
+
+**Service function pattern:**
+```python
+def close_engagement(engagement: Engagement, user: User) -> Engagement:
+ engagement.active = False
+ engagement.status = "Completed"
+ engagement.save()
+ if jira_helper.get_jira_project(engagement):
+ dojo_dispatch_task(jira_helper.close_epic, engagement.id, push_to_jira=True)
+ return engagement
+```
+
+Update UI views and API viewsets to call the service instead of containing logic inline.
+
+### Phase 3: Extract Forms to `ui/forms.py`
+
+1. Create `dojo/{module}/ui/__init__.py` (empty)
+2. Create `dojo/{module}/ui/forms.py` — move form classes from `dojo/forms.py`
+3. Add re-exports in `dojo/forms.py`
+
+### Phase 4: Extract UI Filters to `ui/filters.py`
+
+1. Create `dojo/{module}/ui/filters.py` — move module-specific filters from `dojo/filters.py`
+2. Shared base classes (`DojoFilter`, `DateRangeFilter`, `ReportBooleanFilter`) stay in `dojo/filters.py`
+3. Add re-exports in `dojo/filters.py`
+
+### Phase 5: Move UI Views/URLs into `ui/`
+
+1. Move `dojo/{module}/views.py` -> `dojo/{module}/ui/views.py`
+2. Move `dojo/{module}/urls.py` -> `dojo/{module}/ui/urls.py`
+3. Update URL imports:
+ - product: update `dojo/asset/urls.py`
+ - product_type: update `dojo/organization/urls.py`
+ - others: update the include in `dojo/urls.py`
+
+### Phase 6: Extract API Serializers to `api/serializer.py`
+
+1. Create `dojo/{module}/api/__init__.py` with `path = "{module}"`
+2. Create `dojo/{module}/api/serializer.py` — move from `dojo/api_v2/serializers.py`
+3. Add re-exports in `dojo/api_v2/serializers.py`
+
+### Phase 7: Extract API Filters to `api/filters.py`
+
+1. Create `dojo/{module}/api/filters.py` — move `Api{Model}Filter` from `dojo/filters.py`
+2. Add re-exports
+
+### Phase 8: Extract API ViewSets to `api/views.py`
+
+1. Create `dojo/{module}/api/views.py` — move from `dojo/api_v2/views.py`
+2. Add re-exports in `dojo/api_v2/views.py`
+
+### Phase 9: Extract API URL Registration
+
+1. Create `dojo/{module}/api/urls.py`:
+ ```python
+ from dojo.{module}.api import path
+ from dojo.{module}.api.views import {ViewSet}
+
+ def add_{module}_urls(router):
+ router.register(path, {ViewSet}, path)
+ return router
+ ```
+2. Update `dojo/urls.py` — replace `v2_api.register(...)` with `add_{module}_urls(v2_api)`
+
+### After Each Phase: Verify
+
+```bash
+python manage.py check
+python manage.py makemigrations --check
+python -m pytest unittests/ -x --timeout=120
+```
+
+---
+
+## Cross-Module Dependencies
+
+The model hierarchy is: Product_Type -> Product -> Engagement -> Test -> Finding
+
+Extract in this order (top to bottom) so that upward FKs can import from already-extracted modules. The recommended order is: product_type, test, engagement, product, finding.
+
+For downward references (e.g., Product_Type's cached properties querying Finding), always use lazy imports:
+```python
+@cached_property
+def critical_present(self):
+ from dojo.models import Finding # lazy import
+ return Finding.objects.filter(test__engagement__product__prod_type=self, severity="Critical").exists()
+```
+
+---
+
+## Key Technical Details
+
+- **Single Django app**: Everything is under `app_label = "dojo"`. Moving models to subdirectories does NOT require migration changes.
+- **Model discovery**: Triggered by `__init__.py` importing `admin.py`, which imports `models.py`. This is the same chain `dojo/url/` uses.
+- **Signal registration**: Handled in `dojo/apps.py` via `import dojo.{module}.signals`. Already set up for test, engagement, product, product_type.
+- **Watson search**: Uses `self.get_model("Product")` in `apps.py` — works via Django's model registry regardless of file location.
+- **Admin registration**: Currently at the bottom of `dojo/models.py` (lines 4888-4973). Must be moved to `{module}/admin.py` and removed from `dojo/models.py` to avoid `AlreadyRegistered` errors.
diff --git a/Dockerfile.django-alpine b/Dockerfile.django-alpine
index 7e3993dab31..2ee92f81fdb 100644
--- a/Dockerfile.django-alpine
+++ b/Dockerfile.django-alpine
@@ -5,7 +5,7 @@
# Dockerfile.nginx to use the caching mechanism of Docker.
# Ref: https://devguide.python.org/#branchstatus
-FROM python:3.13.13-alpine3.22@sha256:d739d0ecd8cd56c6f842d4ce55087ed551c519ec88fa0b3b98a5717c22a083c9 AS base
+FROM python:3.13.13-alpine3.22@sha256:e81548ac35b07a3bd4805f275107592ef458b1e893c0e04d45aedaa19416cca5 AS base
FROM base AS build
WORKDIR /app
RUN \
diff --git a/Dockerfile.django-debian b/Dockerfile.django-debian
index 421716a8fdc..74d3a25abe5 100644
--- a/Dockerfile.django-debian
+++ b/Dockerfile.django-debian
@@ -5,7 +5,7 @@
# Dockerfile.nginx to use the caching mechanism of Docker.
# Ref: https://devguide.python.org/#branchstatus
-FROM python:3.13.13-slim-trixie@sha256:2ba73a4dc380f21137fc75296abfa2add90b51fd10b609ce530b40cc097269b1 AS base
+FROM python:3.13.13-slim-trixie@sha256:9213d136547f0602c3337ff48291e937f9cc43060b3e123402cf2aaff1a08b75 AS base
FROM base AS build
WORKDIR /app
RUN \
diff --git a/Dockerfile.integration-tests-debian b/Dockerfile.integration-tests-debian
index af47d8a0b55..db0f3aeeb43 100644
--- a/Dockerfile.integration-tests-debian
+++ b/Dockerfile.integration-tests-debian
@@ -3,7 +3,7 @@
FROM openapitools/openapi-generator-cli:v7.21.0@sha256:ce308310f3c1f8761e65338b8ab87b651bf4862c6acb80de510f381fffc4510b AS openapitools
# currently only supports x64, no arm yet due to chrome and selenium dependencies
-FROM python:3.13.13-slim-trixie@sha256:2ba73a4dc380f21137fc75296abfa2add90b51fd10b609ce530b40cc097269b1 AS build
+FROM python:3.13.13-slim-trixie@sha256:9213d136547f0602c3337ff48291e937f9cc43060b3e123402cf2aaff1a08b75 AS build
WORKDIR /app
RUN \
apt-get -y update && \
diff --git a/Dockerfile.nginx-alpine b/Dockerfile.nginx-alpine
index ca80193599d..6df6efa8f82 100644
--- a/Dockerfile.nginx-alpine
+++ b/Dockerfile.nginx-alpine
@@ -5,7 +5,7 @@
# Dockerfile.django-alpine to use the caching mechanism of Docker.
# Ref: https://devguide.python.org/#branchstatus
-FROM python:3.13.13-alpine3.22@sha256:d739d0ecd8cd56c6f842d4ce55087ed551c519ec88fa0b3b98a5717c22a083c9 AS base
+FROM python:3.13.13-alpine3.22@sha256:e81548ac35b07a3bd4805f275107592ef458b1e893c0e04d45aedaa19416cca5 AS base
FROM base AS build
WORKDIR /app
RUN \
diff --git a/docker-compose.yml b/docker-compose.yml
index 5aaaa49e9aa..333387fb6bc 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -120,7 +120,7 @@ services:
source: ./docker/extra_settings
target: /app/docker/extra_settings
postgres:
- image: postgres:18.3-alpine@sha256:4da1a4828be12604092fa55311276f08f9224a74a62dcb4708bd7439e2a03911
+ image: postgres:18.3-alpine@sha256:54451ecb8ab38c24c3ec123f2fd501303a3a1856a5c66e98cecf2460d5e1e9d7
environment:
PGDATA: /var/lib/postgresql/data
POSTGRES_DB: ${DD_DATABASE_NAME:-defectdojo}
diff --git a/dojo/announcement/os_message.py b/dojo/announcement/os_message.py
new file mode 100644
index 00000000000..dfdb9288710
--- /dev/null
+++ b/dojo/announcement/os_message.py
@@ -0,0 +1,119 @@
+import logging
+
+import bleach
+import markdown
+import requests
+from django.core.cache import cache
+
+logger = logging.getLogger(__name__)
+
+BUCKET_URL = "https://storage.googleapis.com/defectdojo-os-messages-prod/open_source_message.md"
+CACHE_SECONDS = 3600
+HTTP_TIMEOUT_SECONDS = 2
+CACHE_KEY = "os_message:v1"
+
+INLINE_TAGS = ["strong", "em", "a"]
+INLINE_ATTRS = {"a": ["href", "title"]}
+
+# Keep BLOCK_TAGS / BLOCK_ATTRS in sync with the DaaS publisher's
+# MARKDOWNIFY["default"]["WHITELIST_TAGS"] / WHITELIST_ATTRS so previews
+# on DaaS and rendering in OSS stay byte-identical.
+BLOCK_TAGS = [
+ "p", "ul", "ol", "li", "a", "strong", "em", "code", "pre",
+ "blockquote", "h2", "h3", "h4", "hr", "br", "b", "i",
+ "abbr", "acronym",
+]
+BLOCK_ATTRS = {
+ "a": ["href", "title"],
+ "abbr": ["title"],
+ "acronym": ["title"],
+}
+
+_MISS = object()
+
+
+def fetch_os_message():
+ cached = cache.get(CACHE_KEY, default=_MISS)
+ if cached is not _MISS:
+ return cached
+
+ try:
+ response = requests.get(BUCKET_URL, timeout=HTTP_TIMEOUT_SECONDS)
+ except Exception:
+ logger.debug("os_message: fetch failed", exc_info=True)
+ cache.set(CACHE_KEY, None, CACHE_SECONDS)
+ return None
+
+ if response.status_code != 200 or not response.text.strip():
+ cache.set(CACHE_KEY, None, CACHE_SECONDS)
+ return None
+
+ cache.set(CACHE_KEY, response.text, CACHE_SECONDS)
+ return response.text
+
+
+def _strip_outer_p(html):
+ stripped = html.strip()
+ if stripped.startswith("
") and stripped.endswith("
"):
+ return stripped[3:-4]
+ return stripped
+
+
+def parse_os_message(text):
+ lines = text.splitlines()
+
+ headline_source = None
+ body_start = None
+ for index, line in enumerate(lines):
+ if line.startswith("# "):
+ headline_source = line[2:].strip()
+ body_start = index + 1
+ break
+
+ if not headline_source:
+ return None
+
+ headline_source = headline_source[:100]
+ headline_rendered = markdown.markdown(headline_source)
+ headline_cleaned = bleach.clean(
+ headline_rendered,
+ tags=INLINE_TAGS,
+ attributes=INLINE_ATTRS,
+ strip=True,
+ )
+ headline_html = _strip_outer_p(headline_cleaned)
+
+ expanded_html = None
+ expanded_marker = "## Expanded Message"
+ expanded_body_lines = None
+ for offset, line in enumerate(lines[body_start:], start=body_start):
+ if line.strip() == expanded_marker:
+ expanded_body_lines = lines[offset + 1:]
+ break
+
+ if expanded_body_lines is not None:
+ expanded_source = "\n".join(expanded_body_lines).strip()
+ if expanded_source:
+ expanded_rendered = markdown.markdown(
+ expanded_source,
+ extensions=["extra", "fenced_code", "nl2br"],
+ )
+ expanded_html = bleach.clean(
+ expanded_rendered,
+ tags=BLOCK_TAGS,
+ attributes=BLOCK_ATTRS,
+ strip=True,
+ )
+
+ return {"message": headline_html, "expanded_html": expanded_html}
+
+
+def get_os_banner():
+ try:
+ text = fetch_os_message()
+ if not text:
+ return None
+ return parse_os_message(text)
+ except Exception:
+ logger.debug("os_message: get_os_banner failed", exc_info=True)
+ return None
diff --git a/dojo/announcement/signals.py b/dojo/announcement/signals.py
index dedd3444654..c74fd0e5d50 100644
--- a/dojo/announcement/signals.py
+++ b/dojo/announcement/signals.py
@@ -1,4 +1,3 @@
-from django.conf import settings
from django.db.models.signals import post_save
from django.dispatch import receiver
@@ -7,22 +6,11 @@
@receiver(post_save, sender=Dojo_User)
def add_announcement_to_new_user(sender, instance, **kwargs):
- announcements = Announcement.objects.all()
- if announcements.count() > 0:
- dojo_user = Dojo_User.objects.get(id=instance.id)
- announcement = announcements.first()
- cloud_announcement = (
- "DefectDojo Pro Cloud and On-Premise Subscriptions Now Available!"
- in announcement.message
+ announcement = Announcement.objects.first()
+ if announcement is not None:
+ UserAnnouncement.objects.get_or_create(
+ user=instance, announcement=announcement,
)
- if not cloud_announcement or settings.CREATE_CLOUD_BANNER:
- user_announcements = UserAnnouncement.objects.filter(
- user=dojo_user, announcement=announcement,
- )
- if user_announcements.count() == 0:
- UserAnnouncement.objects.get_or_create(
- user=dojo_user, announcement=announcement,
- )
@receiver(post_save, sender=Announcement)
diff --git a/dojo/api_v2/permissions.py b/dojo/api_v2/permissions.py
index 807bbcf7b2a..10e012ede5b 100644
--- a/dojo/api_v2/permissions.py
+++ b/dojo/api_v2/permissions.py
@@ -473,7 +473,13 @@ def has_permission(self, request, view):
# Raise an explicit drf exception here
raise ValidationError(e)
if engagement := converted_dict.get("engagement"):
- # existing engagement, nothing special to check
+ # Validate the resolved engagement's parent chain matches any provided identifiers
+ if (product := converted_dict.get("product")) and engagement.product_id != product.id:
+ msg = "The provided identifiers are inconsistent — the engagement does not belong to the specified product."
+ raise ValidationError(msg)
+ if (engagement_name := converted_dict.get("engagement_name")) and engagement.name != engagement_name:
+ msg = "The provided identifiers are inconsistent — the engagement name does not match the specified engagement."
+ raise ValidationError(msg)
return user_has_permission(
request.user, engagement, Permissions.Import_Scan_Result,
)
@@ -764,6 +770,11 @@ def has_permission(self, request, view):
try:
converted_dict = auto_create.convert_querydict_to_dict(request.data)
auto_create.process_import_meta_data_from_dict(converted_dict)
+ # engagement is not a declared field on ReImportScanSerializer and will be
+ # stripped during validation — don't use it in the permission check either,
+ # so the permission check resolves targets the same way execution does
+ converted_dict.pop("engagement", None)
+ converted_dict.pop("engagement_id", None)
# Get an existing product
converted_dict["product_type"] = auto_create.get_target_product_type_if_exists(**converted_dict)
converted_dict["product"] = auto_create.get_target_product_if_exists(**converted_dict)
@@ -774,7 +785,20 @@ def has_permission(self, request, view):
raise ValidationError(e)
if test := converted_dict.get("test"):
- # existing test, nothing special to check
+ # Validate the resolved test's parent chain matches any provided identifiers
+ if (product := converted_dict.get("product")) and test.engagement.product_id != product.id:
+ msg = "The provided identifiers are inconsistent — the test does not belong to the specified product."
+ raise ValidationError(msg)
+ if (engagement := converted_dict.get("engagement")) and test.engagement_id != engagement.id:
+ msg = "The provided identifiers are inconsistent — the test does not belong to the specified engagement."
+ raise ValidationError(msg)
+ # Also validate by name when the objects were not resolved (e.g. names that match no existing record)
+ if not converted_dict.get("product") and (product_name := converted_dict.get("product_name")) and test.engagement.product.name != product_name:
+ msg = "The provided identifiers are inconsistent — the test does not belong to the specified product."
+ raise ValidationError(msg)
+ if not converted_dict.get("engagement") and (engagement_name := converted_dict.get("engagement_name")) and test.engagement.name != engagement_name:
+ msg = "The provided identifiers are inconsistent — the test does not belong to the specified engagement."
+ raise ValidationError(msg)
return user_has_permission(
request.user, test, Permissions.Import_Scan_Result,
)
@@ -1181,7 +1205,10 @@ def check_auto_create_permission(
raise ValidationError(msg)
if engagement:
- # existing engagement, nothing special to check
+ # Validate the resolved engagement's parent chain matches any provided names
+ if product is not None and engagement.product_id != product.id:
+ msg = "The provided identifiers are inconsistent — the engagement does not belong to the specified product."
+ raise ValidationError(msg)
return user_has_permission(
user, engagement, Permissions.Import_Scan_Result,
)
diff --git a/dojo/api_v2/serializers.py b/dojo/api_v2/serializers.py
index 342287aba94..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,
@@ -1123,6 +1120,18 @@ def validate(self, data):
if data.get("target_start") > data.get("target_end"):
msg = "Your target start date exceeds your target end date"
raise serializers.ValidationError(msg)
+ if (
+ self.instance is not None
+ and "product" in data
+ and data.get("product") != self.instance.product
+ and not user_has_permission(
+ self.context["request"].user,
+ data.get("product"),
+ Permissions.Engagement_Edit,
+ )
+ ):
+ msg = "You are not permitted to edit engagements in the destination product"
+ raise PermissionDenied(msg)
return data
def build_relational_field(self, field_name, relation_info):
@@ -1364,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):
@@ -1758,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)
@@ -1832,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):
@@ -1912,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)
@@ -2071,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(
@@ -3069,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/context_processors.py b/dojo/context_processors.py
index cc53af0f1e0..792e1eb6b42 100644
--- a/dojo/context_processors.py
+++ b/dojo/context_processors.py
@@ -5,13 +5,14 @@
from django.conf import settings
from django.contrib import messages
+from dojo.announcement.os_message import get_os_banner
from dojo.labels import get_labels
from dojo.models import Alerts, System_Settings, UserAnnouncement
def globalize_vars(request):
# return the value you want as a dictionnary. you may add multiple values in there.
- return {
+ context = {
"SHOW_LOGIN_FORM": settings.SHOW_LOGIN_FORM,
"FORGOT_PASSWORD": settings.FORGOT_PASSWORD,
"FORGOT_USERNAME": settings.FORGOT_USERNAME,
@@ -35,11 +36,32 @@ def globalize_vars(request):
"DOCUMENTATION_URL": settings.DOCUMENTATION_URL,
"API_TOKENS_ENABLED": settings.API_TOKENS_ENABLED,
"API_TOKEN_AUTH_ENDPOINT_ENABLED": settings.API_TOKEN_AUTH_ENDPOINT_ENABLED,
- "CREATE_CLOUD_BANNER": settings.CREATE_CLOUD_BANNER,
+ "SHOW_PLG_LINK": True,
# V3 Feature Flags
"V3_FEATURE_LOCATIONS": settings.V3_FEATURE_LOCATIONS,
}
+ additional_banners = []
+
+ if (os_banner := get_os_banner()) is not None:
+ additional_banners.append({
+ "source": "os",
+ "message": os_banner["message"],
+ "style": "info",
+ "url": "",
+ "link_text": "",
+ "expanded_html": os_banner["expanded_html"],
+ })
+
+ if hasattr(request, "session"):
+ for banner in request.session.pop("_product_banners", []):
+ additional_banners.append(banner)
+
+ if additional_banners:
+ context["additional_banners"] = additional_banners
+
+ return context
+
def bind_system_settings(request):
"""Load system settings and display warning if there's a database error."""
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 1b94ace21f4..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,11 +281,17 @@ 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
new_status = form.cleaned_data.get("status")
+ if form.cleaned_data.get("product") != engagement.product:
+ user_has_permission_or_403(
+ request.user,
+ form.cleaned_data.get("product"),
+ Permissions.Engagement_Edit,
+ )
engagement.product = form.cleaned_data.get("product")
engagement = form.save(commit=False)
if (new_status in {"Cancelled", "Completed"}):
@@ -301,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:
@@ -321,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)
@@ -465,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)
@@ -534,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)
@@ -796,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,
@@ -1195,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,
@@ -1442,9 +1448,10 @@ def view_edit_risk_acceptance(request, eid, raid, *, edit_mode=False):
"Since you are not the note's author, it was not deleted.",
extra_tags="alert-danger")
- if "remove_finding" in request.POST:
+ if edit_mode and "remove_finding" in request.POST:
finding = get_object_or_404(
- Finding, pk=request.POST["remove_finding_id"])
+ risk_acceptance.accepted_findings,
+ pk=request.POST["remove_finding_id"])
ra_helper.remove_finding_from_risk_acceptance(request.user, risk_acceptance, finding)
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/auto_create_context.py b/dojo/importers/auto_create_context.py
index 26d37ae65b0..916bbea056e 100644
--- a/dojo/importers/auto_create_context.py
+++ b/dojo/importers/auto_create_context.py
@@ -181,6 +181,9 @@ def get_target_engagement_if_exists(
"""
if engagement := get_object_or_none(Engagement, pk=engagement_id):
logger.debug("Using existing engagement by id: %s", engagement_id)
+ if product is not None and engagement.product_id != product.id:
+ msg = "The provided identifiers are inconsistent — the engagement does not belong to the specified product."
+ raise ValueError(msg)
return engagement
# if there's no product, then for sure there's no engagement either
if product is None:
@@ -203,6 +206,9 @@ def get_target_test_if_exists(
"""
if test := get_object_or_none(Test, pk=test_id):
logger.debug("Using existing Test by id: %s", test_id)
+ if engagement is not None and test.engagement_id != engagement.id:
+ msg = "The provided identifiers are inconsistent — the test does not belong to the specified engagement."
+ raise ValueError(msg)
return test
# If the engagement is not supplied, we cannot do anything
if not engagement:
diff --git a/dojo/importers/base_importer.py b/dojo/importers/base_importer.py
index c149f4e169d..85a1387d3aa 100644
--- a/dojo/importers/base_importer.py
+++ b/dojo/importers/base_importer.py
@@ -16,7 +16,7 @@
from dojo.celery_dispatch import dojo_dispatch_task
from dojo.importers.location_manager import LocationManager, UnsavedLocation
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
@@ -950,7 +950,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 e8bd56baf55..6b3f3e1ec96 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.endpoint_manager import EndpointManager
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,
@@ -304,9 +303,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")
@@ -413,8 +412,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 2a22da10a35..d7759921240 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.endpoint_manager import EndpointManager
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.location.status import FindingLocationStatus
from dojo.models import (
Development_Environment,
@@ -564,8 +563,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)
@@ -1065,8 +1064,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)):
@@ -1076,8 +1075,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/complete_initialization.py b/dojo/management/commands/complete_initialization.py
index 556c77867fb..9ae58926324 100644
--- a/dojo/management/commands/complete_initialization.py
+++ b/dojo/management/commands/complete_initialization.py
@@ -14,7 +14,6 @@
from django.db.utils import ProgrammingError
from dojo.auditlog import configure_pghistory_triggers
-from dojo.models import Announcement, Dojo_User, UserAnnouncement
class Command(BaseCommand):
@@ -38,13 +37,11 @@ def handle(self, *args: Any, **options: Any) -> None:
if self.admin_user_exists():
self.stdout.write("Admin user already exists; skipping first-boot setup")
- self.create_announcement_banner()
self.initialize_data()
return
self.ensure_admin_secrets()
self.first_boot_setup()
- self.create_announcement_banner()
self.initialize_data()
# ------------------------------------------------------------------
@@ -58,29 +55,6 @@ def initialize_data(self) -> None:
self.stdout.write("Initializing non-standard permissions")
call_command("initialize_permissions")
- def create_announcement_banner(self) -> None:
- if os.getenv("DD_CREATE_CLOUD_BANNER"):
- return
-
- self.stdout.write("Creating announcement banner")
-
- announcement, _ = Announcement.objects.get_or_create(id=1)
- announcement.message = (
- ''
- "DefectDojo Pro Cloud and On-Premise Subscriptions Now Available! "
- "Create an account to try Pro for free!"
- ""
- )
- announcement.dismissable = True
- announcement.save()
-
- for user in Dojo_User.objects.all():
- UserAnnouncement.objects.get_or_create(
- user=user,
- announcement=announcement,
- )
-
# ------------------------------------------------------------------
# Auditlog consistency
# ------------------------------------------------------------------
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/product_announcements.py b/dojo/product_announcements.py
index 8510b42a0f8..90280885007 100644
--- a/dojo/product_announcements.py
+++ b/dojo/product_announcements.py
@@ -1,8 +1,6 @@
import logging
-from django.conf import settings
-from django.contrib import messages
from django.http import HttpRequest, HttpResponse
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
@@ -30,12 +28,8 @@ def __init__(
response_data: dict | None = None,
**kwargs: dict,
):
- """Skip all this if the CREATE_CLOUD_BANNER is not set"""
- if not settings.CREATE_CLOUD_BANNER:
- return
- # Fill in the vars if the were supplied correctly
if request is not None and isinstance(request, HttpRequest):
- self._add_django_message(
+ self._add_session_banner(
request=request,
message=mark_safe(f"{self.base_message} {self.ui_outreach}"),
)
@@ -51,18 +45,21 @@ def __init__(
msg = "At least one of request, response, or response_data must be supplied"
raise ValueError(msg)
- def _add_django_message(self, request: HttpRequest, message: str):
- """Add a message to the UI"""
+ def _add_session_banner(self, request: HttpRequest, message: str):
+ """Store a banner in the session for rendering via additional_banners."""
try:
- messages.add_message(
- request=request,
- level=messages.INFO,
- message=_(message),
- extra_tags="alert-info",
- )
+ banners = request.session.get("_product_banners", [])
+ banners.append({
+ "source": "product_announcement",
+ "message": str(_(message)),
+ "style": "info",
+ "url": "",
+ "link_text": "",
+ "expanded_html": None,
+ })
+ request.session["_product_banners"] = banners
except Exception:
- # make sure we catch any exceptions that might happen: https://github.com/DefectDojo/django-DefectDojo/issues/14041
- logger.exception(f"Error adding message to Django: {message}")
+ logger.exception(f"Error storing product announcement banner: {message}")
def _add_api_response_key(self, message: str, data: dict) -> dict:
"""Update the response data in place"""
diff --git a/dojo/risk_acceptance/api.py b/dojo/risk_acceptance/api.py
index 2fdaadf0afb..4db4e09c76d 100644
--- a/dojo/risk_acceptance/api.py
+++ b/dojo/risk_acceptance/api.py
@@ -1,18 +1,20 @@
from abc import ABC, abstractmethod
from typing import NamedTuple
+from django.core.exceptions import PermissionDenied
from django.db.models import QuerySet
from django.utils import timezone
from drf_spectacular.utils import extend_schema
from rest_framework import serializers, status
from rest_framework.decorators import action
-from rest_framework.permissions import IsAdminUser
+from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
+from dojo.api_v2.permissions import UserHasRiskAcceptanceRelatedObjectPermission
from dojo.api_v2.serializers import RiskAcceptanceSerializer
from dojo.authorization.roles_permissions import Permissions
from dojo.engagement.queries import get_authorized_engagements
-from dojo.models import Risk_Acceptance, User, Vulnerability_Id
+from dojo.models import Engagement, Risk_Acceptance, User, Vulnerability_Id
AcceptedRisk = NamedTuple("AcceptedRisk", (("vulnerability_id", str), ("justification", str), ("accepted_by", str)))
@@ -40,10 +42,13 @@ def risk_application_model_class(self):
request=AcceptedRiskSerializer(many=True),
responses={status.HTTP_201_CREATED: RiskAcceptanceSerializer(many=True)},
)
- @action(methods=["post"], detail=True, permission_classes=[IsAdminUser], serializer_class=AcceptedRiskSerializer,
- filter_backends=[], pagination_class=None)
+ @action(methods=["post"], detail=True, permission_classes=[IsAuthenticated, UserHasRiskAcceptanceRelatedObjectPermission],
+ serializer_class=AcceptedRiskSerializer, filter_backends=[], pagination_class=None)
def accept_risks(self, request, pk=None):
model = self.get_object()
+ product = model.product if isinstance(model, Engagement) else model.engagement.product
+ if not product.enable_full_risk_acceptance:
+ raise PermissionDenied
serializer = AcceptedRiskSerializer(data=request.data, many=True)
if serializer.is_valid():
accepted_risks = serializer.save()
@@ -63,7 +68,7 @@ class AcceptedFindingsMixin(ABC):
request=AcceptedRiskSerializer(many=True),
responses={status.HTTP_201_CREATED: RiskAcceptanceSerializer(many=True)},
)
- @action(methods=["post"], detail=False, permission_classes=[IsAdminUser], serializer_class=AcceptedRiskSerializer)
+ @action(methods=["post"], detail=False, permission_classes=[IsAuthenticated], serializer_class=AcceptedRiskSerializer)
def accept_risks(self, request):
serializer = AcceptedRiskSerializer(data=request.data, many=True)
if serializer.is_valid():
@@ -72,7 +77,9 @@ def accept_risks(self, request):
return Response(data=serializer.errors, status=status.HTTP_400_BAD_REQUEST)
owner = request.user
accepted_result = []
- for engagement in get_authorized_engagements(Permissions.Engagement_View):
+ for engagement in get_authorized_engagements(Permissions.Risk_Acceptance):
+ if not engagement.product.enable_full_risk_acceptance:
+ continue
base_findings = engagement.unaccepted_open_findings
accepted = _accept_risks(accepted_risks, base_findings, owner)
engagement.accept_risks(accepted)
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/settings/settings.dist.py b/dojo/settings/settings.dist.py
index 429b5646b90..b922005ff0a 100644
--- a/dojo/settings/settings.dist.py
+++ b/dojo/settings/settings.dist.py
@@ -356,8 +356,6 @@
DD_HASHCODE_FIELDS_PER_SCANNER=(str, ""),
# Set deduplication algorithms per parser, via en env variable that contains a JSON string
DD_DEDUPLICATION_ALGORITHM_PER_PARSER=(str, ""),
- # Dictates whether cloud banner is created or not
- DD_CREATE_CLOUD_BANNER=(bool, True),
# With this setting turned on, Dojo maintains an audit log of changes made to entities (Findings, Tests, Engagements, Products, ...)
# If you run big import you may want to disable this because there's a performance hit during (re-)imports.
DD_ENABLE_AUDITLOG=(bool, True),
@@ -1339,13 +1337,6 @@ def saml2_attrib_map_format(din):
"expires": int(60 * 1 * 1.2), # If a task is not executed within 72 seconds, it should be dropped from the queue. Two more tasks should be scheduled in the meantime.
},
},
- "trigger_evaluate_pro_proposition": {
- "task": "dojo.tasks.evaluate_pro_proposition",
- "schedule": timedelta(hours=8),
- "options": {
- "expires": int(60 * 60 * 8 * 1.2), # If a task is not executed within 9.6 hours, it should be dropped from the queue. Two more tasks should be scheduled in the meantime.
- },
- },
"clear_sessions": {
"task": "dojo.tasks.clear_sessions",
"schedule": crontab(hour=0, minute=0, day_of_week=0),
@@ -2036,6 +2027,7 @@ def saml2_attrib_map_format(din):
"KHV": "https://avd.aquasec.com/misconfig/kubernetes/", # e.g. https://avd.aquasec.com/misconfig/kubernetes/khv045
"LEN-": "https://support.lenovo.com/cl/de/product_security/", # e.g. https://support.lenovo.com/cl/de/product_security/LEN-94953
"MAL-": "https://cvepremium.circl.lu/vuln/", # e.g. https://cvepremium.circl.lu/vuln/mal-2025-49305
+ "MFSA": "https://www.mozilla.org/en-US/security/advisories/", # e.g. https://www.mozilla.org/en-US/security/advisories/mfsa2025-01/
"MGAA-": "https://advisories.mageia.org/&&.html", # e.g. https://advisories.mageia.org/MGAA-2013-0054.html
"MGASA-": "https://advisories.mageia.org/&&.html", # e.g. https://advisories.mageia.org/MGASA-2025-0023.html
"MSRC_": "https://cvepremium.circl.lu/vuln/", # e.g. https://cvepremium.circl.lu/vuln/msrc_cve-2025-59200
@@ -2082,9 +2074,6 @@ def saml2_attrib_map_format(din):
AUDITLOG_DISABLE_ON_RAW_SAVE = False
# You can set extra Jira headers by suppling a dictionary in header: value format (pass as env var like "headr_name=value,another_header=anohter_value")
ADDITIONAL_HEADERS = env("DD_ADDITIONAL_HEADERS")
-# Dictates whether cloud banner is created or not
-CREATE_CLOUD_BANNER = env("DD_CREATE_CLOUD_BANNER")
-
# ------------------------------------------------------------------------------
# Auditlog
# ------------------------------------------------------------------------------
diff --git a/dojo/static/dojo/css/dojo.css b/dojo/static/dojo/css/dojo.css
index deba72474c9..fea91da6c94 100644
--- a/dojo/static/dojo/css/dojo.css
+++ b/dojo/static/dojo/css/dojo.css
@@ -1124,6 +1124,41 @@ div.custom-search-form {
.announcement-banner {
margin: 0px -15px;
border-radius: 0px 0px 4px 4px;
+ color: #000;
+}
+
+.announcement-banner a {
+ color: #0645ad;
+ text-decoration: underline;
+}
+
+.announcement-banner strong,
+.announcement-banner b {
+ color: #222;
+}
+
+.banner-toggle {
+ background: transparent;
+ border: 0;
+ padding: 0;
+ margin-left: 6px;
+ color: inherit;
+ cursor: pointer;
+ line-height: 1;
+}
+
+.banner-toggle:focus,
+.banner-toggle:active {
+ outline: none;
+ box-shadow: none;
+}
+
+.banner-toggle:not(.collapsed) .fa-caret-down {
+ transform: rotate(180deg);
+}
+
+.banner-expanded {
+ margin-top: 8px;
}
@media (min-width: 795px) {
diff --git a/dojo/tasks.py b/dojo/tasks.py
index 5a494072a8b..dbc2135e560 100644
--- a/dojo/tasks.py
+++ b/dojo/tasks.py
@@ -16,9 +16,8 @@
from dojo.celery import app
from dojo.celery_dispatch import dojo_dispatch_task
from dojo.finding.helper import fix_loop_duplicates
-from dojo.location.models import Location
from dojo.management.commands.jira_status_reconciliation import jira_status_reconciliation
-from dojo.models import Alerts, Announcement, Endpoint, Engagement, Finding, Product, System_Settings, User
+from dojo.models import Alerts, Engagement, Finding, Product, System_Settings, User
from dojo.notifications.helper import create_notification
from dojo.utils import calculate_grade, sla_compute_and_notify
@@ -203,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",
@@ -218,37 +220,6 @@ def fix_loop_duplicates_task(*args, **kwargs):
return fix_loop_duplicates()
-@app.task
-def evaluate_pro_proposition(*args, **kwargs):
- # Ensure we should be doing this
- if not settings.CREATE_CLOUD_BANNER:
- return
- # Get the announcement object
- announcement = Announcement.objects.get_or_create(id=1)[0]
- # Quick check for a user has modified the current banner - if not, exit early as we dont want to stomp
- if not any(
- entry in announcement.message
- for entry in [
- "",
- "DefectDojo Pro Cloud and On-Premise Subscriptions Now Available!",
- "Findings/Endpoints in their systems",
- ]
- ):
- return
- # Count the objects the determine if the banner should be updated
- if settings.V3_FEATURE_LOCATIONS:
- object_count = Finding.objects.count() + Location.objects.count()
- else:
- # TODO: Delete this after the move to Locations
- object_count = Finding.objects.count() + Endpoint.objects.count()
- # Unless the count is greater than 100k, exit early
- if object_count < 100000:
- return
- # Update the announcement
- announcement.message = f'Only professionals have {object_count:,} Findings and Endpoints in their systems... Get DefectDojo Pro today!'
- announcement.save()
-
-
@app.task
def clear_sessions(*args, **kwargs):
call_command("clearsessions")
diff --git a/dojo/templates/base.html b/dojo/templates/base.html
index 54d6e0e58af..b8f5489f1d7 100644
--- a/dojo/templates/base.html
+++ b/dojo/templates/base.html
@@ -199,7 +199,7 @@
{% endif %}
- {% if CREATE_CLOUD_BANNER %}
+ {% if SHOW_PLG_LINK %}
Mitigated Endpoints / Systems ({{ finding.mitigated_endpoint_count }})
{% if V3_FEATURE_LOCATIONS %}
{{ endpoint.location }}{% if endpoint.is_broken %} 🚩{% endif %}
{{ endpoint.status }}
-
{{ endpoint.auditor|safe }}
+
{{ endpoint.auditor }}
{{ endpoint.audit_time|date }}
{% else %}
- {% comment %} TODO: Delete this after the move to Locations {% endcomment %}
+ {% comment %} TODO: Delete this after the move to Locations {% endcomment %}
{{ endpoint }}{% if endpoint.endpoint.is_broken %} 🚩{% endif %}
{{ endpoint.status }}
-
{{ endpoint.mitigated_by|safe }}
+
{{ endpoint.mitigated_by }}
{{ endpoint.mitigated_time|date }}
{% endif %}
@@ -256,7 +256,7 @@
Mitigated Endpoints / Systems ({{ finding.mitigated_endpoint_count }})
{% include "dojo/snippets/tags.html" with tags=endpoint.location.tags.all %}
{{ endpoint.get_status_display }}
-
{{ endpoint.auditor|safe }}
+
{{ endpoint.auditor }}
{{ endpoint.audit_time|date }}
{% else %}
{% comment %} TODO: Delete this after the move to Locations {% endcomment %}
@@ -265,7 +265,7 @@
Mitigated Endpoints / Systems ({{ finding.mitigated_endpoint_count }})
{% include "dojo/snippets/tags.html" with tags=endpoint.endpoint.tags.all %}