From 75d7f6210c4d04a784c5cc4d8ee618da7ec6109b Mon Sep 17 00:00:00 2001 From: DefectDojo release bot Date: Mon, 1 Jun 2026 17:09:35 +0000 Subject: [PATCH 01/31] Update versions in application files --- components/package.json | 2 +- docs/content/en/open_source/upgrading/2.60.md | 7 +++++++ dojo/__init__.py | 2 +- helm/defectdojo/Chart.yaml | 8 ++++---- helm/defectdojo/README.md | 2 +- 5 files changed, 14 insertions(+), 7 deletions(-) create mode 100644 docs/content/en/open_source/upgrading/2.60.md diff --git a/components/package.json b/components/package.json index 2651883b8b7..04a55d739d4 100644 --- a/components/package.json +++ b/components/package.json @@ -1,6 +1,6 @@ { "name": "defectdojo", - "version": "2.59.0", + "version": "2.60.0-dev", "license": "BSD-3-Clause", "private": true, "dependencies": { diff --git a/docs/content/en/open_source/upgrading/2.60.md b/docs/content/en/open_source/upgrading/2.60.md new file mode 100644 index 00000000000..e7811aa0b99 --- /dev/null +++ b/docs/content/en/open_source/upgrading/2.60.md @@ -0,0 +1,7 @@ +--- +title: 'Upgrading to DefectDojo Version 2.60.x' +toc_hide: true +weight: -20260601 +description: No special instructions. +--- +There are no special instructions for upgrading to 2.60.x. Check the [Release Notes](https://github.com/DefectDojo/django-DefectDojo/releases/tag/2.60.0) for the contents of the release. diff --git a/dojo/__init__.py b/dojo/__init__.py index cbea93d34cd..b9bf55e3765 100644 --- a/dojo/__init__.py +++ b/dojo/__init__.py @@ -4,6 +4,6 @@ # Django starts so that shared_task will use this app. from .celery import app as celery_app # noqa: F401 -__version__ = "2.59.0" +__version__ = "2.60.0-dev" __url__ = "https://github.com/DefectDojo/django-DefectDojo" __docs__ = "https://documentation.defectdojo.com" diff --git a/helm/defectdojo/Chart.yaml b/helm/defectdojo/Chart.yaml index eb061f77bb6..49095de5ffa 100644 --- a/helm/defectdojo/Chart.yaml +++ b/helm/defectdojo/Chart.yaml @@ -1,8 +1,8 @@ apiVersion: v2 -appVersion: "2.59.0" +appVersion: "2.60.0-dev" description: A Helm chart for Kubernetes to install DefectDojo name: defectdojo -version: 1.9.30 +version: 1.9.31-dev icon: https://defectdojo.com/hubfs/DefectDojo_favicon.png maintainers: - name: madchap @@ -33,5 +33,5 @@ dependencies: # - kind: security # description: Critical bug annotations: - artifacthub.io/prerelease: "false" - artifacthub.io/changes: "- kind: changed\n description: Update valkey Docker tag from 0.20.0 to v0.20.1 (_/defect_/Chart.yaml)\n- kind: changed\n description: Update valkey Docker tag from 0.20.1 to v0.20.2 (_/defect_/Chart.yaml)\n- kind: changed\n description: Bump DefectDojo to 2.59.0\n" + artifacthub.io/prerelease: "true" + artifacthub.io/changes: "" diff --git a/helm/defectdojo/README.md b/helm/defectdojo/README.md index f8b2e30f27c..30f81ff839e 100644 --- a/helm/defectdojo/README.md +++ b/helm/defectdojo/README.md @@ -511,7 +511,7 @@ The HELM schema will be generated for you. # General information about chart values -![Version: 1.9.30](https://img.shields.io/badge/Version-1.9.30-informational?style=flat-square) ![AppVersion: 2.59.0](https://img.shields.io/badge/AppVersion-2.59.0-informational?style=flat-square) +![Version: 1.9.31-dev](https://img.shields.io/badge/Version-1.9.31--dev-informational?style=flat-square) ![AppVersion: 2.60.0-dev](https://img.shields.io/badge/AppVersion-2.60.0--dev-informational?style=flat-square) A Helm chart for Kubernetes to install DefectDojo From 63a63906916a1845288cc6e5aca3b642d086c039 Mon Sep 17 00:00:00 2001 From: DefectDojo release bot Date: Mon, 1 Jun 2026 17:39:18 +0000 Subject: [PATCH 02/31] Update versions in application files --- components/package.json | 2 +- helm/defectdojo/Chart.yaml | 8 ++++---- helm/defectdojo/README.md | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/components/package.json b/components/package.json index 2651883b8b7..04a55d739d4 100644 --- a/components/package.json +++ b/components/package.json @@ -1,6 +1,6 @@ { "name": "defectdojo", - "version": "2.59.0", + "version": "2.60.0-dev", "license": "BSD-3-Clause", "private": true, "dependencies": { diff --git a/helm/defectdojo/Chart.yaml b/helm/defectdojo/Chart.yaml index eb061f77bb6..49095de5ffa 100644 --- a/helm/defectdojo/Chart.yaml +++ b/helm/defectdojo/Chart.yaml @@ -1,8 +1,8 @@ apiVersion: v2 -appVersion: "2.59.0" +appVersion: "2.60.0-dev" description: A Helm chart for Kubernetes to install DefectDojo name: defectdojo -version: 1.9.30 +version: 1.9.31-dev icon: https://defectdojo.com/hubfs/DefectDojo_favicon.png maintainers: - name: madchap @@ -33,5 +33,5 @@ dependencies: # - kind: security # description: Critical bug annotations: - artifacthub.io/prerelease: "false" - artifacthub.io/changes: "- kind: changed\n description: Update valkey Docker tag from 0.20.0 to v0.20.1 (_/defect_/Chart.yaml)\n- kind: changed\n description: Update valkey Docker tag from 0.20.1 to v0.20.2 (_/defect_/Chart.yaml)\n- kind: changed\n description: Bump DefectDojo to 2.59.0\n" + artifacthub.io/prerelease: "true" + artifacthub.io/changes: "" diff --git a/helm/defectdojo/README.md b/helm/defectdojo/README.md index f8b2e30f27c..30f81ff839e 100644 --- a/helm/defectdojo/README.md +++ b/helm/defectdojo/README.md @@ -511,7 +511,7 @@ The HELM schema will be generated for you. # General information about chart values -![Version: 1.9.30](https://img.shields.io/badge/Version-1.9.30-informational?style=flat-square) ![AppVersion: 2.59.0](https://img.shields.io/badge/AppVersion-2.59.0-informational?style=flat-square) +![Version: 1.9.31-dev](https://img.shields.io/badge/Version-1.9.31--dev-informational?style=flat-square) ![AppVersion: 2.60.0-dev](https://img.shields.io/badge/AppVersion-2.60.0--dev-informational?style=flat-square) A Helm chart for Kubernetes to install DefectDojo From dd70ad8ae730bcfb88698aadfdae3d403798f670 Mon Sep 17 00:00:00 2001 From: manuelsommer <47991713+manuel-sommer@users.noreply.github.com> Date: Tue, 2 Jun 2026 02:50:09 +0200 Subject: [PATCH 03/31] chore(deps): bump ruff from 0.15.13 to 0.15.14 (#14929) --- requirements-lint.txt | 2 +- ruff.toml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements-lint.txt b/requirements-lint.txt index e4647b59e59..98d97774e8a 100644 --- a/requirements-lint.txt +++ b/requirements-lint.txt @@ -1 +1 @@ -ruff==0.15.13 +ruff==0.15.14 diff --git a/ruff.toml b/ruff.toml index 942bd47a00a..dec50306cdf 100644 --- a/ruff.toml +++ b/ruff.toml @@ -92,6 +92,7 @@ select = [ "TRY003", "TRY004", "TRY2", "TRY300", "TRY401", ] ignore = [ + "PLW0717", "E501", "E722", "SIM102", From fe6a87000bfdc25ab9393627dce8cd1efc22c17b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 07:10:12 +0000 Subject: [PATCH 04/31] chore(deps): update dependency node from 24.15.0 to v24.16.0 (.github/workflows/validate_docs_build.yml) --- .github/workflows/gh-pages.yml | 2 +- .github/workflows/validate_docs_build.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index 8766bc8572d..e09146bba0a 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -24,7 +24,7 @@ jobs: - name: Setup Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '24.15.0' # TODO: Renovate helper might not be needed here - needs to be fully tested + node-version: '24.16.0' # TODO: Renovate helper might not be needed here - needs to be fully tested - name: Cache dependencies uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 diff --git a/.github/workflows/validate_docs_build.yml b/.github/workflows/validate_docs_build.yml index 616cbc5910e..b6df3f53dd8 100644 --- a/.github/workflows/validate_docs_build.yml +++ b/.github/workflows/validate_docs_build.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '24.15.0' # TODO: Renovate helper might not be needed here - needs to be fully tested + node-version: '24.16.0' # TODO: Renovate helper might not be needed here - needs to be fully tested - name: Cache dependencies uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 From 1e862807a8bd47b693add909f0b72204f7fa342a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 11:10:18 +0000 Subject: [PATCH 05/31] chore(deps): update docker/build-push-action action from v7.1.0 to v7.2.0 (.github/workflows/release-x-manual-docker-containers.yml) --- .github/workflows/build-docker-images-for-testing.yml | 2 +- .github/workflows/release-x-manual-docker-containers.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-docker-images-for-testing.yml b/.github/workflows/build-docker-images-for-testing.yml index 9f81a64f830..774f5ab1c17 100644 --- a/.github/workflows/build-docker-images-for-testing.yml +++ b/.github/workflows/build-docker-images-for-testing.yml @@ -53,7 +53,7 @@ jobs: - name: Build id: docker_build - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 timeout-minutes: 15 env: DOCKER_BUILD_CHECKS_ANNOTATIONS: false diff --git a/.github/workflows/release-x-manual-docker-containers.yml b/.github/workflows/release-x-manual-docker-containers.yml index 14f0b259584..0e496498bc9 100644 --- a/.github/workflows/release-x-manual-docker-containers.yml +++ b/.github/workflows/release-x-manual-docker-containers.yml @@ -69,7 +69,7 @@ jobs: # we cannot set any tags here, those are set on the merged digest in release-x-manual-merge-container-digests.yml - name: Build and push images id: build - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 env: DOCKER_BUILD_CHECKS_ANNOTATIONS: false with: From 56465240f42f3b81fcb9fbd3ef895a66ad54cf83 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 01:24:37 +0000 Subject: [PATCH 06/31] chore(deps): bump drf-spectacular-sidecar from 2026.5.1 to 2026.6.1 Bumps [drf-spectacular-sidecar](https://github.com/tfranzel/drf-spectacular-sidecar) from 2026.5.1 to 2026.6.1. - [Commits](https://github.com/tfranzel/drf-spectacular-sidecar/compare/2026.5.1...2026.6.1) --- updated-dependencies: - dependency-name: drf-spectacular-sidecar dependency-version: 2026.6.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9f7fd82770e..5d33f52795e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -57,7 +57,7 @@ cvss==3.6 django-fieldsignals==0.8.0 hyperlink==21.0.0 drf-spectacular==0.29.0 -drf-spectacular-sidecar==2026.5.1 +drf-spectacular-sidecar==2026.6.1 django-ratelimit==4.1.0 argon2-cffi==25.1.0 blackduck==1.1.3 From 5ce0657ee54978bc5d87ba803f74f80557bad0d6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:41:43 -0500 Subject: [PATCH 07/31] chore(deps): bump django-polymorphic from 4.11.3 to 4.11.5 (#14957) Bumps [django-polymorphic](https://github.com/django-commons/django-polymorphic) from 4.11.3 to 4.11.5. - [Release notes](https://github.com/django-commons/django-polymorphic/releases) - [Commits](https://github.com/django-commons/django-polymorphic/compare/v4.11.3...v4.11.5) --- updated-dependencies: - dependency-name: django-polymorphic dependency-version: 4.11.5 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9f7fd82770e..2d8132e19d0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ django-filter==25.2 django-htmx==1.27.0 django-imagekit==6.1.0 django-multiselectfield==1.0.1 -django-polymorphic==4.11.3 +django-polymorphic==4.11.5 django-crispy-forms==2.6 django_extensions==4.1 django-slack==5.19.0 From c1b7b8aab5e0b8543ead8752980e0a13f85e6f40 Mon Sep 17 00:00:00 2001 From: Jordi Sayeras <143995941+jsayerascb@users.noreply.github.com> Date: Fri, 5 Jun 2026 04:44:47 +0200 Subject: [PATCH 08/31] feat(parser): set fix_available on GitHub Vulnerability findings (#14943) Populate fix_available from firstPatchedVersion in the GraphQL response. --- .../parsers/file/github_vulnerability.md | 2 + dojo/tools/github_vulnerability/parser.py | 6 ++ .../github-1-vuln-no-fix.json | 58 +++++++++++++++++++ .../tools/test_github_vulnerability_parser.py | 20 +++++++ 4 files changed, 86 insertions(+) create mode 100644 unittests/scans/github_vulnerability/github-1-vuln-no-fix.json diff --git a/docs/content/supported_tools/parsers/file/github_vulnerability.md b/docs/content/supported_tools/parsers/file/github_vulnerability.md index 5705165913a..db2a1ceb4c6 100644 --- a/docs/content/supported_tools/parsers/file/github_vulnerability.md +++ b/docs/content/supported_tools/parsers/file/github_vulnerability.md @@ -21,6 +21,8 @@ vulnerabilityAlerts (RepositoryVulnerabilityAlert object) + severity (CRITICAL/HIGH/LOW/MODERATE) + package (optional) + name (optional) + + firstPatchedVersion (optional, sets fix_available) + + identifier (optional) + advisory (SecurityAdvisory object) + description + summary diff --git a/dojo/tools/github_vulnerability/parser.py b/dojo/tools/github_vulnerability/parser.py index 5c646086aeb..4b9a53afed7 100644 --- a/dojo/tools/github_vulnerability/parser.py +++ b/dojo/tools/github_vulnerability/parser.py @@ -83,6 +83,12 @@ def get_findings(self, filename, test): pkg = vuln.get("package", {}) finding.component_name = pkg.get("name") + first_patched = vuln.get("firstPatchedVersion") + finding.fix_available = ( + first_patched is not None + and first_patched.get("identifier") is not None + ) + if alert.get("createdAt"): finding.date = dateutil.parser.parse(alert.get("createdAt")) if alert.get("state") in {"FIXED", "DISMISSED"}: diff --git a/unittests/scans/github_vulnerability/github-1-vuln-no-fix.json b/unittests/scans/github_vulnerability/github-1-vuln-no-fix.json new file mode 100644 index 00000000000..748bcad7336 --- /dev/null +++ b/unittests/scans/github_vulnerability/github-1-vuln-no-fix.json @@ -0,0 +1,58 @@ +{ + "data": { + "repository": { + "vulnerabilityAlerts": { + "nodes": [ + { + "id": "RVA_kwDOLJyUo88AAAABNoFixAv", + "createdAt": "2024-03-15T10:00:00Z", + "vulnerableManifestPath": "app/package.json", + "dependabotUpdate": null, + "securityVulnerability": { + "severity": "HIGH", + "updatedAt": "2024-03-10T12:00:00Z", + "package": { + "name": "example-package", + "ecosystem": "NPM" + }, + "firstPatchedVersion": null, + "vulnerableVersionRange": "<= 2.0.0", + "advisory": { + "description": "Example vulnerability with no fix available yet.", + "summary": "Prototype pollution in example-package", + "identifiers": [ + { + "value": "GHSA-test-no-fix", + "type": "GHSA" + }, + { + "value": "CVE-2024-99999", + "type": "CVE" + } + ], + "references": [ + { + "url": "https://nvd.nist.gov/vuln/detail/CVE-2024-99999" + } + ], + "cvss": { + "vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N" + } + } + }, + "state": "OPEN", + "vulnerableManifestFilename": "package.json", + "vulnerableRequirements": "= 1.5.0", + "number": 42, + "dependencyScope": "RUNTIME", + "dismissComment": null, + "dismissReason": null, + "dismissedAt": null, + "fixedAt": null + } + ] + }, + "url": "https://github.com/example-org/example-repo" + } + } +} diff --git a/unittests/tools/test_github_vulnerability_parser.py b/unittests/tools/test_github_vulnerability_parser.py index 2e5869476bf..803ac8a5f02 100644 --- a/unittests/tools/test_github_vulnerability_parser.py +++ b/unittests/tools/test_github_vulnerability_parser.py @@ -313,6 +313,26 @@ def test_parser_version(self): self.assertEqual(finding.component_version, "5.3.29") self.assertAlmostEqual(finding.epss_score, 0.00212, places=5) self.assertAlmostEqual(finding.epss_percentile, 0.44035, places=5) + self.assertTrue(finding.fix_available) + + def test_parse_no_fix_available(self): + """Finding with null firstPatchedVersion should have fix_available=False""" + with (get_unit_tests_scans_path("github_vulnerability") / "github-1-vuln-no-fix.json").open( + encoding="utf-8", + ) as testfile: + parser = GithubVulnerabilityParser() + findings = parser.get_findings(testfile, Test()) + self.assertEqual(1, len(findings)) + for finding in findings: + finding.clean() + + with self.subTest(i=0): + finding = findings[0] + self.assertEqual(finding.title, "Prototype pollution in example-package") + self.assertEqual(finding.severity, "High") + self.assertEqual(finding.component_name, "example-package") + self.assertEqual(finding.component_version, "1.5.0") + self.assertFalse(finding.fix_available) def test_parse_file_issue_9582(self): with (get_unit_tests_scans_path("github_vulnerability") / "issue_9582.json").open(encoding="utf-8") as testfile: From 2da6cb62b47c1c69a386bd6ef7297c31c395c523 Mon Sep 17 00:00:00 2001 From: derda17 <33903320+derda17@users.noreply.github.com> Date: Fri, 5 Jun 2026 04:45:38 +0200 Subject: [PATCH 09/31] Check statusCategory instead of the resolution field for Jira issue status (#14611) * Check statusCategory for Jira issue status * status category is mainly used to decide if a jira issue is active or not * if the category is undefined or an unknown status, fall back to resolution checking * the resolution object was compared to a string "None", this always returned False * provide unit tests for new functionality * removed trailing whitespace * Fix JIRA helper tests to comply with JIRA API specification - Remove test_issue_from_jira_is_active_with_unknown_status_and_none_resolution - Remove test_issue_from_jira_is_active_without_status_category_with_none_string_resolution These tests checked for resolution field as string 'None', which violates JIRA API spec. According to JIRA API, resolution is either an object with properties (id, name, etc) or null, never a string value. Remaining 12 tests verify correct behavior per the API spec. * fix import due to restructured modules * Align with previous changes for 14716 * Fix linter --------- Co-authored-by: Bernhard Willert Co-authored-by: valentijnscholten --- dojo/jira/helper.py | 31 +++++++++---------------------- unittests/test_jira_helper.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 22 deletions(-) create mode 100644 unittests/test_jira_helper.py diff --git a/dojo/jira/helper.py b/dojo/jira/helper.py index 4ae822b8002..5b83a0596ab 100644 --- a/dojo/jira/helper.py +++ b/dojo/jira/helper.py @@ -1223,29 +1223,16 @@ def get_jira_issue_from_jira(find): return None -def issue_from_jira_is_active(issue_from_jira): - # "resolution":{ - # "self":"http://www.testjira.com/rest/api/2/resolution/11", - # "id":"11", - # "description":"Cancelled by the customer.", - # "name":"Cancelled" - # }, - - # or - # "resolution": null - - # or - # "resolution": "None" - - if not hasattr(issue_from_jira.fields, "resolution"): - logger.debug(vars(issue_from_jira)) - return True +def issue_status_category_is_done(status_category_key: str | None) -> bool: + return status_category_key == "done" - if not issue_from_jira.fields.resolution: - return True - # some kind of resolution is present that is not null or None - return issue_from_jira.fields.resolution == "None" +def issue_from_jira_is_active(issue_from_jira): + try: + statusCategoryKey = issue_from_jira.fields.status.statusCategory.key + except AttributeError: + statusCategoryKey = None + return not issue_status_category_is_done(statusCategoryKey) def push_status_to_jira(obj, jira_instance, jira, issue, *, save=False): @@ -1930,7 +1917,7 @@ def process_resolution_from_jira( # classify "done" issues as risk-accepted, false-positive, or the # default mitigated category (see jira_instance.accepted_resolutions # and .false_positive_resolutions below). - resolved = status_category_key == "done" + resolved = issue_status_category_is_done(status_category_key) jira_instance = get_jira_instance(finding) if resolved: diff --git a/unittests/test_jira_helper.py b/unittests/test_jira_helper.py new file mode 100644 index 00000000000..b90a304f7a4 --- /dev/null +++ b/unittests/test_jira_helper.py @@ -0,0 +1,31 @@ +import logging +from unittest import TestCase +from unittest.mock import Mock + +import dojo.jira.helper as jira_helper + +logger = logging.getLogger(__name__) + + +class JIRAHelperTest(TestCase): + def _make_issue(self, status_category_key): + issue = Mock() + issue.fields.status.statusCategory.key = status_category_key + return issue + + def test_issue_from_jira_is_active_with_new_status(self): + self.assertTrue(jira_helper.issue_from_jira_is_active(self._make_issue("new"))) + + def test_issue_from_jira_is_active_with_indeterminate_status(self): + self.assertTrue(jira_helper.issue_from_jira_is_active(self._make_issue("indeterminate"))) + + def test_issue_from_jira_is_active_with_done_status(self): + self.assertFalse(jira_helper.issue_from_jira_is_active(self._make_issue("done"))) + + def test_issue_from_jira_is_active_with_unknown_status(self): + """Any key that is not 'done' is treated as active.""" + self.assertTrue(jira_helper.issue_from_jira_is_active(self._make_issue("custom_status"))) + + def test_issue_from_jira_is_active_defaults_to_active_on_missing_attribute(self): + """AttributeError anywhere in the fields.status.statusCategory.key chain defaults to active.""" + self.assertTrue(jira_helper.issue_from_jira_is_active(Mock(spec=[]))) From ea9bd4f94143375fc787650a7ba1821aa96a91a0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:46:16 -0500 Subject: [PATCH 10/31] chore(deps): update release-drafter/release-drafter action from v7.3.0 to v7.3.1 (.github/workflows/release-drafter.yml) (#14948) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/release-drafter.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 4b868f2cd79..31bcc7d6e8a 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -27,7 +27,7 @@ jobs: steps: - name: Create Release id: create_release - uses: release-drafter/release-drafter@c2e2804cc59f45f57076a99af580d0fedb697927 # v7.3.0 + uses: release-drafter/release-drafter@693d20e7c1ce1a81d3a41962f85914253b518449 # v7.3.1 with: version: ${{ inputs.version }} env: From 98e801a1f911a651ac12a51262dda14122cd1e0a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:46:36 -0500 Subject: [PATCH 11/31] chore(deps): update actions/checkout action from v6.0.2 to v6.0.3 (.github/workflows/validate_docs_build.yml) (#14947) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/build-docker-images-for-testing.yml | 2 +- .github/workflows/fetch-oas.yml | 2 +- .github/workflows/gh-pages.yml | 2 +- .github/workflows/integration-tests.yml | 2 +- .github/workflows/k8s-tests.yml | 2 +- .github/workflows/performance-tests.yml | 2 +- .github/workflows/release-1-create-pr.yml | 4 ++-- .github/workflows/release-2-tag-docker-push.yml | 2 +- .github/workflows/release-3-master-into-dev.yml | 8 ++++---- .../workflows/release-x-manual-docker-containers.yml | 2 +- .github/workflows/release-x-manual-helm-chart.yml | 2 +- .github/workflows/release-x-nightly.yml | 2 +- .github/workflows/renovate.yaml | 2 +- .github/workflows/rest-framework-tests.yml | 2 +- .github/workflows/ruff.yml | 2 +- .github/workflows/shellcheck.yml | 2 +- .github/workflows/test-helm-chart.yml | 10 +++++----- .github/workflows/update-sample-data.yml | 2 +- .github/workflows/validate_docs_build.yml | 2 +- 19 files changed, 27 insertions(+), 27 deletions(-) diff --git a/.github/workflows/build-docker-images-for-testing.yml b/.github/workflows/build-docker-images-for-testing.yml index 9f81a64f830..ed4d0fe30ca 100644 --- a/.github/workflows/build-docker-images-for-testing.yml +++ b/.github/workflows/build-docker-images-for-testing.yml @@ -40,7 +40,7 @@ jobs: echo $GITHUB_ENV - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false diff --git a/.github/workflows/fetch-oas.yml b/.github/workflows/fetch-oas.yml index 7873716642e..17d04fcca61 100644 --- a/.github/workflows/fetch-oas.yml +++ b/.github/workflows/fetch-oas.yml @@ -22,7 +22,7 @@ jobs: file-type: [yaml, json] steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: release/${{ env.release_version }} diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index 8766bc8572d..de7fbbb1bb2 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -35,7 +35,7 @@ jobs: ${{ runner.os }}-node- - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: submodules: recursive fetch-depth: 0 diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index c07a22a86a8..a2d6b9d051f 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -82,7 +82,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 # load docker images from build jobs - name: Load images from artifacts diff --git a/.github/workflows/k8s-tests.yml b/.github/workflows/k8s-tests.yml index 311ddd24f3d..d21dca99f15 100644 --- a/.github/workflows/k8s-tests.yml +++ b/.github/workflows/k8s-tests.yml @@ -22,7 +22,7 @@ jobs: os: debian steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Setup Minikube uses: manusa/actions-setup-minikube@b65276017fdec6f1e6498129fb740e34e260dc55 # v2.18.0 diff --git a/.github/workflows/performance-tests.yml b/.github/workflows/performance-tests.yml index 7d443525146..386806a0367 100644 --- a/.github/workflows/performance-tests.yml +++ b/.github/workflows/performance-tests.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set-platform run: | diff --git a/.github/workflows/release-1-create-pr.yml b/.github/workflows/release-1-create-pr.yml index 14747b46cdf..6090d078593 100644 --- a/.github/workflows/release-1-create-pr.yml +++ b/.github/workflows/release-1-create-pr.yml @@ -40,7 +40,7 @@ jobs: run: echo "GITHUB_ORG=${GITHUB_REPOSITORY%%/*}" >> $GITHUB_ENV - name: Checkout from_branch branch - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ inputs.from_branch }} @@ -58,7 +58,7 @@ jobs: run: git push origin HEAD:${NEW_BRANCH} - name: Checkout release branch - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ env.NEW_BRANCH }} diff --git a/.github/workflows/release-2-tag-docker-push.yml b/.github/workflows/release-2-tag-docker-push.yml index 6061238ad2d..00245bd3cd6 100644 --- a/.github/workflows/release-2-tag-docker-push.yml +++ b/.github/workflows/release-2-tag-docker-push.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: master diff --git a/.github/workflows/release-3-master-into-dev.yml b/.github/workflows/release-3-master-into-dev.yml index 8ebdd193247..c338e7092e0 100644 --- a/.github/workflows/release-3-master-into-dev.yml +++ b/.github/workflows/release-3-master-into-dev.yml @@ -23,7 +23,7 @@ jobs: run: echo "GITHUB_ORG=${GITHUB_REPOSITORY%%/*}" >> $GITHUB_ENV - name: Checkout master - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: master @@ -40,7 +40,7 @@ jobs: run: git push origin HEAD:${NEW_BRANCH} - name: Checkout new branch - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ env.NEW_BRANCH }} @@ -121,7 +121,7 @@ jobs: run: echo "GITHUB_ORG=${GITHUB_REPOSITORY%%/*}" >> $GITHUB_ENV - name: Checkout master - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: master @@ -138,7 +138,7 @@ jobs: run: git push origin HEAD:${NEW_BRANCH} - name: Checkout new branch - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ env.NEW_BRANCH }} diff --git a/.github/workflows/release-x-manual-docker-containers.yml b/.github/workflows/release-x-manual-docker-containers.yml index 14f0b259584..7f21549eece 100644 --- a/.github/workflows/release-x-manual-docker-containers.yml +++ b/.github/workflows/release-x-manual-docker-containers.yml @@ -58,7 +58,7 @@ jobs: password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Checkout tag - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ inputs.release_number }} diff --git a/.github/workflows/release-x-manual-helm-chart.yml b/.github/workflows/release-x-manual-helm-chart.yml index 85052bd8a58..cbf2e75f841 100644 --- a/.github/workflows/release-x-manual-helm-chart.yml +++ b/.github/workflows/release-x-manual-helm-chart.yml @@ -43,7 +43,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ inputs.release_number }} fetch-depth: 0 diff --git a/.github/workflows/release-x-nightly.yml b/.github/workflows/release-x-nightly.yml index 5acd3953485..e9c84f698c2 100644 --- a/.github/workflows/release-x-nightly.yml +++ b/.github/workflows/release-x-nightly.yml @@ -39,7 +39,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ inputs.branch-to-build }} diff --git a/.github/workflows/renovate.yaml b/.github/workflows/renovate.yaml index 12334d34d2c..27ce5517328 100644 --- a/.github/workflows/renovate.yaml +++ b/.github/workflows/renovate.yaml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false diff --git a/.github/workflows/rest-framework-tests.yml b/.github/workflows/rest-framework-tests.yml index a9b45675dd3..e15a90bf2d9 100644 --- a/.github/workflows/rest-framework-tests.yml +++ b/.github/workflows/rest-framework-tests.yml @@ -27,7 +27,7 @@ jobs: echo $GITHUB_ENV - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index 2f71cdaa4be..7bcc509de5b 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Install Ruff Linter run: pip install -r requirements-lint.txt diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml index e136364e038..1eaf7e38911 100644 --- a/.github/workflows/shellcheck.yml +++ b/.github/workflows/shellcheck.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Run ShellCheck uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # 2.0.0 diff --git a/.github/workflows/test-helm-chart.yml b/.github/workflows/test-helm-chart.yml index c50866c303b..65bb07334f1 100644 --- a/.github/workflows/test-helm-chart.yml +++ b/.github/workflows/test-helm-chart.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false fetch-depth: 0 @@ -111,7 +111,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: ${{ github.event.pull_request.head.repo.full_name }} ref: ${{ github.event.pull_request.head.ref }} @@ -152,7 +152,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Generate values schema json uses: losisin/helm-values-schema-json-action@39cdf80504f6c95ad3c4f317e2135e2509ea56bb # v3 @@ -172,7 +172,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false fetch-depth: 0 @@ -194,7 +194,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Run ah lint working-directory: ./helm/defectdojo run: |- diff --git a/.github/workflows/update-sample-data.yml b/.github/workflows/update-sample-data.yml index c6f2027a26d..c970573c7fe 100644 --- a/.github/workflows/update-sample-data.yml +++ b/.github/workflows/update-sample-data.yml @@ -16,7 +16,7 @@ jobs: steps: # Checkout the repository - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ github.ref_name || 'dev'}} diff --git a/.github/workflows/validate_docs_build.yml b/.github/workflows/validate_docs_build.yml index 616cbc5910e..1ddbee28873 100644 --- a/.github/workflows/validate_docs_build.yml +++ b/.github/workflows/validate_docs_build.yml @@ -30,7 +30,7 @@ jobs: ${{ runner.os }}-node- - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: submodules: recursive fetch-depth: 0 From c08db32039bf71da000167b24d1b1d3b91c3c611 Mon Sep 17 00:00:00 2001 From: Tracy Walker Date: Thu, 4 Jun 2026 20:47:05 -0600 Subject: [PATCH 12/31] feat(parsers): add Alert Logic CSV parser (#14930) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(parser): scaffold Alert Logic parser package Empty __init__.py + stub parser.py with the 4 required methods returning placeholder values. Sets up the package for TDD tests to import against before the real implementation in Task 8. Authored by T. Walker - DefectDojo * test(parser): add synthetic Alert Logic CSV fixtures Three fixtures matching the 26-column Alert Logic vulnerability export shape (UTF-8 BOM, embedded CRLF in multi-line fields): - no_vuln.csv — header only, 0 data rows - one_vuln.csv — single Medium finding (HTTP/2 Rapid Reset) - many_vulns.csv — 7 rows covering Info / Low / Medium / High / Critical, with/without CVE, single & multi-IP (IPv4+IPv6), CISA Known Exploited Yes/No, multi-line Description and Resolution, a >500-char title for truncation test, empty CVSS and empty Operating System edge cases. All asset names, IPs, deployment names, and the customer account are synthetic (reserved doc IP ranges 192.0.2.x / 198.51.100.x / 203.0.113.x; .example.com hostnames; fictional AcmeCorp account). CVE identifiers and their associated descriptions/resolutions are from public sources. Authored by T. Walker - DefectDojo * test(parser): add failing TDD scaffold for Alert Logic parser Skeleton with 4 tests: get_scan_types, parse_no_findings, parse_one_finding, parse_many_findings. The one/many assertions fail against the Task 3 stub (which returns []) — that's the intended TDD red state. Full field-validation tests will be appended in Task 9 after the parser implementation lands in Task 8. Authored by T. Walker - DefectDojo * feat(parser): implement Alert Logic CSV parser Parses Alert Logic vulnerability scan CSV exports (26 columns, UTF-8 with BOM, multi-line quoted fields). Single-format, monolithic implementation following the IriusRisk skeleton. Field mapping: - Vulnerability → title (truncated at 500 chars with ellipsis) - Severity → severity (direct 1:1 Info/Low/Medium/High/Critical) - CVSS Score → cvssv3_score (float, None if empty) - Asset Name → component_name - IP Address → unsaved_endpoints (comma-split IPv4/IPv6) - Protocol/Port → endpoint protocol + port (port 0 → omitted) - CVE → unsaved_vulnerability_ids - Resolution → mitigation - Vulnerability ID → unique_id_from_tool (stable native ID) - Description, Evidence, OS, Vuln Span ID, Vuln Key, Asset Key/Type, Service, Category, VPC/Network, Deployment Name, Customer Account, First Seen, Last Scanned, Published Date, Age (days), CISA KEV → description (markdown table) - CISA Known Exploited = Yes → unsaved_tags: ["cisa-known-exploited"] static_finding=True, dynamic_finding=False (infrastructure vulnerability scanner pattern, matches Qualys VMDR). All 7 fixture findings parse cleanly with correct severities, multi-IP endpoint extraction (IPv4+IPv6), title truncation, CVE list, CVSS score, and tags. endpoint.clean() passes on all 10 endpoints generated from the many_vulns fixture. Authored by T. Walker - DefectDojo * test(parser): add field-validation tests for Alert Logic parser Adds 28 new tests on top of the TDD scaffold, bringing total coverage to 32 tests. Categories covered: - Scan-type metadata: get_label, get_description - Basic fields: title, severity, component_name, unique_id_from_tool, cvssv3_score, static/dynamic flags, mitigation content, description structure - Severity mapping: one test per source level (Info/Low/Medium/High/Critical) - Title truncation: long (>500) gets [:497] + "...", short stays as-is - unique_id_from_tool: distinct values per finding, matches source - Endpoints: single IPv4, multi-IP (IPv4+IPv6), IPv6-only, port=0 omission, endpoint.clean() on every endpoint - CVE handling: present and absent - CISA Known Exploited tag: added on "Yes", absent on "No" - CVSS score: parsed when present, None when empty - BOM handling: title resolves correctly (proves UTF-8 BOM is stripped) - Multi-line field preservation in description All 32 tests pass against the parser implementation from the previous commit. Authored by T. Walker - DefectDojo * docs(parser): add Alert Logic parser documentation Documents the Alert Logic CSV parser including: - File-export workflow from the Alert Logic console - Default deduplication strategy (unique_id_from_tool + hashcode fallback) - Complete 26-column field mapping table (expandable) - Additional Finding field settings (static/dynamic flags, active default) - Special processing notes covering severity conversion, title truncation, description construction, endpoint multi-IP / IPv6 / port-zero handling, deduplication algorithm, CVE handling, CISA Known Exploited tagging, and UTF-8 BOM + multi-line field handling Authored by T. Walker - DefectDojo * feat(parser): register Alert Logic deduplication configuration Adds Alert Logic Scan entries to: - HASHCODE_FIELDS_PER_SCANNER with ["title", "component_name", "vuln_id_from_tool"] (fallback when Vulnerability ID is missing on a row) - DEDUPLICATION_ALGORITHM_PER_PARSER as DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE (uses Vulnerability ID as the stable native identifier with hashcode fallback) Mirrors the Qualys VMDR dedup pattern (same field set, same algorithm). Authored by T. Walker - DefectDojo * fix(parser): support V3_FEATURE_LOCATIONS in Alert Logic parser The Endpoint model is deprecated and raises NotImplementedError when V3_FEATURE_LOCATIONS is enabled. Build LocationData URL locations in that mode and fall back to Endpoint otherwise, matching the established parser migration pattern (e.g. Qualys VMDR). Endpoint tests now read via the get_unsaved_locations helper so they pass under both settings. Authored by T. Walker - DefectDojo --- .../parsers/file/alertlogic.md | 135 +++++++++++++ dojo/settings/settings.dist.py | 2 + dojo/tools/alertlogic/__init__.py | 0 dojo/tools/alertlogic/parser.py | 172 +++++++++++++++++ unittests/scans/alertlogic/many_vulns.csv | 45 +++++ unittests/scans/alertlogic/no_vuln.csv | 1 + unittests/scans/alertlogic/one_vuln.csv | 10 + unittests/tools/test_alertlogic_parser.py | 182 ++++++++++++++++++ 8 files changed, 547 insertions(+) create mode 100644 docs/content/supported_tools/parsers/file/alertlogic.md create mode 100644 dojo/tools/alertlogic/__init__.py create mode 100644 dojo/tools/alertlogic/parser.py create mode 100644 unittests/scans/alertlogic/many_vulns.csv create mode 100644 unittests/scans/alertlogic/no_vuln.csv create mode 100644 unittests/scans/alertlogic/one_vuln.csv create mode 100644 unittests/tools/test_alertlogic_parser.py diff --git a/docs/content/supported_tools/parsers/file/alertlogic.md b/docs/content/supported_tools/parsers/file/alertlogic.md new file mode 100644 index 00000000000..572a56adbe6 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/alertlogic.md @@ -0,0 +1,135 @@ +--- +title: "Alert Logic" +toc_hide: true +--- + +The [Alert Logic](https://www.alertlogic.com/) parser for DefectDojo supports imports from CSV format. This document details the parsing of Alert Logic vulnerability scan exports into DefectDojo field mappings, unmapped fields, and transformation notes for easier troubleshooting and analysis. + +## Supported File Types + +The Alert Logic parser accepts CSV file format. To generate this file from Alert Logic: + +1. Log into the Alert Logic console +2. Navigate to **Validate → Vulnerabilities** (or the equivalent vulnerability listing view) +3. Apply the filters you want included in the export +4. Export the filtered vulnerability list as CSV +5. Save the file with a `.csv` extension +6. Upload to DefectDojo using the "Alert Logic Scan" scan type + +The parser handles UTF-8 with byte-order mark (BOM) and multi-line quoted fields commonly present in Description, Evidence, and Resolution columns. + +## Default Deduplication Hashcode Fields + +Alert Logic provides a stable native vulnerability identifier in the `Vulnerability ID` column. DefectDojo uses it as `unique_id_from_tool` with hashcode fields as a fallback: + +- title +- component_name +- vuln_id_from_tool + +### Sample Scan Data + +Sample Alert Logic scans can be found in the [sample scan data folder](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/alertlogic). + +## Link To Tool + +- [Alert Logic](https://www.alertlogic.com/) +- [Alert Logic Documentation](https://docs.alertlogic.com/) + +## CSV Format + +### Total Fields in CSV + +- Total data fields: 26 +- Total data fields parsed: 26 +- Total data fields NOT parsed: 0 + +### CSV Format Field Mapping Details + +
+Click to expand Field Mapping Table + +| Source Field | DefectDojo Field | Notes | +| ----------------------- | --------------------------- | ------------------------------------------------------------------------------ | +| Vulnerability | title | Truncated to 500 characters with "..." suffix if longer | +| Severity | severity | Direct one-to-one mapping (Info / Low / Medium / High / Critical) | +| CVSS Score | cvssv3_score | Parsed as float; empty values produce no score | +| Asset Name | component_name | The affected host or service from the scan | +| IP Address | unsaved_endpoints | Comma-separated IPv4 / IPv6 list; each value becomes a separate endpoint | +| Protocol/Port | unsaved_endpoints | Parsed as `PROTOCOL/PORT`; a port of 0 is omitted | +| CVE | unsaved_vulnerability_ids | Single CVE identifier when present | +| Resolution | mitigation | Direct copy, including multi-line content | +| Vulnerability ID | unique_id_from_tool | Alert Logic's stable native vulnerability identifier (used for deduplication) | +| Description | description | Included in structured description block | +| Evidence | description | Included in structured description block | +| Operating System | description | Included in structured description block (CPE strings preserved) | +| Vulnerability Span ID | description | Included in structured description block | +| Vulnerability Key | description | Included in structured description block | +| Asset Key | description | Included in structured description block | +| Asset Type | description | Included in structured description block | +| Service | description | Included in structured description block | +| Category | description | Included in structured description block | +| VPC/Network | description | Included in structured description block | +| Deployment Name | description | Included in structured description block | +| Customer Account | description | Included in structured description block | +| First Seen | description | Included in structured description block | +| Last Scanned | description | Included in structured description block | +| Published Date | description | Included in structured description block | +| Age (days) | description | Included in structured description block | +| CISA Known Exploited | description, unsaved_tags | Added as `cisa-known-exploited` tag when value is "Yes" | + +
+ +### Additional Finding Field Settings (CSV Format) + +
+Click to expand Additional Settings Table + +| Finding Field | Default Value | Notes | +| ---------------- | ------------- | ----------------------------------------------------------- | +| static_finding | True | Alert Logic is an infrastructure vulnerability scanner | +| dynamic_finding | False | Alert Logic is an infrastructure vulnerability scanner | +| active | True | Alert Logic exports do not carry a mitigation status column | + +
+ +## Special Processing Notes + +### Severity Conversion + +Alert Logic uses a five-level severity scale that aligns one-to-one with DefectDojo severity levels: + +- `Critical` → Critical +- `High` → High +- `Medium` → Medium +- `Low` → Low +- `Info` → Info + +Any unrecognized severity value defaults to Info. + +### Title Format + +Finding titles are derived from the "Vulnerability" column. Titles longer than 500 characters are truncated to 497 characters with a "..." suffix appended. Shorter titles are used as-is without modification. + +### Description Construction + +The parser constructs a structured markdown description containing all relevant CSV fields not already mapped to dedicated Finding columns. Each field is rendered as `**Label:** value` with blank lines between entries. Fields are included only when they contain a non-empty value, so the description stays tight for sparsely populated rows. + +### Endpoint Construction + +The "IP Address" column may contain one or more comma-separated IP addresses, mixing IPv4 and IPv6 (for example: `198.51.100.30, fe80::250:56ff:fe96:b97`). Each address becomes a separate endpoint. The "Protocol/Port" column is parsed as `PROTOCOL/PORT` (e.g., `TCP/443`); when the port is `0` the value is treated as "no specific port" and omitted from the endpoint. All endpoints are validated via `endpoint.clean()` before being attached to the finding. + +### Deduplication + +Alert Logic exports include a stable per-vulnerability identifier in the "Vulnerability ID" column. DefectDojo uses this as `unique_id_from_tool` and the deduplication algorithm `DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE`. When the ID is missing (some scan exports omit it for non-vulnerability findings), DefectDojo falls back to the hashcode algorithm using `title`, `component_name`, and `vuln_id_from_tool` (the CVE) as the stable fields. + +### CVE Handling + +The "CVE" column carries a single CVE identifier or is empty. When present it is attached to the finding via `unsaved_vulnerability_ids`; when absent no CVE is set. + +### CISA Known Exploited Tagging + +When the "CISA Known Exploited" column equals "Yes", the finding receives a `cisa-known-exploited` tag. This makes it straightforward to filter, route, or escalate findings already known to be exploited in the wild. + +### BOM and Multi-Line Field Handling + +Alert Logic exports start with a UTF-8 byte-order mark (`\xef\xbb\xbf`). The parser uses `utf-8-sig` decoding to strip the BOM transparently. Description, Evidence, and Resolution columns frequently contain multi-line content (separated by `\r\n` inside the quoted field); these newlines are preserved in the resulting `description` and `mitigation` Finding fields. diff --git a/dojo/settings/settings.dist.py b/dojo/settings/settings.dist.py index f99e62eca2e..abe31dec9f9 100644 --- a/dojo/settings/settings.dist.py +++ b/dojo/settings/settings.dist.py @@ -1107,6 +1107,7 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param "Orca Security Alerts": ["title", "component_name"], "Xygeni SCA Scan": ["vulnerability_ids", "component_name", "component_version"], "Qualys VMDR": ["title", "component_name", "vuln_id_from_tool"], + "Alert Logic Scan": ["title", "component_name", "vuln_id_from_tool"], } # Override the hardcoded settings here via the env var @@ -1381,6 +1382,7 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param "Xygeni SCA Scan": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, "Xygeni Secrets Scan": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL, "Qualys VMDR": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "Alert Logic Scan": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, } # Override the hardcoded settings here via the env var diff --git a/dojo/tools/alertlogic/__init__.py b/dojo/tools/alertlogic/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/alertlogic/parser.py b/dojo/tools/alertlogic/parser.py new file mode 100644 index 00000000000..f56f7bd3fcd --- /dev/null +++ b/dojo/tools/alertlogic/parser.py @@ -0,0 +1,172 @@ +import csv +import io + +from django.conf import settings + +from dojo.models import Endpoint, Finding +from dojo.tools.locations import LocationData + +SEVERITY_MAPPING = { + "Info": "Info", + "Low": "Low", + "Medium": "Medium", + "High": "High", + "Critical": "Critical", +} + + +class AlertlogicParser: + + def get_scan_types(self): + return ["Alert Logic Scan"] + + def get_label_for_scan_types(self, scan_type): + return scan_type + + def get_description_for_scan_types(self, scan_type): + return "Import Alert Logic vulnerability scan findings (CSV)." + + def get_findings(self, filename, test): + content = filename.read() + if isinstance(content, bytes): + content = content.decode("utf-8-sig") + elif content.startswith(""): + content = content.lstrip("") + + reader = csv.DictReader(io.StringIO(content), delimiter=",", quotechar='"') + findings = [] + for row in reader: + vuln = (row.get("Vulnerability") or "").strip() + if not vuln: + continue + + severity_raw = (row.get("Severity") or "").strip() + severity = SEVERITY_MAPPING.get(severity_raw, "Info") + + title = vuln[:497] + "..." if len(vuln) > 500 else vuln + + description = _build_description(row) + mitigation = (row.get("Resolution") or "").strip() + component_name = (row.get("Asset Name") or "").strip() or None + unique_id = (row.get("Vulnerability ID") or "").strip() or None + cve = (row.get("CVE") or "").strip() + + finding = Finding( + test=test, + title=title, + severity=severity, + description=description, + mitigation=mitigation, + component_name=component_name, + unique_id_from_tool=unique_id, + static_finding=True, + dynamic_finding=False, + ) + + cvssv3_score = _parse_cvss(row.get("CVSS Score")) + if cvssv3_score is not None: + finding.cvssv3_score = cvssv3_score + + if cve: + finding.unsaved_vulnerability_ids = [cve] + + _add_locations( + finding, + row.get("IP Address"), + row.get("Protocol/Port"), + ) + + tags = _build_tags(row) + if tags: + finding.unsaved_tags = tags + + findings.append(finding) + + return findings + + +def _build_description(row): + field_order = [ + ("Description", "Description"), + ("Evidence", "Evidence"), + ("Operating System", "Operating System"), + ("Vulnerability ID", "Vulnerability ID"), + ("Vulnerability Span ID", "Vulnerability Span ID"), + ("Vulnerability Key", "Vulnerability Key"), + ("Asset Key", "Asset Key"), + ("Asset Type", "Asset Type"), + ("Service", "Service"), + ("Category", "Category"), + ("VPC/Network", "VPC/Network"), + ("Deployment Name", "Deployment Name"), + ("Customer Account", "Customer Account"), + ("First Seen", "First Seen"), + ("Last Scanned", "Last Scanned"), + ("Published Date", "Published Date"), + ("Age (days)", "Age (days)"), + ("CISA Known Exploited", "CISA Known Exploited"), + ] + parts = [] + for source_field, label in field_order: + value = (row.get(source_field) or "").strip() + if value: + parts.append(f"**{label}:** {value}") + return "\n\n".join(parts) + + +def _parse_cvss(value): + if value is None: + return None + value = value.strip() + if not value: + return None + try: + return float(value) + except ValueError: + return None + + +def _add_locations(finding, ip_field, protoport_field): + if not ip_field: + return + protocol, port = _parse_proto_port(protoport_field) + for raw_host in ip_field.split(","): + host = raw_host.strip() + if not host: + continue + if settings.V3_FEATURE_LOCATIONS: + finding.unsaved_locations.append( + LocationData.url(host=host, protocol=protocol or "", port=port), + ) + else: + # TODO: Delete this after the move to Locations + kwargs = {"host": host} + if protocol: + kwargs["protocol"] = protocol + if port: + kwargs["port"] = port + finding.unsaved_endpoints.append(Endpoint(**kwargs)) + + +def _parse_proto_port(value): + if not value: + return None, None + value = value.strip() + if "/" not in value: + return None, None + proto, _, port_str = value.partition("/") + proto = proto.strip().lower() or None + try: + port = int(port_str.strip()) + except (ValueError, TypeError): + port = None + if port == 0: + port = None + return proto, port + + +def _build_tags(row): + tags = [] + if (row.get("CISA Known Exploited") or "").strip().lower() == "yes": + tags.append("cisa-known-exploited") + return tags diff --git a/unittests/scans/alertlogic/many_vulns.csv b/unittests/scans/alertlogic/many_vulns.csv new file mode 100644 index 00000000000..6c96548f070 --- /dev/null +++ b/unittests/scans/alertlogic/many_vulns.csv @@ -0,0 +1,45 @@ +Vulnerability,CVSS Score,Severity,Deployment Name,Asset Name,IP Address,Protocol/Port,First Seen,Last Scanned,Asset Type,Customer Account,CVE,Service,VPC/Network,Category,Asset Key,Description,Evidence,Operating System,Resolution,Vulnerability ID,Vulnerability Span ID,Vulnerability Key,Age (days),CISA Known Exploited,Published Date +CVE-2021-44228 - Apache Log4j Remote Code Execution,10.0,Critical,Production,app-server-01.example.com,192.0.2.20,TCP/8080,12/10/21 14:00,5/27/26 6:37,host,AcmeCorp,CVE-2021-44228,http,prod-vpc-east,agent,/dc/host/BBBB2222-3333-4444-5555-666666666666/vulnerability/bbbbbbbbbbbbbbbbbbbb,"Apache Log4j2 2.0-beta9 through 2.15.0 (excluding security releases 2.12.2, 2.12.3, and 2.3.1) JNDI features used in configuration, log messages, and parameters do not protect against attacker controlled LDAP and other JNDI related endpoints. An attacker who can control log messages or log message parameters can execute arbitrary code loaded from LDAP servers when message lookup substitution is enabled. + +Known as Log4Shell.","Patch Scanning - oval:org.secpod.oval:def:99999002 +Refs: KB-LOG4J-RCE. Definition: log4j-core JAR detected with version 2.14.1 on classpath.",cpe:2.3:o:ubuntu:ubuntu_linux:22.04:*:*:*:*:*:*:*,"Upgrade Apache Log4j2 to version 2.17.1 or later. + +For Java 8: Log4j 2.17.1 +For Java 7: Log4j 2.12.4 +For Java 6: Log4j 2.3.2",22222222222222222222222222222222,BBBBBBBB-CCCC-DDDD-EEEE-FFFFFFFFFFFF,/dc/host/BBBB2222-3333-4444-5555-666666666666/vulnerability/2b3c4d5e6f7a8b9c0d1e,1630,Yes,12/10/21 +CVE-2014-0160 - OpenSSL Heartbleed Information Disclosure,7.5,High,Staging,tls-gateway.example.com,"198.51.100.30, fe80::250:56ff:fe96:b97",TCP/443,4/8/14 0:00,5/27/26 19:57,host,AcmeCorp,CVE-2014-0160,https,stage-vpc-west,agent,/dc/host/CCCC3333-4444-5555-6666-777777777777/vulnerability/cccccccccccccccccccc,"The (1) TLS and (2) DTLS implementations in OpenSSL 1.0.1 before 1.0.1g do not properly handle Heartbeat Extension packets, which allows remote attackers to obtain sensitive information from process memory via crafted packets that trigger a buffer over-read, as demonstrated by reading private keys, related to d1_both.c and t1_lib.c, aka the Heartbleed bug.","Patch Scanning - oval:org.secpod.oval:def:99999003 +Refs: KB-OPENSSL-HEART. Definition: OpenSSL 1.0.1 through 1.0.1f detected. Specifics: openssl version 1.0.1c is installed.",cpe:2.3:o:ubuntu:ubuntu_linux:14.04:*:*:*:*:*:*:*,"Upgrade OpenSSL to version 1.0.1g or later. + +Ubuntu: apt-get install --only-upgrade openssl libssl1.0.0 +RHEL: yum update openssl",33333333333333333333333333333333,CCCCCCCC-DDDD-EEEE-FFFF-000000000000,/dc/host/CCCC3333-4444-5555-6666-777777777777/vulnerability/3c4d5e6f7a8b9c0d1e2f,4432,Yes,4/7/14 +TCP Timestamp Response,2.6,Low,Production,edge-router.example.com,203.0.113.40,TCP/0,1/2/26 9:15,5/27/26 1:18,host,AcmeCorp,,Non-Attributable,prod-vpc-east,network,/dc/host/DDDD4444-5555-6666-7777-888888888888/vulnerability/dddddddddddddddddddd,The remote host implements TCP timestamps and therefore allows to compute the uptime.,Network Scan probe. Packet: tcp timestamp option present in SYN-ACK.,,"Disable TCP timestamps if not required: + +Linux: sysctl -w net.ipv4.tcp_timestamps=0 +Windows: netsh int tcp set global timestamps=disabled",44444444444444444444444444444444,DDDDDDDD-EEEE-FFFF-0000-111111111111,/dc/host/DDDD4444-5555-6666-7777-888888888888/vulnerability/4d5e6f7a8b9c0d1e2f30,146,No,1/1/97 +Web Server - Version Disclosure in HTTP Server Header,0,Info,Production,static-cdn.example.com,192.0.2.50,TCP/80,9/9/24 0:50,5/27/26 6:37,host,AcmeCorp,,http,prod-vpc-east,agent,/dc/host/EEEE5555-6666-7777-8888-999999999999/vulnerability/eeeeeeeeeeeeeeeeeeee,The remote web server discloses its version number in the HTTP Server response header. This may aid an attacker in fingerprinting the system.,"Server: nginx/1.18.0 + +Observed on GET /",,"Configure the web server to suppress the Server header: + +nginx: server_tokens off; +Apache: ServerTokens Prod / ServerSignature Off",55555555555555555555555555555555,EEEEEEEE-FFFF-0000-1111-222222222222,/dc/host/EEEE5555-6666-7777-8888-999999999999/vulnerability/5e6f7a8b9c0d1e2f3041,260,No,6/9/21 +"CVE-2023-23397 - Microsoft Outlook Elevation of Privilege Vulnerability - Critical zero-click NTLM credential disclosure exploited in the wild by state-sponsored actors targeting government, transportation, energy, and military organizations across Europe and reportedly used in attacks attributed to APT28 / Fancy Bear, where a crafted email with a PidLidReminderFileParameter extended MAPI property triggers an outbound SMB authentication attempt to an attacker-controlled UNC path even when the recipient never opens the message, allowing the attacker to capture the victim's Net-NTLMv2 hash for offline cracking or pass-the-hash relay attacks against domain resources, included in CISA's Known Exploited Vulnerabilities catalog with patch deadline March 2023 and remediated by KB5002375 / KB5002376 plus mitigations blocking outbound SMB",9.8,Critical,Production,mail-relay.example.com,192.0.2.60,TCP/0,3/14/23 8:00,5/27/26 19:57,host,AcmeCorp,CVE-2023-23397,Non-Attributable,prod-vpc-east,agent,/dc/host/FFFF6666-7777-8888-9999-AAAAAAAAAAAA/vulnerability/ffffffffffffffffffff,"Microsoft Outlook Elevation of Privilege Vulnerability. An attacker who successfully exploited this vulnerability could access a user's Net-NTLMv2 hash which could be used as a basis of an NTLM Relay attack against another service to authenticate as the user. + +No user interaction is required.","Patch Scanning - oval:org.secpod.oval:def:99999004 +Refs: KB5002375, KB5002376. Definition: Microsoft Outlook 2013/2016/2019/365 detected with vulnerable build.",cpe:2.3:o:microsoft:windows_server_2019:-:*:standard:*:*:*:*:*,"Apply Microsoft Security Updates: + +Outlook 2013 SP1: KB5002375 +Outlook 2016: KB5002376 +Outlook 2019/365: install latest updates via Click-to-Run + +Mitigation: block outbound SMB (TCP 445) at perimeter; add users to Protected Users group.",66666666666666666666666666666666,FFFFFFFF-0000-1111-2222-333333333333,/dc/host/FFFF6666-7777-8888-9999-AAAAAAAAAAAA/vulnerability/6f7a8b9c0d1e2f304152,805,Yes,3/14/23 +CVE-2024-3094 - XZ Utils Malicious Code Insertion,7.8,High,Staging,build-host.example.com,"198.51.100.70, 2001:db8::1:70",TCP/22,3/29/24 12:00,5/27/26 6:37,host,AcmeCorp,CVE-2024-3094,ssh,stage-vpc-west,agent,/dc/host/AAAA7777-8888-9999-AAAA-BBBBBBBBBBBB/vulnerability/aaaa1111aaaa1111aaaa,"Malicious code was discovered in the upstream tarballs of xz, starting with version 5.6.0. Through a series of complex obfuscations, the liblzma build process extracts a prebuilt object file from a disguised test file existing in the source code, which is then used to modify specific functions in the liblzma code. This results in a modified liblzma library that can be used by any software linked against this library, intercepting and modifying the data interaction with this library.",Package Scanning. Detected: xz-utils 5.6.0-0.2 / xz-utils 5.6.1-1 installed via apt repository before March 28 2024 mitigations.,cpe:2.3:o:ubuntu:ubuntu_linux:24.04:*:*:*:*:*:*:*,"Downgrade or upgrade xz-utils: + +- Downgrade to 5.4.x: apt-get install xz-utils=5.4.5-0.3 +- Upgrade to 5.6.2+ once distribution patches are available +- Reboot is recommended after replacement",77777777777777777777777777777777,AAAA1111-BBBB-2222-CCCC-DDDDDDDDDDDD,/dc/host/AAAA7777-8888-9999-AAAA-BBBBBBBBBBBB/vulnerability/7a8b9c0d1e2f30415263,60,No,3/29/24 +SSH Weak Key Exchange Algorithms Enabled,,Medium,Staging,jumpbox.example.com,2001:db8::1:80,TCP/22,11/4/25 3:30,5/27/26 1:18,host,AcmeCorp,,ssh,stage-vpc-west,agent,/dc/host/BBBB8888-9999-AAAA-BBBB-CCCCCCCCCCCC/vulnerability/bbbb2222bbbb2222bbbb,"The remote SSH server supports key exchange algorithms considered weak (diffie-hellman-group1-sha1, diffie-hellman-group14-sha1).","SSH banner probe: SSH-2.0-OpenSSH_7.4 +Negotiated kex algorithms include diffie-hellman-group1-sha1.",cpe:2.3:o:redhat:enterprise_linux:7.9:*:*:*:*:*:*:*,"Disable weak KEX algorithms in /etc/ssh/sshd_config: + +KexAlgorithms curve25519-sha256@libssh.org,diffie-hellman-group14-sha256,diffie-hellman-group16-sha512 + +Then restart: systemctl restart sshd",88888888888888888888888888888888,BBBB2222-CCCC-3333-DDDD-EEEEEEEEEEEE,/dc/host/BBBB8888-9999-AAAA-BBBB-CCCCCCCCCCCC/vulnerability/8b9c0d1e2f3041526374,204,No,1/15/15 diff --git a/unittests/scans/alertlogic/no_vuln.csv b/unittests/scans/alertlogic/no_vuln.csv new file mode 100644 index 00000000000..5fc29d28432 --- /dev/null +++ b/unittests/scans/alertlogic/no_vuln.csv @@ -0,0 +1 @@ +Vulnerability,CVSS Score,Severity,Deployment Name,Asset Name,IP Address,Protocol/Port,First Seen,Last Scanned,Asset Type,Customer Account,CVE,Service,VPC/Network,Category,Asset Key,Description,Evidence,Operating System,Resolution,Vulnerability ID,Vulnerability Span ID,Vulnerability Key,Age (days),CISA Known Exploited,Published Date diff --git a/unittests/scans/alertlogic/one_vuln.csv b/unittests/scans/alertlogic/one_vuln.csv new file mode 100644 index 00000000000..c73a7927448 --- /dev/null +++ b/unittests/scans/alertlogic/one_vuln.csv @@ -0,0 +1,10 @@ +Vulnerability,CVSS Score,Severity,Deployment Name,Asset Name,IP Address,Protocol/Port,First Seen,Last Scanned,Asset Type,Customer Account,CVE,Service,VPC/Network,Category,Asset Key,Description,Evidence,Operating System,Resolution,Vulnerability ID,Vulnerability Span ID,Vulnerability Key,Age (days),CISA Known Exploited,Published Date +CVE-2023-44487 - HTTP/2 Rapid Reset Attack,5.3,Medium,Production,web-01.example.com,192.0.2.10,TCP/443,5/15/26 0:50,5/27/26 6:37,host,AcmeCorp,CVE-2023-44487,https,prod-vpc-east,agent,/dc/host/AAAA1111-2222-3333-4444-555555555555/vulnerability/aaaaaaaaaaaaaaaaaaaa,"The HTTP/2 protocol allows a denial of service (server resource consumption) because request cancellation can reset many streams quickly, as exploited in the wild in August through October 2023. + +CVE-2023-44487 affects multiple HTTP/2 server implementations including nginx, Apache, and load balancers.","Patch Scanning - oval:org.secpod.oval:def:99999001 + +Refs: KB-HTTP2-RST. Definition: HTTP/2 server detected with vulnerable version.",cpe:2.3:o:ubuntu:ubuntu_linux:22.04:*:*:*:*:*:*:*,"Upgrade affected HTTP/2 implementation to a patched version: + +- nginx >= 1.25.3 +- Apache httpd >= 2.4.58 +- HAProxy >= 2.4.24",11111111111111111111111111111111,AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE,/dc/host/AAAA1111-2222-3333-4444-555555555555/vulnerability/1a2b3c4d5e6f7a8b9c0d,12,No,10/10/23 diff --git a/unittests/tools/test_alertlogic_parser.py b/unittests/tools/test_alertlogic_parser.py new file mode 100644 index 00000000000..92ce1338cc1 --- /dev/null +++ b/unittests/tools/test_alertlogic_parser.py @@ -0,0 +1,182 @@ +from dojo.models import Test +from dojo.tools.alertlogic.parser import AlertlogicParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestAlertlogicParser(DojoTestCase): + + @staticmethod + def _findings(filename): + with (get_unit_tests_scans_path("alertlogic") / filename).open(encoding="utf-8") as testfile: + return AlertlogicParser().get_findings(testfile, Test()) + + def test_get_scan_types(self): + self.assertEqual(["Alert Logic Scan"], AlertlogicParser().get_scan_types()) + + def test_get_label_for_scan_types(self): + self.assertEqual("Alert Logic Scan", AlertlogicParser().get_label_for_scan_types("Alert Logic Scan")) + + def test_get_description_for_scan_types(self): + description = AlertlogicParser().get_description_for_scan_types("Alert Logic Scan") + self.assertIn("Alert Logic", description) + + def test_parse_no_findings(self): + self.assertEqual(0, len(self._findings("no_vuln.csv"))) + + def test_parse_one_finding(self): + self.assertEqual(1, len(self._findings("one_vuln.csv"))) + + def test_parse_many_findings(self): + self.assertEqual(7, len(self._findings("many_vulns.csv"))) + + def test_one_finding_basic_fields(self): + finding = self._findings("one_vuln.csv")[0] + self.assertEqual("CVE-2023-44487 - HTTP/2 Rapid Reset Attack", finding.title) + self.assertEqual("Medium", finding.severity) + self.assertEqual("web-01.example.com", finding.component_name) + self.assertEqual("11111111111111111111111111111111", finding.unique_id_from_tool) + self.assertEqual(5.3, finding.cvssv3_score) + self.assertEqual(True, finding.static_finding) + self.assertEqual(False, finding.dynamic_finding) + + def test_one_finding_cve(self): + finding = self._findings("one_vuln.csv")[0] + self.assertEqual(["CVE-2023-44487"], finding.unsaved_vulnerability_ids) + + def test_one_finding_mitigation(self): + finding = self._findings("one_vuln.csv")[0] + self.assertIn("nginx", finding.mitigation) + self.assertIn("Apache httpd", finding.mitigation) + + def test_one_finding_description_includes_evidence_and_os(self): + finding = self._findings("one_vuln.csv")[0] + self.assertIn("**Description:**", finding.description) + self.assertIn("**Evidence:**", finding.description) + self.assertIn("**Operating System:**", finding.description) + self.assertIn("**Vulnerability ID:**", finding.description) + + def test_severity_critical(self): + finding = self._findings("many_vulns.csv")[0] + self.assertEqual("Critical", finding.severity) + + def test_severity_high(self): + finding = self._findings("many_vulns.csv")[1] + self.assertEqual("High", finding.severity) + + def test_severity_low(self): + finding = self._findings("many_vulns.csv")[2] + self.assertEqual("Low", finding.severity) + + def test_severity_info(self): + finding = self._findings("many_vulns.csv")[3] + self.assertEqual("Info", finding.severity) + + def test_severity_medium(self): + finding = self._findings("many_vulns.csv")[6] + self.assertEqual("Medium", finding.severity) + + def test_title_truncation_long(self): + # Row 4 has an 841-char Vulnerability value, should be truncated to 500 + finding = self._findings("many_vulns.csv")[4] + self.assertEqual(500, len(finding.title)) + self.assertTrue(finding.title.endswith("...")) + + def test_title_no_truncation_when_short(self): + # Row 0 has a 51-char title — should not be truncated + finding = self._findings("many_vulns.csv")[0] + self.assertFalse(finding.title.endswith("...")) + self.assertEqual(51, len(finding.title)) + + def test_unique_id_from_tool(self): + # Distinct unique_id per finding — these are the canonical dedup anchors + findings = self._findings("many_vulns.csv") + ids = [f.unique_id_from_tool for f in findings] + self.assertEqual(len(ids), len(set(ids))) # all unique + self.assertEqual("22222222222222222222222222222222", findings[0].unique_id_from_tool) + + def test_endpoint_single_ipv4(self): + # Row 0 has a single IPv4 address + finding = self._findings("many_vulns.csv")[0] + endpoints = self.get_unsaved_locations(finding) + self.assertEqual(1, len(endpoints)) + endpoint = endpoints[0] + self.assertEqual("192.0.2.20", endpoint.host) + self.assertEqual("tcp", endpoint.protocol) + self.assertEqual(8080, endpoint.port) + + def test_endpoint_multi_ipv4_and_ipv6(self): + # Row 1: "198.51.100.30, fe80::250:56ff:fe96:b97" + finding = self._findings("many_vulns.csv")[1] + endpoints = self.get_unsaved_locations(finding) + self.assertEqual(2, len(endpoints)) + hosts = {ep.host for ep in endpoints} + self.assertEqual({"198.51.100.30", "fe80::250:56ff:fe96:b97"}, hosts) + + def test_endpoint_ipv6_only(self): + # Row 6 has IPv6-only address + finding = self._findings("many_vulns.csv")[6] + endpoints = self.get_unsaved_locations(finding) + self.assertEqual(1, len(endpoints)) + self.assertEqual("2001:db8::1:80", endpoints[0].host) + + def test_endpoint_port_zero_is_omitted(self): + # Row 2 has Protocol/Port "TCP/0" — port should not be set + finding = self._findings("many_vulns.csv")[2] + endpoints = self.get_unsaved_locations(finding) + self.assertEqual(1, len(endpoints)) + self.assertIsNone(endpoints[0].port) + + def test_endpoint_clean_succeeds(self): + # Hard guardrail: every endpoint/location must pass clean() + # (get_unsaved_locations cleans each entry internally) + self.validate_locations(self._findings("many_vulns.csv")) + + def test_cve_present(self): + # Row 0 has CVE-2021-44228 (Log4Shell) + finding = self._findings("many_vulns.csv")[0] + self.assertEqual(["CVE-2021-44228"], finding.unsaved_vulnerability_ids) + + def test_cve_absent(self): + # Row 2 (TCP Timestamp) has no CVE — attribute should be unset or empty + finding = self._findings("many_vulns.csv")[2] + self.assertFalse(getattr(finding, "unsaved_vulnerability_ids", None)) + + def test_cisa_known_exploited_tag_added(self): + # Rows 0, 1, 4 have CISA KEV = "Yes" + findings = self._findings("many_vulns.csv") + self.assertIn("cisa-known-exploited", findings[0].unsaved_tags) + self.assertIn("cisa-known-exploited", findings[1].unsaved_tags) + self.assertIn("cisa-known-exploited", findings[4].unsaved_tags) + + def test_cisa_known_exploited_tag_not_added(self): + # Row 2 has CISA KEV = "No" — no tag should be added + finding = self._findings("many_vulns.csv")[2] + self.assertFalse(getattr(finding, "unsaved_tags", None)) + + def test_cvssv3_score_parsed(self): + finding = self._findings("many_vulns.csv")[0] + self.assertEqual(10.0, finding.cvssv3_score) + + def test_cvssv3_score_empty_is_none(self): + # Row 6 has empty CVSS Score + finding = self._findings("many_vulns.csv")[6] + self.assertIsNone(finding.cvssv3_score) + + def test_static_dynamic_flags_set_explicitly(self): + for finding in self._findings("many_vulns.csv"): + self.assertEqual(True, finding.static_finding) + self.assertEqual(False, finding.dynamic_finding) + + def test_bom_handling(self): + # All fixtures have a UTF-8 BOM; the parser must consume it without + # producing a phantom field name with the BOM prefix. + finding = self._findings("one_vuln.csv")[0] + self.assertEqual("CVE-2023-44487 - HTTP/2 Rapid Reset Attack", finding.title) + # If BOM were not stripped, the first column key would be "Vulnerability" + # and finding.title would be empty. + + def test_multiline_field_preserved_in_description(self): + # Row 0 (Log4Shell) has a multi-line Description field + finding = self._findings("many_vulns.csv")[0] + self.assertIn("Log4Shell", finding.description) + self.assertIn("\n", finding.description) From 74a525ec45f7eeda9c4c521e39687269f1e4eec3 Mon Sep 17 00:00:00 2001 From: valentijnscholten Date: Fri, 5 Jun 2026 04:47:43 +0200 Subject: [PATCH 13/31] feat: allow users to request peer review from themselves (#14946) Remove the exclusion of the current user from the reviewer dropdown in ReviewFindingForm so users can self-assign as reviewer. --- dojo/forms.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/dojo/forms.py b/dojo/forms.py index e33d7ef51c4..cb90d0f57de 100644 --- a/dojo/forms.py +++ b/dojo/forms.py @@ -2176,16 +2176,13 @@ class ReviewFindingForm(forms.Form): def __init__(self, *args, **kwargs): finding = kwargs.pop("finding", None) - user = kwargs.pop("user", None) + kwargs.pop("user", None) super().__init__(*args, **kwargs) # Get the list of users if finding is not None: users = get_authorized_users_for_product_and_product_type(None, finding.test.engagement.product, "edit") else: users = get_authorized_users("edit").filter(is_active=True) - # Remove the current user - if user is not None: - users = users.exclude(id=user.id) # Save a copy of the original query to be used in the validator self.reviewer_queryset = users # Set the users in the form From bae53d1038b364adc1d7ca567998ea0864b8eb82 Mon Sep 17 00:00:00 2001 From: valentijnscholten Date: Fri, 5 Jun 2026 04:48:32 +0200 Subject: [PATCH 14/31] Preserve verified flag when promoting duplicate to new original (#14934) When the original of a duplicate cluster is deleted (e.g. via engagement deletion), reconfigure_duplicate_cluster promotes the first remaining duplicate to the new primary. It already copies active and is_mitigated from the original, but not verified. The promoted finding kept its own verified=False, which blocked Jira's "Push All Issues" (requires active+verified). Add verified to the fields copied to the new original. Fixes #14911 --- dojo/finding/helper.py | 1 + .../test_prepare_duplicates_for_delete.py | 46 ++++++++++++++++++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/dojo/finding/helper.py b/dojo/finding/helper.py index 7808fdd7cf5..140a6bd426d 100644 --- a/dojo/finding/helper.py +++ b/dojo/finding/helper.py @@ -603,6 +603,7 @@ def reconfigure_duplicate_cluster(original, cluster_outside): duplicate=False, duplicate_finding=None, active=original.active, + verified=original.verified, is_mitigated=original.is_mitigated, ) new_original.found_by.set(original.found_by.all()) diff --git a/unittests/test_prepare_duplicates_for_delete.py b/unittests/test_prepare_duplicates_for_delete.py index 78d612dfdec..89ed84069db 100644 --- a/unittests/test_prepare_duplicates_for_delete.py +++ b/unittests/test_prepare_duplicates_for_delete.py @@ -294,15 +294,56 @@ def test_multiple_originals(self): self.assertFalse(dupe_of_b.duplicate) self.assertIsNone(dupe_of_b.duplicate_finding) - def test_original_status_copied_to_new_original(self): - """New original inherits active/is_mitigated status from deleted original.""" + def test_original_status_copied_to_new_original_active_verified(self): + """ + New original inherits active/verified/is_mitigated from deleted original. + + Positive case: original is an open, verified, not-mitigated finding. + Duplicate starts with the opposite of each field so every copy is observable. + + Regression test for issue #14911: promoted duplicates kept verified=False + even when the original was verified, blocking Jira "Push All Issues". + """ + original = self._create_finding(self.test1, "Original") + original.active = True + original.verified = True + original.is_mitigated = False + super(Finding, original).save(skip_validation=True) + + outside_dupe = self._create_finding(self.test2, "Outside Dupe") + outside_dupe.is_mitigated = True + super(Finding, outside_dupe).save(skip_validation=True) + self._make_duplicate(outside_dupe, original) # forces active=False, verified default False + + with impersonate(self.testuser): + prepare_duplicates_for_delete(self.test1) + + outside_dupe.refresh_from_db() + self.assertFalse(outside_dupe.duplicate) + self.assertTrue(outside_dupe.active) + self.assertTrue(outside_dupe.verified) + self.assertFalse(outside_dupe.is_mitigated) + + def test_original_status_copied_to_new_original_inactive_mitigated(self): + """ + New original inherits active/verified/is_mitigated from deleted original. + + Negative case: original is closed, unverified, mitigated. + Duplicate starts with the opposite of each field so every copy is observable. + """ original = self._create_finding(self.test1, "Original") original.active = False + original.verified = False original.is_mitigated = True super(Finding, original).save(skip_validation=True) outside_dupe = self._create_finding(self.test2, "Outside Dupe") + outside_dupe.verified = True + super(Finding, outside_dupe).save(skip_validation=True) self._make_duplicate(outside_dupe, original) + # _make_duplicate forces active=False; flip to True so the copy is observable + outside_dupe.active = True + super(Finding, outside_dupe).save(skip_validation=True) with impersonate(self.testuser): prepare_duplicates_for_delete(self.test1) @@ -310,6 +351,7 @@ def test_original_status_copied_to_new_original(self): outside_dupe.refresh_from_db() self.assertFalse(outside_dupe.duplicate) self.assertFalse(outside_dupe.active) + self.assertFalse(outside_dupe.verified) self.assertTrue(outside_dupe.is_mitigated) def test_found_by_copied_to_new_original(self): From 136f54fdb436e1e550edeba91e91e244255dc4fc Mon Sep 17 00:00:00 2001 From: valentijnscholten Date: Fri, 5 Jun 2026 04:48:53 +0200 Subject: [PATCH 15/31] Prevent reimport from reactivating duplicate findings as active/verified (#14935) * Prevent reimport from reactivating duplicate findings as active/verified Fixes #14910. process_matched_mitigated_finding reactivated a matched mitigated finding without checking whether it is a duplicate, producing an invalid active/verified duplicate state that the finding edit form rejects. Keep duplicates inactive/unverified on reactivation (un-mitigate only), matching the set_duplicate invariant. * Initialise reimporter accumulators in duplicate reactivation tests process_matched_mitigated_finding appends to self.reactivated_items, which is normally created in process_findings(). The tests drive the method directly, so set the accumulator lists explicitly. --- dojo/importers/default_reimporter.py | 19 ++++- unittests/test_importers_importer.py | 113 ++++++++++++++++++++++++++- 2 files changed, 127 insertions(+), 5 deletions(-) diff --git a/dojo/importers/default_reimporter.py b/dojo/importers/default_reimporter.py index aa33c6153b0..e9c6567107a 100644 --- a/dojo/importers/default_reimporter.py +++ b/dojo/importers/default_reimporter.py @@ -801,9 +801,16 @@ def process_matched_mitigated_finding( existing_finding.mitigated = None existing_finding.is_mitigated = False existing_finding.mitigated_by = None - existing_finding.active = True - if self.verified is not None: - existing_finding.verified = self.verified + # A duplicate finding must stay inactive/unverified (see set_duplicate and the + # "Duplicate findings cannot be verified or active" form validation). Un-mitigate it + # but do not reactivate it, otherwise we create an invalid active/verified duplicate state. + if existing_finding.duplicate: + existing_finding.active = False + existing_finding.verified = False + else: + existing_finding.active = True + if self.verified is not None: + existing_finding.verified = self.verified component_name = getattr(unsaved_finding, "component_name", None) component_version = getattr(unsaved_finding, "component_version", None) @@ -819,7 +826,11 @@ def process_matched_mitigated_finding( # don't dedupe before endpoints/locations are added, postprocessing will be done on next save (in calling method) existing_finding.save_no_options() - note = Notes(entry=f"Re-activated by {self.scan_type} re-upload.", author=self.user) + if existing_finding.duplicate: + note_entry = f"Un-mitigated by {self.scan_type} re-upload but kept inactive because the finding is a duplicate." + else: + note_entry = f"Re-activated by {self.scan_type} re-upload." + note = Notes(entry=note_entry, author=self.user) note.save() self.location_handler.record_reactivations_for_finding(existing_finding) existing_finding.notes.add(note) diff --git a/unittests/test_importers_importer.py b/unittests/test_importers_importer.py index aa14ace8beb..ad9dd8f66c3 100644 --- a/unittests/test_importers_importer.py +++ b/unittests/test_importers_importer.py @@ -932,4 +932,115 @@ def test_change_vulnerability_ids_on_reimport(self): # Verify only new Vulnerability_Id objects exist vuln_ids = list(Vulnerability_Id.objects.filter(finding=finding).values_list("vulnerability_id", flat=True)) self.assertEqual(set(new_vulnerability_ids), set(vuln_ids)) - finding.delete() + + +class ReimportDuplicateReactivationTest(DojoTestCase): + + """ + Regression test for https://github.com/DefectDojo/django-DefectDojo/issues/14910 + + Reimport reactivation of a mitigated finding must not produce an invalid + active/verified duplicate finding state. + """ + + def setUp(self): + self.user, _ = User.objects.get_or_create(username="admin", is_superuser=True) + Development_Environment.objects.get_or_create(name="Development") + self.product_type, _ = Product_Type.objects.get_or_create(name="dup_reactivation_pt") + self.product, _ = Product.objects.get_or_create( + name="DupReactivationProduct", + description="test product", + prod_type=self.product_type, + ) + self.engagement = Engagement.objects.create( + name="Dup Reactivation Engagement", + product=self.product, + target_start=timezone.now(), + target_end=timezone.now(), + ) + self.test = self.create_test(engagement=self.engagement, scan_type=NPM_AUDIT_SCAN_TYPE, title="dup reactivation test") + + def _make_finding(self, title, **kwargs): + return Finding.objects.create( + title=title, + test=self.test, + severity="High", + reporter=self.user, + **kwargs, + ) + + def test_reactivation_keeps_duplicate_inactive_and_unverified(self): + # Original active finding + original = self._make_finding("original finding", active=True, verified=True) + # Mitigated finding that is marked as a duplicate of the original + existing_duplicate = self._make_finding( + "duplicate finding", + active=False, + verified=False, + duplicate=True, + duplicate_finding=original, + is_mitigated=True, + mitigated=timezone.now(), + mitigated_by=self.user, + ) + # The reimported (unsaved) finding that re-matches the duplicate, and is active/not mitigated + unsaved_finding = self._make_finding("duplicate finding incoming", active=True, verified=True) + + reimporter = DefaultReImporter( + test=self.test, + user=self.user, + scan_type=NPM_AUDIT_SCAN_TYPE, + active=True, + verified=True, + do_not_reactivate=False, + ) + # These accumulators are normally initialised inside process_findings(); set them + # here because the test drives process_matched_mitigated_finding() directly. + reimporter.new_items = [] + reimporter.reactivated_items = [] + reimporter.unchanged_items = [] + + result_finding, _ = reimporter.process_matched_mitigated_finding(unsaved_finding, existing_duplicate) + + result_finding.refresh_from_db() + # The mitigation is cleared (the finding reappeared in the scan)... + self.assertFalse(result_finding.is_mitigated) + self.assertIsNone(result_finding.mitigated) + # ...but a duplicate must never become active or verified (issue #14910) + self.assertTrue(result_finding.duplicate) + self.assertFalse(result_finding.active) + self.assertFalse(result_finding.verified) + + def test_reactivation_of_non_duplicate_still_activates(self): + # A regular mitigated finding (not a duplicate) must still reactivate as before + existing = self._make_finding( + "regular finding", + active=False, + verified=False, + is_mitigated=True, + mitigated=timezone.now(), + mitigated_by=self.user, + ) + unsaved_finding = self._make_finding("regular finding incoming", active=True, verified=True) + + reimporter = DefaultReImporter( + test=self.test, + user=self.user, + scan_type=NPM_AUDIT_SCAN_TYPE, + active=True, + verified=True, + do_not_reactivate=False, + ) + # These accumulators are normally initialised inside process_findings(); set them + # here because the test drives process_matched_mitigated_finding() directly. + reimporter.new_items = [] + reimporter.reactivated_items = [] + reimporter.unchanged_items = [] + + result_finding, _ = reimporter.process_matched_mitigated_finding(unsaved_finding, existing) + + result_finding.refresh_from_db() + self.assertFalse(result_finding.is_mitigated) + self.assertIsNone(result_finding.mitigated) + self.assertTrue(result_finding.active) + self.assertTrue(result_finding.verified) From 75e7834824f9f1cabf5be0f031936aeadc7c9e1c Mon Sep 17 00:00:00 2001 From: valentijnscholten Date: Fri, 5 Jun 2026 04:49:24 +0200 Subject: [PATCH 16/31] fix(dependency_check): fold related dependency paths into description (#14941) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dependency-Check's DependencyBundlingAnalyzer merges co-grouped artifacts into one main dependency and lists the others under . The vulnerability is attached only to the main dependency in the XML; related entries are metadata for other files in the same logical component. Previously the parser emitted one finding per related entry in addition to the main finding. This multiplied a single CVE into N findings sharing the same title, CVE, component name, and version — only the file path differed. Projects with Spring Boot, ActiveMQ, or other libraries whose CPE matches many sibling artifacts (DC bundling scenario 4) were hit hardest. Instead, emit one finding per vulnerability per main dependency and surface related file paths in the description under a "**Related Filepaths:**" block. The five DependencyBundlingAnalyzer bundling scenarios are documented in the new build_related_dependencies_block() helper, the parser docs page, and the 2.59.1 upgrade notes. Closes-style note: findings previously tagged `related` will be closed on the next reimport as they are no longer emitted. The `related` tag is not applied. --- docs/content/releases/os_upgrading/2.59.1.md | 32 +++ .../parsers/file/dependency_check.md | 16 +- dojo/tools/dependency_check/parser.py | 75 ++++-- .../tools/test_dependency_check_parser.py | 220 +++++++----------- 4 files changed, 193 insertions(+), 150 deletions(-) create mode 100644 docs/content/releases/os_upgrading/2.59.1.md diff --git a/docs/content/releases/os_upgrading/2.59.1.md b/docs/content/releases/os_upgrading/2.59.1.md new file mode 100644 index 00000000000..701fe466513 --- /dev/null +++ b/docs/content/releases/os_upgrading/2.59.1.md @@ -0,0 +1,32 @@ +--- +title: 'Upgrading to DefectDojo Version 2.59.1' +toc_hide: true +weight: -20260603 +description: Dependency Check parser no longer emits separate findings for related dependencies; related file paths are now listed in the main finding's description. +--- + +## Dependency Check parser: related dependencies folded into the main finding + +The Dependency Check parser previously created one finding per `` in the report, in addition to the finding for the main vulnerable dependency. Because OWASP Dependency-Check attaches the vulnerability only to the main dependency in the XML and the related entries are metadata pointing to other files in the same logical component, this produced N findings sharing the same title, CVE, component name and component version — only the file path differed. For projects with Spring Boot, ActiveMQ, or other libraries whose CPE matches many sibling artifacts this produced significant noise. + +Starting in 2.59.1, the parser emits exactly one finding per vulnerability per main dependency. The file paths of any related dependencies are surfaced in the finding description under a `**Related Filepaths:**` block. + +### Background: what `` actually contains + +OWASP Dependency-Check's `DependencyBundlingAnalyzer` merges co-grouped artifacts into a single main dependency and lists the others under ``. It does this under five scenarios: + +1. **Identical content (`hashesMatch`)** — the same jar (matching sha1) found at multiple paths, e.g. the same library packaged into multiple ear/war archives. +2. **Shaded jar (`isShadedJar`)** — a `.jar` and a `pom.xml` extracted from inside it share the same CPE; the pom.xml is recorded as related. +3. **WebJar (`isWebJar`)** — a `.js` file extracted from a WebJar matches the jar's CPE (mapped via `pkg:maven/org.webjars/@`); the js file is recorded as related to the jar. +4. **Same CPE + base path + vulnerabilities + filename match** — sibling artifacts in the same project that share a CPE. Example: `spring-boot`, `spring-boot-actuator`, `spring-boot-starter-jdbc`, etc. all map to the `spring_boot` CPE and are grouped under the main `spring-boot` jar. +5. **NPM same name + version** — the same npm package discovered via different resolution paths (for example `package-lock.json` plus `node_modules`). + +Only scenario 1 represents the same vulnerable artifact at multiple deploy locations. Scenarios 2-5 are different files representing one logical component. Both cases were previously inflated into separate findings; both now collapse to one finding with the related paths listed in the description. + +### Required actions + +- **Users filtering or grouping by the `related` tag**: that tag is no longer applied because related findings are no longer created. Update any saved filters, dashboards, or rules that depend on it. Equivalent information is now available in the finding description (look for `**Related Filepaths:**`). +- **Reimport behavior**: on the next reimport of an existing Dependency Check report, the previously-created `related` findings will be closed as no longer present in the report. This is expected and matches the new parsing behavior. + + +For more information, check the [Release Notes](https://github.com/DefectDojo/django-DefectDojo/releases/tag/2.59.1). diff --git a/docs/content/supported_tools/parsers/file/dependency_check.md b/docs/content/supported_tools/parsers/file/dependency_check.md index f4f56ce8325..76d51277801 100644 --- a/docs/content/supported_tools/parsers/file/dependency_check.md +++ b/docs/content/supported_tools/parsers/file/dependency_check.md @@ -7,7 +7,21 @@ OWASP Dependency Check output can be imported in Xml format. This parser ingests * Suppressed vulnerabilities are tagged with the tag: `suppressed`. * Suppressed vulnerabilities are marked as mitigated. * If the suppression is missing any `` tag, it tags them as `no_suppression_document`. -* Related vulnerable dependencies are tagged with `related` tag. + +### Related dependencies + +OWASP Dependency-Check's `DependencyBundlingAnalyzer` merges co-grouped artifacts into a single main dependency and lists the others under `` in the report. The vulnerability is attached only to the main dependency; the related entries are metadata pointing to other files in the same logical component. + +The parser emits one finding per vulnerability per main dependency and surfaces the related file paths in the finding's description under a `**Related Filepaths:**` block (rather than creating a separate finding per related entry, which produced N findings sharing the same title and CVE differing only in file path). + +DC bundles dependencies under five scenarios: + +1. **Identical content (`hashesMatch`)** — the same jar (matching sha1) found at multiple paths (e.g. packaged into multiple ear/war archives). +2. **Shaded jar (`isShadedJar`)** — a `.jar` and a `pom.xml` extracted from inside it share the same CPE. +3. **WebJar (`isWebJar`)** — a `.js` file extracted from a WebJar maps to the jar's CPE via `pkg:maven/org.webjars/@`. +4. **Same CPE + base path + vulnerabilities + filename match** — sibling artifacts sharing a CPE (e.g. `spring-boot`, `spring-boot-actuator`, `spring-boot-starter-jdbc` all map to the `spring_boot` CPE). +5. **NPM same name + version** — the same npm package discovered via different resolution paths (e.g. `package-lock.json` + `node_modules`). + ### Sample Scan Data Sample Dependency Check scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/dependency_check). diff --git a/dojo/tools/dependency_check/parser.py b/dojo/tools/dependency_check/parser.py index 4c472e2f4c4..03590bdd02d 100644 --- a/dojo/tools/dependency_check/parser.py +++ b/dojo/tools/dependency_check/parser.py @@ -86,6 +86,57 @@ def add_finding(self, finding, dupes): if key not in dupes: dupes[key] = finding + def build_related_dependencies_block(self, dependency, namespace): + """ + Return a markdown block listing related dependencies, or ''. + + Dependency-Check's DependencyBundlingAnalyzer merges co-grouped artifacts + into one main dependency and lists the others under . + The vulnerability is attached only to the main dependency in the XML; the + related entries are metadata pointing to other files in the same logical + component. Previously we emitted one finding per related entry, which + multiplied a single CVE into N findings sharing the same title and CVE + with only the file_path differing — pure noise for the user. Instead we + keep one finding for the main dependency and surface the related file + paths in its description. + + DC bundles dependencies under five scenarios (see + DependencyBundlingAnalyzer.evaluateDependencies): + + 1. hashesMatch — identical content (same sha1) found at multiple paths, + e.g. the same jar packaged into multiple ear/war archives. + 2. isShadedJar — a .jar and a pom.xml extracted from inside it share the + same CPE; the pom.xml is recorded as related to the jar. + 3. isWebJar — a .js file extracted from a WebJar matches the jar's CPE + (mapped via pkg:maven/org.webjars/@); the js is + related to the jar. + 4. CPE + base path + vulnerabilities + filename match — sibling artifacts + in the same project that share a CPE (e.g. spring-boot, + spring-boot-actuator, spring-boot-starter all map to the + spring_boot CPE). + 5. NPM same name + version — the same npm package discovered via + different resolution paths (e.g. package-lock.json + node_modules). + + Scenario 1 is the only case where the related entries are genuinely + separate vulnerable locations; scenarios 2-5 are different files + representing one logical component. Listing all related paths in the + description preserves the per-location information for scenario 1 while + removing the noise from scenarios 2-5. + """ + related = dependency.find(namespace + "relatedDependencies") + if related is None: + return "" + entries = [] + for rd in related.findall(namespace + "relatedDependency"): + file_name = rd.findtext(f"{namespace}fileName") + if not file_name: + continue + file_path = (rd.findtext(f"{namespace}filePath") or "").strip() + entries.append(f"- {file_name} ({file_path})" if file_path else f"- {file_name}") + if not entries: + return "" + return "\n**Related Filepaths:**\n" + "\n".join(entries) + def get_filename_and_path_from_dependency( self, dependency, @@ -448,6 +499,7 @@ def get_findings(self, filename, test): namespace + "vulnerabilities", ) if vulnerabilities is not None: + related_block = self.build_related_dependencies_block(dependency, namespace) for vulnerability in vulnerabilities.findall( namespace + "vulnerability", ): @@ -459,29 +511,12 @@ def get_findings(self, filename, test): test, namespace, ) + if related_block: + finding.description += related_block if scan_date: finding.date = scan_date self.add_finding(finding, dupes) - relatedDependencies = dependency.find( - namespace + "relatedDependencies", - ) - if relatedDependencies is not None: - for relatedDependency in relatedDependencies.findall( - namespace + "relatedDependency", - ): - finding = self.get_finding_from_vulnerability( - dependency, - relatedDependency, - vulnerability, - test, - namespace, - ) - if finding: # could be None - if scan_date: - finding.date = scan_date - self.add_finding(finding, dupes) - for suppressedVulnerability in vulnerabilities.findall( namespace + "suppressedVulnerability", ): @@ -493,6 +528,8 @@ def get_findings(self, filename, test): test, namespace, ) + if related_block: + finding.description += related_block if scan_date: finding.date = scan_date self.add_finding(finding, dupes) diff --git a/unittests/tools/test_dependency_check_parser.py b/unittests/tools/test_dependency_check_parser.py index 31e1394ec51..67349f25871 100644 --- a/unittests/tools/test_dependency_check_parser.py +++ b/unittests/tools/test_dependency_check_parser.py @@ -58,11 +58,11 @@ def test_parse_file_with_multiple_vulnerabilities_has_multiple_findings(self): parser = DependencyCheckParser() findings = parser.get_findings(testfile, Test()) items = findings - self.assertEqual(11, len(items)) + self.assertEqual(9, len(items)) # test also different component_name formats with self.subTest(i=0): - # identifier -> package url java + 2 relateddependencies + # identifier -> package url java + 2 relateddependencies (now folded into description) self.assertEqual(items[0].title, "org.dom4j:dom4j:2.1.1.redhat-00001 | CVE-0000-0001") self.assertEqual(items[0].component_name, "org.dom4j:dom4j") self.assertEqual(items[0].component_version, "2.1.1.redhat-00001") @@ -74,6 +74,15 @@ def test_parse_file_with_multiple_vulnerabilities_has_multiple_findings(self): "/var/lib/adapter-ear1.ear/dom4j-2.1.1.jar", items[0].description, ) + self.assertIn("**Related Filepaths:**", items[0].description) + self.assertIn( + "adapter-ear8.ear: dom4j-2.1.1.jar (/var/lib/adapter-ear8.ear/dom4j-2.1.1.jar)", + items[0].description, + ) + self.assertIn( + "adapter-ear1.ear: dom4j-extensions-2.1.1.jar (/var/lib/adapter-ear1.ear/dom4j-extensions-2.1.1.jar)", + items[0].description, + ) self.assertEqual(items[0].severity, "High") self.assertEqual(items[0].cvssv3, None) self.assertEqual(items[0].cvssv3_score, None) @@ -89,192 +98,143 @@ def test_parse_file_with_multiple_vulnerabilities_has_multiple_findings(self): self.assertEqual("CVE-0000-0001", items[0].unsaved_vulnerability_ids[0]) with self.subTest(i=1): - self.assertEqual(items[1].title, "org.dom4j:dom4j:2.1.1.redhat-00001 | CVE-0000-0001") - self.assertEqual(items[1].component_name, "org.dom4j:dom4j") - self.assertEqual(items[1].component_version, "2.1.1.redhat-00001") - self.assertIn( - "Description of a bad vulnerability.", - items[1].description, - ) - self.assertIn( - "/var/lib/adapter-ear8.ear/dom4j-2.1.1.jar", - items[1].description, + # identifier -> package url javascript, no vulnerabilitids, 3 vulnerabilities, relateddependencies without filename (pre v6.0.0) + self.assertEqual( + items[1].title, "yargs-parser:5.0.0 | 1500", ) - self.assertEqual(items[1].severity, "High") + self.assertEqual(items[1].component_name, "yargs-parser") + self.assertEqual(items[1].component_version, "5.0.0") + self.assertEqual(items[1].severity, "Low") self.assertEqual(items[1].cvssv3, None) self.assertEqual(items[1].cvssv3_score, None) - self.assertEqual(items[1].file_path, "adapter-ear8.ear: dom4j-2.1.1.jar") + self.assertEqual(items[1].file_path, "yargs-parser:5.0.0") self.assertEqual( - items[1].mitigation, - "Update org.dom4j:dom4j:2.1.1.redhat-00001 to at least the version recommended in the description", - ) - self.assertEqual(items[1].unsaved_tags, ["related"]) - self.assertEqual(1, len(items[1].unsaved_vulnerability_ids)) - self.assertEqual("CVE-0000-0001", items[1].unsaved_vulnerability_ids[0]) - - with self.subTest(i=2): - self.assertEqual(items[2].title, "org.dom4j:dom4j:2.1.1.redhat-00001 | CVE-0000-0001") - self.assertEqual(items[2].component_name, "org.dom4j:dom4j") - self.assertEqual(items[2].component_version, "2.1.1.redhat-00001") - self.assertIn( - "Description of a bad vulnerability.", - items[2].description, - ) - self.assertIn( - "/var/lib/adapter-ear1.ear/dom4j-extensions-2.1.1.jar", - items[2].description, - ) - self.assertEqual(items[2].severity, "High") - self.assertEqual(items[2].cvssv3, None) - self.assertEqual(items[2].cvssv3_score, None) - self.assertEqual(items[2].file_path, "adapter-ear1.ear: dom4j-extensions-2.1.1.jar") - self.assertEqual( - items[2].mitigation, - "Update org.dom4j:dom4j:2.1.1.redhat-00001 to at least the version recommended in the description", - ) - self.assertEqual(1, len(items[2].unsaved_vulnerability_ids)) - self.assertEqual("CVE-0000-0001", items[2].unsaved_vulnerability_ids[0]) - - with self.subTest(i=3): - # identifier -> package url javascript, no vulnerabilitids, 3 vulnerabilities, relateddependencies without filename (pre v6.0.0) - self.assertEqual( - items[3].title, "yargs-parser:5.0.0 | 1500", - ) - self.assertEqual(items[3].component_name, "yargs-parser") - self.assertEqual(items[3].component_version, "5.0.0") - # assert fails due to special characters, not too important - # self.assertEqual(items[1].description, "Affected versions of `yargs-parser` are vulnerable to prototype pollution. Arguments are not properly sanitized, allowing an attacker to modify the prototype of `Object`, causing the addition or modification of an existing property that will exist on all objects.Parsing the argument `--foo.__proto__.bar baz'` adds a `bar` property with value `baz` to all objects. This is only exploitable if attackers have control over the arguments being passed to `yargs-parser`.") - self.assertEqual(items[3].severity, "Low") - self.assertEqual(items[3].cvssv3, None) - self.assertEqual(items[3].cvssv3_score, None) - self.assertEqual(items[3].file_path, "yargs-parser:5.0.0") - self.assertEqual( - items[3].mitigation, "Update yargs-parser:5.0.0 to at least the version recommended in the description", + items[1].mitigation, "Update yargs-parser:5.0.0 to at least the version recommended in the description", ) self.assertIn( "**Source:** NPM", - items[3].description, + items[1].description, ) - self.assertIsNone(items[3].unsaved_vulnerability_ids) + self.assertIsNone(items[1].unsaved_vulnerability_ids) - with self.subTest(i=4): + with self.subTest(i=2): self.assertEqual( - items[4].title, + items[2].title, "yargs-parser:5.0.0 | CVE-2020-7608", ) - self.assertEqual(items[4].component_name, "yargs-parser") - self.assertEqual(items[4].component_version, "5.0.0") + self.assertEqual(items[2].component_name, "yargs-parser") + self.assertEqual(items[2].component_version, "5.0.0") self.assertIn( 'yargs-parser could be tricked into adding or modifying properties\n of Object.prototype using a "__proto__" payload.\n**Source:** OSSINDEX\n**Filepath:** \n /var/lib/jenkins/workspace/nl-selfservice_-_metrics_develop/package-lock.json?yargs-parser', - items[4].description, + items[2].description, ) self.assertIn( "/var/lib/jenkins/workspace/nl-selfservice_-_metrics_develop/package-lock.json?yargs-parser", - items[4].description, + items[2].description, ) - self.assertEqual(items[4].severity, "High") - self.assertEqual(items[4].cvssv3, "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N") - self.assertEqual(items[4].cvssv3_score, 7.5) - self.assertEqual(items[4].file_path, "yargs-parser:5.0.0") + self.assertEqual(items[2].severity, "High") + self.assertEqual(items[2].cvssv3, "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N") + self.assertEqual(items[2].cvssv3_score, 7.5) + self.assertEqual(items[2].file_path, "yargs-parser:5.0.0") self.assertEqual( - items[4].mitigation, "Update yargs-parser:5.0.0 to at least the version recommended in the description", + items[2].mitigation, "Update yargs-parser:5.0.0 to at least the version recommended in the description", ) - self.assertEqual(1, len(items[4].unsaved_vulnerability_ids)) - self.assertEqual("CVE-2020-7608", items[4].unsaved_vulnerability_ids[0]) + self.assertEqual(1, len(items[2].unsaved_vulnerability_ids)) + self.assertEqual("CVE-2020-7608", items[2].unsaved_vulnerability_ids[0]) - with self.subTest(i=5): + with self.subTest(i=3): self.assertEqual( - items[5].title, + items[3].title, "yargs-parser:5.0.0 | CWE-400: Uncontrolled Resource Consumption ('Resource Exhaustion')", ) - self.assertEqual(items[5].component_name, "yargs-parser") - self.assertEqual(items[5].component_version, "5.0.0") + self.assertEqual(items[3].component_name, "yargs-parser") + self.assertEqual(items[3].component_version, "5.0.0") self.assertIn( "The software does not properly restrict the size or amount of resources that are requested or influenced by an actor, which can be used to consume more resources than intended.", - items[5].description, + items[3].description, ) # check that the filepath is in the description self.assertIn( "/var/lib/jenkins/workspace/nl-selfservice_-_metrics_develop/package-lock.json?yargs-parser", - items[5].description, + items[3].description, ) - self.assertEqual(items[5].severity, "High") - self.assertEqual(items[5].cvssv3, "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H") - self.assertEqual(items[5].cvssv3_score, 7.5) - self.assertEqual(items[5].file_path, "yargs-parser:5.0.0") + self.assertEqual(items[3].severity, "High") + self.assertEqual(items[3].cvssv3, "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H") + self.assertEqual(items[3].cvssv3_score, 7.5) + self.assertEqual(items[3].file_path, "yargs-parser:5.0.0") self.assertEqual( - items[5].mitigation, "Update yargs-parser:5.0.0 to at least the version recommended in the description", + items[3].mitigation, "Update yargs-parser:5.0.0 to at least the version recommended in the description", ) - self.assertIsNone(items[5].unsaved_vulnerability_ids) + self.assertIsNone(items[3].unsaved_vulnerability_ids) - with self.subTest(i=6): + with self.subTest(i=4): # identifier -> cpe java - self.assertEqual(items[6].title, "org.dom4j:dom4j:2.1.1.redhat-00001 | CVE-0000-0001") - self.assertEqual(items[6].component_name, "org.dom4j:dom4j") - self.assertEqual(items[6].component_version, "2.1.1.redhat-00001") - self.assertEqual(items[6].severity, "High") - self.assertEqual(items[6].cvssv3, None) - self.assertEqual(items[6].cvssv3_score, None) - self.assertEqual(items[6].file_path, "adapter-ear2.ear: dom4j-2.1.1.jar") + self.assertEqual(items[4].title, "org.dom4j:dom4j:2.1.1.redhat-00001 | CVE-0000-0001") + self.assertEqual(items[4].component_name, "org.dom4j:dom4j") + self.assertEqual(items[4].component_version, "2.1.1.redhat-00001") + self.assertEqual(items[4].severity, "High") + self.assertEqual(items[4].cvssv3, None) + self.assertEqual(items[4].cvssv3_score, None) + self.assertEqual(items[4].file_path, "adapter-ear2.ear: dom4j-2.1.1.jar") self.assertEqual( - items[6].mitigation, + items[4].mitigation, "Update org.dom4j:dom4j:2.1.1.redhat-00001 to at least the version recommended in the description", ) - self.assertEqual(1, len(items[6].unsaved_vulnerability_ids)) - self.assertEqual("CVE-0000-0001", items[6].unsaved_vulnerability_ids[0]) + self.assertEqual(1, len(items[4].unsaved_vulnerability_ids)) + self.assertEqual("CVE-0000-0001", items[4].unsaved_vulnerability_ids[0]) - with self.subTest(i=7): + with self.subTest(i=5): # identifier -> maven java - self.assertEqual(items[7].title, "dom4j:2.1.1 | CVE-0000-0001") - self.assertEqual(items[7].component_name, "dom4j") - self.assertEqual(items[7].component_version, "2.1.1") - self.assertEqual(items[7].severity, "High") - self.assertEqual(items[7].cvssv3, None) - self.assertEqual(items[7].cvssv3_score, None) + self.assertEqual(items[5].title, "dom4j:2.1.1 | CVE-0000-0001") + self.assertEqual(items[5].component_name, "dom4j") + self.assertEqual(items[5].component_version, "2.1.1") + self.assertEqual(items[5].severity, "High") + self.assertEqual(items[5].cvssv3, None) + self.assertEqual(items[5].cvssv3_score, None) self.assertEqual( - items[7].mitigation, "Update dom4j:2.1.1 to at least the version recommended in the description", + items[5].mitigation, "Update dom4j:2.1.1 to at least the version recommended in the description", ) - with self.subTest(i=8): + with self.subTest(i=6): # evidencecollected -> single product + single verison javascript self.assertEqual( - items[8].title, + items[6].title, "jquery:3.1.1 | CVE-0000-0001", ) - self.assertEqual(items[8].component_name, "jquery") - self.assertEqual(items[8].component_version, "3.1.1") - self.assertEqual(items[8].severity, "High") - self.assertEqual(items[8].cvssv3, None) - self.assertEqual(items[8].cvssv3_score, None) + self.assertEqual(items[6].component_name, "jquery") + self.assertEqual(items[6].component_version, "3.1.1") + self.assertEqual(items[6].severity, "High") + self.assertEqual(items[6].cvssv3, None) + self.assertEqual(items[6].cvssv3_score, None) self.assertEqual( - items[8].mitigation, "Update jquery:3.1.1 to at least the version recommended in the description", + items[6].mitigation, "Update jquery:3.1.1 to at least the version recommended in the description", ) - with self.subTest(i=9): + with self.subTest(i=7): # Tests for two suppressed vulnerabilities, # One for Suppressed with notes, the other is without. - self.assertEqual(items[9].active, False) + self.assertEqual(items[7].active, False) self.assertEqual( - items[9].mitigation, + items[7].mitigation, "**This vulnerability is mitigated and/or suppressed:** Document on why we are suppressing this vulnerability is missing!\nUpdate jquery:3.1.1 to at least the version recommended in the description", ) - self.assertEqual(items[9].unsaved_tags, ["no_suppression_document", "suppressed"]) - self.assertEqual(items[9].severity, "Critical") - self.assertEqual(items[9].cvssv3, "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H") - self.assertEqual(items[9].cvssv3_score, 9.8) - self.assertEqual(items[9].is_mitigated, True) + self.assertEqual(items[7].unsaved_tags, ["no_suppression_document", "suppressed"]) + self.assertEqual(items[7].severity, "Critical") + self.assertEqual(items[7].cvssv3, "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H") + self.assertEqual(items[7].cvssv3_score, 9.8) + self.assertEqual(items[7].is_mitigated, True) - with self.subTest(i=10): - self.assertEqual(items[10].active, False) + with self.subTest(i=8): + self.assertEqual(items[8].active, False) self.assertEqual( - items[10].mitigation, + items[8].mitigation, "**This vulnerability is mitigated and/or suppressed:** This is our reason for not to upgrade it.\nUpdate jquery:3.1.1 to at least the version recommended in the description", ) - self.assertEqual(items[10].unsaved_tags, ["suppressed"]) - self.assertEqual(items[10].severity, "Critical") - self.assertEqual(items[10].cvssv3, "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H") - self.assertEqual(items[10].cvssv3_score, 9.8) - self.assertEqual(items[10].is_mitigated, True) + self.assertEqual(items[8].unsaved_tags, ["suppressed"]) + self.assertEqual(items[8].severity, "Critical") + self.assertEqual(items[8].cvssv3, "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H") + self.assertEqual(items[8].cvssv3_score, 9.8) + self.assertEqual(items[8].is_mitigated, True) def test_parse_java_6_5_3(self): """Test with version 6.5.3""" @@ -303,7 +263,7 @@ def test_parse_file_pr6439(self): parser = DependencyCheckParser() findings = parser.get_findings(testfile, Test()) items = findings - self.assertEqual(37, len(items)) + self.assertEqual(34, len(items)) # test also different component_name formats with self.subTest(i=0): From 51f926156f330c9a6319cae636a0b6e7e428e3c2 Mon Sep 17 00:00:00 2001 From: valentijnscholten Date: Fri, 5 Jun 2026 04:49:45 +0200 Subject: [PATCH 17/31] fix: guard filter snippet include when no form passed to metrics template (#14945) critical_product_metrics view renders metrics.html without a form context variable. Django resolves undefined template vars as empty string, causing get_filter_groups to crash with AttributeError on str.visible_fields(). Wrapping the filter_snippet include in {% if form %} prevents the crash. Fixes #14944. --- dojo/templates/dojo/metrics.html | 2 ++ dojo/templates_classic/dojo/metrics.html | 2 ++ 2 files changed, 4 insertions(+) diff --git a/dojo/templates/dojo/metrics.html b/dojo/templates/dojo/metrics.html index 6e56099463f..2c7ab40fbf6 100644 --- a/dojo/templates/dojo/metrics.html +++ b/dojo/templates/dojo/metrics.html @@ -147,9 +147,11 @@

+ {% if form %}
{% include "dojo/filter_snippet.html" with form=form clear_link="/metrics/product/type" %}
+ {% endif %} diff --git a/dojo/templates_classic/dojo/metrics.html b/dojo/templates_classic/dojo/metrics.html index e011fc21055..777467e0e59 100644 --- a/dojo/templates_classic/dojo/metrics.html +++ b/dojo/templates_classic/dojo/metrics.html @@ -155,9 +155,11 @@

+ {% if form %}
{% include "dojo/filter_snippet.html" with form=form clear_link="/metrics/product/type" %}
+ {% endif %} From 68a272f299d096249fd3ba9c2676bf69012857bf Mon Sep 17 00:00:00 2001 From: dogboat Date: Thu, 4 Jun 2026 22:51:41 -0400 Subject: [PATCH 18/31] Fix for GHSA-w2j3-x3j3-mm43 (#14952) * prevent non-superusers from setting is_staff on a user * tighten /admin/ access to superusers only * linter fixes * disable django admin panel by default --- dojo/admin.py | 14 ++++ dojo/api_v2/serializers.py | 6 ++ dojo/settings/settings.dist.py | 2 +- dojo/settings/template-env | 5 +- unittests/test_adminsite.py | 56 ++++++++++++++++ unittests/test_apiv2_user.py | 113 +++++++++++++++++++++++++++++++++ 6 files changed, 193 insertions(+), 3 deletions(-) diff --git a/dojo/admin.py b/dojo/admin.py index c7a21b91019..94d149af035 100644 --- a/dojo/admin.py +++ b/dojo/admin.py @@ -14,6 +14,20 @@ TextQuestion, ) + +# Django's default admin gate is `is_active and is_staff`. Under the legacy +# OS auth model is_staff is already a near-superuser bypass for queryset +# filters and most permission checks, so the standard UserAdmin change form +# would let any is_staff user with auth.change_user tick is_superuser on +# themselves. Rather than narrowing each ModelAdmin individually, restrict +# the entire admin site to superusers — there is no DefectDojo OS role that +# legitimately needs /admin/ access without being a superuser. +def _admin_site_has_permission(request): + return request.user.is_active and request.user.is_superuser + + +admin.site.has_permission = _admin_site_has_permission + # Conditionally unregister LogEntry from auditlog if it's registered try: from auditlog.models import LogEntry diff --git a/dojo/api_v2/serializers.py b/dojo/api_v2/serializers.py index dc15ac6c2dc..b3b83a239b5 100644 --- a/dojo/api_v2/serializers.py +++ b/dojo/api_v2/serializers.py @@ -613,6 +613,12 @@ def validate(self, data): msg = "Only superusers are allowed to add or edit superusers." raise ValidationError(msg) + instance_is_staff = self.instance.is_staff if self.instance is not None else False + data_is_staff = data.get("is_staff", instance_is_staff) + if not self.context["request"].user.is_superuser and data_is_staff != instance_is_staff: + msg = "Only superusers are allowed to add or edit staff users." + raise ValidationError(msg) + if self.context["request"].method in {"PATCH", "PUT"} and "password" in data: msg = "Update of password though API is not allowed" raise ValidationError(msg) diff --git a/dojo/settings/settings.dist.py b/dojo/settings/settings.dist.py index f99e62eca2e..4409fcf44ee 100644 --- a/dojo/settings/settings.dist.py +++ b/dojo/settings/settings.dist.py @@ -51,7 +51,7 @@ DD_DJANGO_METRICS_ENABLED=(bool, False), DD_LOGIN_REDIRECT_URL=(str, "/"), DD_LOGIN_URL=(str, "/login"), - DD_DJANGO_ADMIN_ENABLED=(bool, True), + DD_DJANGO_ADMIN_ENABLED=(bool, False), DD_SESSION_COOKIE_HTTPONLY=(bool, True), DD_CSRF_COOKIE_HTTPONLY=(bool, True), DD_SECURE_SSL_REDIRECT=(bool, False), diff --git a/dojo/settings/template-env b/dojo/settings/template-env index bbfc9e90ad9..0263dc941a7 100644 --- a/dojo/settings/template-env +++ b/dojo/settings/template-env @@ -1,8 +1,9 @@ # Django Debug, don't enable on production! DD_DEBUG=False -# Enables Django Admin -DD_DJANGO_ADMIN_ENABLED=True +# Enables the Django admin interface at /admin/. Off by default; uncomment +# this line to expose it. Access is restricted to superusers regardless. +# DD_DJANGO_ADMIN_ENABLED=True # A secret key for a particular Django installation. DD_SECRET_KEY=#DD_SECRET_KEY# diff --git a/unittests/test_adminsite.py b/unittests/test_adminsite.py index 015c65ee467..70f08da8df2 100644 --- a/unittests/test_adminsite.py +++ b/unittests/test_adminsite.py @@ -1,5 +1,8 @@ import django.apps from django.contrib import admin +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AnonymousUser +from django.http import HttpRequest from .dojo_test_case import DojoTestCase @@ -20,3 +23,56 @@ def test_is_model_defined(self): else: with self.subTest(type="tag", subclass=subclass): self.assertIn(subclass, admin.site._registry.keys(), f"{subclass} is not registered in 'tagulous.admin' in models.py") + + +class AdminAccessGate(DojoTestCase): + + """ + is_staff is a near-superuser bypass under the legacy OS auth model, so + /admin/ must require is_superuser. Django's default UserAdmin change + form would otherwise let any is_staff user with auth.change_user tick + is_superuser on themselves. Tested at the gate-function level so the + assertions hold regardless of whether DD_DJANGO_ADMIN_ENABLED mounts + the admin URLConf in the current environment. + """ + + @staticmethod + def _request_for(user): + req = HttpRequest() + req.user = user + return req + + def test_staff_non_superuser_denied(self): + User = get_user_model() + password = "testTEST1234!@#$" + staff = User.objects.create_user( + username="staff-no-root", password=password, is_staff=True, + ) + self.assertFalse(admin.site.has_permission(self._request_for(staff))) + + def test_non_staff_non_superuser_denied(self): + User = get_user_model() + password = "testTEST1234!@#$" + plain = User.objects.create_user(username="plain-user", password=password) + self.assertFalse(admin.site.has_permission(self._request_for(plain))) + + def test_anonymous_denied(self): + self.assertFalse(admin.site.has_permission(self._request_for(AnonymousUser()))) + + def test_inactive_superuser_denied(self): + User = get_user_model() + password = "testTEST1234!@#$" + root = User.objects.create_superuser( + username="inactive-root", email="i@example.com", password=password, + ) + root.is_active = False + root.save(update_fields=["is_active"]) + self.assertFalse(admin.site.has_permission(self._request_for(root))) + + def test_active_superuser_allowed(self): + User = get_user_model() + password = "testTEST1234!@#$" + root = User.objects.create_superuser( + username="root-test", email="r@example.com", password=password, + ) + self.assertTrue(admin.site.has_permission(self._request_for(root))) diff --git a/unittests/test_apiv2_user.py b/unittests/test_apiv2_user.py index afce21948f2..f6273fe9b47 100644 --- a/unittests/test_apiv2_user.py +++ b/unittests/test_apiv2_user.py @@ -1,3 +1,4 @@ +from django.contrib.auth.models import Permission from django.urls import reverse from django.utils import timezone from rest_framework.authtoken.models import Token @@ -201,6 +202,118 @@ def test_user_reset_api_token_denies_non_privileged(self): r = nonpriv_client.post(url) self.assertEqual(r.status_code, 403, r.content[:1000]) + def test_non_superuser_cannot_set_is_staff_via_api(self): + """ + A delegated user-manager (auth.change_user) must not be able to + flip is_staff on themselves or anyone else — is_staff is a + superuser-only flag under the legacy OS auth model, and granting + it via API would let a non-superuser pivot into Django admin / + full RBAC bypass. + """ + password = "testTEST1234!@#$" + r = self.client.post(reverse("user-list"), { + "username": "api-user-mgr", + "email": "admin@dojo.com", + "password": password, + }, format="json") + self.assertEqual(r.status_code, 201, r.content[:1000]) + mgr = User.objects.get(username="api-user-mgr") + mgr.user_permissions.add( + Permission.objects.get(codename="change_user"), + Permission.objects.get(codename="add_user"), + ) + + token_resp = self.client.post(reverse("api-token-auth"), { + "username": "api-user-mgr", + "password": password, + }, format="json") + self.assertEqual(token_resp.status_code, 200, token_resp.content[:1000]) + mgr_client = APIClient() + mgr_client.credentials(HTTP_AUTHORIZATION="Token " + token_resp.json()["token"]) + + # Self-escalation: setting is_staff on own account must be rejected. + r = mgr_client.patch("{}{}/".format(reverse("user-list"), mgr.id), { + "is_staff": True, + }, format="json") + self.assertEqual(r.status_code, 400, r.content[:1000]) + self.assertIn( + "Only superusers are allowed to add or edit staff users.", + r.content.decode("utf-8"), + ) + mgr.refresh_from_db() + self.assertFalse(mgr.is_staff) + + # Target-escalation: setting is_staff on another user must be rejected. + r = self.client.post(reverse("user-list"), { + "username": "api-user-target", + "email": "admin@dojo.com", + "password": password, + }, format="json") + self.assertEqual(r.status_code, 201, r.content[:1000]) + target_id = r.json()["id"] + + r = mgr_client.patch("{}{}/".format(reverse("user-list"), target_id), { + "is_staff": True, + }, format="json") + self.assertEqual(r.status_code, 400, r.content[:1000]) + target = User.objects.get(id=target_id) + self.assertFalse(target.is_staff) + + # Create-time escalation must also be rejected. + r = mgr_client.post(reverse("user-list"), { + "username": "api-user-staff-on-create", + "email": "admin@dojo.com", + "password": password, + "is_staff": True, + }, format="json") + self.assertEqual(r.status_code, 400, r.content[:1000]) + self.assertFalse(User.objects.filter(username="api-user-staff-on-create").exists()) + + def test_non_superuser_can_patch_self_without_touching_is_staff(self): + """ + Negative control for the is_staff guard: a delegated user-manager + can still PATCH non-privileged fields on their own account; the + new check only fires when is_staff actually changes. + """ + password = "testTEST1234!@#$" + r = self.client.post(reverse("user-list"), { + "username": "api-user-mgr2", + "email": "admin@dojo.com", + "password": password, + }, format="json") + self.assertEqual(r.status_code, 201, r.content[:1000]) + mgr = User.objects.get(username="api-user-mgr2") + mgr.user_permissions.add(Permission.objects.get(codename="change_user")) + + token_resp = self.client.post(reverse("api-token-auth"), { + "username": "api-user-mgr2", + "password": password, + }, format="json") + self.assertEqual(token_resp.status_code, 200, token_resp.content[:1000]) + mgr_client = APIClient() + mgr_client.credentials(HTTP_AUTHORIZATION="Token " + token_resp.json()["token"]) + + r = mgr_client.patch("{}{}/".format(reverse("user-list"), mgr.id), { + "first_name": "Renamed", + }, format="json") + self.assertEqual(r.status_code, 200, r.content[:1000]) + + def test_superuser_can_set_is_staff_via_api(self): + """Positive control: a superuser is still allowed to toggle is_staff.""" + r = self.client.post(reverse("user-list"), { + "username": "api-user-promotable", + "email": "admin@dojo.com", + "password": "testTEST1234!@#$", + }, format="json") + self.assertEqual(r.status_code, 201, r.content[:1000]) + user_id = r.json()["id"] + + r = self.client.patch("{}{}/".format(reverse("user-list"), user_id), { + "is_staff": True, + }, format="json") + self.assertEqual(r.status_code, 200, r.content[:1000]) + self.assertTrue(User.objects.get(id=user_id).is_staff) + def test_user_reset_api_token_denies_global_owner_legacy(self): """ Legacy: Global_Role(role=Owner) is inert. Resetting another From d8074fc90b762eee6f7dd8afcc1c9acbdd3066de Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 7 Jun 2026 21:34:52 -0500 Subject: [PATCH 19/31] chore(deps): bump ruff from 0.15.14 to 0.15.15 (#14959) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.14 to 0.15.15. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.15.14...0.15.15) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.15.15 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements-lint.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-lint.txt b/requirements-lint.txt index 98d97774e8a..6fe09022800 100644 --- a/requirements-lint.txt +++ b/requirements-lint.txt @@ -1 +1 @@ -ruff==0.15.14 +ruff==0.15.15 From 9f6c826fc42672087c9e851b9f36740fc5675327 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:37:20 -0500 Subject: [PATCH 20/31] chore(deps): bump redis from 7.4.0 to 8.0.0 (#14958) Bumps [redis](https://github.com/redis/redis-py) from 7.4.0 to 8.0.0. - [Release notes](https://github.com/redis/redis-py/releases) - [Changelog](https://github.com/redis/redis-py/blob/master/CHANGES) - [Commits](https://github.com/redis/redis-py/compare/v7.4.0...v8.0.0) --- updated-dependencies: - dependency-name: redis dependency-version: 8.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index f5316ac01e6..27f74fef357 100644 --- a/requirements.txt +++ b/requirements.txt @@ -34,7 +34,7 @@ Pillow==12.2.0 # required by django-imagekit psycopg[c]==3.3.4 cryptography==46.0.7 python-dateutil==2.9.0.post0 -redis==7.4.0 +redis==8.0.0 requests==2.34.2 sqlalchemy==2.0.49 # Required by Celery broker transport urllib3==2.7.0 From 2799d2bfb6c6a514f7ac7678cc74204e43901377 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:37:09 -0500 Subject: [PATCH 21/31] chore(deps): update actions/stale action from v10.2.0 to v10.3.0 (.github/workflows/close-stale.yml) (#14949) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/close-stale.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/close-stale.yml b/.github/workflows/close-stale.yml index 5c770d0a9eb..23e5a705f2a 100644 --- a/.github/workflows/close-stale.yml +++ b/.github/workflows/close-stale.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Close issues and PRs that are pending closure - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 + uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 with: # Disable automatic stale marking - only close manually labeled items days-before-stale: -1 @@ -27,7 +27,7 @@ jobs: close-pr-message: 'This PR has been automatically closed because it was manually labeled as stale. If you believe this was closed in error, please reopen it and remove the stale label.' - name: Close stale issues and PRs - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 + uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 with: # Disable automatic stale marking - only close manually labeled items days-before-stale: -1 From daf6d57bae1a11182e8f2db2160652c62bbbd00e Mon Sep 17 00:00:00 2001 From: valentijnscholten Date: Mon, 8 Jun 2026 23:38:06 +0200 Subject: [PATCH 22/31] test(perf): re-enable import performance tests with recalibrated query counts (#14967) Re-baseline expected query counts after upstream merge that switched from RBAC to legacy authorization. Legacy auth has lower per-action overhead (no role-permission lookups, simpler dispatch), so all counts decreased by 1-7 queries. Also removes the unused `unittest.skip` import. --- unittests/test_importers_performance.py | 73 +++++++++++-------------- 1 file changed, 32 insertions(+), 41 deletions(-) diff --git a/unittests/test_importers_performance.py b/unittests/test_importers_performance.py index dc82f28114d..ce9133e4a6b 100644 --- a/unittests/test_importers_performance.py +++ b/unittests/test_importers_performance.py @@ -20,7 +20,6 @@ import logging from contextlib import contextmanager -from unittest import skip from unittest.mock import patch from crum import impersonate @@ -275,11 +274,6 @@ def _import_reimport_performance( self.assertGreater(len_closed_findings4, 0, "Step 4 (empty reimport with close_old_findings=True) should close findings") -@skip("Re-baseline pending: Track B legacy authorization reduces auth-layer query " - "overhead (no per-action role-permission lookups, simpler permission_to_action " - "dispatch). Expected query counts here were calibrated under RBAC and are " - "consistently 1-7 queries higher than legacy actual. Re-baseline with a fresh " - "calibration run after the upstream merge.") @tag("performance") @skip_unless_v2 class TestDojoImporterPerformanceSmall(TestDojoImporterPerformanceBase): @@ -349,13 +343,13 @@ def test_import_reimport_reimport_performance_pghistory_async(self): configure_pghistory_triggers() self._import_reimport_performance( - expected_num_queries1=171, + expected_num_queries1=170, expected_num_async_tasks1=2, - expected_num_queries2=124, + expected_num_queries2=123, expected_num_async_tasks2=1, - expected_num_queries3=29, + expected_num_queries3=28, expected_num_async_tasks3=1, - expected_num_queries4=100, + expected_num_queries4=99, expected_num_async_tasks4=0, ) @@ -373,13 +367,13 @@ def test_import_reimport_reimport_performance_pghistory_no_async(self): testuser.usercontactinfo.save() self._import_reimport_performance( - expected_num_queries1=187, + expected_num_queries1=184, expected_num_async_tasks1=2, - expected_num_queries2=132, + expected_num_queries2=131, expected_num_async_tasks2=1, - expected_num_queries3=37, + expected_num_queries3=36, expected_num_async_tasks3=1, - expected_num_queries4=100, + expected_num_queries4=99, expected_num_async_tasks4=0, ) @@ -398,13 +392,13 @@ def test_import_reimport_reimport_performance_pghistory_no_async_with_product_gr self.system_settings(enable_product_grade=True) self._import_reimport_performance( - expected_num_queries1=197, + expected_num_queries1=194, expected_num_async_tasks1=4, - expected_num_queries2=142, + expected_num_queries2=141, expected_num_async_tasks2=3, - expected_num_queries3=44, + expected_num_queries3=43, expected_num_async_tasks3=3, - expected_num_queries4=109, + expected_num_queries4=108, expected_num_async_tasks4=2, ) @@ -530,9 +524,9 @@ def test_deduplication_performance_pghistory_async(self): self.system_settings(enable_deduplication=True) self._deduplication_performance( - expected_num_queries1=110, + expected_num_queries1=109, expected_num_async_tasks1=2, - expected_num_queries2=90, + expected_num_queries2=89, expected_num_async_tasks2=2, check_duplicates=False, # Async mode - deduplication happens later ) @@ -551,18 +545,15 @@ def test_deduplication_performance_pghistory_no_async(self): testuser.usercontactinfo.save() self._deduplication_performance( - expected_num_queries1=126, + expected_num_queries1=123, expected_num_async_tasks1=2, - expected_num_queries2=107, + expected_num_queries2=104, expected_num_async_tasks2=2, ) @tag("performance") @override_settings(V3_FEATURE_LOCATIONS=True) -@skip("Re-baseline pending: same RBAC→legacy query-count drift as " - "TestDojoImporterPerformanceSmall. See that class's skip note for the " - "rationale.") class TestDojoImporterPerformanceSmallLocations(TestDojoImporterPerformanceBase): r""" @@ -642,13 +633,13 @@ def test_import_reimport_reimport_performance_pghistory_async(self): configure_pghistory_triggers() self._import_reimport_performance( - expected_num_queries1=178, + expected_num_queries1=177, expected_num_async_tasks1=2, - expected_num_queries2=133, + expected_num_queries2=132, expected_num_async_tasks2=1, - expected_num_queries3=37, + expected_num_queries3=36, expected_num_async_tasks3=1, - expected_num_queries4=101, + expected_num_queries4=100, expected_num_async_tasks4=0, ) @@ -666,13 +657,13 @@ def test_import_reimport_reimport_performance_pghistory_no_async(self): testuser.usercontactinfo.save() self._import_reimport_performance( - expected_num_queries1=196, + expected_num_queries1=193, expected_num_async_tasks1=2, - expected_num_queries2=143, + expected_num_queries2=142, expected_num_async_tasks2=1, - expected_num_queries3=47, + expected_num_queries3=46, expected_num_async_tasks3=1, - expected_num_queries4=101, + expected_num_queries4=100, expected_num_async_tasks4=0, ) @@ -691,13 +682,13 @@ def test_import_reimport_reimport_performance_pghistory_no_async_with_product_gr self.system_settings(enable_product_grade=True) self._import_reimport_performance( - expected_num_queries1=209, + expected_num_queries1=206, expected_num_async_tasks1=4, - expected_num_queries2=156, + expected_num_queries2=155, expected_num_async_tasks2=3, - expected_num_queries3=54, + expected_num_queries3=53, expected_num_async_tasks3=3, - expected_num_queries4=113, + expected_num_queries4=112, expected_num_async_tasks4=2, ) @@ -798,9 +789,9 @@ def test_deduplication_performance_pghistory_async(self): self.system_settings(enable_deduplication=True) self._deduplication_performance( - expected_num_queries1=117, + expected_num_queries1=116, expected_num_async_tasks1=2, - expected_num_queries2=93, + expected_num_queries2=92, expected_num_async_tasks2=2, check_duplicates=False, # Async mode - deduplication happens later ) @@ -818,8 +809,8 @@ def test_deduplication_performance_pghistory_no_async(self): testuser.usercontactinfo.save() self._deduplication_performance( - expected_num_queries1=135, + expected_num_queries1=132, expected_num_async_tasks1=2, - expected_num_queries2=218, + expected_num_queries2=215, expected_num_async_tasks2=2, ) From 81782818bdd1877b64a3ee89b2bab117bcdf4653 Mon Sep 17 00:00:00 2001 From: valentijnscholten Date: Mon, 8 Jun 2026 23:41:50 +0200 Subject: [PATCH 23/31] test(perf): re-enable import performance tests with recalibrated query counts (#14968) Re-baseline expected query counts after upstream merge that switched from RBAC to legacy authorization. Legacy auth has lower per-action overhead (no role-permission lookups, simpler dispatch), so all counts decreased by 1-7 queries. Also removes the unused `unittest.skip` import. --- unittests/test_importers_performance.py | 73 +++++++++++-------------- 1 file changed, 32 insertions(+), 41 deletions(-) diff --git a/unittests/test_importers_performance.py b/unittests/test_importers_performance.py index dc82f28114d..ce9133e4a6b 100644 --- a/unittests/test_importers_performance.py +++ b/unittests/test_importers_performance.py @@ -20,7 +20,6 @@ import logging from contextlib import contextmanager -from unittest import skip from unittest.mock import patch from crum import impersonate @@ -275,11 +274,6 @@ def _import_reimport_performance( self.assertGreater(len_closed_findings4, 0, "Step 4 (empty reimport with close_old_findings=True) should close findings") -@skip("Re-baseline pending: Track B legacy authorization reduces auth-layer query " - "overhead (no per-action role-permission lookups, simpler permission_to_action " - "dispatch). Expected query counts here were calibrated under RBAC and are " - "consistently 1-7 queries higher than legacy actual. Re-baseline with a fresh " - "calibration run after the upstream merge.") @tag("performance") @skip_unless_v2 class TestDojoImporterPerformanceSmall(TestDojoImporterPerformanceBase): @@ -349,13 +343,13 @@ def test_import_reimport_reimport_performance_pghistory_async(self): configure_pghistory_triggers() self._import_reimport_performance( - expected_num_queries1=171, + expected_num_queries1=170, expected_num_async_tasks1=2, - expected_num_queries2=124, + expected_num_queries2=123, expected_num_async_tasks2=1, - expected_num_queries3=29, + expected_num_queries3=28, expected_num_async_tasks3=1, - expected_num_queries4=100, + expected_num_queries4=99, expected_num_async_tasks4=0, ) @@ -373,13 +367,13 @@ def test_import_reimport_reimport_performance_pghistory_no_async(self): testuser.usercontactinfo.save() self._import_reimport_performance( - expected_num_queries1=187, + expected_num_queries1=184, expected_num_async_tasks1=2, - expected_num_queries2=132, + expected_num_queries2=131, expected_num_async_tasks2=1, - expected_num_queries3=37, + expected_num_queries3=36, expected_num_async_tasks3=1, - expected_num_queries4=100, + expected_num_queries4=99, expected_num_async_tasks4=0, ) @@ -398,13 +392,13 @@ def test_import_reimport_reimport_performance_pghistory_no_async_with_product_gr self.system_settings(enable_product_grade=True) self._import_reimport_performance( - expected_num_queries1=197, + expected_num_queries1=194, expected_num_async_tasks1=4, - expected_num_queries2=142, + expected_num_queries2=141, expected_num_async_tasks2=3, - expected_num_queries3=44, + expected_num_queries3=43, expected_num_async_tasks3=3, - expected_num_queries4=109, + expected_num_queries4=108, expected_num_async_tasks4=2, ) @@ -530,9 +524,9 @@ def test_deduplication_performance_pghistory_async(self): self.system_settings(enable_deduplication=True) self._deduplication_performance( - expected_num_queries1=110, + expected_num_queries1=109, expected_num_async_tasks1=2, - expected_num_queries2=90, + expected_num_queries2=89, expected_num_async_tasks2=2, check_duplicates=False, # Async mode - deduplication happens later ) @@ -551,18 +545,15 @@ def test_deduplication_performance_pghistory_no_async(self): testuser.usercontactinfo.save() self._deduplication_performance( - expected_num_queries1=126, + expected_num_queries1=123, expected_num_async_tasks1=2, - expected_num_queries2=107, + expected_num_queries2=104, expected_num_async_tasks2=2, ) @tag("performance") @override_settings(V3_FEATURE_LOCATIONS=True) -@skip("Re-baseline pending: same RBAC→legacy query-count drift as " - "TestDojoImporterPerformanceSmall. See that class's skip note for the " - "rationale.") class TestDojoImporterPerformanceSmallLocations(TestDojoImporterPerformanceBase): r""" @@ -642,13 +633,13 @@ def test_import_reimport_reimport_performance_pghistory_async(self): configure_pghistory_triggers() self._import_reimport_performance( - expected_num_queries1=178, + expected_num_queries1=177, expected_num_async_tasks1=2, - expected_num_queries2=133, + expected_num_queries2=132, expected_num_async_tasks2=1, - expected_num_queries3=37, + expected_num_queries3=36, expected_num_async_tasks3=1, - expected_num_queries4=101, + expected_num_queries4=100, expected_num_async_tasks4=0, ) @@ -666,13 +657,13 @@ def test_import_reimport_reimport_performance_pghistory_no_async(self): testuser.usercontactinfo.save() self._import_reimport_performance( - expected_num_queries1=196, + expected_num_queries1=193, expected_num_async_tasks1=2, - expected_num_queries2=143, + expected_num_queries2=142, expected_num_async_tasks2=1, - expected_num_queries3=47, + expected_num_queries3=46, expected_num_async_tasks3=1, - expected_num_queries4=101, + expected_num_queries4=100, expected_num_async_tasks4=0, ) @@ -691,13 +682,13 @@ def test_import_reimport_reimport_performance_pghistory_no_async_with_product_gr self.system_settings(enable_product_grade=True) self._import_reimport_performance( - expected_num_queries1=209, + expected_num_queries1=206, expected_num_async_tasks1=4, - expected_num_queries2=156, + expected_num_queries2=155, expected_num_async_tasks2=3, - expected_num_queries3=54, + expected_num_queries3=53, expected_num_async_tasks3=3, - expected_num_queries4=113, + expected_num_queries4=112, expected_num_async_tasks4=2, ) @@ -798,9 +789,9 @@ def test_deduplication_performance_pghistory_async(self): self.system_settings(enable_deduplication=True) self._deduplication_performance( - expected_num_queries1=117, + expected_num_queries1=116, expected_num_async_tasks1=2, - expected_num_queries2=93, + expected_num_queries2=92, expected_num_async_tasks2=2, check_duplicates=False, # Async mode - deduplication happens later ) @@ -818,8 +809,8 @@ def test_deduplication_performance_pghistory_no_async(self): testuser.usercontactinfo.save() self._deduplication_performance( - expected_num_queries1=135, + expected_num_queries1=132, expected_num_async_tasks1=2, - expected_num_queries2=218, + expected_num_queries2=215, expected_num_async_tasks2=2, ) From 589f08460c58d8fb1262193e6f80b7bed9e205f7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:01:24 -0600 Subject: [PATCH 24/31] chore(deps): bump sqlalchemy from 2.0.49 to 2.0.50 (#14918) Bumps [sqlalchemy](https://github.com/sqlalchemy/sqlalchemy) from 2.0.49 to 2.0.50. - [Release notes](https://github.com/sqlalchemy/sqlalchemy/releases) - [Changelog](https://github.com/sqlalchemy/sqlalchemy/blob/main/CHANGES.rst) - [Commits](https://github.com/sqlalchemy/sqlalchemy/commits) --- updated-dependencies: - dependency-name: sqlalchemy dependency-version: 2.0.50 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 27f74fef357..1389f552d35 100644 --- a/requirements.txt +++ b/requirements.txt @@ -36,7 +36,7 @@ cryptography==46.0.7 python-dateutil==2.9.0.post0 redis==8.0.0 requests==2.34.2 -sqlalchemy==2.0.49 # Required by Celery broker transport +sqlalchemy==2.0.50 # Required by Celery broker transport urllib3==2.7.0 uWSGI==2.0.31 vobject==0.9.9 From d9d4fa65e6e9a14ed0be82023c1218b53d20a986 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 12:50:10 -0500 Subject: [PATCH 25/31] chore(deps): update gcr.io/cloudsql-docker/gce-proxy docker tag from 1.37.12 to v1.38.0 (helm/defectdojo/values.yaml) (#14993) * chore(deps): update gcr.io/cloudsql-docker/gce-proxy docker tag from 1.37.12 to v1.38.0 (helm/defectdojo/values.yaml) * update Helm documentation --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] --- helm/defectdojo/Chart.yaml | 2 +- helm/defectdojo/README.md | 4 ++-- helm/defectdojo/values.yaml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/helm/defectdojo/Chart.yaml b/helm/defectdojo/Chart.yaml index 49095de5ffa..85a72ca0df9 100644 --- a/helm/defectdojo/Chart.yaml +++ b/helm/defectdojo/Chart.yaml @@ -34,4 +34,4 @@ dependencies: # description: Critical bug annotations: artifacthub.io/prerelease: "true" - artifacthub.io/changes: "" + artifacthub.io/changes: "- kind: changed\n description: chore(deps)_ update gcr.io/cloudsql__/gce_proxy _ tag from 1.37.12 to v1.38.0 (_/defect_/values.yaml)\n" diff --git a/helm/defectdojo/README.md b/helm/defectdojo/README.md index 30f81ff839e..0dcfe389213 100644 --- a/helm/defectdojo/README.md +++ b/helm/defectdojo/README.md @@ -589,13 +589,13 @@ A Helm chart for Kubernetes to install DefectDojo | celery.worker.startupProbe | object | `{}` | Enable startup probe for Celery worker container. | | celery.worker.terminationGracePeriodSeconds | int | `300` | | | celery.worker.tolerations | list | `[]` | | -| cloudsql | object | `{"containerSecurityContext":{},"enable_iam_login":false,"enabled":false,"extraEnv":[],"extraVolumeMounts":[],"image":{"pullPolicy":"IfNotPresent","repository":"gcr.io/cloudsql-docker/gce-proxy","tag":"1.37.12"},"instance":"","resources":{},"use_private_ip":false,"verbose":true}` | Google CloudSQL support in GKE via gce-proxy | +| cloudsql | object | `{"containerSecurityContext":{},"enable_iam_login":false,"enabled":false,"extraEnv":[],"extraVolumeMounts":[],"image":{"pullPolicy":"IfNotPresent","repository":"gcr.io/cloudsql-docker/gce-proxy","tag":"1.38.0"},"instance":"","resources":{},"use_private_ip":false,"verbose":true}` | Google CloudSQL support in GKE via gce-proxy | | cloudsql.containerSecurityContext | object | `{}` | Optional: security context for the CloudSQL proxy container. | | cloudsql.enable_iam_login | bool | `false` | use IAM database authentication | | cloudsql.enabled | bool | `false` | To use CloudSQL in GKE set 'enable: true' | | cloudsql.extraEnv | list | `[]` | Additional environment variables for the CloudSQL proxy container. | | cloudsql.extraVolumeMounts | list | `[]` | Array of additional volume mount points for the CloudSQL proxy container | -| cloudsql.image | object | `{"pullPolicy":"IfNotPresent","repository":"gcr.io/cloudsql-docker/gce-proxy","tag":"1.37.12"}` | set repo and image tag of gce-proxy | +| cloudsql.image | object | `{"pullPolicy":"IfNotPresent","repository":"gcr.io/cloudsql-docker/gce-proxy","tag":"1.38.0"}` | set repo and image tag of gce-proxy | | cloudsql.instance | string | `""` | set CloudSQL instance: 'project:zone:instancename' | | cloudsql.resources | object | `{}` | Optional: add resource requests/limits for the CloudSQL proxy container. | | cloudsql.use_private_ip | bool | `false` | whether to use a private IP to connect to the database | diff --git a/helm/defectdojo/values.yaml b/helm/defectdojo/values.yaml index 792930707e4..659c7a2271c 100644 --- a/helm/defectdojo/values.yaml +++ b/helm/defectdojo/values.yaml @@ -601,7 +601,7 @@ cloudsql: # -- set repo and image tag of gce-proxy image: repository: gcr.io/cloudsql-docker/gce-proxy - tag: 1.37.12 + tag: 1.38.0 pullPolicy: IfNotPresent # -- set CloudSQL instance: 'project:zone:instancename' instance: "" From 4e5b4b53282fb6a0fca883d4afe78380be40be58 Mon Sep 17 00:00:00 2001 From: Cody Maffucci <46459665+Maffooch@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:27:14 -0600 Subject: [PATCH 26/31] Add release notes for upgrading to DefectDojo Version 3.0.x (#15010) * docs: add release notes for upgrading to DefectDojo Version 3.0.x * docs: repoint 2.59 upgrade-note links to 3.0 PR #15010 renamed releases/os_upgrading/2.59.md to 3.0.md, but seven pages under admin/ still deep-linked to /releases/os_upgrading/2.59/. Since the 2.59 page is no longer generated, the lychee internal-link check in the docs deploy job failed on those broken links. Repoint the links (and accompanying prose) to the 3.0 upgrade notes; the referenced anchors are unchanged and still present on the 3.0 page. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- docs/content/admin/sso/_index.md | 4 +- .../user_management/OS__authorized_users.md | 6 +- docs/content/admin/user_management/_index.md | 2 +- .../user_management/about_perms_and_roles.md | 2 +- .../user_management/create_user_group.md | 2 +- .../user_management/set_user_permissions.md | 2 +- .../user_management/user_permission_chart.md | 2 +- .../releases/os_upgrading/{2.59.md => 3.0.md} | 62 +++++++++++++++++-- docs/content/releases/pro/changelog.md | 13 ++++ 9 files changed, 81 insertions(+), 14 deletions(-) rename docs/content/releases/os_upgrading/{2.59.md => 3.0.md} (52%) diff --git a/docs/content/admin/sso/_index.md b/docs/content/admin/sso/_index.md index 65c46706ad7..858967cc969 100644 --- a/docs/content/admin/sso/_index.md +++ b/docs/content/admin/sso/_index.md @@ -29,9 +29,9 @@ aliases: - /admin/sso/os__remote_user/ --- -Single Sign-On is a **DefectDojo Pro** feature. As of DefectDojo 2.59, the SSO surface — SAML, OIDC, and the bundled OAuth providers — is available only in DefectDojo Pro. Open-source DefectDojo uses local username/password login and the password-reset flow. +Single Sign-On is a **DefectDojo Pro** feature. As of DefectDojo 3.0, the SSO surface — SAML, OIDC, and the bundled OAuth providers — is available only in DefectDojo Pro. Open-source DefectDojo uses local username/password login and the password-reset flow. -If you're running open-source DefectDojo and want SSO, you'll need to switch to [DefectDojo Pro](https://defectdojo.com); the migration is covered in the [2.59 upgrade notes](/releases/os_upgrading/2.59/#sso-providers-are-available-in-defectdojo-pro-only). Existing user accounts and group memberships are preserved on upgrade. For access control on open-source DefectDojo, see the [Authorized Users](/admin/user_management/os__authorized_users/) page. +If you're running open-source DefectDojo and want SSO, you'll need to switch to [DefectDojo Pro](https://defectdojo.com); the migration is covered in the [3.0 upgrade notes](/releases/os_upgrading/3.0/#sso-providers-are-available-in-defectdojo-pro-only). Existing user accounts and group memberships are preserved on upgrade. For access control on open-source DefectDojo, see the [Authorized Users](/admin/user_management/os__authorized_users/) page. ## Supported SSO providers (DefectDojo Pro) diff --git a/docs/content/admin/user_management/OS__authorized_users.md b/docs/content/admin/user_management/OS__authorized_users.md index 1baf228c015..c758382f4ee 100644 --- a/docs/content/admin/user_management/OS__authorized_users.md +++ b/docs/content/admin/user_management/OS__authorized_users.md @@ -51,10 +51,10 @@ A few rules of thumb: ## Coming from a previous version of DefectDojo -DefectDojo open-source moved back to the Authorized Users model in version 2.59. If you're upgrading from a release that had the Members / Groups / Global Roles system, your existing access is carried forward into Authorized Users automatically by the upgrade — no manual mapping is needed. +DefectDojo open-source moved back to the Authorized Users model in version 3.0. If you're upgrading from a release that had the Members / Groups / Global Roles system, your existing access is carried forward into Authorized Users automatically by the upgrade — no manual mapping is needed. -The upgrade ships with a read-only management command, `preview_legacy_authorization_migration`, that summarizes what an upgrade would change against a copy of your database. The recommended workflow is to install 2.59 in a staging environment with a snapshot of production, run the command, review the summary, and then upgrade production. +The upgrade ships with a read-only management command, `preview_legacy_authorization_migration`, that summarizes what an upgrade would change against a copy of your database. The recommended workflow is to install 3.0 in a staging environment with a snapshot of production, run the command, review the summary, and then upgrade production. If you're moving the other direction — from open-source to DefectDojo Pro — Pro ships a `reconcile_authorized_users_to_rbac` command that brings Authorized Users access forward into Pro's RBAC. It supports `--dry-run` and is idempotent. -For more detail on both paths, see the [2.59 upgrade notes](/releases/os_upgrading/2.59/#authorized-users-panel-replaces-membersgroups-under-legacy-authorization). +For more detail on both paths, see the [3.0 upgrade notes](/releases/os_upgrading/3.0/#authorized-users-panel-replaces-membersgroups-under-legacy-authorization). diff --git a/docs/content/admin/user_management/_index.md b/docs/content/admin/user_management/_index.md index 33a52cc5583..ae5322fdda9 100644 --- a/docs/content/admin/user_management/_index.md +++ b/docs/content/admin/user_management/_index.md @@ -38,4 +38,4 @@ DefectDojo Pro uses a role-based system with Members, Groups, and Global Roles. ## Migrating between editions -If you're moving from open-source's Authorized Users to Pro's RBAC, or upgrading from a pre-2.59 open-source release that used RBAC into the current Authorized Users model, see the [2.59 upgrade notes](/releases/os_upgrading/2.59/#authorized-users-panel-replaces-membersgroups-under-legacy-authorization). Existing access is preserved automatically. +If you're moving from open-source's Authorized Users to Pro's RBAC, or upgrading from a pre-3.0 open-source release that used RBAC into the current Authorized Users model, see the [3.0 upgrade notes](/releases/os_upgrading/3.0/#authorized-users-panel-replaces-membersgroups-under-legacy-authorization). Existing access is preserved automatically. diff --git a/docs/content/admin/user_management/about_perms_and_roles.md b/docs/content/admin/user_management/about_perms_and_roles.md index 9a662bfd231..85ff79b50f9 100644 --- a/docs/content/admin/user_management/about_perms_and_roles.md +++ b/docs/content/admin/user_management/about_perms_and_roles.md @@ -7,7 +7,7 @@ aliases: - /en/customize_dojo/user_management/about_perms_and_roles --- -> **DefectDojo Pro feature.** The Members / Groups / Global Roles RBAC system described on this page is part of DefectDojo Pro. Open-source DefectDojo uses the [Authorized Users](../os__authorized_users/) model — see that page for open-source access control, and the [2.59 upgrade notes](/releases/os_upgrading/2.59/#authorized-users-panel-replaces-membersgroups-under-legacy-authorization) if you're moving between editions. +> **DefectDojo Pro feature.** The Members / Groups / Global Roles RBAC system described on this page is part of DefectDojo Pro. Open-source DefectDojo uses the [Authorized Users](../os__authorized_users/) model — see that page for open-source access control, and the [3.0 upgrade notes](/releases/os_upgrading/3.0/#authorized-users-panel-replaces-membersgroups-under-legacy-authorization) if you're moving between editions. If you have a team of users working in DefectDojo, it's important to set up Role\-Based Access Control (RBAC) appropriately so that users can only access specific data. Security data is highly sensitive, and DefectDojo's options for access control allow you to be specific about each team member’s access to information. diff --git a/docs/content/admin/user_management/create_user_group.md b/docs/content/admin/user_management/create_user_group.md index d432470212a..8107cd9dae4 100644 --- a/docs/content/admin/user_management/create_user_group.md +++ b/docs/content/admin/user_management/create_user_group.md @@ -7,7 +7,7 @@ aliases: - /en/customize_dojo/user_management/create_user_group --- -> **DefectDojo Pro feature.** User Groups and the underlying RBAC system are part of DefectDojo Pro. Open-source DefectDojo uses the [Authorized Users](../os__authorized_users/) model — see that page for open-source access control, and the [2.59 upgrade notes](/releases/os_upgrading/2.59/#authorized-users-panel-replaces-membersgroups-under-legacy-authorization) if you're moving between editions. +> **DefectDojo Pro feature.** User Groups and the underlying RBAC system are part of DefectDojo Pro. Open-source DefectDojo uses the [Authorized Users](../os__authorized_users/) model — see that page for open-source access control, and the [3.0 upgrade notes](/releases/os_upgrading/3.0/#authorized-users-panel-replaces-membersgroups-under-legacy-authorization) if you're moving between editions. If you have a significant number of DefectDojo users, you may want to create one or more **Groups**, in order to set the same Role\-Based Access Control (RBAC) rules for many users simultaneously. Only Superusers can create User Groups. diff --git a/docs/content/admin/user_management/set_user_permissions.md b/docs/content/admin/user_management/set_user_permissions.md index ed2018423fc..9134f2f61e8 100644 --- a/docs/content/admin/user_management/set_user_permissions.md +++ b/docs/content/admin/user_management/set_user_permissions.md @@ -7,7 +7,7 @@ aliases: - /en/customize_dojo/user_management/set_user_permissions --- -> **DefectDojo Pro feature.** The Members / Groups / Global Roles RBAC system described on this page is part of DefectDojo Pro. Open-source DefectDojo uses the [Authorized Users](../os__authorized_users/) model — see that page for open-source access control, and the [2.59 upgrade notes](/releases/os_upgrading/2.59/#authorized-users-panel-replaces-membersgroups-under-legacy-authorization) if you're moving between editions. +> **DefectDojo Pro feature.** The Members / Groups / Global Roles RBAC system described on this page is part of DefectDojo Pro. Open-source DefectDojo uses the [Authorized Users](../os__authorized_users/) model — see that page for open-source access control, and the [3.0 upgrade notes](/releases/os_upgrading/3.0/#authorized-users-panel-replaces-membersgroups-under-legacy-authorization) if you're moving between editions. ## Introduction to Permission Types diff --git a/docs/content/admin/user_management/user_permission_chart.md b/docs/content/admin/user_management/user_permission_chart.md index e29c65b0189..b119d48d0a3 100644 --- a/docs/content/admin/user_management/user_permission_chart.md +++ b/docs/content/admin/user_management/user_permission_chart.md @@ -7,7 +7,7 @@ aliases: - /en/customize_dojo/user_management/user_permission_chart --- -> **DefectDojo Pro feature.** The Members / Groups / Global Roles RBAC system described on this page is part of DefectDojo Pro. Open-source DefectDojo uses the [Authorized Users](../os__authorized_users/) model — see that page for open-source access control, and the [2.59 upgrade notes](/releases/os_upgrading/2.59/#authorized-users-panel-replaces-membersgroups-under-legacy-authorization) if you're moving between editions. +> **DefectDojo Pro feature.** The Members / Groups / Global Roles RBAC system described on this page is part of DefectDojo Pro. Open-source DefectDojo uses the [Authorized Users](../os__authorized_users/) model — see that page for open-source access control, and the [3.0 upgrade notes](/releases/os_upgrading/3.0/#authorized-users-panel-replaces-membersgroups-under-legacy-authorization) if you're moving between editions. ## Role Permission Chart diff --git a/docs/content/releases/os_upgrading/2.59.md b/docs/content/releases/os_upgrading/3.0.md similarity index 52% rename from docs/content/releases/os_upgrading/2.59.md rename to docs/content/releases/os_upgrading/3.0.md index a36e9e88b65..95e0ce984ea 100644 --- a/docs/content/releases/os_upgrading/2.59.md +++ b/docs/content/releases/os_upgrading/3.0.md @@ -1,10 +1,64 @@ --- -title: 'Upgrading to DefectDojo Version 2.59.x' +title: 'Upgrading to DefectDojo Version 3.0.x' toc_hide: true -weight: -20260602 -description: Authorized Users panel replaces Members/Groups under legacy authorization; SSO providers move to DefectDojo Pro; removal of Questionnaire API Endpoints, Credential Manager, and Stub Findings +weight: -20260615 +description: Locations and Asset/Organization labels are now enabled by default; Authorized Users panel replaces Members/Groups under legacy authorization; SSO providers move to DefectDojo Pro; removal of Questionnaire API Endpoints, Credential Manager, and Stub Findings --- +## Locations enabled by default + +`DD_V3_FEATURE_LOCATIONS` now defaults to `True`. Locations is a polymorphic location/asset model that replaces the legacy `Endpoint` model. **URL Locations** are the direct equivalent of Endpoints (same `protocol`, `host`, `port`, `path`, `query`, and `fragment` fields), and the model additionally supports **Dependency Locations** for SBOM/library data that Endpoints could never represent. + +### How to migrate + +After the feature is enabled on an existing instance, run the **`migrate_endpoints_to_locations`** management command to carry your Endpoint data forward into Locations. Enabling the flag alone does **not** move data — the command performs the one-time conversion. For every Endpoint, it: + +1. Creates (or re-uses) a **URL Location** from the Endpoint's `protocol`, `userinfo`, `host`, `port`, `path`, `query`, and `fragment`. +2. Carries over all **tags** and re-points all **metadata** (`DojoMeta`) onto the new Location. +3. Creates a **`LocationProductReference`** so the URL appears under the correct Asset (Product). +4. Creates a **`LocationFindingReference`** for every `Endpoint_Status`, collapsing the old multi-flag combinations into a single canonical status (first match wins): + + | Endpoint_Status flag | Resulting Location status | + | --- | --- | + | `risk_accepted=True` | **Risk Accepted** | + | `false_positive=True` | **False Positive** | + | `out_of_scope=True` | **Out of Scope** | + | `mitigated=True` | **Mitigated** | + | (none of the above) | **Active** | + +#### Running the migration + +``` +python manage.py migrate_endpoints_to_locations +``` + +- **Idempotent — safe to re-run.** Each phase uses `bulk_create(..., ignore_conflicts=True)`, so re-running the command will not create duplicates and will pick up any Endpoints not yet converted. After converting, it runs a tag-inheritance pass so migrated Locations pick up inherited product tags. +- **Resilient to per-row failures.** A single bad Endpoint is logged (with its ID) and skipped rather than aborting the whole run; re-run after addressing the cause to convert the remainder. The command prints live progress with an ETA and a final migrated/total summary. +- **Tuning flags (optional):** `--batch-size` (DB iterator chunk size, default `1000`) and `--progress-every` (progress-line cadence, default `50`). `--benchmark` and `--query-count` exist for profiling only and add overhead. + +For full details (including the read-compatibility behavior of the legacy API), see [Migrating from Endpoints](/asset_modelling/locations/pro__migrating_from_endpoints/). + +### What happens to existing endpoint data + +- **Nothing is deleted.** The original `Endpoint` and `Endpoint_Status` rows remain in the database to back the read-only legacy API. They are simply no longer used by the new UI or by imports. +- **Reads keep working; writes return 403.** `GET /api/v2/endpoints/` and `GET /api/v2/endpoint_status/` return rows projected from Locations, preserving the original Endpoint IDs and familiar fields. `POST`/`PUT`/`PATCH`/`DELETE` on those routes return `HTTP 403` — write clients should move to `POST /api/v2/urls/`, `POST /api/v2/location_findings/`, and `POST /api/v2/location_products/`. + +### How to roll back + +Set `DD_V3_FEATURE_LOCATIONS=False` to return to the legacy Endpoint model, UI, and read/write API. Because the original Endpoint rows are never deleted, your pre-upgrade endpoint data is still there. + +> **Caveat — migration is one-way.** There is no automated path that re-creates Endpoints from Locations. Any endpoint changes you made through the new Location endpoints while the feature was enabled are **not** back-ported into the legacy `Endpoint` tables, so they will not be visible after rolling back. + +## Asset / Organization labels enabled by default + +`DD_ENABLE_V3_ORGANIZATION_ASSET_RELABEL` now defaults to `True`. This renames **"Product Type" → "Organization"** and **"Product" → "Asset"** throughout the UI, and routes `/product/type` → `/organization` and `/product` → `/asset` (with backward-compatibility redirects from the old paths). + +This change is **cosmetic only**: the database model names, field names, and API endpoints are unchanged, so existing automation and integrations continue to work without modification. + +### How to roll back + +Set `DD_ENABLE_V3_ORGANIZATION_ASSET_RELABEL=False` to restore the "Product" / "Product Type" labels and the original URLs. No data is changed by this feature, so the rollback is fully reversible. + ## Authorized Users panel replaces Members/Groups under legacy authorization Open Source DefectDojo uses the legacy authorization model: access to a Product is granted by `Product.authorized_users` (with cascade via `Product_Type.authorized_users`), and `is_staff` / `is_superuser` bypass everything. @@ -93,4 +147,4 @@ Any requests to this endpoint will now return a 404 Not Found error. The Stub Fi In [PR 14881](https://github.com/DefectDojo/django-DefectDojo/pull/14881)We optimized the way the Django Watson search index is updated during imports and reimports. There is not a single configuration setting to manage the threshold: `DD_WATSON_ASYNC_INDEX_UPDATE_BATCH_SIZE`. The default value should work fine for most instances. -For more information, check the [Release Notes](https://github.com/DefectDojo/django-DefectDojo/releases/tag/2.59.0). +For more information, check the [Release Notes](https://github.com/DefectDojo/django-DefectDojo/releases/tag/3.0.0). diff --git a/docs/content/releases/pro/changelog.md b/docs/content/releases/pro/changelog.md index 762cd12c74a..1216f187b62 100644 --- a/docs/content/releases/pro/changelog.md +++ b/docs/content/releases/pro/changelog.md @@ -10,6 +10,19 @@ Here are the release notes for **DefectDojo Pro (Cloud Version)**. These release For Open Source release notes, please see the [Releases page on GitHub](https://github.com/DefectDojo/django-DefectDojo/releases), or alternatively consult the Open Source [upgrade notes](/releases/os_upgrading/upgrading_guide/). +## June 2026: v3.0 + +### June 15, 2026: v3.0.0 + +* **(Locations)** Locations are now enabled by default, superseding the legacy Endpoint model. The legacy Endpoint API stays read-compatible and your data is preserved. See [Locations enabled by default](/releases/os_upgrading/3.0/#locations-enabled-by-default). +* **(Assets & Organizations)** "Product Type" → "Organization" and "Product" → "Asset" relabeling (UI labels + URL routing) is now on by default. The change is cosmetic — API endpoints and field names are unchanged. See [Asset / Organization labels enabled by default](/releases/os_upgrading/3.0/#asset--organization-labels-enabled-by-default). +* **(Authorization)** Open Source restores the **Authorized Users** panel on Product/Product Type detail under the legacy authorization model; Pro deployments retain full RBAC and are not impacted. See [Authorized Users panel replaces Members/Groups under legacy authorization](/releases/os_upgrading/3.0/#authorized-users-panel-replaces-membersgroups-under-legacy-authorization). +* **(SSO)** SSO providers (SAML, OIDC, Google, Okta, Azure AD, GitLab, Auth0, Keycloak, GitHub Enterprise, remote-user header auth) are now DefectDojo Pro-only. See [SSO providers are available in DefectDojo Pro only](/releases/os_upgrading/3.0/#sso-providers-are-available-in-defectdojo-pro-only). +* **(API)** Removed the Questionnaire API endpoints. See [Removal: Questionnaire API Endpoints](/releases/os_upgrading/3.0/#removal-questionnaire-api-endpoints). +* **(API)** Removed the Credential Manager feature and its API endpoints. See [Removal: Credential Manager](/releases/os_upgrading/3.0/#removal-credential-manager). +* **(API)** Removed the Stub Findings feature and its API endpoint. See [Removal: Stub Findings](/releases/os_upgrading/3.0/#removal-stub-findings). +* **(Search)** Watson search index updates during import/reimport are now batched, tunable via `DD_WATSON_ASYNC_INDEX_UPDATE_BATCH_SIZE`. See [Configuration change in Watson Search Indexing](/releases/os_upgrading/3.0/#configuration-change-in-watson-search-indexing). + ## June 2026: v2.59 ### June 1, 2026: v2.59.0 From f086fd6b065643ffe2b95f9cc5fc8f413bb1fa82 Mon Sep 17 00:00:00 2001 From: Cody Maffucci <46459665+Maffooch@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:27:27 -0600 Subject: [PATCH 27/31] Enable v3 functionality and organization/asset relabeling by default (#15011) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(settings): enable v3 functionality and organization/asset relabeling by default * fix(v3): register authorized-users URLs in relabel branch When ENABLE_V3_ORGANIZATION_ASSET_RELABEL is on (now the default), the asset and organization URL configs failed to register the add/delete_product[_type]_authorized_user routes — those patterns only existed in the legacy (else) branch. The product/asset and product-type/organization detail templates still reverse those names, so rendering raised NoReverseMatch and returned HTTP 500, cascading into nearly all REST and UI test failures. Add the missing native patterns to the v3 branch plus the corresponding cross-edition redirects so both flag states stay at parity. Co-Authored-By: Claude Opus 4.8 (1M context) * test(v3): update UI and unit tests for org/asset relabeling The v3 organization/asset relabeling is now on by default, but the test suites still asserted the legacy "Product"/"Product Type" labels and the legacy /product/ URL routing, so both the integration (Selenium) and rest-framework unit test workflows failed. Integration tests: update relabel-driven UI strings (link text, success messages, headings, page text) to "Asset"/"Organization". This also fixes the wide cascade where most suites failed because the shared ProductTest.test_create_product fixture broke on the "Add Product" -> "Add Asset" link text. Unit tests: update the shared JIRA redirect helpers and the report-scoping URLs from /product/ to /asset/ (v3 URL routing), and the product type deletion audit message to the relabeled "Organization" form. Relabeling is the default in every CI leg (neither workflow overrides DD_ENABLE_V3_ORGANIZATION_ASSET_RELABEL), so the new strings/URLs are correct in both the v3_feature_locations true and false legs. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- dojo/asset/urls.py | 15 +++++++++++++++ dojo/organization/urls.py | 14 ++++++++++++++ dojo/settings/settings.dist.py | 8 ++++---- tests/metrics_extended_test.py | 8 ++++---- tests/product_test.py | 12 ++++++------ tests/product_type_test.py | 14 +++++++------- tests/report_builder_test.py | 2 +- tests/risk_acceptance_test.py | 2 +- unittests/dojo_test_case.py | 4 ++-- unittests/test_jira_config_product.py | 2 +- unittests/test_notifications.py | 2 +- unittests/test_product_endpoint_report_scoping.py | 4 ++-- 12 files changed, 58 insertions(+), 29 deletions(-) diff --git a/dojo/asset/urls.py b/dojo/asset/urls.py index 3f4c5019fcc..1b71a03dddf 100644 --- a/dojo/asset/urls.py +++ b/dojo/asset/urls.py @@ -123,6 +123,16 @@ views.delete_engagement_presets, name="delete_engagement_presets", ), + re_path( + r"^asset/(?P\d+)/authorized_users/add$", + views.add_product_authorized_users, + name="add_product_authorized_users", + ), + re_path( + r"^asset/(?P\d+)/authorized_users/(?P\d+)/delete$", + views.delete_product_authorized_user, + name="delete_product_authorized_user", + ), re_path( r"^asset/(?P\d+)/add_api_scan_configuration$", views.add_api_scan_configuration, @@ -165,6 +175,8 @@ re_path(r"^product/(?P\d+)/engagement_presets/(?P\d+)/edit$", redirect_view("edit_engagement_presets")), re_path(r"^product/(?P\d+)/engagement_presets/add$", redirect_view("add_engagement_presets")), re_path(r"^product/(?P\d+)/engagement_presets/(?P\d+)/delete$", redirect_view("delete_engagement_presets")), + re_path(r"^product/(?P\d+)/authorized_users/add$", redirect_view("add_product_authorized_users")), + re_path(r"^product/(?P\d+)/authorized_users/(?P\d+)/delete$", redirect_view("delete_product_authorized_user")), re_path(r"^product/(?P\d+)/add_api_scan_configuration$", redirect_view("add_api_scan_configuration")), re_path(r"^product/(?P\d+)/view_api_scan_configurations$", redirect_view("view_api_scan_configurations")), re_path(r"^product/(?P\d+)/edit_api_scan_configuration/(?P\d+)$", redirect_view("edit_api_scan_configuration")), @@ -260,6 +272,9 @@ re_path(r"^asset/(?P\d+)/engagement_presets/add$", redirect_view("add_engagement_presets")), re_path(r"^asset/(?P\d+)/engagement_presets/(?P\d+)/delete$", redirect_view("delete_engagement_presets")), + re_path(r"^asset/(?P\d+)/authorized_users/add$", redirect_view("add_product_authorized_users")), + re_path(r"^asset/(?P\d+)/authorized_users/(?P\d+)/delete$", + redirect_view("delete_product_authorized_user")), re_path(r"^asset/(?P\d+)/add_api_scan_configuration$", redirect_view("add_api_scan_configuration")), re_path(r"^asset/(?P\d+)/view_api_scan_configurations$", redirect_view("view_api_scan_configurations")), re_path(r"^asset/(?P\d+)/edit_api_scan_configuration/(?P\d+)$", diff --git a/dojo/organization/urls.py b/dojo/organization/urls.py index ea9e8d00cc3..0555a654b20 100644 --- a/dojo/organization/urls.py +++ b/dojo/organization/urls.py @@ -38,6 +38,16 @@ product_views.new_product, name="add_product_to_product_type", ), + re_path( + r"^organization/(?P\d+)/authorized_users/add$", + views.add_product_type_authorized_users, + name="add_product_type_authorized_users", + ), + re_path( + r"^organization/(?P\d+)/authorized_users/(?P\d+)/delete$", + views.delete_product_type_authorized_user, + name="delete_product_type_authorized_user", + ), # TODO: Backwards compatibility; remove after v3 migration is complete re_path(r"^product/type$", redirect_view("product_type")), re_path(r"^product/type/(?P\d+)$", redirect_view("view_product_type")), @@ -45,6 +55,8 @@ re_path(r"^product/type/(?P\d+)/delete$", redirect_view("delete_product_type")), re_path(r"^product/type/add$", redirect_view("add_product_type")), re_path(r"^product/type/(?P\d+)/add_product", redirect_view("add_product_to_product_type")), + re_path(r"^product/type/(?P\d+)/authorized_users/add$", redirect_view("add_product_type_authorized_users")), + re_path(r"^product/type/(?P\d+)/authorized_users/(?P\d+)/delete$", redirect_view("delete_product_type_authorized_user")), ] else: urlpatterns = [ @@ -74,4 +86,6 @@ re_path(r"^organization/(?P\d+)/delete$", redirect_view("delete_product_type")), re_path(r"^organization/add$", redirect_view("add_product_type")), re_path(r"^organization/(?P\d+)/add_product", redirect_view("add_product_to_product_type")), + re_path(r"^organization/(?P\d+)/authorized_users/add$", redirect_view("add_product_type_authorized_users")), + re_path(r"^organization/(?P\d+)/authorized_users/(?P\d+)/delete$", redirect_view("delete_product_type_authorized_user")), ] diff --git a/dojo/settings/settings.dist.py b/dojo/settings/settings.dist.py index abe31dec9f9..ce0966da78e 100644 --- a/dojo/settings/settings.dist.py +++ b/dojo/settings/settings.dist.py @@ -261,10 +261,10 @@ # For HTTP requests, how long connection is open before timeout # This settings apply only on requests performed by "requests" lib used in Dojo code (if some included lib is using "requests" as well, this does not apply there) DD_REQUESTS_TIMEOUT=(int, 30), - # Dictates if v3 functionality will be enabled - DD_V3_FEATURE_LOCATIONS=(bool, False), - # Dictates if v3 org/asset relabeling (+url routing) will be enabled - DD_ENABLE_V3_ORGANIZATION_ASSET_RELABEL=(bool, False), + # Dictates if v3 functionality will be enabled (on by default as of 3.0.0; set to False to revert to the legacy Endpoint model) + DD_V3_FEATURE_LOCATIONS=(bool, True), + # Dictates if v3 org/asset relabeling (+url routing) will be enabled (on by default as of 3.0.0; set to False to restore Product/Product Type labels and URLs) + DD_ENABLE_V3_ORGANIZATION_ASSET_RELABEL=(bool, True), # Notification env-vars (SLA notify, alert refresh/counter/cap, system-level trump). Defined in dojo.notifications.settings. **NOTIFICATIONS_ENV_DEFAULTS, ) diff --git a/tests/metrics_extended_test.py b/tests/metrics_extended_test.py index 4bb0972ab7e..7fa4c44eb9e 100644 --- a/tests/metrics_extended_test.py +++ b/tests/metrics_extended_test.py @@ -16,7 +16,7 @@ def test_metrics_all_page(self): time.sleep(1) self.assertTrue( self.is_text_present_on_page(text="Metrics") - or self.is_text_present_on_page(text="Product Type"), + or self.is_text_present_on_page(text="Organization"), ) @on_exception_html_source_logger @@ -27,7 +27,7 @@ def test_metrics_organization_page(self): time.sleep(1) self.assertTrue( self.is_text_present_on_page(text="Metric") - or self.is_text_present_on_page(text="Product Type"), + or self.is_text_present_on_page(text="Organization"), ) @on_exception_html_source_logger @@ -59,7 +59,7 @@ def test_product_type_counts_page(self): driver.get(self.base_url + "metrics/product/type/counts") time.sleep(1) self.assertTrue( - self.is_text_present_on_page(text="Product Type") + self.is_text_present_on_page(text="Organization") or self.is_text_present_on_page(text="Metric") or self.is_text_present_on_page(text="Count"), ) @@ -72,7 +72,7 @@ def test_critical_product_metrics_page(self): time.sleep(1) self.assertTrue( self.is_text_present_on_page(text="Metric") - or self.is_text_present_on_page(text="Product"), + or self.is_text_present_on_page(text="Asset"), ) diff --git a/tests/product_test.py b/tests/product_test.py index 8cc7d3cc379..29a979ed4bd 100644 --- a/tests/product_test.py +++ b/tests/product_test.py @@ -43,7 +43,7 @@ def test_create_product(self): # "Click" the dropdown button to see options driver.find_element(By.ID, "dropdownMenu1").click() # "Click" the add prodcut button - driver.find_element(By.LINK_TEXT, "Add Product").click() + driver.find_element(By.LINK_TEXT, "Add Asset").click() # Fill in th product name driver.find_element(By.ID, "id_name").clear() driver.find_element(By.ID, "id_name").send_keys("QA Test") @@ -59,7 +59,7 @@ def test_create_product(self): # Assert ot the query to dtermine status of failure # Also confirm success even if Product is returned as already exists for test sake - self.assertTrue(self.is_success_message_present(text="Product added successfully") + self.assertTrue(self.is_success_message_present(text="Asset added successfully") or self.is_success_message_present(text="Product with this Name already exists.")) self.assertFalse(self.is_error_message_present()) @@ -102,7 +102,7 @@ def test_edit_product_description(self): # Query the site to determine if the product has been added # Assert ot the query to dtermine status of failure - self.assertTrue(self.is_success_message_present(text="Product updated successfully") + self.assertTrue(self.is_success_message_present(text="Asset updated successfully") or self.is_success_message_present(text="Product with this Name already exists.")) self.assertFalse(self.is_error_message_present()) @@ -130,7 +130,7 @@ def test_enable_simple_risk_acceptance(self): # Query the site to determine if the product has been added # Assert ot the query to dtermine status of failure - self.assertTrue(self.is_success_message_present(text="Product updated successfully") + self.assertTrue(self.is_success_message_present(text="Asset updated successfully") or self.is_success_message_present(text="Product with this Name already exists.")) self.assertFalse(self.is_error_message_present()) @@ -426,7 +426,7 @@ def test_add_product_tracking_files(self): # Query the site to determine if the finding has been added # Assert ot the query to dtermine status of failure - self.assertTrue(self.is_success_message_present(text="Added Tracked File to a Product")) + self.assertTrue(self.is_success_message_present(text="Added Tracked File to an Asset")) @on_exception_html_source_logger def test_edit_product_tracking_files(self): @@ -483,7 +483,7 @@ def test_delete_product(self, name="QA Test"): # Query the site to determine if the product has been added # Assert ot the query to determine status of failure - self.assertTrue(self.is_success_message_present(text="Product and relationships removed.")) + self.assertTrue(self.is_success_message_present(text="Asset and relationships removed.")) @on_exception_html_source_logger def test_product_notifications_change(self): diff --git a/tests/product_type_test.py b/tests/product_type_test.py index 2037abeded8..ef25b5a9a6c 100644 --- a/tests/product_type_test.py +++ b/tests/product_type_test.py @@ -16,13 +16,13 @@ def test_create_product_type(self): driver = self.driver driver.get(self.base_url + "product/type") driver.find_element(By.ID, "dropdownMenu1").click() - driver.find_element(By.LINK_TEXT, "Add Product Type").click() + driver.find_element(By.LINK_TEXT, "Add Organization").click() driver.find_element(By.ID, "id_name").clear() driver.find_element(By.ID, "id_name").send_keys("Product test type") driver.find_element(By.ID, "id_critical_product").click() driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() - self.assertTrue(self.is_success_message_present(text="Product Type added successfully.")) + self.assertTrue(self.is_success_message_present(text="Organization added successfully.")) self.assertFalse(self.is_error_message_present()) @on_exception_html_source_logger @@ -35,7 +35,7 @@ def test_create_product_for_product_type(self): self.goto_product_type_overview(driver) driver.find_element(By.ID, "dropdownMenuProductType").click() - driver.find_element(By.PARTIAL_LINK_TEXT, "Add Product").click() + driver.find_element(By.PARTIAL_LINK_TEXT, "Add Asset").click() # Fill in th product name driver.find_element(By.ID, "id_name").clear() driver.find_element(By.ID, "id_name").send_keys("QA Test PT") @@ -46,7 +46,7 @@ def test_create_product_for_product_type(self): # Assert ot the query to dtermine status of failure # Also confirm success even if Product is returned as already exists for test sake - self.assertTrue(self.is_success_message_present(text="Product added successfully")) + self.assertTrue(self.is_success_message_present(text="Asset added successfully")) self.assertFalse(self.is_error_message_present()) def test_view_product_type(self): @@ -57,7 +57,7 @@ def test_view_product_type(self): driver.find_element(By.PARTIAL_LINK_TEXT, "View").click() product_type_text = driver.find_element(By.ID, "id_heading").text - self.assertEqual("Product Type Product test type", product_type_text) + self.assertEqual("Organization Product test type", product_type_text) def test_edit_product_type(self): logger.debug("\n\nDebug Print Log: testing 'edit product type' \n") @@ -69,7 +69,7 @@ def test_edit_product_type(self): driver.find_element(By.ID, "id_name").send_keys("Edited product test type") driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() - self.assertTrue(self.is_success_message_present(text="Product Type updated successfully.")) + self.assertTrue(self.is_success_message_present(text="Organization updated successfully.")) def test_delete_product_type(self): logger.debug("\n\nDebug Print Log: testing 'delete product type' \n") @@ -80,7 +80,7 @@ def test_delete_product_type(self): driver.find_element(By.PARTIAL_LINK_TEXT, "Delete").click() driver.find_element(By.CSS_SELECTOR, "button.btn.btn-danger").click() - self.assertTrue(self.is_success_message_present(text="Product Type and relationships removed.")) + self.assertTrue(self.is_success_message_present(text="Organization and relationships removed.")) def suite(): diff --git a/tests/report_builder_test.py b/tests/report_builder_test.py index 556a7dfc16b..7f11eb93fec 100644 --- a/tests/report_builder_test.py +++ b/tests/report_builder_test.py @@ -64,7 +64,7 @@ def test_product_report(self): self.goto_product_overview(driver) driver.find_element(By.LINK_TEXT, "QA Test").click() driver.find_element(By.ID, "dropdownMenu1").click() - driver.find_element(By.PARTIAL_LINK_TEXT, "Product Report").click() + driver.find_element(By.PARTIAL_LINK_TEXT, "Asset Report").click() my_select = Select(driver.find_element(By.ID, "id_include_finding_notes")) my_select.select_by_index(1) diff --git a/tests/risk_acceptance_test.py b/tests/risk_acceptance_test.py index 5f6278c6d4f..5ed3a52bdf0 100644 --- a/tests/risk_acceptance_test.py +++ b/tests/risk_acceptance_test.py @@ -26,7 +26,7 @@ def test_enable_full_risk_acceptance(self): driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() self.assertTrue( - self.is_success_message_present(text="Product updated successfully") + self.is_success_message_present(text="Asset updated successfully") or self.is_text_present_on_page(text="QA Test"), ) diff --git a/unittests/dojo_test_case.py b/unittests/dojo_test_case.py index 13cc0a20341..9f8cf55f89f 100644 --- a/unittests/dojo_test_case.py +++ b/unittests/dojo_test_case.py @@ -346,7 +346,7 @@ def get_product_with_empty_jira_project_data(self, product): } def get_expected_redirect_product(self, product): - return f"/product/{product.id}" + return f"/asset/{product.id}" def add_product_jira(self, data, expect_redirect_to=None, *, expect_200=False): response = self.client.get(reverse("new_product")) @@ -355,7 +355,7 @@ def add_product_jira(self, data, expect_redirect_to=None, *, expect_200=False): # self.log_model_instance(JIRA_Project.objects.last()) if not expect_redirect_to and not expect_200: - expect_redirect_to = "/product/%i" + expect_redirect_to = "/asset/%i" response = self.client.post(reverse("new_product"), urlencode(data), content_type="application/x-www-form-urlencoded") diff --git a/unittests/test_jira_config_product.py b/unittests/test_jira_config_product.py index c4992caf049..52387c29d23 100644 --- a/unittests/test_jira_config_product.py +++ b/unittests/test_jira_config_product.py @@ -169,7 +169,7 @@ def test_add_product_with_jira_project(self, jira_mock): @patch("dojo.jira.views.jira_helper.is_jira_project_valid") def test_add_product_with_jira_project_invalid_jira_project(self, jira_mock): jira_mock.return_value = False # cannot set return_value in decorated AND have the mock into the method - product = self.add_product_with_jira_project(expected_delta_jira_project_db=0, expect_redirect_to="/product/%i/edit") + product = self.add_product_with_jira_project(expected_delta_jira_project_db=0, expect_redirect_to="/asset/%i/edit") # product is still saved, even with invalid jira project key self.assertIsNotNone(product) self.assertEqual(jira_mock.call_count, 1) diff --git a/unittests/test_notifications.py b/unittests/test_notifications.py index 28921fdf961..1980f73504d 100644 --- a/unittests/test_notifications.py +++ b/unittests/test_notifications.py @@ -476,7 +476,7 @@ def _minimal_create_payload(self, title: str): def test_auditlog_on(self, mock): prod_type = Product_Type.objects.create(name="notif prod type API") self.client.delete(reverse("product_type-detail", args=(prod_type.pk,)), format="json") - self.assertEqual(mock.call_args_list[-1].kwargs["description"], 'The product type "notif prod type API" was deleted by admin') + self.assertEqual(mock.call_args_list[-1].kwargs["description"], 'The Organization "notif prod type API" was deleted by admin') @patch("dojo.api_v2.serializers.dojo_dispatch_task") def test_create_calls_notification_with_auto_assigned_reporter(self, mock_dispatch): diff --git a/unittests/test_product_endpoint_report_scoping.py b/unittests/test_product_endpoint_report_scoping.py index ccfe17a01e9..ae79b7edb90 100644 --- a/unittests/test_product_endpoint_report_scoping.py +++ b/unittests/test_product_endpoint_report_scoping.py @@ -124,7 +124,7 @@ def setUp(self): self.client.force_login(self.user) def test_product_endpoint_report_only_includes_target_product_findings(self): - url = f"/product/{self.product_a.id}/endpoint/report?_generate=1&report_type=HTML" + url = f"/asset/{self.product_a.id}/endpoint/report?_generate=1&report_type=HTML" response = self.client.get(url) self.assertEqual(response.status_code, 200, response.content[:500]) body = response.content.decode() @@ -137,7 +137,7 @@ def test_product_endpoint_report_only_includes_target_product_findings(self): ) def test_product_b_report_only_includes_product_b_findings(self): - url = f"/product/{self.product_b.id}/endpoint/report?_generate=1&report_type=HTML" + url = f"/asset/{self.product_b.id}/endpoint/report?_generate=1&report_type=HTML" response = self.client.get(url) self.assertEqual(response.status_code, 200, response.content[:500]) body = response.content.decode() From e6da78b48bd2101326386ea9adf9c2fb58fc9b05 Mon Sep 17 00:00:00 2001 From: Cody Maffucci <46459665+Maffooch@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:27:39 -0600 Subject: [PATCH 28/31] Refactor removal of deprecated features while preserving database state (#15009) * refactor(migrations): update removal of Stub Findings and Credential Manager features to preserve database state * fix(migrations): keep enable_credentials column insertable after state-only removal The state-only removal in 0266 left dojo_system_settings.enable_credentials in place (NOT NULL, no DB default) for downgrade safety, but the model no longer supplies a value on INSERT. New System_Settings rows then failed with a NotNullViolation, surfacing as 28 errors in unittests.test_apply_finding_template. Split the field handling into its own SeparateDatabaseAndState: drop the field from Django state while a database_operations RunSQL sets a server-side default of true (matching the field's original default) on the retained column, so inserts that omit it still satisfy the NOT NULL constraint. Verified locally: the 28 test_apply_finding_template errors reproduce before the change and pass after; makemigrations --check reports no drift; and the cred_*/stub_finding tables, the enable_credentials column, and the cred_user pghistory triggers all remain in the database. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../db_migrations/0265_remove_stub_finding.py | 21 +++-- .../0266_remove_credential_manager.py | 92 ++++++++++++------- 2 files changed, 72 insertions(+), 41 deletions(-) diff --git a/dojo/db_migrations/0265_remove_stub_finding.py b/dojo/db_migrations/0265_remove_stub_finding.py index a9432846d8f..65d813cb211 100644 --- a/dojo/db_migrations/0265_remove_stub_finding.py +++ b/dojo/db_migrations/0265_remove_stub_finding.py @@ -1,8 +1,10 @@ -"""Remove the Stub Findings feature. +"""Remove the Stub Findings feature (state only). -Drops the ``Stub_Finding`` model. Stub Findings was deprecated in 2.57.0 and -is end-of-life in 2.59. The model has no inbound foreign keys, so the -deletion is self-contained. +Drops the ``Stub_Finding`` model from Django's state but leaves the +``dojo_stub_finding`` table in place so a downgrade to a release that still +defines the model keeps its data. Stub Findings was deprecated in 2.57.0 and +is end-of-life in 2.59. The model has no inbound foreign keys, so the removal +is self-contained. Note: rebase the filename and the ``dependencies`` tuple to point at whatever the latest migration is at merge time if another migration has @@ -19,7 +21,14 @@ class Migration(migrations.Migration): ] operations = [ - migrations.DeleteModel( - name="Stub_Finding", + migrations.SeparateDatabaseAndState( + # State only: forget the model so it no longer has to be defined + # in dojo/models.py. database_operations is intentionally empty so + # the dojo_stub_finding table is preserved for downgrades. + state_operations=[ + migrations.DeleteModel( + name="Stub_Finding", + ), + ], ), ] diff --git a/dojo/db_migrations/0266_remove_credential_manager.py b/dojo/db_migrations/0266_remove_credential_manager.py index ba04cf317db..ddcd088fcd6 100644 --- a/dojo/db_migrations/0266_remove_credential_manager.py +++ b/dojo/db_migrations/0266_remove_credential_manager.py @@ -1,9 +1,11 @@ -"""Remove the Credential Manager feature. +"""Remove the Credential Manager feature (state only). -Drops the `Cred_User`, `Cred_Mapping`, and `Cred_UserEvent` models, removes -the pghistory triggers that wrote into the latter, and removes the -`enable_credentials` switch from System_Settings. The Credential Manager -feature was deprecated in 2.57.0 and is end-of-life in 2.59. +Removes the `Cred_User`, `Cred_Mapping`, and `Cred_UserEvent` models, their +pghistory triggers, and the `enable_credentials` switch from System_Settings +from Django's state, but leaves the underlying tables, columns, and triggers +in the database so a downgrade to a release that still defines them keeps its +data. The Credential Manager feature was deprecated in 2.57.0 and is +end-of-life in 2.59. """ import pgtrigger.migrations @@ -17,36 +19,56 @@ class Migration(migrations.Migration): ] operations = [ - # Remove pghistory triggers that mirror Cred_User changes into - # Cred_UserEvent. Triggers must be dropped before the source / event - # tables can be removed. - pgtrigger.migrations.RemoveTrigger( - model_name="cred_user", - name="insert_insert", + # State only: forget the models and their triggers so they no longer + # have to be defined in dojo/models.py. There are no database_operations + # so the dojo_cred_user, dojo_cred_mapping, and dojo_cred_userevent + # tables and the cred_user pghistory triggers are preserved for + # downgrades. + migrations.SeparateDatabaseAndState( + state_operations=[ + # Drop the pghistory triggers from state before the model they + # hang off of is removed. + pgtrigger.migrations.RemoveTrigger( + model_name="cred_user", + name="insert_insert", + ), + pgtrigger.migrations.RemoveTrigger( + model_name="cred_user", + name="update_update", + ), + pgtrigger.migrations.RemoveTrigger( + model_name="cred_user", + name="delete_delete", + ), + # Cred_UserEvent FKs Cred_User; Cred_Mapping FKs Cred_User too, + # so both come out of state before Cred_User itself. + migrations.DeleteModel( + name="Cred_UserEvent", + ), + migrations.DeleteModel( + name="Cred_Mapping", + ), + migrations.DeleteModel( + name="Cred_User", + ), + ], ), - pgtrigger.migrations.RemoveTrigger( - model_name="cred_user", - name="update_update", - ), - pgtrigger.migrations.RemoveTrigger( - model_name="cred_user", - name="delete_delete", - ), - # Drop the audit/event table (FKs from Cred_UserEvent → Cred_User get - # cleaned up automatically as part of DeleteModel). - migrations.DeleteModel( - name="Cred_UserEvent", - ), - # Cred_Mapping holds an FK to Cred_User and must be dropped first. - migrations.DeleteModel( - name="Cred_Mapping", - ), - migrations.DeleteModel( - name="Cred_User", - ), - # The UI toggle no longer has anything to gate. - migrations.RemoveField( - model_name="system_settings", - name="enable_credentials", + # Drop the enable_credentials field from state but keep the column for + # downgrades. The model no longer supplies a value on INSERT, so give + # the column a server-side default (the field defaulted to True) to + # keep new System_Settings rows satisfying its NOT NULL constraint. + migrations.SeparateDatabaseAndState( + state_operations=[ + migrations.RemoveField( + model_name="system_settings", + name="enable_credentials", + ), + ], + database_operations=[ + migrations.RunSQL( + sql="ALTER TABLE dojo_system_settings ALTER COLUMN enable_credentials SET DEFAULT true;", + reverse_sql="ALTER TABLE dojo_system_settings ALTER COLUMN enable_credentials DROP DEFAULT;", + ), + ], ), ] From e392bd8179c896857770b3f72537300597e42a48 Mon Sep 17 00:00:00 2001 From: Cody Maffucci <46459665+Maffooch@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:35:21 -0600 Subject: [PATCH 29/31] fix: update app version to 2.59.0 and adjust artifacthub annotations; add upgrade notes for version 3.0.0 --- docs/content/releases/os_upgrading/2.59.md | 96 ------------------- .../os_upgrading/{2.59.1.md => 3.0.md} | 3 +- helm/defectdojo/Chart.yaml | 6 +- 3 files changed, 4 insertions(+), 101 deletions(-) delete mode 100644 docs/content/releases/os_upgrading/2.59.md rename docs/content/releases/os_upgrading/{2.59.1.md => 3.0.md} (98%) diff --git a/docs/content/releases/os_upgrading/2.59.md b/docs/content/releases/os_upgrading/2.59.md deleted file mode 100644 index a36e9e88b65..00000000000 --- a/docs/content/releases/os_upgrading/2.59.md +++ /dev/null @@ -1,96 +0,0 @@ ---- -title: 'Upgrading to DefectDojo Version 2.59.x' -toc_hide: true -weight: -20260602 -description: Authorized Users panel replaces Members/Groups under legacy authorization; SSO providers move to DefectDojo Pro; removal of Questionnaire API Endpoints, Credential Manager, and Stub Findings ---- - -## Authorized Users panel replaces Members/Groups under legacy authorization - -Open Source DefectDojo uses the legacy authorization model: access to a Product is granted by `Product.authorized_users` (with cascade via `Product_Type.authorized_users`), and `is_staff` / `is_superuser` bypass everything. - -In 2.59 the classic UI restores the **"Authorized Users"** panel on the Product and Product Type detail pages. The panel reads from and writes to `Product.authorized_users` / `Product_Type.authorized_users` directly, so adding a user actually grants them the access the UI suggests it does. - -### New endpoints - -- `GET/POST /product//authorized_users/add` — list / add users to `Product.authorized_users` -- `POST /product//authorized_users//delete` — remove a user -- `GET/POST /product/type//authorized_users/add` — same for `Product_Type.authorized_users` -- `POST /product/type//authorized_users//delete` - -Both endpoints are gated so only `is_staff` / `is_superuser` users can add or remove. Non-staff users see the panel but no management actions. - -### How RBAC rows are converted - -The data migration `0267_backfill_authorized_users` translates RBAC tables into the legacy model with the following rules: - -| RBAC row | Legacy effect | -|---|---| -| `Product_Member` (any role, direct or via `Product_Group` + `Dojo_Group_Member`) | Adds the user to `Product.authorized_users` | -| `Product_Type_Member` (any role, direct or via `Product_Type_Group` + `Dojo_Group_Member`) | Adds the user to `Product_Type.authorized_users` | -| `Global_Role(Owner)` (direct or via group) | Sets `User.is_superuser = True` | -| `Global_Role(Writer | Maintainer | API_Importer)` (direct or via group) | Sets `User.is_staff = True` | -| `Global_Role(Reader)` | No global elevation — relies on per-product membership | - -Per-product role granularity (Reader vs Writer vs Maintainer vs Owner) collapses to membership-only because the legacy model has no per-product role concept. `Dojo_Group` structure as a permission-bearing entity is also lost; only the flattened individual user memberships remain. - -### Required actions - -- **Database migrations run automatically on upgrade.** Existing access is carried forward into the legacy `authorized_users` model. Existing data is preserved. -- **Audit the upgrade in staging first.** A new `python manage.py preview_legacy_authorization_migration` management command is shipped in 2.59 to summarize what an upgrade would change against a given database. It is read-only. Recommended workflow: install 2.59 in a staging environment with a snapshot of your production database, run the command, review the summary, then upgrade production. -- **Migrating from OS to Pro?** A new `python manage.py reconcile_authorized_users_to_rbac` management command is available on Pro to bring any access changes you made under OS forward into Pro RBAC. It supports `--dry-run` and is idempotent. - -### Pro customers are not impacted - -DefectDojo Pro deployments retain full RBAC. The Pro UX is unchanged — same Members/Groups management surface as before. - -## SSO providers are available in DefectDojo Pro only - -Single sign-on (SAML, OIDC, Google, Okta, Azure AD, GitLab, Auth0, Keycloak, GitHub Enterprise, and remote-user header authentication) has been consolidated into DefectDojo Pro. Open source DefectDojo now exposes only local username/password login and the password-reset flow. - -### Required actions - -- **No customizations or local-only login:** No action required. -- **Currently logging in via SSO on open source:** Existing user accounts and group memberships are preserved on upgrade, but SSO sign-in will no longer work after 2.59. To keep an SSO-driven login experience, switch to [DefectDojo Pro](https://defectdojo.com), which carries forward and extends the SSO surface (provider configuration moves to a UI-managed tuner). - -## Removal: Questionnaire API Endpoints - -As announced in DefectDojo 2.56.0, the following Questionnaire API endpoints have been removed: - -- `/api/v2/questionnaire_answered_questionnaires/` -- `/api/v2/questionnaire_answers/` -- `/api/v2/questionnaire_engagement_questionnaires/` -- `/api/v2/questionnaire_general_questionnaires/` -- `/api/v2/questionnaire_questions` - -### Required Actions - -Any requests to these endpoints will now return a 404 Not Found error. - -## Removal: Credential Manager - -As announced in DefectDojo 2.57.0, the Credential Manager feature has been removed. The following API endpoints are no longer available: - -- `/api/v2/credentials/` -- `/api/v2/credential_mappings/` - -### Required Actions - -Any requests to these endpoints will now return a 404 Not Found error. The Credential Manager UI is no longer available. - -## Removal: Stub Findings - -As announced in DefectDojo 2.57.0, the Stub Findings feature has been removed. The following API endpoint is no longer available: - -- `/api/v2/stub_findings/` - -### Required Actions - -Any requests to this endpoint will now return a 404 Not Found error. The Stub Findings UI is no longer available. - -## Configuration change in Watson Search Indexing - -In [PR 14881](https://github.com/DefectDojo/django-DefectDojo/pull/14881)We optimized the way the Django Watson search index is updated during imports and reimports. There is not a single configuration setting to manage the threshold: `DD_WATSON_ASYNC_INDEX_UPDATE_BATCH_SIZE`. The default value should work fine for most instances. - - -For more information, check the [Release Notes](https://github.com/DefectDojo/django-DefectDojo/releases/tag/2.59.0). diff --git a/docs/content/releases/os_upgrading/2.59.1.md b/docs/content/releases/os_upgrading/3.0.md similarity index 98% rename from docs/content/releases/os_upgrading/2.59.1.md rename to docs/content/releases/os_upgrading/3.0.md index 701fe466513..4f4f2a6934c 100644 --- a/docs/content/releases/os_upgrading/2.59.1.md +++ b/docs/content/releases/os_upgrading/3.0.md @@ -1,5 +1,5 @@ --- -title: 'Upgrading to DefectDojo Version 2.59.1' +title: "Upgrading to DefectDojo Version 3.0.0" toc_hide: true weight: -20260603 description: Dependency Check parser no longer emits separate findings for related dependencies; related file paths are now listed in the main finding's description. @@ -28,5 +28,4 @@ Only scenario 1 represents the same vulnerable artifact at multiple deploy locat - **Users filtering or grouping by the `related` tag**: that tag is no longer applied because related findings are no longer created. Update any saved filters, dashboards, or rules that depend on it. Equivalent information is now available in the finding description (look for `**Related Filepaths:**`). - **Reimport behavior**: on the next reimport of an existing Dependency Check report, the previously-created `related` findings will be closed as no longer present in the report. This is expected and matches the new parsing behavior. - For more information, check the [Release Notes](https://github.com/DefectDojo/django-DefectDojo/releases/tag/2.59.1). diff --git a/helm/defectdojo/Chart.yaml b/helm/defectdojo/Chart.yaml index 49095de5ffa..af9c542b028 100644 --- a/helm/defectdojo/Chart.yaml +++ b/helm/defectdojo/Chart.yaml @@ -1,5 +1,5 @@ apiVersion: v2 -appVersion: "2.60.0-dev" +appVersion: "2.59.0" description: A Helm chart for Kubernetes to install DefectDojo name: defectdojo version: 1.9.31-dev @@ -33,5 +33,5 @@ dependencies: # - kind: security # description: Critical bug annotations: - artifacthub.io/prerelease: "true" - artifacthub.io/changes: "" + artifacthub.io/prerelease: "false" + artifacthub.io/changes: "- kind: changed\n description: Update valkey Docker tag from 0.20.0 to v0.20.1 (_/defect_/Chart.yaml)\n- kind: changed\n description: Update valkey Docker tag from 0.20.1 to v0.20.2 (_/defect_/Chart.yaml)\n- kind: changed\n description: Bump DefectDojo to 2.59.0\n" From 90ff9c1c9d64d3138451eee159b476fbc12d9ce6 Mon Sep 17 00:00:00 2001 From: dogboat Date: Mon, 15 Jun 2026 17:23:53 -0400 Subject: [PATCH 30/31] Merge pull request #15015 from dogboat/location-ui-updates Locations updates --- dojo/authorization/middleware.py | 5 +- dojo/authorization/url_permissions.py | 40 ++- dojo/finding/urls.py | 2 +- dojo/reports/views.py | 14 +- unittests/test_v3_endpoint_route_authz.py | 316 ++++++++++++++++++++++ 5 files changed, 364 insertions(+), 13 deletions(-) create mode 100644 unittests/test_v3_endpoint_route_authz.py diff --git a/dojo/authorization/middleware.py b/dojo/authorization/middleware.py index f5543753a0c..b27479044cb 100644 --- a/dojo/authorization/middleware.py +++ b/dojo/authorization/middleware.py @@ -43,7 +43,10 @@ def process_view(self, request, view_func, view_args, view_kwargs): _, model, permission, arg_name = check lookup_value = view_kwargs.get(arg_name) if lookup_value is None: - continue # kwarg not present, skip this check + # The URL pattern and the URL_PERMISSIONS entry have drifted + # apart on the kwarg name. Treat as a configuration error + # and deny rather than silently allowing the request. + raise PermissionDenied obj = get_object_or_404(model, pk=lookup_value) user_has_permission_or_403(request.user, obj, permission) diff --git a/dojo/authorization/url_permissions.py b/dojo/authorization/url_permissions.py index 65807ee52c2..70ac4ab20bb 100644 --- a/dojo/authorization/url_permissions.py +++ b/dojo/authorization/url_permissions.py @@ -1,3 +1,6 @@ +from django.conf import settings + +from dojo.location.models import Location from dojo.models import ( App_Analysis, Endpoint, @@ -149,17 +152,11 @@ # ----------------------------------------------------------------------- # URL / Location UI (dojo/url/ui/views.py -> dojo/url/ui/urls.py) # - # These URL names overlap with the endpoint module above. Since Django - # uses the last-registered pattern for reverse() and the middleware reads - # view_kwargs from the matched pattern, the kwarg names from the actually - # matched URL are used. The endpoint entries above use "eid"; if the - # url/ui pattern matched instead, "location_id" will be present and the - # middleware will fall back (skip checks where the kwarg is missing). - # - # Unique URL names from url/ui: + # When V3_FEATURE_LOCATIONS is enabled, the endpoint URL names above + # are remapped below to the active routes' model + kwarg names. # ----------------------------------------------------------------------- "add_endpoint_to_product": [("object", Product, "add", "product_id")], - "add_endpoint_to_finding": [("object", Product, "add", "finding_id")], + "add_endpoint_to_finding": [("object", Finding, "add", "finding_id")], # ----------------------------------------------------------------------- # Reports (dojo/reports/views.py -> dojo/reports/urls.py) @@ -293,3 +290,28 @@ "delete_empty_questionnaire": [("config", "dojo.delete_engagement_survey")], "delete_general_questionnaire": [("config", "dojo.delete_engagement_survey")], } + + +# When the V3 location routes are active they replace the legacy endpoint +# routes (dojo/urls.py). The new routes operate on Location rows and carry +# a "location_id" kwarg, so the URL-name -> check mapping needs to point +# at the active route's model + kwarg for the middleware to apply the +# right per-object check. +if settings.V3_FEATURE_LOCATIONS: + URL_PERMISSIONS.update({ + "view_endpoint": [("object", Location, "view", "location_id")], + "view_endpoint_host": [("object", Location, "view", "location_id")], + "edit_endpoint": [("object", Location, "edit", "location_id")], + "delete_endpoint": [("object", Location, "delete", "location_id")], + "endpoint_report": [("object", Location, "view", "location_id")], + "endpoint_host_report": [("object", Location, "view", "location_id")], + "add_endpoint_meta_data": [("object", Location, "edit", "location_id")], + "edit_endpoint_meta_data": [("object", Location, "edit", "location_id")], + # The V3 "add_endpoint" route is an alias for add_endpoint_to_product; + # it carries product_id rather than the legacy pid. + "add_endpoint": [("object", Product, "add", "product_id")], + # Remaining V3 routes that share a URL name with the legacy module + # but carry a different kwarg. + "import_endpoint_meta": [("object", Product, "edit", "product_id")], + "endpoints_status_bulk": [("object", Finding, "edit", "finding_id")], + }) diff --git a/dojo/finding/urls.py b/dojo/finding/urls.py index fda259ee895..96bceeec4e8 100644 --- a/dojo/finding/urls.py +++ b/dojo/finding/urls.py @@ -152,7 +152,7 @@ name="reopen_finding"), re_path(r"^finding/image/(?P[^/]+)$", views.download_finding_pic, name="download_finding_pic"), - re_path(r"^finding/(?P\d+)/merge$", + re_path(r"^finding/(?P\d+)/merge$", views.merge_finding_product, name="merge_finding"), re_path(r"^product/(?P\d+)/merge$", views.merge_finding_product, name="merge_finding_product"), diff --git a/dojo/reports/views.py b/dojo/reports/views.py index 6b372ad8df2..f346dd89900 100644 --- a/dojo/reports/views.py +++ b/dojo/reports/views.py @@ -557,16 +557,26 @@ def generate_report(request, obj, *, host_view=False): endpoint = obj if host_view: report_name = "Endpoint Host Report: " + endpoint.url.host - endpoints = Location.objects.prefetch_related("url").filter(url__host=endpoint.url.host).distinct() + endpoints = get_authorized_locations( + Permissions.Location_View, + queryset=Location.objects.prefetch_related("url").filter(url__host=endpoint.url.host), + user=request.user, + ).distinct() report_title = "Endpoint Host Report" else: report_name = "Endpoint Report: " + str(endpoint) endpoints = Location.objects.filter(id=endpoint.id).distinct() report_title = "Endpoint Report" template = "dojo/endpoint_pdf_report.html" + # Reduce the finding queryset to the requesting user's product scope -- + # a shared Location's auth check passes for any associated product the + # user can see, but rendered findings must still be limited to that scope. + findings_for_locations = get_authorized_findings( + Permissions.Finding_View, user=request.user, + ).filter(locations__location__in=endpoints) findings = report_finding_filter_class( request.GET, - queryset=prefetch_related_findings_for_report(Finding.objects.filter(locations__location__in=endpoints)), + queryset=prefetch_related_findings_for_report(findings_for_locations), ) context = { diff --git a/unittests/test_v3_endpoint_route_authz.py b/unittests/test_v3_endpoint_route_authz.py new file mode 100644 index 00000000000..3aa34b07a6a --- /dev/null +++ b/unittests/test_v3_endpoint_route_authz.py @@ -0,0 +1,316 @@ +""" +Regression tests for object-permission enforcement on the V3 endpoint +(``dojo.url.ui``) routes. + +The V3 routes carry a ``location_id`` kwarg and operate on +``dojo.location.models.Location`` rows. They share URL names with the +legacy endpoint UI, so the existing ``URL_PERMISSIONS`` mapping and the +``AuthorizationMiddleware`` need to line up with the active route's +model and kwarg for the per-object check to actually run. + +These tests fix the contract: an authenticated user who has no +membership on the product backing a Location must not be able to view, +edit, delete, attach metadata to, or reach Location-scoped routes for +that Location. +""" +from unittest.mock import MagicMock + +from django.core.exceptions import PermissionDenied +from django.test import RequestFactory +from django.urls import reverse +from django.utils.timezone import now + +from dojo.authorization.middleware import AuthorizationMiddleware +from dojo.authorization.url_permissions import URL_PERMISSIONS +from dojo.location.models import Location, LocationFindingReference +from dojo.location.status import FindingLocationStatus +from dojo.models import ( + Dojo_User, + Engagement, + Finding, + Product, + Product_Type, + Test, + Test_Type, +) +from dojo.url.models import URL +from unittests.dojo_test_case import DojoTestCase, skip_unless_v3 + + +@skip_unless_v3 +class V3EndpointRouteAuthorizationTests(DojoTestCase): + + """ + Two products, two users -- each user is authorized only for their own + product. Locations are created and associated to one product apiece. + Each test crosses the membership boundary and asserts the request is + rejected. + """ + + @classmethod + def setUpTestData(cls): + cls.prod_type = Product_Type.objects.create(name="v3_authz_pt") + cls.product_a = Product.objects.create( + name="v3_authz_product_a", + description="a", + prod_type=cls.prod_type, + ) + cls.product_b = Product.objects.create( + name="v3_authz_product_b", + description="b", + prod_type=cls.prod_type, + ) + + cls.user_a = Dojo_User.objects.create(username="v3_authz_user_a", is_active=True) + cls.user_b = Dojo_User.objects.create(username="v3_authz_user_b", is_active=True) + # Legacy authorization uses ``authorized_users`` directly. + cls.product_a.authorized_users.add(cls.user_a) + cls.product_b.authorized_users.add(cls.user_b) + + # Each Location is tied to exactly one product. + cls.url_a = URL.get_or_create_from_object( + URL.from_value("https://product-a.example.test/secret"), + ) + cls.url_a.location.associate_with_product(cls.product_a) + cls.location_a = cls.url_a.location + + cls.url_b = URL.get_or_create_from_object( + URL.from_value("https://product-b.example.test/secret"), + ) + cls.url_b.location.associate_with_product(cls.product_b) + cls.location_b = cls.url_b.location + + # A Finding whose product is product_b -- used for add_endpoint_to_finding. + test_type, _ = Test_Type.objects.get_or_create(name="v3_authz_scan") + engagement_b = Engagement.objects.create( + name="v3_authz_eng", + product=cls.product_b, + target_start=now(), + target_end=now(), + ) + test_b = Test.objects.create( + engagement=engagement_b, + test_type=test_type, + target_start=now(), + target_end=now(), + ) + cls.finding_in_b = Finding.objects.create( + test=test_b, + title="v3_authz_finding", + description="x", + severity="High", + numerical_severity="S0", + active=True, + verified=True, + reporter=cls.user_b, + ) + + # A Location associated with both products, with a Finding from each + # product linked through it. Used for the shared-location scoping + # tests. + cls.shared_host = "shared.example.test" + cls.url_shared = URL.get_or_create_from_object( + URL.from_value(f"https://{cls.shared_host}/shared"), + ) + cls.location_shared = cls.url_shared.location + cls.location_shared.associate_with_product(cls.product_a) + cls.location_shared.associate_with_product(cls.product_b) + + engagement_a = Engagement.objects.create( + name="v3_authz_eng_a", + product=cls.product_a, + target_start=now(), + target_end=now(), + ) + test_a = Test.objects.create( + engagement=engagement_a, + test_type=test_type, + target_start=now(), + target_end=now(), + ) + cls.finding_in_a_on_shared = Finding.objects.create( + test=test_a, + title="v3_authz_finding_on_shared_in_a", + description="x", + severity="High", + numerical_severity="S0", + active=True, + verified=True, + reporter=cls.user_a, + ) + cls.finding_in_b_on_shared = Finding.objects.create( + test=test_b, + title="v3_authz_finding_on_shared_in_b", + description="x", + severity="High", + numerical_severity="S0", + active=True, + verified=True, + reporter=cls.user_b, + ) + LocationFindingReference.objects.create( + location=cls.location_shared, + finding=cls.finding_in_a_on_shared, + status=FindingLocationStatus.Active, + ) + LocationFindingReference.objects.create( + location=cls.location_shared, + finding=cls.finding_in_b_on_shared, + status=FindingLocationStatus.Active, + ) + + # A second Location on the same host, associated only with product_b. + # Used to verify host_view aggregation respects per-product membership. + cls.url_unauthorized_on_shared_host = URL.get_or_create_from_object( + URL.from_value(f"https://{cls.shared_host}/private-to-b"), + ) + cls.location_unauthorized_on_shared_host = cls.url_unauthorized_on_shared_host.location + cls.location_unauthorized_on_shared_host.associate_with_product(cls.product_b) + + # ------------------------------------------------------------------ + # Positive control: the authorized user can reach their own Location. + # ------------------------------------------------------------------ + def test_authorized_user_can_view_own_location(self): + self.client.force_login(self.user_a) + response = self.client.get(reverse("view_endpoint", args=(self.location_a.id,))) + self.assertEqual(response.status_code, 200) + + # ------------------------------------------------------------------ + # View / view-host / report routes must reject cross-product access. + # ------------------------------------------------------------------ + def test_view_endpoint_rejects_cross_product(self): + self.client.force_login(self.user_a) + response = self.client.get(reverse("view_endpoint", args=(self.location_b.id,))) + self.assertEqual(response.status_code, 400) + + def test_view_endpoint_host_rejects_cross_product(self): + self.client.force_login(self.user_a) + response = self.client.get(reverse("view_endpoint_host", args=(self.location_b.id,))) + self.assertEqual(response.status_code, 400) + + def test_endpoint_report_rejects_cross_product(self): + self.client.force_login(self.user_a) + response = self.client.get(reverse("endpoint_report", args=(self.location_b.id,))) + self.assertEqual(response.status_code, 400) + + def test_endpoint_host_report_rejects_cross_product(self): + self.client.force_login(self.user_a) + response = self.client.get(reverse("endpoint_host_report", args=(self.location_b.id,))) + self.assertEqual(response.status_code, 400) + + # ------------------------------------------------------------------ + # Reports on shared Locations must not surface data from products + # the requesting user is not a member of. Location-level authorization + # grants access when *any* associated product is authorized, but the + # rendered report must still be reduced to the user's product scope. + # ------------------------------------------------------------------ + def test_endpoint_report_excludes_findings_from_unauthorized_products(self): + self.client.force_login(self.user_a) + response = self.client.get( + reverse("endpoint_report", args=(self.location_shared.id,)) + "?_generate=1", + HTTP_HOST="testserver", + ) + self.assertEqual(response.status_code, 200) + finding_ids = {f.id for f in response.context["findings"]} + self.assertIn(self.finding_in_a_on_shared.id, finding_ids) + self.assertNotIn(self.finding_in_b_on_shared.id, finding_ids) + + def test_endpoint_host_report_excludes_locations_from_unauthorized_products(self): + self.client.force_login(self.user_a) + response = self.client.get( + reverse("endpoint_host_report", args=(self.location_shared.id,)) + "?_generate=1", + HTTP_HOST="testserver", + ) + self.assertEqual(response.status_code, 200) + location_ids = {loc.id for loc in response.context["endpoints"]} + self.assertIn(self.location_shared.id, location_ids) + self.assertNotIn(self.location_unauthorized_on_shared_host.id, location_ids) + finding_ids = {f.id for f in response.context["findings"]} + self.assertIn(self.finding_in_a_on_shared.id, finding_ids) + self.assertNotIn(self.finding_in_b_on_shared.id, finding_ids) + + # ------------------------------------------------------------------ + # Edit / delete routes must reject cross-product mutation. + # ------------------------------------------------------------------ + def test_edit_endpoint_rejects_cross_product(self): + self.client.force_login(self.user_a) + original_host = self.url_b.host + response = self.client.post( + reverse("edit_endpoint", args=(self.location_b.id,)), + data={ + "protocol": "https", + "host": "changed.example.test", + "path": "changed", + }, + ) + self.assertEqual(response.status_code, 400) + self.url_b.refresh_from_db() + self.assertEqual(self.url_b.host, original_host) + + def test_delete_endpoint_rejects_cross_product(self): + self.client.force_login(self.user_a) + response = self.client.post( + reverse("delete_endpoint", args=(self.location_b.id,)), + data={"id": self.location_b.id}, + ) + self.assertEqual(response.status_code, 400) + self.assertTrue(Location.objects.filter(pk=self.location_b.id).exists()) + + # ------------------------------------------------------------------ + # Metadata routes go through the same view + URL name overlap. + # ------------------------------------------------------------------ + def test_add_endpoint_meta_data_rejects_cross_product(self): + self.client.force_login(self.user_a) + response = self.client.get(reverse("add_endpoint_meta_data", args=(self.location_b.id,))) + self.assertEqual(response.status_code, 400) + + def test_edit_endpoint_meta_data_rejects_cross_product(self): + self.client.force_login(self.user_a) + response = self.client.get(reverse("edit_endpoint_meta_data", args=(self.location_b.id,))) + self.assertEqual(response.status_code, 400) + + # ------------------------------------------------------------------ + # The /endpoints/finding//add route ties URL creation to a Finding, + # so authorization has to be checked against the Finding's product -- + # not against an unrelated Product whose pk coincides with finding_id. + # ------------------------------------------------------------------ + def test_add_endpoint_to_finding_rejects_cross_product(self): + self.client.force_login(self.user_a) + response = self.client.post( + reverse("add_endpoint_to_finding", args=(self.finding_in_b.id,)), + data={ + "protocol": "https", + "host": "attacker.example.test", + "path": "", + }, + ) + self.assertEqual(response.status_code, 400) + + +class AuthorizationMiddlewareKwargContractTests(DojoTestCase): + + """ + The middleware must not silently skip an object-permission check when + the configured kwarg is absent from ``view_kwargs``. A missing kwarg + is a sign that the URL pattern and the URL_PERMISSIONS entry have + drifted apart; treating that as "allowed" is unsafe. + """ + + def test_missing_configured_kwarg_is_treated_as_denied(self): + middleware = AuthorizationMiddleware(get_response=lambda _request: None) + request = RequestFactory().get("/somepath") + request.user = Dojo_User.objects.create(username="middleware_kwarg_test_user", is_active=True) + + resolver_match = MagicMock() + # Pick any URL name that has an object check configured. + resolver_match.url_name = next( + name for name, checks in URL_PERMISSIONS.items() + if checks and checks[0][0] == "object" + ) + request.resolver_match = resolver_match + + def _view(_request, **_kwargs): + return None + + with self.assertRaises(PermissionDenied): + middleware.process_view(request, view_func=_view, view_args=(), view_kwargs={}) From 2b2b2551124632fe3f6d7069173f03824a7587ca Mon Sep 17 00:00:00 2001 From: DefectDojo release bot Date: Mon, 15 Jun 2026 21:26:06 +0000 Subject: [PATCH 31/31] Update versions in application files --- components/package.json | 2 +- dojo/__init__.py | 2 +- helm/defectdojo/Chart.yaml | 8 ++++---- helm/defectdojo/README.md | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/components/package.json b/components/package.json index 04a55d739d4..bd883a010f6 100644 --- a/components/package.json +++ b/components/package.json @@ -1,6 +1,6 @@ { "name": "defectdojo", - "version": "2.60.0-dev", + "version": "3.0.0", "license": "BSD-3-Clause", "private": true, "dependencies": { diff --git a/dojo/__init__.py b/dojo/__init__.py index b9bf55e3765..5432d8118f7 100644 --- a/dojo/__init__.py +++ b/dojo/__init__.py @@ -4,6 +4,6 @@ # Django starts so that shared_task will use this app. from .celery import app as celery_app # noqa: F401 -__version__ = "2.60.0-dev" +__version__ = "3.0.0" __url__ = "https://github.com/DefectDojo/django-DefectDojo" __docs__ = "https://documentation.defectdojo.com" diff --git a/helm/defectdojo/Chart.yaml b/helm/defectdojo/Chart.yaml index 85a72ca0df9..842c37aedd3 100644 --- a/helm/defectdojo/Chart.yaml +++ b/helm/defectdojo/Chart.yaml @@ -1,8 +1,8 @@ apiVersion: v2 -appVersion: "2.60.0-dev" +appVersion: "3.0.0" description: A Helm chart for Kubernetes to install DefectDojo name: defectdojo -version: 1.9.31-dev +version: 1.9.31 icon: https://defectdojo.com/hubfs/DefectDojo_favicon.png maintainers: - name: madchap @@ -33,5 +33,5 @@ dependencies: # - kind: security # description: Critical bug annotations: - artifacthub.io/prerelease: "true" - artifacthub.io/changes: "- kind: changed\n description: chore(deps)_ update gcr.io/cloudsql__/gce_proxy _ tag from 1.37.12 to v1.38.0 (_/defect_/values.yaml)\n" + artifacthub.io/prerelease: "false" + artifacthub.io/changes: "- kind: changed\n description: chore(deps)_ update gcr.io/cloudsql__/gce_proxy _ tag from 1.37.12 to v1.38.0 (_/defect_/values.yaml)\n- kind: changed\n description: Bump DefectDojo to 3.0.0\n" diff --git a/helm/defectdojo/README.md b/helm/defectdojo/README.md index 0dcfe389213..924fac6ef7c 100644 --- a/helm/defectdojo/README.md +++ b/helm/defectdojo/README.md @@ -511,7 +511,7 @@ The HELM schema will be generated for you. # General information about chart values -![Version: 1.9.31-dev](https://img.shields.io/badge/Version-1.9.31--dev-informational?style=flat-square) ![AppVersion: 2.60.0-dev](https://img.shields.io/badge/AppVersion-2.60.0--dev-informational?style=flat-square) +![Version: 1.9.31](https://img.shields.io/badge/Version-1.9.31-informational?style=flat-square) ![AppVersion: 3.0.0](https://img.shields.io/badge/AppVersion-3.0.0-informational?style=flat-square) A Helm chart for Kubernetes to install DefectDojo