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 %}
  • @@ -671,8 +671,21 @@ {% endif %} {% for banner in additional_banners %} -
  • Major feature A
  • ", result["expanded_html"]) + self.assertIn("
  • Major feature B
  • ", result["expanded_html"]) + + def test_missing_headline_returns_none(self): + text = "No headline here\n## Expanded Message\nbody\n" + self.assertIsNone(os_message.parse_os_message(text)) + + def test_headline_inline_markdown(self): + text = "# Read the **release notes** at [link](https://example.com)\n" + result = os_message.parse_os_message(text) + self.assertIn("release notes", result["message"]) + self.assertIn('link', result["message"]) + self.assertIsNone(result["expanded_html"]) + + def test_headline_strips_disallowed_html(self): + text = "# Headline tail\n" + result = os_message.parse_os_message(text) + self.assertNotIn("", result["message"]) + self.assertIn("Headline", result["message"]) + + def test_missing_expanded_section(self): + text = "# Just a headline\n" + result = os_message.parse_os_message(text) + self.assertEqual(result["message"], "Just a headline") + self.assertIsNone(result["expanded_html"]) + + def test_expanded_with_fenced_code(self): + text = ( + "# Headline\n" + "## Expanded Message\n" + "```python\n" + "print('hi')\n" + "```\n" + ) + result = os_message.parse_os_message(text) + self.assertIn("
    ", result["expanded_html"])
    +        self.assertIn("", result["expanded_html"])
    +        self.assertIn("print('hi')", result["expanded_html"])
    +
    +    def test_expanded_strips_script_tag(self):
    +        text = (
    +            "# Headline\n"
    +            "## Expanded Message\n"
    +            "\n"
    +            "Body paragraph\n"
    +        )
    +        result = os_message.parse_os_message(text)
    +        self.assertNotIn("", result["expanded_html"])
    +        self.assertIn("Body paragraph", result["expanded_html"])
    +
    +    def test_headline_outer_p_is_stripped(self):
    +        text = "# Plain headline\n"
    +        result = os_message.parse_os_message(text)
    +        self.assertFalse(result["message"].startswith("

    ")) + self.assertFalse(result["message"].endswith("

    ")) + + def test_headline_truncated_to_100_chars(self): + long_headline = "x" * 200 + text = f"# {long_headline}\n" + result = os_message.parse_os_message(text) + self.assertLessEqual(len(result["message"]), 100) + + +@override_settings(CACHES={"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}) +class TestFetchOsMessage(SimpleTestCase): + + def setUp(self): + cache.clear() + + def test_200_with_body_caches_body(self): + body = "# headline\n" + with patch("dojo.announcement.os_message.requests.get", return_value=_Resp(200, body)) as mock_get: + result = os_message.fetch_os_message() + self.assertEqual(result, body) + self.assertEqual(cache.get(os_message.CACHE_KEY), body) + mock_get.assert_called_once() + + def test_404_caches_none(self): + with patch("dojo.announcement.os_message.requests.get", return_value=_Resp(404, "not found")): + result = os_message.fetch_os_message() + self.assertIsNone(result) + self.assertIsNone(cache.get(os_message.CACHE_KEY, default="sentinel")) + + def test_timeout_caches_none(self): + with patch("dojo.announcement.os_message.requests.get", side_effect=requests.exceptions.Timeout): + result = os_message.fetch_os_message() + self.assertIsNone(result) + self.assertIsNone(cache.get(os_message.CACHE_KEY, default="sentinel")) + + def test_connection_error_caches_none(self): + with patch("dojo.announcement.os_message.requests.get", side_effect=requests.exceptions.ConnectionError): + result = os_message.fetch_os_message() + self.assertIsNone(result) + self.assertIsNone(cache.get(os_message.CACHE_KEY, default="sentinel")) + + def test_empty_body_caches_none(self): + with patch("dojo.announcement.os_message.requests.get", return_value=_Resp(200, " \n\n")): + result = os_message.fetch_os_message() + self.assertIsNone(result) + self.assertIsNone(cache.get(os_message.CACHE_KEY, default="sentinel")) + + def test_second_call_hits_cache(self): + with patch("dojo.announcement.os_message.requests.get", return_value=_Resp(200, "# h\n")) as mock_get: + os_message.fetch_os_message() + os_message.fetch_os_message() + self.assertEqual(mock_get.call_count, 1) + + def test_second_call_after_failure_also_hits_cache(self): + with patch("dojo.announcement.os_message.requests.get", side_effect=requests.exceptions.Timeout) as mock_get: + os_message.fetch_os_message() + os_message.fetch_os_message() + self.assertEqual(mock_get.call_count, 1) + + +@override_settings(CACHES={"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}) +class TestGetOsBanner(SimpleTestCase): + + def setUp(self): + cache.clear() + + def test_returns_none_when_fetch_returns_none(self): + with patch("dojo.announcement.os_message.fetch_os_message", return_value=None): + self.assertIsNone(os_message.get_os_banner()) + + def test_swallows_parse_exception(self): + with patch("dojo.announcement.os_message.fetch_os_message", return_value="# ok\n"), \ + patch("dojo.announcement.os_message.parse_os_message", side_effect=RuntimeError("boom")): + self.assertIsNone(os_message.get_os_banner()) + + +@override_settings(CACHES={"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}) +class TestGlobalizeVarsOsBanner(SimpleTestCase): + + def setUp(self): + cache.clear() + self.request = RequestFactory().get("/") + + def test_additional_banners_populated_when_banner_present(self): + banner = {"message": "Hi", "expanded_html": "

    body

    "} + with patch.object(context_processors, "get_os_banner", return_value=banner): + result = context_processors.globalize_vars(self.request) + self.assertIn("additional_banners", result) + entry = result["additional_banners"][0] + self.assertEqual(entry["source"], "os") + self.assertEqual(entry["message"], "Hi") + self.assertEqual(entry["expanded_html"], "

    body

    ") + self.assertEqual(entry["style"], "info") + self.assertEqual(entry["url"], "") + self.assertEqual(entry["link_text"], "") + + def test_additional_banners_absent_when_no_banner(self): + with patch.object(context_processors, "get_os_banner", return_value=None): + result = context_processors.globalize_vars(self.request) + self.assertNotIn("additional_banners", result) + + def test_show_plg_link_is_true_by_default(self): + with patch.object(context_processors, "get_os_banner", return_value=None): + result = context_processors.globalize_vars(self.request) + self.assertTrue(result["SHOW_PLG_LINK"]) + + def test_create_cloud_banner_not_in_context(self): + with patch.object(context_processors, "get_os_banner", return_value=None): + result = context_processors.globalize_vars(self.request) + self.assertNotIn("CREATE_CLOUD_BANNER", result) + + def test_template_renders_bleached_message(self): + banner = {"message": "Hi", "expanded_html": None} + with patch.object(context_processors, "get_os_banner", return_value=banner): + ctx = context_processors.globalize_vars(self.request) + rendered = Template( + "{% for b in additional_banners %}{{ b.message|safe }}{% endfor %}", + ).render(Context(ctx)) + self.assertIn("Hi", rendered) + + def test_session_product_banners_merged_into_additional_banners(self): + session_banner = { + "source": "product_announcement", + "message": "Pro has async imports!", + "style": "info", + "url": "", + "link_text": "", + "expanded_html": None, + } + self.request.session = {"_product_banners": [session_banner]} + with patch.object(context_processors, "get_os_banner", return_value=None): + result = context_processors.globalize_vars(self.request) + self.assertIn("additional_banners", result) + self.assertEqual(len(result["additional_banners"]), 1) + self.assertEqual(result["additional_banners"][0]["source"], "product_announcement") + self.assertEqual(self.request.session.get("_product_banners"), None) + + def test_os_and_session_banners_combined(self): + os_banner = {"message": "OS msg", "expanded_html": None} + session_banner = { + "source": "product_announcement", + "message": "Pro msg", + "style": "info", + "url": "", + "link_text": "", + "expanded_html": None, + } + self.request.session = {"_product_banners": [session_banner]} + with patch.object(context_processors, "get_os_banner", return_value=os_banner): + result = context_processors.globalize_vars(self.request) + self.assertEqual(len(result["additional_banners"]), 2) + self.assertEqual(result["additional_banners"][0]["source"], "os") + self.assertEqual(result["additional_banners"][1]["source"], "product_announcement") diff --git a/unittests/test_pdf_report_rendering.py b/unittests/test_pdf_report_rendering.py new file mode 100644 index 00000000000..1cdfe1e34e1 --- /dev/null +++ b/unittests/test_pdf_report_rendering.py @@ -0,0 +1,223 @@ +from django.template import engines +from django.utils.timezone import now + +from dojo.models import ( + Engagement, + Finding, + Product, + Product_Type, + Test, + Test_Type, + User, +) +from unittests.dojo_test_case import DojoTestCase, versioned_fixtures + + +@versioned_fixtures +class TestPdfReportTextWrapping(DojoTestCase): + + """ + Tests that PDF report templates render long and pre-wrapped content + within margins instead of overflowing. + """ + + fixtures = ["dojo_testdata.json"] + + LONG_URL = "https://app.example.com/assets/vendor-" + "a1b2c3d4" * 8 + ".js.map" + + # Content with an embedded
     tag, simulating imports (e.g. BugCrowd CSV)
    +    # that store HTML-wrapped text in finding fields.
    +    DESCRIPTION_WITH_PRE = (
    +        "
    \n"
    +        "An internal debug configuration file (debug-config-e7f3a901.json) is publicly "
    +        "accessible at the URL: " + LONG_URL + ". "
    +        "Debug configuration files can reveal internal service addresses, feature flags, "
    +        "and environment variables. Exposing such files can leak sensitive information "
    +        "about the application infrastructure, aiding attackers in lateral movement and "
    +        "facilitating exploitation of internal services.\n"
    +        "
    " + ) + + MITIGATION_WITH_PRE = ( + '
    \n'
    +        "Remove Debug Files From Public Directories: Ensure .json debug configuration "
    +        "files are not deployed to publicly accessible paths on the web server.\n"
    +        "Restrict Access: If debug configurations are required in staging environments, "
    +        "restrict access to authenticated admin users only via IP whitelisting.\n"
    +        "Environment-Specific Builds: Use separate build profiles for development and "
    +        "production to ensure debug artifacts are excluded from release bundles.\n"
    +        "Audit Build Artifacts: Regularly scan deployment artifacts for unintended "
    +        "inclusion of debug or configuration files.\n"
    +        "
    " + ) + + IMPACT_WITH_PRE = ( + '
    \n'
    +        "Information Disclosure: Attackers can discover internal microservice endpoints, "
    +        "feature flag states, and environment-specific configuration values.\n"
    +        "Lateral Movement: Revealed internal addresses may allow attackers to probe "
    +        "backend services that are not intended to be publicly reachable.\n"
    +        "Credential Exposure: If the debug configuration includes API keys or tokens "
    +        "left by developers, this could lead to unauthorized access.\n"
    +        "
    " + ) + + STEPS_WITH_PRE = ( + '
    \n'
    +        "Open a web browser.\n"
    +        "Navigate to the URL: " + LONG_URL + ".\n"
    +        "Observe that the configuration file is accessible and can be downloaded.\n"
    +        "Review the file contents for internal service addresses and environment variables.\n"
    +        "
    " + ) + + # Plain markdown content (no embedded
     tags) with a very long unbroken token
    +    DESCRIPTION_LONG_TOKEN = (
    +        "A session token was observed in the query string: "
    +        "token=" + "x" * 300 + " "
    +        "which exceeds normal length and may cause rendering issues in reports."
    +    )
    +
    +    def setUp(self):
    +        super().setUp()
    +        self.user = User.objects.get(username="admin")
    +        self.product_type = Product_Type.objects.create(name="Report Test PT")
    +        self.product = Product.objects.create(
    +            name="Report Test Product",
    +            description="Product for report tests",
    +            prod_type=self.product_type,
    +        )
    +        self.engagement = Engagement.objects.create(
    +            name="Report Test Engagement",
    +            product=self.product,
    +            target_start=now(),
    +            target_end=now(),
    +        )
    +        self.test_type = Test_Type.objects.create(name="Report Test Scan")
    +        self.test_obj = Test.objects.create(
    +            engagement=self.engagement,
    +            test_type=self.test_type,
    +            title="Report Rendering Test",
    +            target_start=now(),
    +            target_end=now(),
    +        )
    +        self.django_engine = engines["django"]
    +
    +    def _create_finding(self, **kwargs):
    +        defaults = {
    +            "title": "Debug Configuration File Exposed",
    +            "test": self.test_obj,
    +            "severity": "Medium",
    +            "description": self.DESCRIPTION_WITH_PRE,
    +            "mitigation": self.MITIGATION_WITH_PRE,
    +            "impact": self.IMPACT_WITH_PRE,
    +            "steps_to_reproduce": self.STEPS_WITH_PRE,
    +            "active": True,
    +            "verified": True,
    +            "reporter": self.user,
    +            "numerical_severity": "S2",
    +            "date": now().date(),
    +        }
    +        defaults.update(kwargs)
    +        return Finding.objects.create(**defaults)
    +
    +    def _render_finding_report(self, findings):
    +        """Render finding_pdf_report.html with the given findings and return HTML."""
    +        template = self.django_engine.get_template("dojo/finding_pdf_report.html")
    +        context = {
    +            "report_name": "Finding Report",
    +            "findings": findings,
    +            "include_finding_notes": 0,
    +            "include_finding_images": 0,
    +            "include_executive_summary": 0,
    +            "include_table_of_contents": 0,
    +            "include_disclaimer": 0,
    +            "disclaimer": "",
    +            "user": self.user,
    +            "team_name": "Test Team",
    +            "title": "Finding Report",
    +            "host": "http://localhost:8080",
    +            "user_id": self.user.id,
    +        }
    +        return template.render(context)
    +
    +    def test_no_nested_pre_tags_in_report(self):
    +        """
    +        Markdown-rendered fields should not produce nested 
     elements.
    +
    +        When imported data already contains 
     tags (common with BugCrowd CSV
    +        imports), the template wrapper must not add an additional 
     layer.
    +        The outer wrapper should be a 
    . + """ + finding = self._create_finding() + html = self._render_finding_report(Finding.objects.filter(pk=finding.pk)) + + # The template should wrap markdown-rendered fields in div.report-field, + # not in
     tags. We should not see 
     nesting.
    +        self.assertNotIn("
    ", html)
    +        self.assertNotIn("
    +        # Find the section containing our long token
    +        idx = html.index("x" * 300)
    +        # Walk backwards to find the nearest opening tag
    +        preceding = html[max(0, idx - 500):idx]
    +        self.assertIn("report-field", preceding)
    +
    +    def test_report_base_css_has_overflow_wrap(self):
    +        """The report base template must include overflow-wrap for text wrapping."""
    +        template = self.django_engine.get_template("report_base.html")
    +        source = template.template.source
    +
    +        self.assertIn("overflow-wrap: break-word", source)
    +
    +    def test_report_base_css_styles_nested_pre(self):
    +        """
    +        The report base CSS must style .report-field pre to prevent
    +        nested 
     elements from breaking out of margins.
    +        """
    +        template = self.django_engine.get_template("report_base.html")
    +        source = template.template.source
    +
    +        self.assertIn(".report-field pre", source)
    +        self.assertIn("overflow-wrap: break-word", source)
    +
    +    def test_raw_request_pre_tags_preserved(self):
    +        """
    +        Raw request/response 
     tags should remain unchanged.
    +
    +        Only markdown-rendered fields should use div.report-field wrappers.
    +        The raw_request class pre tags are for literal request/response data
    +        and should stay as 
    .
    +        """
    +        template = self.django_engine.get_template("dojo/finding_pdf_report.html")
    +        source = template.template.source
    +        self.assertIn('class="raw_request"', source)
    +        # raw_request should still be inside 
     tags
    +        self.assertIn('
    ', source)
    diff --git a/unittests/test_permissions_audit.py b/unittests/test_permissions_audit.py
    index 8904c6270be..9d4ca0a746e 100644
    --- a/unittests/test_permissions_audit.py
    +++ b/unittests/test_permissions_audit.py
    @@ -12,6 +12,7 @@
     8. Questionnaire cross-engagement IDOR (H1 #3571957)
     9. Finding Templates exposure via find_template_to_apply (H1 #3577363)
     10. Jira Epic BFLA - Reader cannot trigger update_jira_epic (H1 #3577193)
    +11. Risk Acceptance remove_finding: edit_mode guard + scoped finding lookup (PR #14633)
     """
     import datetime
     
    @@ -701,6 +702,266 @@ def test_view_risk_acceptance_same_engagement(self):
             self.assertEqual(response.status_code, 200)
     
     
    +class TestRiskAcceptanceRemoveFindingGuard(DojoTestCase):
    +
    +    """
    +    PR #14633: view_edit_risk_acceptance must:
    +    1. Only process 'remove_finding' when edit_mode is True (Writer+ via edit URL).
    +    2. Scope the finding lookup to risk_acceptance.accepted_findings (not global Finding).
    +
    +    Prevents a Reader from removing findings via the view URL, and prevents
    +    cross-product blind enumeration of finding IDs.
    +    """
    +
    +    @classmethod
    +    def setUpTestData(cls):
    +        cls.reader_role = Role.objects.get(name="Reader")
    +        cls.owner_role = Role.objects.get(name="Owner")
    +
    +        # ── Product A ────────────────────────────────────────────────
    +        cls.product_type_a = Product_Type.objects.create(
    +            name="RA Remove Guard Test PT A",
    +        )
    +        cls.product_a = Product.objects.create(
    +            name="RA Remove Guard Product A",
    +            description="Test",
    +            prod_type=cls.product_type_a,
    +            enable_full_risk_acceptance=True,
    +        )
    +
    +        # ── Product B (for cross-product IDOR test) ─────────────────
    +        cls.product_type_b = Product_Type.objects.create(
    +            name="RA Remove Guard Test PT B",
    +        )
    +        cls.product_b = Product.objects.create(
    +            name="RA Remove Guard Product B",
    +            description="Test",
    +            prod_type=cls.product_type_b,
    +            enable_full_risk_acceptance=True,
    +        )
    +
    +        # ── Users ────────────────────────────────────────────────────
    +        cls.reader_user_a = Dojo_User.objects.create_user(
    +            username="ra_remove_reader_a",
    +            password="testTEST1234!@#$",  # noqa: S106
    +            is_active=True,
    +        )
    +        cls.owner_user_a = Dojo_User.objects.create_user(
    +            username="ra_remove_owner_a",
    +            password="testTEST1234!@#$",  # noqa: S106
    +            is_active=True,
    +        )
    +        cls.owner_user_b = Dojo_User.objects.create_user(
    +            username="ra_remove_owner_b",
    +            password="testTEST1234!@#$",  # noqa: S106
    +            is_active=True,
    +        )
    +
    +        # ── Role assignments ─────────────────────────────────────────
    +        Product_Member.objects.create(
    +            product=cls.product_a,
    +            user=cls.reader_user_a,
    +            role=cls.reader_role,
    +        )
    +        Product_Member.objects.create(
    +            product=cls.product_a,
    +            user=cls.owner_user_a,
    +            role=cls.owner_role,
    +        )
    +        Product_Member.objects.create(
    +            product=cls.product_b,
    +            user=cls.owner_user_b,
    +            role=cls.owner_role,
    +        )
    +
    +        # ── Product A: engagement, test, findings, risk acceptance ───
    +        cls.engagement_a = Engagement.objects.create(
    +            name="RA Remove Guard Engagement A",
    +            product=cls.product_a,
    +            target_start=datetime.date(2024, 1, 1),
    +            target_end=datetime.date(2024, 12, 31),
    +        )
    +        test_type, _ = Test_Type.objects.get_or_create(
    +            name="Manual Code Review",
    +        )
    +        cls.test_a = Test.objects.create(
    +            engagement=cls.engagement_a,
    +            test_type=test_type,
    +            target_start=timezone.now(),
    +            target_end=timezone.now(),
    +        )
    +
    +        # Finding that IS in the risk acceptance
    +        cls.finding_a = Finding.objects.create(
    +            title="RA Remove Guard Finding A",
    +            test=cls.test_a,
    +            severity="High",
    +            numerical_severity="S1",
    +            active=False,
    +            risk_accepted=True,
    +            reporter=cls.owner_user_a,
    +        )
    +
    +        # Finding in same engagement but NOT in the risk acceptance
    +        cls.finding_a_extra = Finding.objects.create(
    +            title="RA Remove Guard Finding A Extra",
    +            test=cls.test_a,
    +            severity="Medium",
    +            numerical_severity="S2",
    +            active=True,
    +            risk_accepted=False,
    +            reporter=cls.owner_user_a,
    +        )
    +
    +        cls.risk_acceptance_a = Risk_Acceptance.objects.create(
    +            name="RA Remove Guard RA A",
    +            owner=cls.owner_user_a,
    +        )
    +        cls.risk_acceptance_a.accepted_findings.add(cls.finding_a)
    +        cls.engagement_a.risk_acceptance.add(cls.risk_acceptance_a)
    +
    +        # ── Product B: engagement, test, finding, risk acceptance ────
    +        cls.engagement_b = Engagement.objects.create(
    +            name="RA Remove Guard Engagement B",
    +            product=cls.product_b,
    +            target_start=datetime.date(2024, 1, 1),
    +            target_end=datetime.date(2024, 12, 31),
    +        )
    +        cls.test_b = Test.objects.create(
    +            engagement=cls.engagement_b,
    +            test_type=test_type,
    +            target_start=timezone.now(),
    +            target_end=timezone.now(),
    +        )
    +        cls.finding_b = Finding.objects.create(
    +            title="RA Remove Guard Finding B",
    +            test=cls.test_b,
    +            severity="High",
    +            numerical_severity="S1",
    +            active=False,
    +            risk_accepted=True,
    +            reporter=cls.owner_user_b,
    +        )
    +
    +        cls.risk_acceptance_b = Risk_Acceptance.objects.create(
    +            name="RA Remove Guard RA B",
    +            owner=cls.owner_user_b,
    +        )
    +        cls.risk_acceptance_b.accepted_findings.add(cls.finding_b)
    +        cls.engagement_b.risk_acceptance.add(cls.risk_acceptance_b)
    +
    +    def _login(self, username):
    +        client = Client()
    +        client.login(
    +            username=username,
    +            password="testTEST1234!@#$",  # noqa: S106
    +        )
    +        return client
    +
    +    def _remove_finding_data(self, finding_id):
    +        return {
    +            "remove_finding": "Remove",
    +            "remove_finding_id": finding_id,
    +        }
    +
    +    # ── Test 1: edit_mode guard (BFLA) ───────────────────────────────
    +
    +    def test_reader_cannot_remove_finding_via_view_url(self):
    +        """Reader POSTing remove_finding to view URL must be silently ignored."""
    +        client = self._login("ra_remove_reader_a")
    +        url = reverse("view_risk_acceptance", args=(
    +            self.engagement_a.id, self.risk_acceptance_a.id,
    +        ))
    +        response = client.post(url, self._remove_finding_data(self.finding_a.id))
    +        # View still redirects (302) because errors=False, but finding is untouched
    +        self.assertEqual(response.status_code, 302)
    +        self.finding_a.refresh_from_db()
    +        self.assertFalse(self.finding_a.active)
    +        self.assertTrue(self.finding_a.risk_accepted)
    +        self.assertTrue(
    +            self.risk_acceptance_a.accepted_findings
    +            .filter(pk=self.finding_a.pk)
    +            .exists(),
    +        )
    +
    +    # ── Test 2: positive regression (edit URL works) ─────────────────
    +
    +    def test_owner_can_remove_finding_via_edit_url(self):
    +        """Owner POSTing remove_finding to edit URL must succeed."""
    +        client = self._login("ra_remove_owner_a")
    +        url = reverse("edit_risk_acceptance", args=(
    +            self.engagement_a.id, self.risk_acceptance_a.id,
    +        ))
    +        response = client.post(url, self._remove_finding_data(self.finding_a.id))
    +        self.assertEqual(response.status_code, 302)
    +        self.finding_a.refresh_from_db()
    +        self.assertTrue(self.finding_a.active)
    +        self.assertFalse(self.finding_a.risk_accepted)
    +        self.assertFalse(
    +            self.risk_acceptance_a.accepted_findings
    +            .filter(pk=self.finding_a.pk)
    +            .exists(),
    +        )
    +
    +    # ── Test 3: scoped lookup (finding not in this RA) ───────────────
    +
    +    def test_finding_not_in_risk_acceptance_returns_404(self):
    +        """Supplying a finding ID not in the RA's accepted_findings must 404."""
    +        client = self._login("ra_remove_owner_a")
    +        url = reverse("edit_risk_acceptance", args=(
    +            self.engagement_a.id, self.risk_acceptance_a.id,
    +        ))
    +        # finding_a_extra exists in the same engagement but is NOT accepted
    +        response = client.post(
    +            url, self._remove_finding_data(self.finding_a_extra.id),
    +        )
    +        self.assertEqual(response.status_code, 404)
    +
    +    # ── Test 4: cross-product IDOR ───────────────────────────────────
    +
    +    def test_cross_product_finding_id_rejected(self):
    +        """Finding from Product B cannot be removed via Product A's RA."""
    +        client = self._login("ra_remove_owner_a")
    +        url = reverse("edit_risk_acceptance", args=(
    +            self.engagement_a.id, self.risk_acceptance_a.id,
    +        ))
    +        response = client.post(
    +            url, self._remove_finding_data(self.finding_b.id),
    +        )
    +        self.assertEqual(response.status_code, 404)
    +        # Product B's finding must remain untouched
    +        self.finding_b.refresh_from_db()
    +        self.assertFalse(self.finding_b.active)
    +        self.assertTrue(self.finding_b.risk_accepted)
    +
    +    # ── Test 5: Reader blocked by decorator on edit URL ──────────────
    +
    +    def test_reader_blocked_on_edit_url_by_decorator(self):
    +        """Reader lacks Risk_Acceptance permission — edit URL itself is denied."""
    +        client = self._login("ra_remove_reader_a")
    +        url = reverse("edit_risk_acceptance", args=(
    +            self.engagement_a.id, self.risk_acceptance_a.id,
    +        ))
    +        response = client.post(url, self._remove_finding_data(self.finding_a.id))
    +        # PermissionDenied raised; custom handler403 returns 400 (DD bug)
    +        self.assertIn(response.status_code, [400, 403])
    +        # Finding must remain untouched
    +        self.finding_a.refresh_from_db()
    +        self.assertFalse(self.finding_a.active)
    +        self.assertTrue(self.finding_a.risk_accepted)
    +
    +    # ── Test 6: nonexistent finding ID ───────────────────────────────
    +
    +    def test_nonexistent_finding_id_returns_404(self):
    +        """A bogus finding ID must produce 404 from the scoped lookup."""
    +        client = self._login("ra_remove_owner_a")
    +        url = reverse("edit_risk_acceptance", args=(
    +            self.engagement_a.id, self.risk_acceptance_a.id,
    +        ))
    +        response = client.post(url, self._remove_finding_data(999999))
    +        self.assertEqual(response.status_code, 404)
    +
    +
     class TestEngagementPresetsCrossProductIDOR(DojoTestCase):
     
         """
    @@ -1425,3 +1686,162 @@ def test_risk_acceptance_download_proof_writer_allowed(self):
     
             # Clean up uploaded file
             self.risk_acceptance.path.delete(save=True)
    +
    +
    +class TestEngagementMovePermission(DojoTestCase):
    +
    +    """Moving an engagement to another product requires Engagement_Edit on the destination."""
    +
    +    @classmethod
    +    def setUpTestData(cls):
    +        cls.owner_role = Role.objects.get(name="Owner")
    +        cls.product_type = Product_Type.objects.create(name="Eng Move Test PT")
    +
    +        cls.product_a = Product.objects.create(
    +            name="Eng Move Product A", description="Test", prod_type=cls.product_type,
    +        )
    +        cls.product_b = Product.objects.create(
    +            name="Eng Move Product B", description="Test", prod_type=cls.product_type,
    +        )
    +        cls.product_c = Product.objects.create(
    +            name="Eng Move Product C", description="Test", prod_type=cls.product_type,
    +        )
    +
    +        cls.user = Dojo_User.objects.create_user(
    +            username="eng_move_owner",
    +            password="testTEST1234!@#$",  # noqa: S106
    +            is_active=True,
    +        )
    +        Product_Member.objects.create(product=cls.product_a, user=cls.user, role=cls.owner_role)
    +        # No membership on product_b -- user cannot move engagements there
    +        Product_Member.objects.create(product=cls.product_c, user=cls.user, role=cls.owner_role)
    +
    +    def setUp(self):
    +        self.engagement = Engagement.objects.create(
    +            name="Move Test Engagement",
    +            product=self.product_a,
    +            target_start=datetime.date.today(),
    +            target_end=datetime.date.today(),
    +        )
    +
    +    def _api_client(self):
    +        token, _ = Token.objects.get_or_create(user=self.user)
    +        client = APIClient()
    +        client.credentials(HTTP_AUTHORIZATION="Token " + token.key)
    +        return client
    +
    +    def _ui_client(self):
    +        client = Client()
    +        client.login(username="eng_move_owner", password="testTEST1234!@#$")  # noqa: S106
    +        return client
    +
    +    # ── API: PATCH ────────────────────────────────────────────────────
    +
    +    def test_api_patch_move_to_authorized_product(self):
    +        """PATCH with product the user has access to should succeed."""
    +        client = self._api_client()
    +        url = reverse("engagement-detail", args=(self.engagement.id,))
    +        response = client.patch(url, {"product": self.product_c.id}, format="json")
    +        self.assertEqual(response.status_code, 200, response.content)
    +        self.engagement.refresh_from_db()
    +        self.assertEqual(self.engagement.product, self.product_c)
    +
    +    def test_api_patch_move_to_unauthorized_product(self):
    +        """PATCH with product the user lacks access to should be denied."""
    +        client = self._api_client()
    +        url = reverse("engagement-detail", args=(self.engagement.id,))
    +        response = client.patch(url, {"product": self.product_b.id}, format="json")
    +        self.assertEqual(response.status_code, 403, response.content)
    +        self.engagement.refresh_from_db()
    +        self.assertEqual(self.engagement.product, self.product_a)
    +
    +    def test_api_patch_same_product(self):
    +        """PATCH with the same product should succeed without extra permission check."""
    +        client = self._api_client()
    +        url = reverse("engagement-detail", args=(self.engagement.id,))
    +        response = client.patch(url, {"product": self.product_a.id}, format="json")
    +        self.assertEqual(response.status_code, 200, response.content)
    +
    +    def test_api_patch_without_product_field(self):
    +        """PATCH without product field should succeed (no spurious check)."""
    +        client = self._api_client()
    +        url = reverse("engagement-detail", args=(self.engagement.id,))
    +        response = client.patch(url, {"version": "1.0"}, format="json")
    +        self.assertEqual(response.status_code, 200, response.content)
    +
    +    # ── API: PUT ──────────────────────────────────────────────────────
    +
    +    def test_api_put_move_to_authorized_product(self):
    +        """PUT with product the user has access to should succeed."""
    +        client = self._api_client()
    +        url = reverse("engagement-detail", args=(self.engagement.id,))
    +        payload = {
    +            "name": "Move Test Engagement",
    +            "product": self.product_c.id,
    +            "target_start": str(datetime.date.today()),
    +            "target_end": str(datetime.date.today()),
    +            "engagement_type": "Interactive",
    +            "status": "Not Started",
    +        }
    +        response = client.put(url, payload, format="json")
    +        self.assertEqual(response.status_code, 200, response.content)
    +        self.engagement.refresh_from_db()
    +        self.assertEqual(self.engagement.product, self.product_c)
    +
    +    def test_api_put_move_to_unauthorized_product(self):
    +        """PUT with product the user lacks access to should be denied."""
    +        client = self._api_client()
    +        url = reverse("engagement-detail", args=(self.engagement.id,))
    +        payload = {
    +            "name": "Move Test Engagement",
    +            "product": self.product_b.id,
    +            "target_start": str(datetime.date.today()),
    +            "target_end": str(datetime.date.today()),
    +            "engagement_type": "Interactive",
    +            "status": "Not Started",
    +        }
    +        response = client.put(url, payload, format="json")
    +        self.assertEqual(response.status_code, 403, response.content)
    +        self.engagement.refresh_from_db()
    +        self.assertEqual(self.engagement.product, self.product_a)
    +
    +    # ── UI ────────────────────────────────────────────────────────────
    +
    +    def test_ui_move_to_authorized_product(self):
    +        """Edit engagement form moving to authorized product should succeed."""
    +        client = self._ui_client()
    +        url = reverse("edit_engagement", args=(self.engagement.id,))
    +        form_data = {
    +            "product": self.product_c.id,
    +            "target_start": datetime.date.today().strftime("%Y-%m-%d"),
    +            "target_end": datetime.date.today().strftime("%Y-%m-%d"),
    +            "lead": self.user.id,
    +            "status": "Not Started",
    +        }
    +        response = client.post(url, form_data)
    +        self.assertIn(response.status_code, [200, 302], response.content[:500])
    +        self.engagement.refresh_from_db()
    +        self.assertEqual(self.engagement.product, self.product_c)
    +
    +    def test_ui_move_to_unauthorized_product(self):
    +        """
    +        Edit engagement form moving to unauthorized product should be denied.
    +
    +        The form's product queryset is filtered to authorized products, so
    +        submitting an unauthorized product fails form validation (200 with
    +        errors) before the view-level permission check runs.  Either way the
    +        engagement must NOT move.
    +        """
    +        client = self._ui_client()
    +        url = reverse("edit_engagement", args=(self.engagement.id,))
    +        form_data = {
    +            "product": self.product_b.id,
    +            "target_start": datetime.date.today().strftime("%Y-%m-%d"),
    +            "target_end": datetime.date.today().strftime("%Y-%m-%d"),
    +            "lead": self.user.id,
    +            "status": "Not Started",
    +        }
    +        response = client.post(url, form_data)
    +        self.assertIn(response.status_code, [200, 403])
    +        self.engagement.refresh_from_db()
    +        self.assertEqual(self.engagement.product, self.product_a)
    diff --git a/unittests/test_product_announcements.py b/unittests/test_product_announcements.py
    new file mode 100644
    index 00000000000..d9aa39a191f
    --- /dev/null
    +++ b/unittests/test_product_announcements.py
    @@ -0,0 +1,214 @@
    +from collections import UserDict
    +
    +from django.http import HttpRequest, HttpResponse
    +from django.test import SimpleTestCase
    +
    +from dojo.product_announcements import (
    +    ErrorPageProductAnnouncement,
    +    LargeScanSizeProductAnnouncement,
    +    LongRunningRequestProductAnnouncement,
    +    ScanTypeProductAnnouncement,
    +)
    +
    +
    +class _SessionDict(UserDict):
    +
    +    """Minimal session stand-in that supports .get/.pop/[] like Django sessions."""
    +
    +
    +def _make_request():
    +    request = HttpRequest()
    +    request.session = _SessionDict()
    +    return request
    +
    +
    +def _make_response(data=None):
    +    response = HttpResponse()
    +    response.data = data if data is not None else {}
    +    return response
    +
    +
    +class TestProductAnnouncementSessionBanner(SimpleTestCase):
    +
    +    def test_stores_banner_in_session(self):
    +        request = _make_request()
    +        ErrorPageProductAnnouncement(request=request)
    +        banners = request.session["_product_banners"]
    +        self.assertEqual(len(banners), 1)
    +        self.assertEqual(banners[0]["source"], "product_announcement")
    +        self.assertEqual(banners[0]["style"], "info")
    +        self.assertIn("Pro comes with support.", banners[0]["message"])
    +        self.assertIsNone(banners[0]["expanded_html"])
    +
    +    def test_multiple_announcements_accumulate_in_session(self):
    +        request = _make_request()
    +        ErrorPageProductAnnouncement(request=request)
    +        ErrorPageProductAnnouncement(request=request)
    +        banners = request.session["_product_banners"]
    +        self.assertEqual(len(banners), 2)
    +
    +    def test_banner_message_contains_outreach_link(self):
    +        request = _make_request()
    +        ErrorPageProductAnnouncement(request=request)
    +        message = request.session["_product_banners"][0]["message"]
    +        self.assertIn("cloud.defectdojo.com", message)
    +        self.assertIn("Try today for free", message)
    +
    +    def test_session_error_is_swallowed(self):
    +        request = HttpRequest()
    +        request.session = None
    +        ErrorPageProductAnnouncement(request=request)
    +
    +    def test_no_settings_guard(self):
    +        """Product announcements fire without any settings check."""
    +        request = _make_request()
    +        ErrorPageProductAnnouncement(request=request)
    +        self.assertEqual(len(request.session["_product_banners"]), 1)
    +
    +
    +class TestProductAnnouncementApiPath(SimpleTestCase):
    +
    +    def test_api_response_gets_pro_key(self):
    +        response = _make_response(data={})
    +        ErrorPageProductAnnouncement(response=response)
    +        self.assertIn("pro", response.data)
    +        self.assertEqual(len(response.data["pro"]), 1)
    +        self.assertIn("Pro comes with support.", str(response.data["pro"][0]))
    +
    +    def test_api_response_appends_to_existing_pro_list(self):
    +        response = _make_response(data={"pro": ["existing"]})
    +        ErrorPageProductAnnouncement(response=response)
    +        self.assertEqual(len(response.data["pro"]), 2)
    +        self.assertEqual(response.data["pro"][0], "existing")
    +
    +    def test_api_response_data_dict_gets_pro_key(self):
    +        data = {}
    +        LargeScanSizeProductAnnouncement(response_data=data, duration=120.0)
    +        self.assertIn("pro", data)
    +
    +    def test_requires_at_least_one_target(self):
    +        with self.assertRaises(ValueError):
    +            ErrorPageProductAnnouncement()
    +
    +
    +class TestErrorPageProductAnnouncement(SimpleTestCase):
    +
    +    def test_message_content(self):
    +        request = _make_request()
    +        ErrorPageProductAnnouncement(request=request)
    +        message = request.session["_product_banners"][0]["message"]
    +        self.assertIn("Pro comes with support.", message)
    +
    +    def test_api_path(self):
    +        response = _make_response()
    +        ErrorPageProductAnnouncement(response=response)
    +        self.assertIn("Pro comes with support.", str(response.data["pro"][0]))
    +
    +
    +class TestLargeScanSizeProductAnnouncement(SimpleTestCase):
    +
    +    def test_fires_when_duration_exceeds_threshold(self):
    +        request = _make_request()
    +        LargeScanSizeProductAnnouncement(request=request, duration=120.0)
    +        banners = request.session["_product_banners"]
    +        self.assertEqual(len(banners), 1)
    +        self.assertIn("import took about 2 minute(s)", banners[0]["message"])
    +        self.assertIn("async imports", banners[0]["message"])
    +
    +    def test_does_not_fire_when_duration_below_threshold(self):
    +        request = _make_request()
    +        LargeScanSizeProductAnnouncement(request=request, duration=30.0)
    +        self.assertEqual(len(request.session.get("_product_banners", [])), 0)
    +
    +    def test_fires_at_boundary(self):
    +        request = _make_request()
    +        LargeScanSizeProductAnnouncement(request=request, duration=60.1)
    +        self.assertEqual(len(request.session["_product_banners"]), 1)
    +
    +    def test_does_not_fire_at_exact_threshold(self):
    +        request = _make_request()
    +        LargeScanSizeProductAnnouncement(request=request, duration=60.0)
    +        self.assertEqual(len(request.session.get("_product_banners", [])), 0)
    +
    +
    +class TestLongRunningRequestProductAnnouncement(SimpleTestCase):
    +
    +    def test_fires_when_duration_exceeds_threshold(self):
    +        request = _make_request()
    +        LongRunningRequestProductAnnouncement(request=request, duration=20.0)
    +        banners = request.session["_product_banners"]
    +        self.assertEqual(len(banners), 1)
    +        self.assertIn("performance tested", banners[0]["message"])
    +
    +    def test_does_not_fire_when_duration_below_threshold(self):
    +        request = _make_request()
    +        LongRunningRequestProductAnnouncement(request=request, duration=10.0)
    +        self.assertEqual(len(request.session.get("_product_banners", [])), 0)
    +
    +    def test_does_not_fire_at_exact_threshold(self):
    +        request = _make_request()
    +        LongRunningRequestProductAnnouncement(request=request, duration=15.0)
    +        self.assertEqual(len(request.session.get("_product_banners", [])), 0)
    +
    +
    +class TestScanTypeProductAnnouncement(SimpleTestCase):
    +
    +    def test_fires_for_supported_scan_type(self):
    +        request = _make_request()
    +        ScanTypeProductAnnouncement(request=request, scan_type="Snyk Scan")
    +        banners = request.session["_product_banners"]
    +        self.assertEqual(len(banners), 1)
    +        self.assertIn("Snyk Scan", banners[0]["message"])
    +        self.assertIn("no-code connector", banners[0]["message"])
    +
    +    def test_does_not_fire_for_unsupported_scan_type(self):
    +        request = _make_request()
    +        ScanTypeProductAnnouncement(request=request, scan_type="Unknown Scanner")
    +        self.assertEqual(len(request.session.get("_product_banners", [])), 0)
    +
    +    def test_does_not_fire_for_none_scan_type(self):
    +        request = _make_request()
    +        ScanTypeProductAnnouncement(request=request, scan_type=None)
    +        self.assertEqual(len(request.session.get("_product_banners", [])), 0)
    +
    +    def test_all_supported_scan_types_fire(self):
    +        for scan_type in ScanTypeProductAnnouncement.supported_scan_types:
    +            request = _make_request()
    +            ScanTypeProductAnnouncement(request=request, scan_type=scan_type)
    +            self.assertEqual(
    +                len(request.session["_product_banners"]), 1,
    +                f"Expected banner for {scan_type}",
    +            )
    +
    +    def test_api_path_for_supported_scan_type(self):
    +        data = {}
    +        ScanTypeProductAnnouncement(response_data=data, scan_type="Wiz Scan")
    +        self.assertIn("pro", data)
    +        self.assertIn("Wiz Scan", str(data["pro"][0]))
    +
    +
    +class TestBannerDictSchema(SimpleTestCase):
    +
    +    """Verify every banner stored in the session has the expected keys."""
    +
    +    EXPECTED_KEYS = {"source", "message", "style", "url", "link_text", "expanded_html"}
    +
    +    def test_error_page_banner_has_all_keys(self):
    +        request = _make_request()
    +        ErrorPageProductAnnouncement(request=request)
    +        self.assertEqual(set(request.session["_product_banners"][0].keys()), self.EXPECTED_KEYS)
    +
    +    def test_large_scan_banner_has_all_keys(self):
    +        request = _make_request()
    +        LargeScanSizeProductAnnouncement(request=request, duration=120.0)
    +        self.assertEqual(set(request.session["_product_banners"][0].keys()), self.EXPECTED_KEYS)
    +
    +    def test_long_running_banner_has_all_keys(self):
    +        request = _make_request()
    +        LongRunningRequestProductAnnouncement(request=request, duration=20.0)
    +        self.assertEqual(set(request.session["_product_banners"][0].keys()), self.EXPECTED_KEYS)
    +
    +    def test_scan_type_banner_has_all_keys(self):
    +        request = _make_request()
    +        ScanTypeProductAnnouncement(request=request, scan_type="Snyk Scan")
    +        self.assertEqual(set(request.session["_product_banners"][0].keys()), self.EXPECTED_KEYS)
    diff --git a/unittests/test_rest_framework.py b/unittests/test_rest_framework.py
    index 95807d4c536..084857fa5d2 100644
    --- a/unittests/test_rest_framework.py
    +++ b/unittests/test_rest_framework.py
    @@ -1002,8 +1002,8 @@ def test_close_finding_rejects_unauthorized_mitigated_by(self):
     
         def test_close_finding_pushes_note_to_jira_when_configured(self):
             finding = Finding.objects.get(id=7)
    -        with patch("dojo.jira_link.helper.add_comment") as add_comment_mock, \
    -             patch("dojo.jira_link.helper.is_push_all_issues", return_value=True), \
    +        with patch("dojo.jira.helper.add_comment") as add_comment_mock, \
    +             patch("dojo.jira.helper.is_push_all_issues", return_value=True), \
                  patch.object(Finding, "has_jira_issue", new_callable=PropertyMock, return_value=True):
                 payload = {
                     "is_mitigated": True,
    @@ -3429,6 +3429,95 @@ def test_create_not_authorized_product_name_engagement_name_scan_type_title(self
                 importer_mock.assert_not_called()
                 reimporter_mock.assert_not_called()
     
    +    # Security tests: verify that conflicting ID-based and name-based identifiers are rejected
    +
    +    @patch("dojo.importers.default_reimporter.DefaultReImporter.process_scan")
    +    @patch("dojo.importers.default_importer.DefaultImporter.process_scan")
    +    @patch("dojo.api_v2.permissions.user_has_permission")
    +    def test_reimport_engagement_param_ignored_permission_checked_on_name_resolved_target(self, mock, importer_mock, reimporter_mock):
    +        """
    +        Engagement is not a declared field on ReImportScanSerializer — verify
    +        the permission check uses the name-resolved target, not the engagement param.
    +        """
    +        mock.return_value = False
    +        importer_mock.return_value = IMPORTER_MOCK_RETURN_VALUE
    +        reimporter_mock.return_value = REIMPORTER_MOCK_RETURN_VALUE
    +
    +        with Path("tests/zap_sample.xml").open(encoding="utf-8") as testfile:
    +            payload = {
    +                "minimum_severity": "Low",
    +                "active": True,
    +                "verified": True,
    +                "scan_type": "ZAP Scan",
    +                "file": testfile,
    +                # engagement=1 belongs to Product 2 Engagement 1, but it should be ignored
    +                "engagement": 1,
    +                # These names resolve to Product 2's Engagement 4 -> Test 4
    +                "product_name": "Security How-to",
    +                "engagement_name": "April monthly engagement",
    +                "version": "1.0.0",
    +            }
    +            response = self.client.post(self.url, payload)
    +            self.assertEqual(403, response.status_code, response.content[:1000])
    +            # Permission must be checked on name-resolved Test 4 (in Engagement 4),
    +            # NOT on Test 3 (which belongs to the engagement=1 param)
    +            mock.assert_called_with(User.objects.get(username="admin"),
    +                Test.objects.get(id=4),
    +                Permissions.Import_Scan_Result)
    +            importer_mock.assert_not_called()
    +            reimporter_mock.assert_not_called()
    +
    +    @patch("dojo.importers.default_reimporter.DefaultReImporter.process_scan")
    +    @patch("dojo.importers.default_importer.DefaultImporter.process_scan")
    +    def test_reimport_with_test_id_mismatched_product_name_is_rejected(self, importer_mock, reimporter_mock):
    +        """Sending test ID from one product with product_name from another must be rejected."""
    +        importer_mock.return_value = IMPORTER_MOCK_RETURN_VALUE
    +        reimporter_mock.return_value = REIMPORTER_MOCK_RETURN_VALUE
    +
    +        with Path("tests/zap_sample.xml").open(encoding="utf-8") as testfile:
    +            payload = {
    +                "minimum_severity": "Low",
    +                "active": True,
    +                "verified": True,
    +                "scan_type": "ZAP Scan",
    +                "file": testfile,
    +                # Test 3 belongs to Engagement 1 -> Product 2 ("Security How-to")
    +                "test": 3,
    +                # But product_name points to Product 1 ("Python How-to")
    +                "product_name": "Python How-to",
    +                "version": "1.0.0",
    +            }
    +            response = self.client.post(self.url, payload)
    +            self.assertEqual(400, response.status_code, response.content[:1000])
    +            importer_mock.assert_not_called()
    +            reimporter_mock.assert_not_called()
    +
    +    @patch("dojo.importers.default_reimporter.DefaultReImporter.process_scan")
    +    @patch("dojo.importers.default_importer.DefaultImporter.process_scan")
    +    def test_reimport_with_test_id_mismatched_engagement_name_is_rejected(self, importer_mock, reimporter_mock):
    +        """Sending test ID from one engagement with engagement_name from another must be rejected."""
    +        importer_mock.return_value = IMPORTER_MOCK_RETURN_VALUE
    +        reimporter_mock.return_value = REIMPORTER_MOCK_RETURN_VALUE
    +
    +        with Path("tests/zap_sample.xml").open(encoding="utf-8") as testfile:
    +            payload = {
    +                "minimum_severity": "Low",
    +                "active": True,
    +                "verified": True,
    +                "scan_type": "ZAP Scan",
    +                "file": testfile,
    +                # Test 3 belongs to Engagement 1 ("1st Quarter Engagement")
    +                "test": 3,
    +                # But engagement_name points to a different engagement
    +                "product_name": "Security How-to",
    +                "engagement_name": "April monthly engagement",
    +                "version": "1.0.0",
    +            }
    +            response = self.client.post(self.url, payload)
    +            self.assertEqual(400, response.status_code, response.content[:1000])
    +            importer_mock.assert_not_called()
    +            reimporter_mock.assert_not_called()
    +
     
     @versioned_fixtures
     class ProductTypeTest(BaseClass.BaseClassTest):
    diff --git a/unittests/test_risk_acceptance.py b/unittests/test_risk_acceptance.py
    index c0613bd49ee..628624b6db1 100644
    --- a/unittests/test_risk_acceptance.py
    +++ b/unittests/test_risk_acceptance.py
    @@ -111,7 +111,7 @@ def test_remove_findings_from_risk_acceptance_findings_active(self):
             data = copy.copy(self.data_remove_finding_from_ra)
             data["remove_finding_id"] = 2
             ra = Risk_Acceptance.objects.last()
    -        response = self.client.post(reverse("view_risk_acceptance", args=(1, ra.id)), data)
    +        response = self.client.post(reverse("edit_risk_acceptance", args=(1, ra.id)), data)
             self.assertEqual(302, response.status_code, response.content[:1000])
             self.assert_all_active_not_risk_accepted(Finding.objects.filter(id=2))
             self.assert_all_inactive_risk_accepted(Finding.objects.filter(id=3))
    diff --git a/unittests/test_utils_ssrf.py b/unittests/test_utils_ssrf.py
    new file mode 100644
    index 00000000000..904abf8101c
    --- /dev/null
    +++ b/unittests/test_utils_ssrf.py
    @@ -0,0 +1,75 @@
    +import socket
    +from unittest.mock import patch
    +
    +import requests
    +
    +from dojo.utils_ssrf import SSRFError, _SSRFSafeAdapter, make_ssrf_safe_session, validate_url_for_ssrf  # noqa: PLC2701
    +from unittests.dojo_test_case import DojoTestCase
    +
    +
    +def _addr_info(ip, port=80):
    +    """Build a minimal getaddrinfo-style return value for a single IP."""
    +    return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, port))]
    +
    +
    +_MIXED_ADDR_INFO = [
    +    (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("8.8.8.8", 80)),
    +    (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("192.168.1.1", 80)),
    +]
    +
    +
    +class TestValidateUrlForSsrf(DojoTestCase):
    +
    +    @patch("dojo.utils_ssrf.socket.getaddrinfo", return_value=_addr_info("8.8.8.8"))
    +    def test_valid_public_url_does_not_raise(self, mock_getaddrinfo):
    +        validate_url_for_ssrf("http://example.com/api")  # should not raise
    +
    +    def test_file_scheme_raises(self):
    +        with self.assertRaisesRegex(SSRFError, "not permitted"):
    +            validate_url_for_ssrf("file:///etc/passwd")
    +
    +    def test_gopher_scheme_raises(self):
    +        with self.assertRaisesRegex(SSRFError, "not permitted"):
    +            validate_url_for_ssrf("gopher://example.com")
    +
    +    def test_no_hostname_raises(self):
    +        with self.assertRaisesRegex(SSRFError, "no hostname"):
    +            validate_url_for_ssrf("http://")
    +
    +    def test_loopback_ip_raises(self):
    +        with self.assertRaisesRegex(SSRFError, "non-public address"):
    +            validate_url_for_ssrf("http://127.0.0.1/")
    +
    +    def test_private_class_c_raises(self):
    +        with self.assertRaisesRegex(SSRFError, "non-public address"):
    +            validate_url_for_ssrf("http://192.168.1.1/")
    +
    +    def test_private_class_a_raises(self):
    +        with self.assertRaisesRegex(SSRFError, "non-public address"):
    +            validate_url_for_ssrf("http://10.0.0.1/")
    +
    +    def test_link_local_raises(self):
    +        with self.assertRaisesRegex(SSRFError, "non-public address"):
    +            validate_url_for_ssrf("http://169.254.1.1/")
    +
    +    @patch("dojo.utils_ssrf.socket.getaddrinfo", side_effect=socket.gaierror("Name or service not known"))
    +    def test_unresolvable_hostname_raises(self, mock_getaddrinfo):
    +        with self.assertRaisesRegex(SSRFError, "Unable to resolve"):
    +            validate_url_for_ssrf("http://nonexistent.invalid/")
    +
    +    @patch("dojo.utils_ssrf.socket.getaddrinfo", return_value=_MIXED_ADDR_INFO)
    +    def test_multi_address_with_private_ip_raises(self, mock_getaddrinfo):
    +        with self.assertRaisesRegex(SSRFError, "non-public address"):
    +            validate_url_for_ssrf("http://example.com/")
    +
    +
    +class TestMakeSsrfSafeSession(DojoTestCase):
    +
    +    def test_returns_requests_session(self):
    +        session = make_ssrf_safe_session()
    +        self.assertIsInstance(session, requests.Session)
    +
    +    def test_http_and_https_mounted_with_safe_adapter(self):
    +        session = make_ssrf_safe_session()
    +        self.assertIsInstance(session.get_adapter("http://example.com"), _SSRFSafeAdapter)
    +        self.assertIsInstance(session.get_adapter("https://example.com"), _SSRFSafeAdapter)
    diff --git a/unittests/tools/test_govulncheck_parser.py b/unittests/tools/test_govulncheck_parser.py
    index 90499dc48fd..3b0a8efc474 100644
    --- a/unittests/tools/test_govulncheck_parser.py
    +++ b/unittests/tools/test_govulncheck_parser.py
    @@ -127,6 +127,8 @@ def test_parse_new_version_many_findings_custom_severity(self):
                     self.assertIsNotNone(finding.impact)
                     self.assertIsNotNone(finding.description)
                     self.assertIsNotNone(finding.references)
    +                self.assertTrue(finding.fix_available)
    +                self.assertEqual("0.3.8", finding.fix_version)
     
         def test_parse_issue_14642(self):
             with (get_unit_tests_scans_path("govulncheck") / "issue_14642.json").open(encoding="utf-8") as testfile:
    diff --git a/unittests/tools/test_risk_recon_parser.py b/unittests/tools/test_risk_recon_parser.py
    index c59b39bd7d9..a2fec88180d 100644
    --- a/unittests/tools/test_risk_recon_parser.py
    +++ b/unittests/tools/test_risk_recon_parser.py
    @@ -1,9 +1,10 @@
     import datetime
    -
    -import requests
    +from unittest.mock import MagicMock, patch
     
     from dojo.models import Test
    +from dojo.tools.risk_recon.api import RiskReconAPI
     from dojo.tools.risk_recon.parser import RiskReconParser
    +from dojo.utils_ssrf import SSRFError
     from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path
     
     
    @@ -11,7 +12,7 @@ class TestRiskReconAPIParser(DojoTestCase):
     
         def test_api_with_bad_url(self):
             with (get_unit_tests_scans_path("risk_recon") / "bad_url.json").open(encoding="utf-8") as testfile, \
    -          self.assertRaises(requests.exceptions.ConnectionError):
    +          self.assertRaises(Exception):  # noqa: B017  # SSRFError is caught and re-raised as Exception in api.py
                 parser = RiskReconParser()
                 parser.get_findings(testfile, Test())
     
    @@ -34,3 +35,20 @@ def test_parser_without_api(self):
                     finding = findings[1]
                     self.assertEqual(datetime.date(2017, 3, 17), finding.date.date())
                     self.assertEqual("ff2bbdbfc2b6gsrgwergwe6b1fasfwefb", finding.unique_id_from_tool)
    +
    +    @patch("dojo.tools.risk_recon.api.validate_url_for_ssrf", side_effect=SSRFError("blocked: private address"))
    +    def test_ssrf_error_is_raised_as_exception(self, mock_validate):
    +        with self.assertRaisesRegex(Exception, "Invalid Risk Recon API url"):
    +            RiskReconAPI(api_key="somekey", endpoint="http://192.168.1.1/api", data=[])
    +        mock_validate.assert_called_once_with("http://192.168.1.1/api")
    +
    +    @patch.object(RiskReconAPI, "get_findings")
    +    @patch.object(RiskReconAPI, "map_toes")
    +    @patch("dojo.tools.risk_recon.api.make_ssrf_safe_session")
    +    @patch("dojo.tools.risk_recon.api.validate_url_for_ssrf")
    +    def test_make_ssrf_safe_session_called_on_init(self, mock_validate, mock_make_session, mock_map_toes, mock_get_findings):
    +        mock_session = MagicMock()
    +        mock_make_session.return_value = mock_session
    +        api = RiskReconAPI(api_key="somekey", endpoint="https://api.riskrecon.com/v1", data=[])
    +        mock_make_session.assert_called_once()
    +        self.assertIs(api.session, mock_session)