From 9ccf58e67a0424c9a879eaa3258ed2cc09d647cf Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 1 Dec 2025 11:31:53 -0600 Subject: [PATCH 001/126] Update dependency prettier from 3.7.2 to v3.7.3 (docs/package.json) (#13787) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docs/package-lock.json | 8 ++++---- docs/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index aaf7bf14240..a2934fe4a95 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -19,7 +19,7 @@ "thulite": "2.6.3" }, "devDependencies": { - "prettier": "3.7.2", + "prettier": "3.7.3", "vite": "7.2.4" }, "engines": { @@ -3944,9 +3944,9 @@ "license": "MIT" }, "node_modules/prettier": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.2.tgz", - "integrity": "sha512-n3HV2J6QhItCXndGa3oMWvWFAgN1ibnS7R9mt6iokScBOC0Ul9/iZORmU2IWUMcyAQaMPjTlY3uT34TqocUxMA==", + "version": "3.7.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.3.tgz", + "integrity": "sha512-QgODejq9K3OzoBbuyobZlUhznP5SKwPqp+6Q6xw6o8gnhr4O85L2U915iM2IDcfF2NPXVaM9zlo9tdwipnYwzg==", "dev": true, "license": "MIT", "bin": { diff --git a/docs/package.json b/docs/package.json index 34632cb1913..74286127ef0 100644 --- a/docs/package.json +++ b/docs/package.json @@ -26,7 +26,7 @@ "thulite": "2.6.3" }, "devDependencies": { - "prettier": "3.7.2", + "prettier": "3.7.3", "vite": "7.2.4" }, "engines": { From 61b8b75d6bd79b915b527c4513068ba481fda960 Mon Sep 17 00:00:00 2001 From: DefectDojo release bot Date: Mon, 1 Dec 2025 17:40:48 +0000 Subject: [PATCH 002/126] Update versions in application files --- components/package.json | 2 +- docs/content/en/open_source/upgrading/2.54.md | 7 +++++++ dojo/__init__.py | 2 +- helm/defectdojo/Chart.yaml | 20 ++++--------------- helm/defectdojo/README.md | 2 +- 5 files changed, 14 insertions(+), 19 deletions(-) create mode 100644 docs/content/en/open_source/upgrading/2.54.md diff --git a/components/package.json b/components/package.json index b133070063f..d9500b421b6 100644 --- a/components/package.json +++ b/components/package.json @@ -1,6 +1,6 @@ { "name": "defectdojo", - "version": "2.53.0", + "version": "2.54.0-dev", "license" : "BSD-3-Clause", "private": true, "dependencies": { diff --git a/docs/content/en/open_source/upgrading/2.54.md b/docs/content/en/open_source/upgrading/2.54.md new file mode 100644 index 00000000000..e5a492696ce --- /dev/null +++ b/docs/content/en/open_source/upgrading/2.54.md @@ -0,0 +1,7 @@ +--- +title: 'Upgrading to DefectDojo Version 2.54.x' +toc_hide: true +weight: -20251201 +description: No special instructions. +--- +There are no special instructions for upgrading to 2.54.x. Check the [Release Notes](https://github.com/DefectDojo/django-DefectDojo/releases/tag/2.54.0) for the contents of the release. diff --git a/dojo/__init__.py b/dojo/__init__.py index 13641d30772..7337d10b9c1 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.53.0" +__version__ = "2.54.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 73fb143f401..ca8df8c043f 100644 --- a/helm/defectdojo/Chart.yaml +++ b/helm/defectdojo/Chart.yaml @@ -1,8 +1,8 @@ apiVersion: v2 -appVersion: "2.53.0" +appVersion: "2.54.0-dev" description: A Helm chart for Kubernetes to install DefectDojo name: defectdojo -version: 1.9.0 +version: 1.9.1-dev icon: https://defectdojo.com/hubfs/DefectDojo_favicon.png maintainers: - name: madchap @@ -33,17 +33,5 @@ dependencies: # - kind: security # description: Critical bug annotations: - artifacthub.io/prerelease: "false" - artifacthub.io/changes: | - - kind: added - description: Added HPA and PDB for celery worker and Django - - kind: fixed - description: extraAnnotations spec doesn't affect initializer job - - kind: changed - description: chore(deps)_ update gcr.io/cloudsql_docker/gce_proxy docker tag from 1.37.9 to v1.37.10 (helm/defectdojo/values.yaml) - - kind: changed - description: chore(deps)_ update nginx/nginx_prometheus_exporter docker tag from 1.4.2 to v1.5.1 (helm/defectdojo/values.yaml) - - kind: changed - description: Replace Redis with Valkey - - kind: changed - description: Bump DefectDojo to 2.53.0 + artifacthub.io/prerelease: "true" + artifacthub.io/changes: "" diff --git a/helm/defectdojo/README.md b/helm/defectdojo/README.md index a0acfbdb0ec..214efff7835 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.0](https://img.shields.io/badge/Version-1.9.0-informational?style=flat-square) ![AppVersion: 2.53.0](https://img.shields.io/badge/AppVersion-2.53.0-informational?style=flat-square) +![Version: 1.9.1-dev](https://img.shields.io/badge/Version-1.9.1--dev-informational?style=flat-square) ![AppVersion: 2.54.0-dev](https://img.shields.io/badge/AppVersion-2.54.0--dev-informational?style=flat-square) A Helm chart for Kubernetes to install DefectDojo From d3cefdf15de4355642ca801111b4ccb1c5b5f9ea Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 2 Dec 2025 17:05:03 -0700 Subject: [PATCH 003/126] chore(deps): update dependency vite from 7.2.4 to v7.2.6 (docs/package.json) (#13792) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docs/package-lock.json | 8 ++++---- docs/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index a2934fe4a95..8129f5a5b4a 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -20,7 +20,7 @@ }, "devDependencies": { "prettier": "3.7.3", - "vite": "7.2.4" + "vite": "7.2.6" }, "engines": { "node": ">=20.11.0" @@ -4572,9 +4572,9 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.2.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.4.tgz", - "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==", + "version": "7.2.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.6.tgz", + "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/docs/package.json b/docs/package.json index 74286127ef0..f9fd2d47051 100644 --- a/docs/package.json +++ b/docs/package.json @@ -27,7 +27,7 @@ }, "devDependencies": { "prettier": "3.7.3", - "vite": "7.2.4" + "vite": "7.2.6" }, "engines": { "node": ">=20.11.0" From 7ec2943280041f4c0fc58156920185f94f3ffaf4 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 2 Dec 2025 17:05:37 -0700 Subject: [PATCH 004/126] chore(deps): update softprops/action-gh-release action from v2.4.2 to v2.5.0 (.github/workflows/release-x-manual-helm-chart.yml) (#13793) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/release-x-manual-helm-chart.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-x-manual-helm-chart.yml b/.github/workflows/release-x-manual-helm-chart.yml index 719071c68e0..b1359b6c64b 100644 --- a/.github/workflows/release-x-manual-helm-chart.yml +++ b/.github/workflows/release-x-manual-helm-chart.yml @@ -77,7 +77,7 @@ jobs: echo "chart_version=$(ls build | cut -d '-' -f 2,3 | sed 's|\.tgz||')" >> $GITHUB_ENV - name: Create release ${{ inputs.release_number }} - uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2 + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 with: name: '${{ inputs.release_number }} 🌈' tag_name: ${{ inputs.release_number }} From ef7ca9730105c61f48fe076fb8a57bdd2f827470 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Dec 2025 17:06:26 -0700 Subject: [PATCH 005/126] chore(deps): bump celery from 5.5.3 to 5.6.0 (#13794) Bumps [celery](https://github.com/celery/celery) from 5.5.3 to 5.6.0. - [Release notes](https://github.com/celery/celery/releases) - [Changelog](https://github.com/celery/celery/blob/main/Changelog.rst) - [Commits](https://github.com/celery/celery/compare/v5.5.3...v5.6.0) --- updated-dependencies: - dependency-name: celery dependency-version: 5.6.0 dependency-type: direct:production update-type: version-update:semver-minor ... 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 5308a61ddaf..37549c30e7b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ asteval==1.0.7 bleach==6.3.0 bleach[css] -celery==5.5.3 +celery==5.6.0 defusedxml==0.7.1 django_celery_results==2.6.0 django-auditlog==3.2.1 From e2cf1577e51c42a3c4c283b67438e18469df0387 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Dec 2025 17:07:14 -0700 Subject: [PATCH 006/126] chore(deps): bump django-pghistory from 3.8.3 to 3.9.0 (#13795) Bumps [django-pghistory](https://github.com/AmbitionEng/django-pghistory) from 3.8.3 to 3.9.0. - [Release notes](https://github.com/AmbitionEng/django-pghistory/releases) - [Changelog](https://github.com/AmbitionEng/django-pghistory/blob/main/CHANGELOG.md) - [Commits](https://github.com/AmbitionEng/django-pghistory/compare/3.8.3...3.9.0) --- updated-dependencies: - dependency-name: django-pghistory dependency-version: 3.9.0 dependency-type: direct:production update-type: version-update:semver-minor ... 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 37549c30e7b..8b7284057d4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ celery==5.6.0 defusedxml==0.7.1 django_celery_results==2.6.0 django-auditlog==3.2.1 -django-pghistory==3.8.3 +django-pghistory==3.9.0 django-dbbackup==5.0.1 django-environ==0.12.0 django-filter==25.1 From 0adb90432fd1313accb96c3b8b6c6104d1fcbdbe Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Dec 2025 17:07:39 -0700 Subject: [PATCH 007/126] chore(deps): bump drf-spectacular-sidecar from 2025.10.1 to 2025.12.1 (#13797) Bumps [drf-spectacular-sidecar](https://github.com/tfranzel/drf-spectacular-sidecar) from 2025.10.1 to 2025.12.1. - [Commits](https://github.com/tfranzel/drf-spectacular-sidecar/compare/2025.10.1...2025.12.1) --- updated-dependencies: - dependency-name: drf-spectacular-sidecar dependency-version: 2025.12.1 dependency-type: direct:production update-type: version-update:semver-minor ... 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 8b7284057d4..ce757f22133 100644 --- a/requirements.txt +++ b/requirements.txt @@ -57,7 +57,7 @@ django-fieldsignals==0.7.0 hyperlink==21.0.0 djangosaml2==1.11.1 drf-spectacular==0.29.0 -drf-spectacular-sidecar==2025.10.1 +drf-spectacular-sidecar==2025.12.1 django-ratelimit==4.1.0 argon2-cffi==25.1.0 blackduck==1.1.3 From b9ec210f472e6f7785ec8a1adf763c3f50558e3b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Dec 2025 17:08:01 -0700 Subject: [PATCH 008/126] chore(deps): bump psycopg[c] from 3.2.13 to 3.3.0 (#13798) Bumps [psycopg[c]](https://github.com/psycopg/psycopg) from 3.2.13 to 3.3.0. - [Changelog](https://github.com/psycopg/psycopg/blob/master/docs/news_pool.rst) - [Commits](https://github.com/psycopg/psycopg/compare/3.2.13...3.3.0) --- updated-dependencies: - dependency-name: psycopg[c] dependency-version: 3.3.0 dependency-type: direct:production update-type: version-update:semver-minor ... 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 ce757f22133..8141740a9a6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,7 +29,7 @@ lxml==6.0.2 Markdown==3.10 openpyxl==3.1.5 Pillow==12.0.0 # required by django-imagekit -psycopg[c]==3.2.13 +psycopg[c]==3.3.0 cryptography==46.0.3 python-dateutil==2.9.0.post0 redis==7.1.0 From c30909bf50feca6c41efde38ed5aebc70efed36a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 3 Dec 2025 14:34:13 -0600 Subject: [PATCH 009/126] chore(deps): update actions/checkout action from v6.0.0 to v6.0.1 (.github/workflows/validate_docs_build.yml) (#13806) 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/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 +- 18 files changed, 26 insertions(+), 26 deletions(-) diff --git a/.github/workflows/build-docker-images-for-testing.yml b/.github/workflows/build-docker-images-for-testing.yml index 052fb5896a7..f9193592623 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@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false diff --git a/.github/workflows/fetch-oas.yml b/.github/workflows/fetch-oas.yml index b74f88b4429..816d338fd5a 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@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: release/${{ env.release_version }} diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index 2c383433fd7..2c88fb3068d 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@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: submodules: recursive fetch-depth: 0 diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index d1f1bbab941..f1b05e49f0b 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -54,7 +54,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 # 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 a96dbfa7bee..3419406f9f0 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@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Minikube uses: manusa/actions-setup-minikube@b589f2d61bf96695c546929c72b38563e856059d # v2.14.0 diff --git a/.github/workflows/release-1-create-pr.yml b/.github/workflows/release-1-create-pr.yml index 2c0cd53c786..9dca5715225 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@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ inputs.from_branch }} @@ -58,7 +58,7 @@ jobs: run: git push origin HEAD:${NEW_BRANCH} - name: Checkout release branch - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 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 dd3369eadd1..112e8ccecbd 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@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: master diff --git a/.github/workflows/release-3-master-into-dev.yml b/.github/workflows/release-3-master-into-dev.yml index 708b9f31c44..14f3e532706 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@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: master @@ -40,7 +40,7 @@ jobs: run: git push origin HEAD:${NEW_BRANCH} - name: Checkout new branch - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ env.NEW_BRANCH }} @@ -115,7 +115,7 @@ jobs: run: echo "GITHUB_ORG=${GITHUB_REPOSITORY%%/*}" >> $GITHUB_ENV - name: Checkout master - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: master @@ -132,7 +132,7 @@ jobs: run: git push origin HEAD:${NEW_BRANCH} - name: Checkout new branch - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 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 b376923d5b4..a52ae55b3b4 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@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 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 b1359b6c64b..0ee1d731bbf 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@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 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 9ce48ef4254..d10f1d87050 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@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ inputs.branch-to-build }} diff --git a/.github/workflows/renovate.yaml b/.github/workflows/renovate.yaml index fdb4ae1b5fd..50ae1f41bd9 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@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false diff --git a/.github/workflows/rest-framework-tests.yml b/.github/workflows/rest-framework-tests.yml index 591f9cabf27..a3bc7914768 100644 --- a/.github/workflows/rest-framework-tests.yml +++ b/.github/workflows/rest-framework-tests.yml @@ -30,7 +30,7 @@ jobs: echo $GITHUB_ENV - name: Checkout - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index 58a56dc5aa0..3eb100c8b58 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@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Install Ruff Linter run: pip install -r requirements-lint.txt diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml index cb53b4b76ad..e42f9a46e2d 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@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - 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 e448160859f..9406fd10905 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@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false fetch-depth: 0 @@ -106,7 +106,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: repository: ${{ github.event.pull_request.head.repo.full_name }} ref: ${{ github.event.pull_request.head.ref }} @@ -147,7 +147,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Generate values schema json uses: losisin/helm-values-schema-json-action@660c441a4a507436a294fc55227e1df54aca5407 # v2.3.1 @@ -167,7 +167,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false fetch-depth: 0 @@ -189,7 +189,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - 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 e208e57a46a..f528fc828e2 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@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.ref_name || 'dev'}} diff --git a/.github/workflows/validate_docs_build.yml b/.github/workflows/validate_docs_build.yml index f83d6d189b8..d6242bb929d 100644 --- a/.github/workflows/validate_docs_build.yml +++ b/.github/workflows/validate_docs_build.yml @@ -29,7 +29,7 @@ jobs: ${{ runner.os }}-node- - name: Checkout - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: submodules: recursive fetch-depth: 0 From 32e4e0fd4ee163b6546f8a92ea4b6b121c8bcc7a Mon Sep 17 00:00:00 2001 From: kiblik <5609770+kiblik@users.noreply.github.com> Date: Fri, 5 Dec 2025 18:37:43 +0100 Subject: [PATCH 010/126] fix(parsers): DeprecationWarning: Testing an element's truth ... Signed-off-by: kiblik <5609770+kiblik@users.noreply.github.com> --- dojo/tools/dependency_check/parser.py | 26 +++++++++++++------------- dojo/tools/fortify/xml_parser.py | 6 +++--- dojo/tools/nmap/parser.py | 3 ++- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/dojo/tools/dependency_check/parser.py b/dojo/tools/dependency_check/parser.py index ec14ac8f196..d6f524325d3 100644 --- a/dojo/tools/dependency_check/parser.py +++ b/dojo/tools/dependency_check/parser.py @@ -87,7 +87,7 @@ def add_finding(self, finding, dupes): def get_filename_and_path_from_dependency( self, dependency, related_dependency, namespace, ): - if not related_dependency: + if related_dependency is None: return dependency.findtext( f"{namespace}fileName", ), dependency.findtext(f"{namespace}filePath") @@ -105,10 +105,10 @@ def get_component_name_and_version_from_dependency( self, dependency, related_dependency, namespace, ): identifiers_node = dependency.find(namespace + "identifiers") - if identifiers_node: + if identifiers_node is not None: # analyzing identifier from the more generic to package_node = identifiers_node.find(".//" + namespace + "package") - if package_node: + if package_node is not None: pck_id = package_node.findtext(f"{namespace}id") purl = PackageURL.from_string(pck_id) purl_parts = purl.to_dict() @@ -166,7 +166,7 @@ def get_component_name_and_version_from_dependency( maven_node = identifiers_node.find( ".//" + namespace + 'identifier[@type="maven"]', ) - if maven_node: + if maven_node is not None: maven_parts = maven_node.findtext(f"{namespace}name").split( ":", ) @@ -181,7 +181,7 @@ def get_component_name_and_version_from_dependency( evidence_collected_node = dependency.find( namespace + "evidenceCollected", ) - if evidence_collected_node: + if evidence_collected_node is not None: # # # file @@ -199,12 +199,12 @@ def get_component_name_and_version_from_dependency( product_node = evidence_collected_node.find( ".//" + namespace + 'evidence[@type="product"]', ) - if product_node: + if product_node is not None: component_name = product_node.findtext(f"{namespace}value") version_node = evidence_collected_node.find( ".//" + namespace + 'evidence[@type="version"]', ) - if version_node: + if version_node is not None: component_version = version_node.findtext( f"{namespace}value", ) @@ -280,7 +280,7 @@ def get_finding_from_vulnerability( mitigated = None is_Mitigated = False name = vulnerability.findtext(f"{namespace}name") - if vulnerability.find(f"{namespace}cwes"): + if vulnerability.find(f"{namespace}cwes") is not None: cwe_field = vulnerability.find(f"{namespace}cwes").findtext( f"{namespace}cwe", ) @@ -425,14 +425,14 @@ def get_findings(self, filename, test): dependencies = scan.find(namespace + "dependencies") scan_date = None - if scan.find(f"{namespace}projectInfo"): + if scan.find(f"{namespace}projectInfo") is not None: projectInfo_node = scan.find(f"{namespace}projectInfo") if projectInfo_node.findtext(f"{namespace}reportDate"): scan_date = dateutil.parser.parse( projectInfo_node.findtext(f"{namespace}reportDate"), ) - if dependencies: + if dependencies is not None: for dependency in dependencies.findall(namespace + "dependency"): vulnerabilities = dependency.find( namespace + "vulnerabilities", @@ -441,7 +441,7 @@ def get_findings(self, filename, test): for vulnerability in vulnerabilities.findall( namespace + "vulnerability", ): - if vulnerability: + if vulnerability is not None: finding = self.get_finding_from_vulnerability( dependency, None, @@ -456,7 +456,7 @@ def get_findings(self, filename, test): relatedDependencies = dependency.find( namespace + "relatedDependencies", ) - if relatedDependencies: + if relatedDependencies is not None: for ( relatedDependency ) in relatedDependencies.findall( @@ -479,7 +479,7 @@ def get_findings(self, filename, test): for suppressedVulnerability in vulnerabilities.findall( namespace + "suppressedVulnerability", ): - if suppressedVulnerability: + if suppressedVulnerability is not None: finding = self.get_finding_from_vulnerability( dependency, None, diff --git a/dojo/tools/fortify/xml_parser.py b/dojo/tools/fortify/xml_parser.py index ce86719e7c1..f04b55db3c4 100644 --- a/dojo/tools/fortify/xml_parser.py +++ b/dojo/tools/fortify/xml_parser.py @@ -86,7 +86,7 @@ def xml_structure_before_24_2(self, root, test): for group in ReportSection.iter("GroupingSection"): title = group.findtext("groupTitle") maj_attr_summary = group.find("MajorAttributeSummary") - if maj_attr_summary: + if maj_attr_summary is not None: meta_info = maj_attr_summary.findall("MetaInfo") meta_pair[place][title] = { x.findtext("Name"): x.findtext("Value") @@ -115,11 +115,11 @@ def xml_structure_before_24_2(self, root, test): "FilePath": issue.find("Primary").find("FilePath").text, "LineStart": issue.find("Primary").find("LineStart").text, } - if issue.find("Primary").find("Snippet"): + if issue.find("Primary").find("Snippet") is not None: details["Snippet"] = issue.find("Primary").find("Snippet").text else: details["Snippet"] = "n/a" - if issue.find("Source"): + if issue.find("Source") is not None: source = { "FileName": issue.find("Source").find("FileName").text, "FilePath": issue.find("Source").find("FilePath").text, diff --git a/dojo/tools/nmap/parser.py b/dojo/tools/nmap/parser.py index a402d421e65..0f8af861df0 100644 --- a/dojo/tools/nmap/parser.py +++ b/dojo/tools/nmap/parser.py @@ -96,7 +96,8 @@ def get_findings(self, file, test): ) description += service_info script_id = None - if script := port_element.find("script"): + script = port_element.find("script") + if script is not None: if script_id := script.attrib.get("id"): description += f"**Script ID:** {script_id}\n" if script_output := script.attrib.get("output"): From 1d4df38730428ac0b18d6ff8805980d8676d4150 Mon Sep 17 00:00:00 2001 From: kiblik <5609770+kiblik@users.noreply.github.com> Date: Fri, 5 Dec 2025 19:21:27 +0100 Subject: [PATCH 011/126] fix(node_modules): Avoid staticfiles.W004 --- components/node_modules/.gitkeep | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 components/node_modules/.gitkeep diff --git a/components/node_modules/.gitkeep b/components/node_modules/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d From 01afaf5e7073781a757ffc0c1a97a0f7d6f2e74d Mon Sep 17 00:00:00 2001 From: kiblik <5609770+kiblik@users.noreply.github.com> Date: Fri, 5 Dec 2025 19:39:13 +0100 Subject: [PATCH 012/126] fix(unittest): avoid ResourceWarning: unclosed file Signed-off-by: kiblik <5609770+kiblik@users.noreply.github.com> --- unittests/tools/test_snyk_issue_api_parser_with_json.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/unittests/tools/test_snyk_issue_api_parser_with_json.py b/unittests/tools/test_snyk_issue_api_parser_with_json.py index ae61dc0cf12..16473bb0e55 100644 --- a/unittests/tools/test_snyk_issue_api_parser_with_json.py +++ b/unittests/tools/test_snyk_issue_api_parser_with_json.py @@ -7,9 +7,9 @@ class TestSnykIssueApiParserWithJson(DojoTestCase): def parse_json(self, filename): - testfile = (get_unit_tests_scans_path("snyk_issue_api") / filename).open(encoding="utf-8") - parser = SnykIssueApiParser() - return parser.get_findings(testfile, Test()) + with (get_unit_tests_scans_path("snyk_issue_api") / filename).open(encoding="utf-8") as testfile: + parser = SnykIssueApiParser() + return parser.get_findings(testfile, Test()) def test_parse_sca_single_finding(self): findings = self.parse_json("snyk_sca_scan_api_single_vuln.json") From 38950fefabd43e105410a118e54c117a5d96d86d Mon Sep 17 00:00:00 2001 From: manuelsommer <47991713+manuel-sommer@users.noreply.github.com> Date: Fri, 5 Dec 2025 22:33:35 +0100 Subject: [PATCH 013/126] :arrow_up: Bump ruff from 0.14.6 to 0.14.8 (#13799) * :arrow_up: Bump ruff from 0.14.6 to 0.14.7 * ruff fixes * Update dojo/importers/base_importer.py Co-authored-by: kiblik <5609770+kiblik@users.noreply.github.com> * Fix indentation in base_importer.py * Update labels.py * bump --------- Co-authored-by: kiblik <5609770+kiblik@users.noreply.github.com> --- dojo/importers/base_importer.py | 4 ++-- dojo/labels.py | 6 +++--- dojo/tools/scout_suite/parser.py | 6 +++--- requirements-lint.txt | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/dojo/importers/base_importer.py b/dojo/importers/base_importer.py index 212c976dc33..096161db3fa 100644 --- a/dojo/importers/base_importer.py +++ b/dojo/importers/base_importer.py @@ -390,8 +390,8 @@ def update_import_history( # In longer running imports it can happen that the async_dupe_delete task removes a finding before the history record is created # We filter out these findings here to avoid FK violations (IntegrityError) all_findings = [] - for _list, _ in finding_action_mappings: - all_findings.extend(_list) + for list_, _ in finding_action_mappings: + all_findings.extend(list_) existing_findings = finding_helper.filter_findings_by_existence(all_findings) if all_findings else [] existing_ids = {f.id for f in existing_findings} diff --git a/dojo/labels.py b/dojo/labels.py index ce5ea520c5d..504c8e55fe1 100644 --- a/dojo/labels.py +++ b/dojo/labels.py @@ -63,11 +63,11 @@ def __init__(self, label_set: dict[str, str]): As a side benefit, this will explode if any label defined on this class is not present in the given dict: a runtime check that a labels dict must be complete. """ - for _l, _v in self._get_label_entries().items(): + for l_, v_ in self._get_label_entries().items(): try: - setattr(self, _l, label_set[_v]) + setattr(self, l_, label_set[v_]) except KeyError: - error_message = f"Supplied copy dictionary does not provide entry for {_l}" + error_message = f"Supplied copy dictionary does not provide entry for {l_}" logger.error(error_message) raise ValueError(error_message) diff --git a/dojo/tools/scout_suite/parser.py b/dojo/tools/scout_suite/parser.py index 36a63888ea9..f3671466f34 100644 --- a/dojo/tools/scout_suite/parser.py +++ b/dojo/tools/scout_suite/parser.py @@ -157,10 +157,10 @@ def recursive_print(self, src, depth=0, key=""): def tabs(n): return " " * n * 2 if isinstance(src, dict): - for _key, value in src.items(): + for dictkey, value in src.items(): if isinstance(src, str): - self.item_data = self.item_data + _key + "\n" - self.recursive_print(value, depth + 1, _key) + self.item_data = self.item_data + dictkey + "\n" + self.recursive_print(value, depth + 1, dictkey) elif isinstance(src, list): for litem in src: self.recursive_print(litem, depth + 2) diff --git a/requirements-lint.txt b/requirements-lint.txt index c7e2cafe88d..c159d0ea01b 100644 --- a/requirements-lint.txt +++ b/requirements-lint.txt @@ -1 +1 @@ -ruff==0.14.6 \ No newline at end of file +ruff==0.14.8 \ No newline at end of file From edbc453fb407bcacc7db02f6f92329e4df1d2d18 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 5 Dec 2025 14:35:36 -0700 Subject: [PATCH 014/126] chore(deps): update actions/stale action from v10.1.0 to v10.1.1 (.github/workflows/close-stale.yml) (#13807) 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 857f619c78b..9651b0c341c 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@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0 + uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1 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@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0 + uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1 with: # Disable automatic stale marking - only close manually labeled items days-before-stale: -1 From 67e40d152923ed3350210a46e4a028fa10ed4cc8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 5 Dec 2025 14:36:01 -0700 Subject: [PATCH 015/126] chore(deps): update dependency prettier from 3.7.3 to v3.7.4 (docs/package.json) (#13808) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docs/package-lock.json | 8 ++++---- docs/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index 8129f5a5b4a..3267cf7e2dd 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -19,7 +19,7 @@ "thulite": "2.6.3" }, "devDependencies": { - "prettier": "3.7.3", + "prettier": "3.7.4", "vite": "7.2.6" }, "engines": { @@ -3944,9 +3944,9 @@ "license": "MIT" }, "node_modules/prettier": { - "version": "3.7.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.3.tgz", - "integrity": "sha512-QgODejq9K3OzoBbuyobZlUhznP5SKwPqp+6Q6xw6o8gnhr4O85L2U915iM2IDcfF2NPXVaM9zlo9tdwipnYwzg==", + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", + "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "dev": true, "license": "MIT", "bin": { diff --git a/docs/package.json b/docs/package.json index f9fd2d47051..846d85daf9b 100644 --- a/docs/package.json +++ b/docs/package.json @@ -26,7 +26,7 @@ "thulite": "2.6.3" }, "devDependencies": { - "prettier": "3.7.3", + "prettier": "3.7.4", "vite": "7.2.6" }, "engines": { From d1eed3c3ca374f92f1dc4f3418e33c22edeb033c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 5 Dec 2025 14:40:58 -0700 Subject: [PATCH 016/126] chore(deps): update actions/setup-node action from v6.0.0 to v6.1.0 (.github/workflows/validate_docs_build.yml) (#13810) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .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 2c88fb3068d..e9a043237cf 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -22,7 +22,7 @@ jobs: extended: true - name: Setup Node - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 with: node-version: '24.11.1' # TODO: Renovate helper might not be needed here - needs to be fully tested diff --git a/.github/workflows/validate_docs_build.yml b/.github/workflows/validate_docs_build.yml index d6242bb929d..28e3bc8faa3 100644 --- a/.github/workflows/validate_docs_build.yml +++ b/.github/workflows/validate_docs_build.yml @@ -16,7 +16,7 @@ jobs: extended: true - name: Setup Node - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 with: node-version: '24.11.1' # TODO: Renovate helper might not be needed here - needs to be fully tested From 5455c4a860c0e68605f88659967f0f1ae351d696 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 5 Dec 2025 14:41:19 -0700 Subject: [PATCH 017/126] chore(deps): bump psycopg[c] from 3.3.0 to 3.3.1 (#13812) Bumps [psycopg[c]](https://github.com/psycopg/psycopg) from 3.3.0 to 3.3.1. - [Changelog](https://github.com/psycopg/psycopg/blob/master/docs/news.rst) - [Commits](https://github.com/psycopg/psycopg/compare/3.3.0...3.3.1) --- updated-dependencies: - dependency-name: psycopg[c] dependency-version: 3.3.1 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 8141740a9a6..cf54544c6b5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,7 +29,7 @@ lxml==6.0.2 Markdown==3.10 openpyxl==3.1.5 Pillow==12.0.0 # required by django-imagekit -psycopg[c]==3.3.0 +psycopg[c]==3.3.1 cryptography==46.0.3 python-dateutil==2.9.0.post0 redis==7.1.0 From e1af6ecebf6ed86e38cca8a4acd0d798fd512412 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 5 Dec 2025 14:43:17 -0700 Subject: [PATCH 018/126] chore(deps): update dependency vcrpy from 7.0.0 to v8 (requirements-dev.txt) (#13815) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 4e8b5cd1fd5..785888d0234 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -6,7 +6,7 @@ django-debug-toolbar==6.1.0 django-debug-toolbar-request-history==0.1.4 # Testing dependencies -vcrpy==7.0.0 +vcrpy==8.0.0 vcrpy-unittest==0.1.7 django-test-migrations==1.5.0 parameterized==0.9.0 From ed9a56a810b1310cf800f3f4ffe003606d1b144e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 5 Dec 2025 14:44:10 -0700 Subject: [PATCH 019/126] chore(deps): update postgres:18.1-alpine docker digest from 18.1 to 18.1-alpine (docker-compose.yml) (#13820) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index ada66ba1a57..3a5296252fe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -120,7 +120,7 @@ services: source: ./docker/extra_settings target: /app/docker/extra_settings postgres: - image: postgres:18.1-alpine@sha256:154ea39af68ff30dec041cd1f1b5600009993724c811dbadde54126eb10bedd1 + image: postgres:18.1-alpine@sha256:eca6fb2d91fda290eb8cfb8ba53dd0dcbf3508a08011e30adb039ea7c8e1e9f2 environment: POSTGRES_DB: ${DD_DATABASE_NAME:-defectdojo} POSTGRES_USER: ${DD_DATABASE_USER:-defectdojo} From 332397360f127be05ad9e5277097adb7ad7ce76d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 6 Dec 2025 13:35:36 -0600 Subject: [PATCH 020/126] chore(deps): update peter-evans/create-pull-request action from v7.0.9 to v7.0.11 (.github/workflows/update-sample-data.yml) (#13827) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/update-sample-data.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/update-sample-data.yml b/.github/workflows/update-sample-data.yml index f528fc828e2..b84d7eed3d4 100644 --- a/.github/workflows/update-sample-data.yml +++ b/.github/workflows/update-sample-data.yml @@ -43,7 +43,7 @@ jobs: git push --set-upstream origin $(git rev-parse --abbrev-ref HEAD) - name: Create Pull Request - uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9 + uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11 with: token: ${{ secrets.GITHUB_TOKEN }} commit-message: "Update sample data" From 3a5f124153c8c69f04f0540110d562aa62410e33 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 6 Dec 2025 13:37:09 -0600 Subject: [PATCH 021/126] chore(deps): update valkey/valkey:7.2.11-alpine docker digest from 7.2.11 to v (docker-compose.yml) (#13821) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 3a5296252fe..5825cd3875e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -128,7 +128,7 @@ services: volumes: - defectdojo_postgres:/var/lib/postgresql/data valkey: - image: valkey/valkey:7.2.11-alpine@sha256:7b2019b47ad58be661fa6eba5ea66106eadde03459387113aaed29a464a5876b + image: valkey/valkey:7.2.11-alpine@sha256:36745d7c91d75dde02fa239ebe333fa1c7637249ed76f7da1c5ea838375974ff volumes: # we keep using the redis volume as renaming is not possible and copying data over # would require steps during downtime or complex commands in the intializer From 3a4a6fdd97bb630125708a9fd0f8fe3b74bf8eb7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 6 Dec 2025 13:39:01 -0600 Subject: [PATCH 022/126] chore(deps): bump django-polymorphic from 4.1.0 to 4.2.0 (#13824) Bumps [django-polymorphic](https://github.com/jazzband/django-polymorphic) from 4.1.0 to 4.2.0. - [Release notes](https://github.com/jazzband/django-polymorphic/releases) - [Changelog](https://github.com/jazzband/django-polymorphic/blob/master/docs/changelog.rst) - [Commits](https://github.com/jazzband/django-polymorphic/compare/v4.1.0...v4.2.0) --- updated-dependencies: - dependency-name: django-polymorphic dependency-version: 4.2.0 dependency-type: direct:production update-type: version-update:semver-minor ... 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 cf54544c6b5..10734ec8f7d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ django-environ==0.12.0 django-filter==25.1 django-imagekit==6.0.0 django-multiselectfield==1.0.1 -django-polymorphic==4.1.0 +django-polymorphic==4.2.0 django-crispy-forms==2.5 django_extensions==4.1 django-slack==5.19.0 From b8f5e53a60e81679718e210f8fba07571e77c23c Mon Sep 17 00:00:00 2001 From: manuelsommer <47991713+manuel-sommer@users.noreply.github.com> Date: Mon, 8 Dec 2025 17:10:03 +0100 Subject: [PATCH 023/126] :bug: Remove unselected parsers from filters and test types (#13767) * squashed commits * remove unittest * update * update * add unittest * update --- docs/content/en/open_source/upgrading/2.54.md | 10 ++++--- dojo/filters.py | 5 +++- dojo/finding/views.py | 2 ++ dojo/settings/settings.dist.py | 4 --- dojo/tools/factory.py | 14 +++++----- dojo/utils.py | 6 +++++ unittests/test_test_type_active_toggle.py | 26 +++++++++++++++++++ 7 files changed, 51 insertions(+), 16 deletions(-) create mode 100644 unittests/test_test_type_active_toggle.py diff --git a/docs/content/en/open_source/upgrading/2.54.md b/docs/content/en/open_source/upgrading/2.54.md index e5a492696ce..3286e7ee149 100644 --- a/docs/content/en/open_source/upgrading/2.54.md +++ b/docs/content/en/open_source/upgrading/2.54.md @@ -1,7 +1,11 @@ --- title: 'Upgrading to DefectDojo Version 2.54.x' toc_hide: true -weight: -20251201 -description: No special instructions. +weight: -20250804 +description: Dropped support for DD_PARSER_EXCLUDE --- -There are no special instructions for upgrading to 2.54.x. Check the [Release Notes](https://github.com/DefectDojo/django-DefectDojo/releases/tag/2.54.0) for the contents of the release. + +To simplify the management of the DefectDojo application, parser exclusions are no longer controlled via the environment variable DD_PARSER_EXCLUDE or application settings. This variable is now unsupported. +From now on, you should use the active flag in the Test_Type model to enable or disable parsers. Only parsers associated with active Test_Type entries will be available for use. + +There are other instructions for upgrading to 2.54.x. Check the Release Notes for the contents of the release. diff --git a/dojo/filters.py b/dojo/filters.py index 449b755ef1e..418c83199d1 100644 --- a/dojo/filters.py +++ b/dojo/filters.py @@ -93,7 +93,7 @@ from dojo.risk_acceptance.queries import get_authorized_risk_acceptances from dojo.test.queries import get_authorized_tests from dojo.user.queries import get_authorized_users -from dojo.utils import get_system_setting, is_finding_groups_enabled, truncate_timezone_aware +from dojo.utils import get_system_setting, get_visible_scan_types, is_finding_groups_enabled, truncate_timezone_aware logger = logging.getLogger(__name__) @@ -2030,6 +2030,9 @@ def __init__(self, *args, **kwargs): # Don't show the product filter on the product finding view self.set_related_object_fields(*args, **kwargs) + if "test__test_type" in self.form.fields: + self.form.fields["test__test_type"].queryset = get_visible_scan_types() + def set_related_object_fields(self, *args: list, **kwargs: dict): finding_group_query = Finding_Group.objects.all() if self.pid is not None: diff --git a/dojo/finding/views.py b/dojo/finding/views.py index e48554e613d..040b0212ecf 100644 --- a/dojo/finding/views.py +++ b/dojo/finding/views.py @@ -118,6 +118,7 @@ get_page_items_and_count, get_return_url, get_system_setting, + get_visible_scan_types, get_words_for_field, match_finding_to_existing_findings, process_tag_notifications, @@ -302,6 +303,7 @@ def get_initial_context(self, request: HttpRequest): "enable_table_filtering": get_system_setting("enable_ui_table_based_searching"), "title_words": get_words_for_field(Finding, "title"), "component_words": get_words_for_field(Finding, "component_name"), + "visible_test_types": get_visible_scan_types(), } # Look to see if the product was used if product_id := self.get_product_id(): diff --git a/dojo/settings/settings.dist.py b/dojo/settings/settings.dist.py index f13696c586b..2ef1835b08e 100644 --- a/dojo/settings/settings.dist.py +++ b/dojo/settings/settings.dist.py @@ -275,7 +275,6 @@ # regular expression to exclude one or more parsers # could be usefull to limit parser allowed # AWS Scout2 Scan Parser is deprecated (see https://github.com/DefectDojo/django-DefectDojo/pull/5268) - DD_PARSER_EXCLUDE=(str, ""), # when enabled in sytem settings, every minute a job run to delete excess duplicates # we limit the amount of duplicates that can be deleted in a single run of that job # to prevent overlapping runs of that job from occurrring @@ -1853,9 +1852,6 @@ def saml2_attrib_map_format(din): # If using this, lines for Qualys WAS deduplication functions must be un-commented QUALYS_WAS_UNIQUE_ID = False -# exclusion list for parsers -PARSER_EXCLUDE = env("DD_PARSER_EXCLUDE") - SERIALIZATION_MODULES = { "xml": "tagulous.serializers.xml_serializer", "json": "tagulous.serializers.json", diff --git a/dojo/tools/factory.py b/dojo/tools/factory.py index a536607f640..dfdd887d065 100644 --- a/dojo/tools/factory.py +++ b/dojo/tools/factory.py @@ -6,8 +6,6 @@ from inspect import isclass from pathlib import Path -from django.conf import settings - from dojo.models import Test_Type, Tool_Configuration, Tool_Type PARSERS = {} @@ -37,12 +35,12 @@ def get_parser(scan_type): if scan_type not in PARSERS: msg = f"Parser '{scan_type}' does not exist" raise ValueError(msg) - rg = re.compile(settings.PARSER_EXCLUDE) - if not rg.match(scan_type) or not settings.PARSER_EXCLUDE.strip(): - # update DB dynamically - test_type, _ = Test_Type.objects.get_or_create(name=scan_type) - if test_type.active: - return PARSERS[scan_type] + + # update DB dynamically + test_type, _ = Test_Type.objects.get_or_create(name=scan_type) + if test_type.active: + return PARSERS[scan_type] + msg = f"Parser {scan_type} is not active" raise ValueError(msg) diff --git a/dojo/utils.py b/dojo/utils.py index a00ba7b48f1..f6da8a16513 100644 --- a/dojo/utils.py +++ b/dojo/utils.py @@ -68,6 +68,7 @@ Product, System_Settings, Test, + Test_Type, User, ) from dojo.notifications.helper import create_notification @@ -83,6 +84,11 @@ """ +def get_visible_scan_types(): + """Returns a QuerySet of active Test_Type objects.""" + return Test_Type.objects.filter(active=True) + + def do_false_positive_history(finding, *args, **kwargs): """ Replicate false positives across product. diff --git a/unittests/test_test_type_active_toggle.py b/unittests/test_test_type_active_toggle.py new file mode 100644 index 00000000000..1d0e8a55644 --- /dev/null +++ b/unittests/test_test_type_active_toggle.py @@ -0,0 +1,26 @@ + +from django.test import TestCase + +from dojo.filters import FindingFilter +from dojo.models import Test_Type +from dojo.utils import get_visible_scan_types + + +class TestFindingFilterActiveInactiveTestTypes(TestCase): + def setUp(self): + self.active_type = Test_Type.objects.create(name="Nessus Scan", active=True) + self.inactive_type = Test_Type.objects.create(name="Burp Scan", active=False) + + def test_only_active_types_in_filter(self): + filter_instance = FindingFilter(data={}) + self.assertIn("test__test_type", filter_instance.form.fields) + queryset = filter_instance.form.fields["test__test_type"].queryset + actual_names = set(queryset.values_list("name", flat=True)) + self.assertIn(self.active_type.name, actual_names) + self.assertNotIn(self.inactive_type.name, actual_names) + + def test_helper_function_returns_only_active(self): + visible = get_visible_scan_types() + names = set(visible.values_list("name", flat=True)) + self.assertIn(self.active_type.name, names) + self.assertNotIn(self.inactive_type.name, names) From f01d0c2f9bc0c7cde8d4e74bfafc2f86c7f371ae Mon Sep 17 00:00:00 2001 From: Vincent Ngobeh <124282366+Vincent-Ngobeh@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:10:28 +0000 Subject: [PATCH 024/126] perf: Use lazy loading for Product_Tab to improve edit finding performance (#13805) Replace eager query execution in Product_Tab.__init__ with @cached_property decorators. This defers expensive database queries until they are actually accessed, improving page load performance. Fixes #10313 --- dojo/utils.py | 77 +++++++++++++++++++++++++++++++++------------------ 1 file changed, 50 insertions(+), 27 deletions(-) diff --git a/dojo/utils.py b/dojo/utils.py index f6da8a16513..aae428a9703 100644 --- a/dojo/utils.py +++ b/dojo/utils.py @@ -10,6 +10,7 @@ from calendar import monthrange from collections.abc import Callable from datetime import date, datetime, timedelta +from functools import cached_property from math import pi, sqrt from pathlib import Path @@ -1303,52 +1304,77 @@ def get_celery_worker_status(): # Used to display the counts and enabled tabs in the product view +# Uses @cached_property for lazy loading to avoid expensive queries on every page load +# See: https://github.com/DefectDojo/django-DefectDojo/issues/10313 class Product_Tab: def __init__(self, product, title=None, tab=None): - self.product = product - self.title = title - self.tab = tab - self.engagement_count = Engagement.objects.filter( - product=self.product, active=True).count() - self.open_findings_count = Finding.objects.filter(test__engagement__product=self.product, - false_p=False, - duplicate=False, - out_of_scope=False, - active=True, - mitigated__isnull=True).count() - active_endpoints = Endpoint.objects.filter( - product=self.product, + self._product = product + self._title = title + self._tab = tab + self._engagement = None + + @cached_property + def engagement_count(self): + return Engagement.objects.filter( + product=self._product, active=True).count() + + @cached_property + def open_findings_count(self): + return Finding.objects.filter( + test__engagement__product=self._product, + false_p=False, + duplicate=False, + out_of_scope=False, + active=True, + mitigated__isnull=True).count() + + @cached_property + def _active_endpoints(self): + return Endpoint.objects.filter( + product=self._product, status_endpoint__mitigated=False, status_endpoint__false_positive=False, status_endpoint__out_of_scope=False, status_endpoint__risk_accepted=False, ) - self.endpoints_count = active_endpoints.distinct().count() - self.endpoint_hosts_count = active_endpoints.values("host").distinct().count() - self.benchmark_type = Benchmark_Type.objects.filter( + + @cached_property + def endpoints_count(self): + return self._active_endpoints.distinct().count() + + @cached_property + def endpoint_hosts_count(self): + return self._active_endpoints.values("host").distinct().count() + + @cached_property + def benchmark_type(self): + return Benchmark_Type.objects.filter( enabled=True).order_by("name") - self.engagement = None def setTab(self, tab): - self.tab = tab + self._tab = tab def setEngagement(self, engagement): - self.engagement = engagement + self._engagement = engagement + @property def engagement(self): - return self.engagement + return self._engagement + @property def tab(self): - return self.tab + return self._tab def setTitle(self, title): - self.title = title + self._title = title + @property def title(self): - return self.title + return self._title + @property def product(self): - return self.product + return self._product def engagements(self): return self.engagement_count @@ -1362,9 +1388,6 @@ def endpoints(self): def endpoint_hosts(self): return self.endpoint_hosts_count - def benchmark_type(self): - return self.benchmark_type - # Used to display the counts and enabled tabs in the product view def tab_view_count(product_id): From 6618b2b650abc2f3a1f7e4d29c6eaca9299073fb Mon Sep 17 00:00:00 2001 From: Tracy Walker Date: Mon, 8 Dec 2025 11:00:51 -0600 Subject: [PATCH 025/126] docs: Add Pro vs OSS comparison for cross-product risk acceptances (#13703) * docs: Add Pro vs OSS comparison for cross-product risk acceptances * Update risk_acceptances.md - correct scope b/w Pro and OSS Corrected risk acceptance scope at engagement level for OSS. * Update docs/content/en/working_with_findings/findings_workflows/risk_acceptances.md --------- Co-authored-by: Paul Osinski <42211303+paulOsinski@users.noreply.github.com> Co-authored-by: Cody Maffucci <46459665+Maffooch@users.noreply.github.com> --- .../findings_workflows/risk_acceptances.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/content/en/working_with_findings/findings_workflows/risk_acceptances.md b/docs/content/en/working_with_findings/findings_workflows/risk_acceptances.md index 9746e864a81..db37e0e450d 100644 --- a/docs/content/en/working_with_findings/findings_workflows/risk_acceptances.md +++ b/docs/content/en/working_with_findings/findings_workflows/risk_acceptances.md @@ -25,6 +25,19 @@ Any Findings associated with a Full Risk Acceptance will be set to **Inactive**, Generally, any Risk Acceptances should follow your internal security policy and be re\-examined at an appropriate time. As a result, Risk Acceptances also have expiration dates. Once a Risk Acceptance expires, any Findings will be set to Active again. +### DefectDojo Pro vs Open Source: Cross-Product Risk Acceptances + +**DefectDojo Pro** provides enhanced Risk Acceptance capabilities that aid in managing risk decisions at scale: + +* **Cross-Product Risk Acceptances**: In DefectDojo Pro, you can apply a single Risk Acceptance across multiple Products. For example, if CVE-2024-1234 appears in 10 different products, you can create one Risk Acceptance that governs all instances of that CVE across your entire portfolio. +* **Bulk CVE Management**: Search for all Findings with a specific CVE or vulnerability ID, then apply a Risk Acceptance to all instances simultaneously, regardless of which Product they belong to. + +**DefectDojo Open Source** implements Risk Acceptances at the Engagement level: + +* **Product-Scoped Risk Acceptances**: Risk Acceptances are restricted to individual Products. If CVE-2024-1234 appears in 10 different products, you need to create 10 separate Risk Acceptances—one for each Engagement. + +Both approaches follow the same Risk Acceptance workflow described below, but the scope differs based on your DefectDojo edition. + ### Add a new Full Risk Acceptance Risk Acceptances can be added to a Finding in two ways: From b1eb46f75ebb5c51aae99b3c4a8864b4f1d1007e Mon Sep 17 00:00:00 2001 From: DefectDojo release bot Date: Mon, 8 Dec 2025 17:28:25 +0000 Subject: [PATCH 026/126] Update versions in application files --- components/package.json | 2 +- dojo/__init__.py | 2 +- helm/defectdojo/Chart.yaml | 12 ++++-------- helm/defectdojo/README.md | 2 +- 4 files changed, 7 insertions(+), 11 deletions(-) diff --git a/components/package.json b/components/package.json index 30ae9191c97..d9500b421b6 100644 --- a/components/package.json +++ b/components/package.json @@ -1,6 +1,6 @@ { "name": "defectdojo", - "version": "2.53.1", + "version": "2.54.0-dev", "license" : "BSD-3-Clause", "private": true, "dependencies": { diff --git a/dojo/__init__.py b/dojo/__init__.py index 1d5aa3febd8..7337d10b9c1 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.53.1" +__version__ = "2.54.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 6050a4a021b..aa41d2ab74f 100644 --- a/helm/defectdojo/Chart.yaml +++ b/helm/defectdojo/Chart.yaml @@ -1,8 +1,8 @@ apiVersion: v2 -appVersion: "2.53.1" +appVersion: "2.54.0-dev" description: A Helm chart for Kubernetes to install DefectDojo name: defectdojo -version: 1.9.1 +version: 1.9.2-dev icon: https://defectdojo.com/hubfs/DefectDojo_favicon.png maintainers: - name: madchap @@ -33,9 +33,5 @@ dependencies: # - kind: security # description: Critical bug annotations: - artifacthub.io/prerelease: "false" - artifacthub.io/changes: | - - kind: deprecated - description: djnago.mediaPersistentVolume.fsGroup was removed because it was replaced with django.podSecurityContext.fsGroup - - kind: changed - description: Bump DefectDojo to 2.53.1 + artifacthub.io/prerelease: "true" + artifacthub.io/changes: "" diff --git a/helm/defectdojo/README.md b/helm/defectdojo/README.md index 8016350fb8a..25818516839 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.1](https://img.shields.io/badge/Version-1.9.1-informational?style=flat-square) ![AppVersion: 2.53.1](https://img.shields.io/badge/AppVersion-2.53.1-informational?style=flat-square) +![Version: 1.9.2-dev](https://img.shields.io/badge/Version-1.9.2--dev-informational?style=flat-square) ![AppVersion: 2.54.0-dev](https://img.shields.io/badge/AppVersion-2.54.0--dev-informational?style=flat-square) A Helm chart for Kubernetes to install DefectDojo From 31fa8d75783ff5e8d7fe16c2c3fdb665dd7fb9cc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Dec 2025 12:38:11 -0600 Subject: [PATCH 027/126] chore(deps): bump psycopg[c] from 3.3.1 to 3.3.2 (#13839) Bumps [psycopg[c]](https://github.com/psycopg/psycopg) from 3.3.1 to 3.3.2. - [Changelog](https://github.com/psycopg/psycopg/blob/master/docs/news.rst) - [Commits](https://github.com/psycopg/psycopg/compare/3.3.1...3.3.2) --- updated-dependencies: - dependency-name: psycopg[c] dependency-version: 3.3.2 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 6d51d41e014..180ac997a57 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,7 +29,7 @@ lxml==6.0.2 Markdown==3.10 openpyxl==3.1.5 Pillow==12.0.0 # required by django-imagekit -psycopg[c]==3.3.1 +psycopg[c]==3.3.2 cryptography==46.0.3 python-dateutil==2.9.0.post0 redis==7.1.0 From c59ec2599fb121a7810738e9f6e9064d9b8d08aa Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 9 Dec 2025 12:38:33 -0600 Subject: [PATCH 028/126] chore(deps): update dependency vite from 7.2.6 to v7.2.7 (docs/package.json) (#13837) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docs/package-lock.json | 8 ++++---- docs/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index 3267cf7e2dd..df316a93700 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -20,7 +20,7 @@ }, "devDependencies": { "prettier": "3.7.4", - "vite": "7.2.6" + "vite": "7.2.7" }, "engines": { "node": ">=20.11.0" @@ -4572,9 +4572,9 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.2.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.6.tgz", - "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", + "version": "7.2.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz", + "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/docs/package.json b/docs/package.json index 846d85daf9b..11f75a1227b 100644 --- a/docs/package.json +++ b/docs/package.json @@ -27,7 +27,7 @@ }, "devDependencies": { "prettier": "3.7.4", - "vite": "7.2.6" + "vite": "7.2.7" }, "engines": { "node": ">=20.11.0" From 0ace59c5dcbce81cdc64a1a274768fd75530999e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 9 Dec 2025 12:39:06 -0600 Subject: [PATCH 029/126] chore(deps): update dependency renovatebot/renovate from 42.27.0 to v42.42.0 (.github/workflows/renovate.yaml) (#13836) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/renovate.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/renovate.yaml b/.github/workflows/renovate.yaml index 50ae1f41bd9..9827a2c31b1 100644 --- a/.github/workflows/renovate.yaml +++ b/.github/workflows/renovate.yaml @@ -21,4 +21,4 @@ jobs: uses: suzuki-shunsuke/github-action-renovate-config-validator@c22827f47f4f4a5364bdba19e1fe36907ef1318e # v1.1.1 with: strict: "true" - validator_version: 42.27.0 # renovate: datasource=github-releases depName=renovatebot/renovate + validator_version: 42.42.0 # renovate: datasource=github-releases depName=renovatebot/renovate From be68825b2c8626fb97728e3f4e7c8302b88c90e2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 11 Dec 2025 13:31:47 -0600 Subject: [PATCH 030/126] chore(deps): update gcr.io/cloudsql-docker/gce-proxy docker tag from 1.37.10 to v1.37.11 (helm/defectdojo/values.yaml) (#13856) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- helm/defectdojo/values.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helm/defectdojo/values.yaml b/helm/defectdojo/values.yaml index a02a74abcdc..66bdb88efeb 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.10 + tag: 1.37.11 pullPolicy: IfNotPresent # -- set CloudSQL instance: 'project:zone:instancename' instance: "" From 552ed38c625d6fd881c530d0e1e179e323c21f94 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 11 Dec 2025 13:36:39 -0600 Subject: [PATCH 031/126] chore(deps-dev): bump vcrpy from 8.0.0 to 8.1.0 (#13851) Bumps [vcrpy](https://github.com/kevin1024/vcrpy) from 8.0.0 to 8.1.0. - [Release notes](https://github.com/kevin1024/vcrpy/releases) - [Changelog](https://github.com/kevin1024/vcrpy/blob/master/docs/changelog.rst) - [Commits](https://github.com/kevin1024/vcrpy/compare/v8.0.0...v8.1.0) --- updated-dependencies: - dependency-name: vcrpy dependency-version: 8.1.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 785888d0234..7e22e1ed1af 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -6,7 +6,7 @@ django-debug-toolbar==6.1.0 django-debug-toolbar-request-history==0.1.4 # Testing dependencies -vcrpy==8.0.0 +vcrpy==8.1.0 vcrpy-unittest==0.1.7 django-test-migrations==1.5.0 parameterized==0.9.0 From 2fdc383567ffb802dc04615dc26c3f7e3b7672e4 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 11 Dec 2025 13:37:43 -0600 Subject: [PATCH 032/126] chore(deps): update losisin/helm-values-schema-json-action action from v2.3.1 to v2.3.2 (.github/workflows/test-helm-chart.yml) (#13847) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/test-helm-chart.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-helm-chart.yml b/.github/workflows/test-helm-chart.yml index d32e43469e2..588c2b9930b 100644 --- a/.github/workflows/test-helm-chart.yml +++ b/.github/workflows/test-helm-chart.yml @@ -150,7 +150,7 @@ jobs: uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Generate values schema json - uses: losisin/helm-values-schema-json-action@660c441a4a507436a294fc55227e1df54aca5407 # v2.3.1 + uses: losisin/helm-values-schema-json-action@f3517c55537e26953c8a11be7549ea908990130d # v2.3.2 with: fail-on-diff: true working-directory: "helm/defectdojo" From 084f100061eea472a53a6e57877567972427a4a0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 11 Dec 2025 13:38:59 -0600 Subject: [PATCH 033/126] chore(deps): update peter-evans/create-pull-request action from v7.0.11 to v8 (.github/workflows/update-sample-data.yml) (#13857) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/update-sample-data.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/update-sample-data.yml b/.github/workflows/update-sample-data.yml index b84d7eed3d4..6c5fd95ec4f 100644 --- a/.github/workflows/update-sample-data.yml +++ b/.github/workflows/update-sample-data.yml @@ -43,7 +43,7 @@ jobs: git push --set-upstream origin $(git rev-parse --abbrev-ref HEAD) - name: Create Pull Request - uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11 + uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 with: token: ${{ secrets.GITHUB_TOKEN }} commit-message: "Update sample data" From d343de3d02ac35ad1da1d190aed68b65905c0694 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 11 Dec 2025 13:40:23 -0600 Subject: [PATCH 034/126] chore(deps): bump django-polymorphic from 4.2.0 to 4.3.0 (#13859) Bumps [django-polymorphic](https://github.com/jazzband/django-polymorphic) from 4.2.0 to 4.3.0. - [Release notes](https://github.com/jazzband/django-polymorphic/releases) - [Changelog](https://github.com/jazzband/django-polymorphic/blob/master/docs/changelog.rst) - [Commits](https://github.com/jazzband/django-polymorphic/compare/v4.2.0...v4.3.0) --- updated-dependencies: - dependency-name: django-polymorphic dependency-version: 4.3.0 dependency-type: direct:production update-type: version-update:semver-minor ... 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 180ac997a57..d92f7db39ee 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ django-environ==0.12.0 django-filter==25.1 django-imagekit==6.0.0 django-multiselectfield==1.0.1 -django-polymorphic==4.2.0 +django-polymorphic==4.3.0 django-crispy-forms==2.5 django_extensions==4.1 django-slack==5.19.0 From ecd2f9d8bf8887bc71b1f1a537ebc27fe01404d7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 11 Dec 2025 13:41:47 -0600 Subject: [PATCH 035/126] chore(deps): bump sqlalchemy from 2.0.44 to 2.0.45 (#13860) Bumps [sqlalchemy](https://github.com/sqlalchemy/sqlalchemy) from 2.0.44 to 2.0.45. - [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.45 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 d92f7db39ee..aefc188382e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -34,7 +34,7 @@ cryptography==46.0.3 python-dateutil==2.9.0.post0 redis==7.1.0 requests==2.32.5 -sqlalchemy==2.0.44 # Required by Celery broker transport +sqlalchemy==2.0.45 # Required by Celery broker transport urllib3==2.6.0 uWSGI==2.0.31 vobject==0.9.9 From 7f3d6eeb13dcd088936b367b0124cd609443471d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 11 Dec 2025 13:55:57 -0600 Subject: [PATCH 036/126] chore(deps): bump urllib3 from 2.6.0 to 2.6.1 (#13852) Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.6.0 to 2.6.1. - [Release notes](https://github.com/urllib3/urllib3/releases) - [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst) - [Commits](https://github.com/urllib3/urllib3/compare/2.6.0...2.6.1) --- updated-dependencies: - dependency-name: urllib3 dependency-version: 2.6.1 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 aefc188382e..b81b0bbcd48 100644 --- a/requirements.txt +++ b/requirements.txt @@ -35,7 +35,7 @@ python-dateutil==2.9.0.post0 redis==7.1.0 requests==2.32.5 sqlalchemy==2.0.45 # Required by Celery broker transport -urllib3==2.6.0 +urllib3==2.6.2 uWSGI==2.0.31 vobject==0.9.9 whitenoise==5.2.0 From 2242329e6576197f7f53abb0f7d59758e7d6d0c2 Mon Sep 17 00:00:00 2001 From: kiblik <5609770+kiblik@users.noreply.github.com> Date: Mon, 15 Dec 2025 04:57:10 +0100 Subject: [PATCH 037/126] fix(django): Upgrade to 5.2 (#12524) * fix(django): Upgrade to 5.2 * update deps + fix tagulous * resolve conflicts * use django 5.2 but against tagulous 2.1.0 * Latest Django patch --------- Co-authored-by: Valentijn Scholten --- requirements.txt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index b81b0bbcd48..2a5ddaee8c6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,7 +18,7 @@ django_extensions==4.1 django-slack==5.19.0 django-watson==1.6.3 django-prometheus==2.4.1 -Django==5.1.15 +Django==5.2.9 django-single-session==0.2.0 djangorestframework==3.16.1 html2text==2025.4.15 @@ -50,7 +50,8 @@ django-crum==0.7.9 JSON-log-formatter==1.1.1 django-split-settings==1.3.2 # do not upgrade to 2.1.1 - https://github.com/DefectDojo/django-DefectDojo/issues/12918 -django-tagulous==2.1.0 +# use fork with django 5.2 fixes, but based on 2.1.0 +git+https://github.com/valentijnscholten/django-tagulous.git@2b514f9140acfce608238d1426d864185b3c60a2#egg=django-tagulous PyJWT==2.10.1 cvss==3.6 django-fieldsignals==0.7.0 From 724c53e08be3cd1f455ea842a6bb5d760540e495 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 00:17:37 -0600 Subject: [PATCH 038/126] chore(deps): bump vulners from 3.1.2 to 3.1.3 (#13861) Bumps vulners from 3.1.2 to 3.1.3. --- updated-dependencies: - dependency-name: vulners dependency-version: 3.1.3 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 2a5ddaee8c6..47733830edc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -65,7 +65,7 @@ blackduck==1.1.3 pycurl==7.45.7 # Required for Celery Broker AWS (SQS) support boto3==1.41.5 # Required for Celery Broker AWS (SQS) support netaddr==1.3.0 -vulners==3.1.2 +vulners==3.1.3 fontawesomefree==6.6.0 PyYAML==6.0.3 pyopenssl==25.3.0 From 364f26f8c6fa28d9cfa6d70aed20d534a37b7fce Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 00:20:52 -0600 Subject: [PATCH 039/126] chore(deps): update dependency node from 24.11.1 to v24.12.0 (.github/workflows/validate_docs_build.yml) (#13864) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .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 e9a043237cf..8722b5e501b 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@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 with: - node-version: '24.11.1' # TODO: Renovate helper might not be needed here - needs to be fully tested + node-version: '24.12.0' # TODO: Renovate helper might not be needed here - needs to be fully tested - name: Cache dependencies uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 diff --git a/.github/workflows/validate_docs_build.yml b/.github/workflows/validate_docs_build.yml index 28e3bc8faa3..c94acbb54ac 100644 --- a/.github/workflows/validate_docs_build.yml +++ b/.github/workflows/validate_docs_build.yml @@ -18,7 +18,7 @@ jobs: - name: Setup Node uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 with: - node-version: '24.11.1' # TODO: Renovate helper might not be needed here - needs to be fully tested + node-version: '24.12.0' # TODO: Renovate helper might not be needed here - needs to be fully tested - name: Cache dependencies uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 From f4c693d3896117005bfd68440981760026906458 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 00:23:40 -0600 Subject: [PATCH 040/126] chore(deps): update actions/cache action from v4.3.0 to v5 (.github/workflows/validate_docs_build.yml) (#13871) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .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 8722b5e501b..cee823706e2 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -27,7 +27,7 @@ jobs: node-version: '24.12.0' # TODO: Renovate helper might not be needed here - needs to be fully tested - name: Cache dependencies - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: ~/.npm key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} diff --git a/.github/workflows/validate_docs_build.yml b/.github/workflows/validate_docs_build.yml index c94acbb54ac..e4096677a63 100644 --- a/.github/workflows/validate_docs_build.yml +++ b/.github/workflows/validate_docs_build.yml @@ -21,7 +21,7 @@ jobs: node-version: '24.12.0' # TODO: Renovate helper might not be needed here - needs to be fully tested - name: Cache dependencies - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: ~/.npm key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} From 81196842e893c5ee2851da7b220eea7ece0ca404 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 00:24:03 -0600 Subject: [PATCH 041/126] chore(deps): update dependency kubernetes/kubernetes from v1.34.2 to v1.34.3 (.github/workflows/k8s-tests.yml) (#13873) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/k8s-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/k8s-tests.yml b/.github/workflows/k8s-tests.yml index 3419406f9f0..36b8cfeea1c 100644 --- a/.github/workflows/k8s-tests.yml +++ b/.github/workflows/k8s-tests.yml @@ -16,7 +16,7 @@ jobs: # databases, broker and k8s are independent, so we don't need to test each combination # lastest k8s version (https://kubernetes.io/releases/) and the oldest officially supported version # are tested (https://kubernetes.io/releases/) - - k8s: 'v1.34.2' # renovate: datasource=github-releases depName=kubernetes/kubernetes versioning=loose + - k8s: 'v1.34.3' # renovate: datasource=github-releases depName=kubernetes/kubernetes versioning=loose os: debian - k8s: '1.32.10' # renovate: datasource=custom.endoflife-oldest-maintained depName=kubernetes os: debian From 074affdc0da6eb729e5a7ca8e31f51af80d2dddf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 00:24:29 -0600 Subject: [PATCH 042/126] chore(deps): bump django-pghistory from 3.9.0 to 3.9.1 (#13875) Bumps [django-pghistory](https://github.com/AmbitionEng/django-pghistory) from 3.9.0 to 3.9.1. - [Release notes](https://github.com/AmbitionEng/django-pghistory/releases) - [Changelog](https://github.com/AmbitionEng/django-pghistory/blob/main/CHANGELOG.md) - [Commits](https://github.com/AmbitionEng/django-pghistory/compare/3.9.0...3.9.1) --- updated-dependencies: - dependency-name: django-pghistory dependency-version: 3.9.1 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 47733830edc..e32ad0aa8f0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ celery==5.6.0 defusedxml==0.7.1 django_celery_results==2.6.0 django-auditlog==3.2.1 -django-pghistory==3.9.0 +django-pghistory==3.9.1 django-dbbackup==5.0.1 django-environ==0.12.0 django-filter==25.1 From 72d4586353c4788050abdd9ef46fca6ebdaf790e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 00:25:00 -0600 Subject: [PATCH 043/126] chore(deps): bump ruff from 0.14.8 to 0.14.9 (#13876) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.14.8 to 0.14.9. - [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.14.8...0.14.9) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.14.9 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 c159d0ea01b..9d54705a3c1 100644 --- a/requirements-lint.txt +++ b/requirements-lint.txt @@ -1 +1 @@ -ruff==0.14.8 \ No newline at end of file +ruff==0.14.9 \ No newline at end of file From cdf3c4f853ec337975f73c918b7a07356ce3ea8d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 00:33:17 -0600 Subject: [PATCH 044/126] chore(deps): update github artifact actions (.github/workflows/rest-framework-tests.yml) (#13883) 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/integration-tests.yml | 2 +- .github/workflows/k8s-tests.yml | 2 +- .github/workflows/release-drafter.yml | 2 +- .github/workflows/release-x-manual-docker-containers.yml | 2 +- .github/workflows/release-x-manual-merge-container-digests.yml | 2 +- .github/workflows/rest-framework-tests.yml | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build-docker-images-for-testing.yml b/.github/workflows/build-docker-images-for-testing.yml index f9193592623..36fb5a2e0c5 100644 --- a/.github/workflows/build-docker-images-for-testing.yml +++ b/.github/workflows/build-docker-images-for-testing.yml @@ -67,7 +67,7 @@ jobs: # export docker images to be used in next jobs below - name: Upload image ${{ matrix.docker-image }} as artifact timeout-minutes: 15 - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: built-docker-image-${{ matrix.docker-image }}-${{ matrix.os }}-${{ env.PLATFORM }} path: ${{ matrix.docker-image }}-${{ matrix.os }}-${{ env.PLATFORM }}_img diff --git a/.github/workflows/fetch-oas.yml b/.github/workflows/fetch-oas.yml index 816d338fd5a..9c7010639ce 100644 --- a/.github/workflows/fetch-oas.yml +++ b/.github/workflows/fetch-oas.yml @@ -51,7 +51,7 @@ jobs: run: docker compose down - name: Upload oas.${{ matrix.file-type }} as artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: oas-${{ matrix.file-type }} path: oas.${{ matrix.file-type }} diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index f1b05e49f0b..69315ca32f7 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -58,7 +58,7 @@ jobs: # load docker images from build jobs - name: Load images from artifacts - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: path: built-docker-image pattern: built-docker-image-* diff --git a/.github/workflows/k8s-tests.yml b/.github/workflows/k8s-tests.yml index 36b8cfeea1c..1a2ae525e26 100644 --- a/.github/workflows/k8s-tests.yml +++ b/.github/workflows/k8s-tests.yml @@ -38,7 +38,7 @@ jobs: minikube status - name: Load images from artifacts - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: path: built-docker-image pattern: built-docker-image-* diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index baa804441a0..5f1b5968be3 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -47,7 +47,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Load OAS files from artifacts - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: pattern: oas-* diff --git a/.github/workflows/release-x-manual-docker-containers.yml b/.github/workflows/release-x-manual-docker-containers.yml index a52ae55b3b4..67390b02047 100644 --- a/.github/workflows/release-x-manual-docker-containers.yml +++ b/.github/workflows/release-x-manual-docker-containers.yml @@ -89,7 +89,7 @@ jobs: # upload the digest file as artifact - name: Upload digest - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: digests-${{ matrix.docker-image}}-${{ matrix.os }}-${{ env.PLATFORM }} path: ${{ runner.temp }}/digests/* diff --git a/.github/workflows/release-x-manual-merge-container-digests.yml b/.github/workflows/release-x-manual-merge-container-digests.yml index 156d3dfb28f..f0ac06dc114 100644 --- a/.github/workflows/release-x-manual-merge-container-digests.yml +++ b/.github/workflows/release-x-manual-merge-container-digests.yml @@ -41,7 +41,7 @@ jobs: # only download digests for this image and this os - name: Download digests - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: path: ${{ runner.temp }}/digests pattern: digests-${{ matrix.docker-image}}-${{ matrix.os }}-* diff --git a/.github/workflows/rest-framework-tests.yml b/.github/workflows/rest-framework-tests.yml index a3bc7914768..4ca30ab7db5 100644 --- a/.github/workflows/rest-framework-tests.yml +++ b/.github/workflows/rest-framework-tests.yml @@ -36,7 +36,7 @@ jobs: # load docker images from build jobs - name: Load images from artifacts - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: path: built-docker-image pattern: built-docker-image-* From d56eed44e79596a542bdb6ce5bbbb9364bbd3abb Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 00:40:06 -0600 Subject: [PATCH 045/126] chore(deps): update dependency renovatebot/renovate from 42.42.0 to v42.52.8 (.github/workflows/renovate.yaml) (#13896) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/renovate.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/renovate.yaml b/.github/workflows/renovate.yaml index 9827a2c31b1..c86464c03e7 100644 --- a/.github/workflows/renovate.yaml +++ b/.github/workflows/renovate.yaml @@ -21,4 +21,4 @@ jobs: uses: suzuki-shunsuke/github-action-renovate-config-validator@c22827f47f4f4a5364bdba19e1fe36907ef1318e # v1.1.1 with: strict: "true" - validator_version: 42.42.0 # renovate: datasource=github-releases depName=renovatebot/renovate + validator_version: 42.52.8 # renovate: datasource=github-releases depName=renovatebot/renovate From 7b807646ef69cf4ede779020f0d8eee82716bd94 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 00:40:33 -0600 Subject: [PATCH 046/126] fix(deps): update dependency @tabler/icons from 3.35.0 to v3.36.0 (docs/package.json) (#13897) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docs/package-lock.json | 8 ++++---- docs/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index df316a93700..3d3f3a548b8 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@docsearch/css": "4.3.2", "@docsearch/js": "4.3.2", - "@tabler/icons": "3.35.0", + "@tabler/icons": "3.36.0", "@thulite/doks-core": "1.8.3", "@thulite/images": "3.3.3", "@thulite/inline-svg": "1.2.1", @@ -2439,9 +2439,9 @@ ] }, "node_modules/@tabler/icons": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.35.0.tgz", - "integrity": "sha512-yYXe+gJ56xlZFiXwV9zVoe3FWCGuZ/D7/G4ZIlDtGxSx5CGQK110wrnT29gUj52kEZoxqF7oURTk97GQxELOFQ==", + "version": "3.36.0", + "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.36.0.tgz", + "integrity": "sha512-z9OfTEG6QbaQWM9KBOxxUdpgvMUn0atageXyiaSc2gmYm51ORO8Ua7eUcjlks+Dc0YMK4rrodAFdK9SfjJ4ZcA==", "license": "MIT", "funding": { "type": "github", diff --git a/docs/package.json b/docs/package.json index 11f75a1227b..d18b4b456e8 100644 --- a/docs/package.json +++ b/docs/package.json @@ -18,7 +18,7 @@ "dependencies": { "@docsearch/css": "4.3.2", "@docsearch/js": "4.3.2", - "@tabler/icons": "3.35.0", + "@tabler/icons": "3.36.0", "@thulite/doks-core": "1.8.3", "@thulite/images": "3.3.3", "@thulite/inline-svg": "1.2.1", From 05ec7128bf35f259ed14be1150200a5a9b499e81 Mon Sep 17 00:00:00 2001 From: kiblik <5609770+kiblik@users.noreply.github.com> Date: Mon, 15 Dec 2025 16:45:51 +0100 Subject: [PATCH 047/126] fix(HELM): Annotation and docs correction for #22639 (#13878) Signed-off-by: kiblik <5609770+kiblik@users.noreply.github.com> --- helm/defectdojo/Chart.yaml | 4 +++- helm/defectdojo/README.md | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/helm/defectdojo/Chart.yaml b/helm/defectdojo/Chart.yaml index aa41d2ab74f..4bbb6d1807e 100644 --- a/helm/defectdojo/Chart.yaml +++ b/helm/defectdojo/Chart.yaml @@ -34,4 +34,6 @@ dependencies: # description: Critical bug annotations: artifacthub.io/prerelease: "true" - artifacthub.io/changes: "" + artifacthub.io/changes: | + - kind: changed + description: chore(deps)_ update gcr.io/cloudsql_docker/gce_proxy docker tag from 1.37.10 to v1.37.11 (helm/defectdojo/values.yaml) diff --git a/helm/defectdojo/README.md b/helm/defectdojo/README.md index 25818516839..bd1b39c493b 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.10"},"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.37.11"},"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.10"}` | set repo and image tag of gce-proxy | +| cloudsql.image | object | `{"pullPolicy":"IfNotPresent","repository":"gcr.io/cloudsql-docker/gce-proxy","tag":"1.37.11"}` | 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 | From a2451fe95329d0b6ba1ce2c7ffde15f3042b90b4 Mon Sep 17 00:00:00 2001 From: valentijnscholten Date: Mon, 15 Dec 2025 16:54:53 +0100 Subject: [PATCH 048/126] auditlog: switch to pghistory (for real) (#13587) * auditlog: switch to pghistory * ruff * pghistory: add finding.reviewers to tracked models * fix finding reviewers model registration * remove more references * add migration * rebase migrations * rebase migrations * rebase * ruff * rebase * cleanup * remove obsolete test * ruff * move auditlog in settings.dist.py --------- Co-authored-by: Valentijn Scholten --- .github/workflows/integration-tests.yml | 6 - .github/workflows/rest-framework-tests.yml | 5 - .github/workflows/unit-tests.yml | 8 - docs/content/en/open_source/upgrading/2.53.md | 8 +- docs/content/en/open_source/upgrading/2.54.md | 42 +- dojo/apps.py | 3 +- dojo/auditlog.py | 585 +++++++++++++----- ...eviewers_findingreviewersevent_and_more.py | 64 ++ dojo/db_migrations/0250_pghistory_backfill.py | 85 +++ dojo/endpoint/signals.py | 13 +- dojo/engagement/signals.py | 13 +- dojo/filters.py | 2 +- dojo/finding_group/signals.py | 13 +- .../management/commands/pghistory_backfill.py | 41 +- .../commands/pghistory_backfill_fast.py | 403 +----------- .../commands/pghistory_backfill_simple.py | 6 +- dojo/management/commands/pghistory_clear.py | 5 +- .../commands/stamp_finding_last_reviewed.py | 78 --- dojo/middleware.py | 17 - dojo/product/signals.py | 14 +- dojo/product_type/signals.py | 14 +- dojo/settings/settings.dist.py | 37 +- dojo/templates/dojo/action_history.html | 4 +- dojo/test/signals.py | 13 +- dojo/views.py | 2 +- unittests/test_auditlog.py | 280 ++------- unittests/test_flush_auditlog.py | 86 ++- unittests/test_importers_performance.py | 115 +--- 28 files changed, 824 insertions(+), 1138 deletions(-) create mode 100644 dojo/db_migrations/0249_findingreviewers_findingreviewersevent_and_more.py create mode 100644 dojo/db_migrations/0250_pghistory_backfill.py delete mode 100644 dojo/management/commands/stamp_finding_last_reviewed.py diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 69315ca32f7..5ed6e25a993 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -2,18 +2,12 @@ name: Integration tests on: workflow_call: - inputs: - auditlog_type: - type: string - default: "django-auditlog" jobs: integration_tests: # run tests with docker compose name: User Interface Tests runs-on: ubuntu-latest - env: - AUDITLOG_TYPE: ${{ inputs.auditlog_type }} strategy: matrix: test-case: [ diff --git a/.github/workflows/rest-framework-tests.yml b/.github/workflows/rest-framework-tests.yml index 4ca30ab7db5..4f09b00d675 100644 --- a/.github/workflows/rest-framework-tests.yml +++ b/.github/workflows/rest-framework-tests.yml @@ -6,16 +6,11 @@ on: platform: type: string default: "linux/amd64" - auditlog_type: - type: string - default: "django-auditlog" jobs: unit_tests: name: Rest Framework Unit Tests runs-on: ${{ inputs.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }} - env: - AUDITLOG_TYPE: ${{ inputs.auditlog_type }} strategy: matrix: diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index cbda2b40caf..e16990520df 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -25,26 +25,18 @@ jobs: strategy: matrix: platform: ['linux/amd64', 'linux/arm64'] - auditlog_type: ['django-auditlog', 'django-pghistory'] fail-fast: false needs: build-docker-containers uses: ./.github/workflows/rest-framework-tests.yml secrets: inherit with: platform: ${{ matrix.platform}} - auditlog_type: ${{ matrix.auditlog_type }} # only run integration tests for linux/amd64 (default) test-user-interface: needs: build-docker-containers uses: ./.github/workflows/integration-tests.yml secrets: inherit - strategy: - matrix: - auditlog_type: ['django-auditlog', 'django-pghistory'] - fail-fast: false - with: - auditlog_type: ${{ matrix.auditlog_type }} # only run k8s tests for linux/amd64 (default) test-k8s: diff --git a/docs/content/en/open_source/upgrading/2.53.md b/docs/content/en/open_source/upgrading/2.53.md index b6970b87fc9..8012c5ec7e0 100644 --- a/docs/content/en/open_source/upgrading/2.53.md +++ b/docs/content/en/open_source/upgrading/2.53.md @@ -1,5 +1,5 @@ --- -title: 'Upgrading to DefectDojo Version 2.53.x' +title: "Upgrading to DefectDojo Version 2.53.x" toc_hide: true weight: -20251103 description: "Helm chart: changes for initializer annotations + Replaced Redis with Valkey + HPA & PDB support" @@ -17,9 +17,9 @@ Added Helm chart support for Celery and Django deployments for Horizontal Pod Au ### Breaking changes -#### Valkey +#### Valkey -##### Renamed values +##### Renamed values HELM values had been changed to the following: - `createRedisSecret` → `createValkeySecret` @@ -40,7 +40,7 @@ If an external Redis instance is being used, set the parameter `valkey.enabled` 0. As always, perform a backup of your instance 1. If you would like to be 100% sure that you do not miss any async event (triggered deduplication, email notification, ...) it is recommended to perform the following substeps (if your system is not in production and/or you are willing to miss some notifications or postpone deduplication to a later time, feel free to skip these substeps) 0. Perform the following steps with your previous version of HELM chart (not with the upgraded one - you might lose your data) - 1. Downscale all producers of async tasks: + 1. Downscale all producers of async tasks: - Set `django.replicas` to 0 (if you used HPA, adjust it based on your needs) - Set `celery.beat.replicas` to 0 (if you used HPA, adjust it based on your needs) - Do not change `celery.worker.replicas` (they are responsible for processing your async tasks) diff --git a/docs/content/en/open_source/upgrading/2.54.md b/docs/content/en/open_source/upgrading/2.54.md index 3286e7ee149..625198c3162 100644 --- a/docs/content/en/open_source/upgrading/2.54.md +++ b/docs/content/en/open_source/upgrading/2.54.md @@ -1,11 +1,47 @@ --- title: 'Upgrading to DefectDojo Version 2.54.x' toc_hide: true -weight: -20250804 -description: Dropped support for DD_PARSER_EXCLUDE +weight: -20251201 +description: Removal of django-auditlog and exclusive use of django-pghistory for audit logging & Dropped support for DD_PARSER_EXCLUDE --- +## Breaking Change: Removal of django-auditlog + +Starting with DefectDojo 2.53, `django-auditlog` support has been removed in favour of `django-pghistory`. +This is designed to be a backwards compatible change, unless: +- You're querying the database directly for auditlog events, or, +- You've set the `DD_AUDITLOG_TYPE` environment variable (or `AUDITLOG_TYPE` settings field) + +### Required Actions + +If you're using `DD_AUDITLOG_TYPE`, remove it from your configuration/environment. + +### Existing Records Preserved + +Historical audit log entries stored in the `auditlog_logentry` table will continue to be displayed in the action history view for backward compatibility. No data migration is required. + +### Benefits of django-pghistory + +The switch to `django-pghistory` provides several advantages: + +- **Better performance**: Database-level triggers reduce overhead compared to Django signal-based auditing +- **More features**: Enhanced context tracking and better support for complex queries +- **Better data integrity**: PostgreSQL-native implementation ensures consistency + +### Migration Notes + +- A one-time data migration will take place to populate the `django-pghistory` tables with the initial snapshot of the tracked models. +- The migration is designed to be fail-safe: if it fails for some reason, it will continue where it left off. +- The migration can also be performed up front via + - `docker compose exec uwsgi bash -c "python manage.py pghistory_backfill_fast"`, or + - `docker compose exec uwsgi bash -c "python manage.py pghistory_backfill_simple"`, or + - `docker compose exec uwsgi bash -c "python manage.py pghistory_backfill"` + +The backfill migration is not mandatory to succeed. If it fails for some reason, the only side effect will be that the first auditlog diff will contain all fields of an object instead just the changed fields. + +## Dropped support for DD_PARSER_EXCLUDE + To simplify the management of the DefectDojo application, parser exclusions are no longer controlled via the environment variable DD_PARSER_EXCLUDE or application settings. This variable is now unsupported. From now on, you should use the active flag in the Test_Type model to enable or disable parsers. Only parsers associated with active Test_Type entries will be available for use. -There are other instructions for upgrading to 2.54.x. Check the Release Notes for the contents of the release. +Check the [Release Notes](https://github.com/DefectDojo/django-DefectDojo/releases/tag/2.54.0) for the contents of the release. \ No newline at end of file diff --git a/dojo/apps.py b/dojo/apps.py index f1b2769f760..06733c29771 100644 --- a/dojo/apps.py +++ b/dojo/apps.py @@ -91,7 +91,8 @@ def ready(self): # Configure audit system after all models are loaded # This must be done in ready() to avoid "Models aren't loaded yet" errors # Note: pghistory models are registered here (no database access), but trigger - # enabling is handled via management command to avoid database access warnings + # enabling is handled in the entrpoint script to avoid database access warnings + # during startup register_django_pghistory_models() configure_audit_system() diff --git a/dojo/auditlog.py b/dojo/auditlog.py index 1996fc1cca4..6747f4fa91b 100644 --- a/dojo/auditlog.py +++ b/dojo/auditlog.py @@ -1,23 +1,29 @@ """ Audit logging configuration for DefectDojo. -This module handles conditional registration of models with either django-auditlog -or django-pghistory based on the DD_AUDITLOG_TYPE setting. +This module handles registration of models with django-pghistory. +django-auditlog support has been removed. """ -import contextlib import logging +import os import sys +import time import pghistory from dateutil.relativedelta import relativedelta from django.apps import apps from django.conf import settings from django.core.management import call_command -from django.db import models +from django.db import connection, models from django.utils import timezone logger = logging.getLogger(__name__) +# FindingReviewers proxy model will be created lazily in register_django_pghistory_models() +# Cannot be defined at module level because Finding.reviewers.through requires +# Django's app registry to be ready (AppRegistryNotReady error) +# The function is called from DojoAppConfig.ready() which guarantees the registry is ready + def _flush_models_in_batches(models_to_flush, timestamp_field: str, retention_period: int, batch_size: int, max_batches: int, *, dry_run: bool = False) -> tuple[int, int, bool]: """ @@ -80,13 +86,6 @@ def _flush_models_in_batches(models_to_flush, timestamp_field: str, retention_pe return total_deleted, total_batches, reached_any_limit -def _flush_django_auditlog(retention_period: int, batch_size: int, max_batches: int, *, dry_run: bool = False) -> tuple[int, int, bool]: - # Import inside to avoid model import issues at startup - from auditlog.models import LogEntry # noqa: PLC0415 - - return _flush_models_in_batches([LogEntry], "timestamp", retention_period, batch_size, max_batches, dry_run=dry_run) - - def _iter_pghistory_event_models(): """Yield pghistory Event models registered under the dojo app.""" for model in apps.get_app_config("dojo").get_models(): @@ -107,8 +106,7 @@ def run_flush_auditlog(retention_period: int | None = None, *, dry_run: bool = False) -> tuple[int, int, bool]: """ - Deletes audit entries older than the configured retention from both - django-auditlog and django-pghistory log entries. + Deletes audit entries older than the configured retention from django-pghistory log entries. Returns a tuple of (deleted_total, batches_done, reached_limit). """ @@ -121,93 +119,13 @@ def run_flush_auditlog(retention_period: int | None = None, max_batches = max_batches if max_batches is not None else getattr(settings, "AUDITLOG_FLUSH_MAX_BATCHES", 100) phase = "DRY RUN" if dry_run else "Cleanup" - logger.info("Running %s for django-auditlog entries with %d Months retention across all backends", phase, retention_period) - d_deleted, d_batches, d_limit = _flush_django_auditlog(retention_period, batch_size, max_batches, dry_run=dry_run) logger.info("Running %s for django-pghistory entries with %d Months retention across all backends", phase, retention_period) p_deleted, p_batches, p_limit = _flush_pghistory_events(retention_period, batch_size, max_batches, dry_run=dry_run) - total_deleted = d_deleted + p_deleted - total_batches = d_batches + p_batches - reached_limit = bool(d_limit or p_limit) - verb = "would delete" if dry_run else "deleted" - logger.info("Audit flush summary: django-auditlog %s=%s batches=%s; pghistory %s=%s batches=%s; total_%s=%s total_batches=%s", - verb, d_deleted, d_batches, verb, p_deleted, p_batches, verb.replace(" ", "_"), total_deleted, total_batches) - - return total_deleted, total_batches, reached_limit - - -def enable_django_auditlog(): - """Enable django-auditlog by registering models.""" - # Import inside function to avoid AppRegistryNotReady errors - from auditlog.registry import auditlog # noqa: PLC0415 - - from dojo.models import ( # noqa: PLC0415 - Cred_User, - Dojo_User, - Endpoint, - Engagement, - Finding, - Finding_Group, - Finding_Template, - Notification_Webhooks, - Product, - Product_Type, - Risk_Acceptance, - Test, - ) - - logger.info("Enabling django-auditlog: Registering models") - auditlog.register(Dojo_User, exclude_fields=["password"]) - auditlog.register(Endpoint) - auditlog.register(Engagement) - auditlog.register(Finding, m2m_fields={"reviewers"}) - auditlog.register(Finding_Group) - auditlog.register(Product_Type) - auditlog.register(Product) - auditlog.register(Test) - auditlog.register(Risk_Acceptance) - auditlog.register(Finding_Template) - auditlog.register(Cred_User, exclude_fields=["password"]) - auditlog.register(Notification_Webhooks, exclude_fields=["header_name", "header_value"]) - logger.info("Successfully enabled django-auditlog") - - -def disable_django_auditlog(): - """Disable django-auditlog by unregistering models.""" - # Import inside function to avoid AppRegistryNotReady errors - from auditlog.registry import auditlog # noqa: PLC0415 - - from dojo.models import ( # noqa: PLC0415 - Cred_User, - Dojo_User, - Endpoint, - Engagement, - Finding, - Finding_Group, - Finding_Template, - Notification_Webhooks, - Product, - Product_Type, - Risk_Acceptance, - Test, - ) - - # Only log during actual application startup, not during shell commands - if "shell" not in sys.argv: - logger.info("Django-auditlog disabled - unregistering models") - - # Unregister all models from auditlog - models_to_unregister = [ - Dojo_User, Endpoint, Engagement, Finding, Finding_Group, - Product_Type, Product, Test, Risk_Acceptance, Finding_Template, - Cred_User, Notification_Webhooks, - ] + logger.info("Audit flush summary: pghistory %s=%s batches=%s", verb, p_deleted, p_batches) - for model in models_to_unregister: - with contextlib.suppress(Exception): - # Model might not be registered, ignore the error - auditlog.unregister(model) + return p_deleted, p_batches, bool(p_limit) def register_django_pghistory_models(): @@ -254,7 +172,7 @@ def register_django_pghistory_models(): pghistory.InsertEvent(), pghistory.UpdateEvent(condition=pghistory.AnyChange(exclude_auto=True)), pghistory.DeleteEvent(), - pghistory.ManualEvent(label="initial_import"), + pghistory.ManualEvent(label="initial_backfill"), exclude=["password"], # add some indexes manually so we don't have to define a customer phistory Event model with overridden fields. meta={ @@ -270,7 +188,7 @@ def register_django_pghistory_models(): pghistory.InsertEvent(), pghistory.UpdateEvent(condition=pghistory.AnyChange(exclude_auto=True)), pghistory.DeleteEvent(), - pghistory.ManualEvent(label="initial_import"), + pghistory.ManualEvent(label="initial_backfill"), meta={ "indexes": [ models.Index(fields=["pgh_created_at"]), @@ -284,7 +202,7 @@ def register_django_pghistory_models(): pghistory.InsertEvent(), pghistory.UpdateEvent(condition=pghistory.AnyChange(exclude_auto=True)), pghistory.DeleteEvent(), - pghistory.ManualEvent(label="initial_import"), + pghistory.ManualEvent(label="initial_backfill"), meta={ "indexes": [ models.Index(fields=["pgh_created_at"]), @@ -298,7 +216,7 @@ def register_django_pghistory_models(): pghistory.InsertEvent(), pghistory.UpdateEvent(condition=pghistory.AnyChange(exclude_auto=True)), pghistory.DeleteEvent(), - pghistory.ManualEvent(label="initial_import"), + pghistory.ManualEvent(label="initial_backfill"), meta={ "indexes": [ models.Index(fields=["pgh_created_at"]), @@ -312,7 +230,7 @@ def register_django_pghistory_models(): pghistory.InsertEvent(), pghistory.UpdateEvent(condition=pghistory.AnyChange(exclude_auto=True)), pghistory.DeleteEvent(), - pghistory.ManualEvent(label="initial_import"), + pghistory.ManualEvent(label="initial_backfill"), meta={ "indexes": [ models.Index(fields=["pgh_created_at"]), @@ -326,7 +244,7 @@ def register_django_pghistory_models(): pghistory.InsertEvent(), pghistory.UpdateEvent(condition=pghistory.AnyChange(exclude_auto=True)), pghistory.DeleteEvent(), - pghistory.ManualEvent(label="initial_import"), + pghistory.ManualEvent(label="initial_backfill"), meta={ "indexes": [ models.Index(fields=["pgh_created_at"]), @@ -340,7 +258,7 @@ def register_django_pghistory_models(): pghistory.InsertEvent(), pghistory.UpdateEvent(condition=pghistory.AnyChange(exclude_auto=True)), pghistory.DeleteEvent(), - pghistory.ManualEvent(label="initial_import"), + pghistory.ManualEvent(label="initial_backfill"), meta={ "indexes": [ models.Index(fields=["pgh_created_at"]), @@ -354,7 +272,7 @@ def register_django_pghistory_models(): pghistory.InsertEvent(), pghistory.UpdateEvent(condition=pghistory.AnyChange(exclude_auto=True)), pghistory.DeleteEvent(), - pghistory.ManualEvent(label="initial_import"), + pghistory.ManualEvent(label="initial_backfill"), meta={ "indexes": [ models.Index(fields=["pgh_created_at"]), @@ -368,7 +286,7 @@ def register_django_pghistory_models(): pghistory.InsertEvent(), pghistory.UpdateEvent(condition=pghistory.AnyChange(exclude_auto=True)), pghistory.DeleteEvent(), - pghistory.ManualEvent(label="initial_import"), + pghistory.ManualEvent(label="initial_backfill"), meta={ "indexes": [ models.Index(fields=["pgh_created_at"]), @@ -382,7 +300,7 @@ def register_django_pghistory_models(): pghistory.InsertEvent(), pghistory.UpdateEvent(condition=pghistory.AnyChange(exclude_auto=True)), pghistory.DeleteEvent(), - pghistory.ManualEvent(label="initial_import"), + pghistory.ManualEvent(label="initial_backfill"), meta={ "indexes": [ models.Index(fields=["pgh_created_at"]), @@ -396,7 +314,7 @@ def register_django_pghistory_models(): pghistory.InsertEvent(), pghistory.UpdateEvent(condition=pghistory.AnyChange(exclude_auto=True)), pghistory.DeleteEvent(), - pghistory.ManualEvent(label="initial_import"), + pghistory.ManualEvent(label="initial_backfill"), exclude=["password"], meta={ "indexes": [ @@ -411,7 +329,7 @@ def register_django_pghistory_models(): pghistory.InsertEvent(), pghistory.UpdateEvent(condition=pghistory.AnyChange(exclude_auto=True)), pghistory.DeleteEvent(), - pghistory.ManualEvent(label="initial_import"), + pghistory.ManualEvent(label="initial_backfill"), exclude=["header_name", "header_value"], meta={ "indexes": [ @@ -422,35 +340,47 @@ def register_django_pghistory_models(): }, )(Notification_Webhooks) + # Track Finding.reviewers ManyToMany relationship + # Create a proxy model for the through table as per pghistory docs: + # https://django-pghistory.readthedocs.io/en/2.4.2/tutorial.html#tracking-many-to-many-events + # Note: For auto-generated through models, we don't specify obj_fk/obj_field + # as Django doesn't allow foreign keys to auto-generated through models + # + # We must create the proxy model here (not at module level) because: + # 1. Finding.reviewers.through requires Django's app registry to be ready + # 2. This function is called from DojoAppConfig.ready() which guarantees registry is ready + # 3. We check if it already exists to avoid re-registration warnings + # + # Note: This pattern is not explicitly documented in Django's official documentation. + # Django docs mention AppRegistryNotReady and AppConfig.ready() in general terms, but + # don't specifically cover proxy models for auto-generated ManyToMany through tables. + # This is a common pattern used by libraries like django-pghistory and is necessary + # because accessing Model.field.through at module import time triggers AppRegistryNotReady. + reviewers_through = Finding._meta.get_field("reviewers").remote_field.through + + class FindingReviewers(reviewers_through): + class Meta: + proxy = True + + pghistory.track( + pghistory.InsertEvent(), + pghistory.DeleteEvent(), + pghistory.ManualEvent(label="initial_backfill"), + meta={ + "db_table": "dojo_finding_reviewersevent", + "indexes": [ + models.Index(fields=["pgh_created_at"]), + models.Index(fields=["pgh_label"]), + models.Index(fields=["pgh_context_id"]), + ], + }, + )(FindingReviewers) + # Only log during actual application startup, not during shell commands if "shell" not in sys.argv: logger.info("Successfully registered models with django-pghistory") -def enable_django_pghistory(): - """Enable django-pghistory by enabling triggers.""" - logger.info("Enabling django-pghistory: Enabling triggers") - - # Enable pghistory triggers - try: - call_command("pgtrigger", "enable") - logger.info("Successfully enabled pghistory triggers") - except Exception as e: - logger.warning(f"Failed to enable pgtrigger triggers: {e}") - # Don't raise the exception as this shouldn't prevent Django from starting - - -def disable_django_pghistory(): - """Disable django-pghistory by disabling triggers.""" - logger.info("Disabling django-pghistory: Disabling triggers") - try: - call_command("pgtrigger", "disable") - logger.info("Successfully disabled pghistory triggers") - except Exception as e: - logger.warning(f"Failed to disable pgtrigger triggers: {e}") - # Don't raise the exception as this shouldn't prevent Django from starting - - def configure_pghistory_triggers(): """ Configure pghistory triggers based on audit settings. @@ -466,44 +396,399 @@ def configure_pghistory_triggers(): except Exception as e: logger.error(f"Failed to disable pghistory triggers: {e}") raise - elif settings.AUDITLOG_TYPE == "django-pghistory": + else: + # Only pghistory is supported now try: call_command("pgtrigger", "enable") logger.info("Successfully enabled pghistory triggers") except Exception as e: logger.error(f"Failed to enable pghistory triggers: {e}") raise - else: - try: - call_command("pgtrigger", "disable") - logger.info("Successfully disabled pghistory triggers") - except Exception as e: - logger.error(f"Failed to disable pghistory triggers: {e}") - raise def configure_audit_system(): """ Configure the audit system based on settings. - Note: This function only handles auditlog registration. pghistory model registration - is handled in apps.py, and trigger management should be done via the - configure_pghistory_triggers() function to avoid database access during initialization. + django-auditlog is no longer supported. Only django-pghistory is allowed. """ # Only log during actual application startup, not during shell commands log_enabled = "shell" not in sys.argv + # Fail if DD_AUDITLOG_TYPE is still configured (removed setting) + auditlog_type_env = os.environ.get("DD_AUDITLOG_TYPE") + if auditlog_type_env: + error_msg = ( + "DD_AUDITLOG_TYPE environment variable is no longer supported. " + "DefectDojo now exclusively uses django-pghistory for audit logging. " + "Please remove DD_AUDITLOG_TYPE from your environment configuration. " + "All new audit entries will be created using django-pghistory automatically." + ) + logger.error(error_msg) + raise ValueError(error_msg) + + # Fail if AUDITLOG_TYPE is manually set in settings files (removed setting) + if hasattr(settings, "AUDITLOG_TYPE"): + error_msg = ( + "AUDITLOG_TYPE setting is no longer supported. " + "DefectDojo now exclusively uses django-pghistory for audit logging. " + "Please remove AUDITLOG_TYPE from your settings file (settings.dist.py or local_settings.py). " + "All new audit entries will be created using django-pghistory automatically." + ) + logger.error(error_msg) + raise ValueError(error_msg) + if not settings.ENABLE_AUDITLOG: if log_enabled: logger.info("Audit logging disabled") - disable_django_auditlog() return - if settings.AUDITLOG_TYPE == "django-auditlog": - if log_enabled: - logger.info("Configuring audit system: django-auditlog enabled") - enable_django_auditlog() + if log_enabled: + logger.info("Audit logging configured: django-pghistory") + + +# Backfill functions for pghistory tables +def get_excluded_fields(model_name): + """Get the list of excluded fields for a specific model from pghistory configuration.""" + # Define excluded fields for each model (matching auditlog.py) + excluded_fields_map = { + "Dojo_User": ["password"], + "Product": ["updated"], # This is the key change + "Cred_User": ["password"], + "Notification_Webhooks": ["header_name", "header_value"], + } + return excluded_fields_map.get(model_name, []) + + +def get_table_names(model_name): + """Get the source table name and event table name for a model.""" + # Handle special cases for table naming + if model_name == "Dojo_User": + table_name = "dojo_dojo_user" + event_table_name = "dojo_dojo_userevent" + elif model_name == "Product_Type": + table_name = "dojo_product_type" + event_table_name = "dojo_product_typeevent" + elif model_name == "Finding_Group": + table_name = "dojo_finding_group" + event_table_name = "dojo_finding_groupevent" + elif model_name == "Risk_Acceptance": + table_name = "dojo_risk_acceptance" + event_table_name = "dojo_risk_acceptanceevent" + elif model_name == "Finding_Template": + table_name = "dojo_finding_template" + event_table_name = "dojo_finding_templateevent" + elif model_name == "Cred_User": + table_name = "dojo_cred_user" + event_table_name = "dojo_cred_userevent" + elif model_name == "Notification_Webhooks": + table_name = "dojo_notification_webhooks" + event_table_name = "dojo_notification_webhooksevent" + elif model_name == "FindingReviewers": + # M2M through table: Django creates dojo_finding_reviewers for Finding.reviewers + table_name = "dojo_finding_reviewers" + event_table_name = "dojo_finding_reviewersevent" else: - if log_enabled: - logger.info("django-auditlog disabled (pghistory or other audit type selected)") - disable_django_auditlog() + table_name = f"dojo_{model_name.lower()}" + event_table_name = f"dojo_{model_name.lower()}event" + return table_name, event_table_name + + +def check_tables_exist(table_name, event_table_name): + """Check if both source and event tables exist.""" + with connection.cursor() as cursor: + cursor.execute(""" + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = %s + ) + """, [table_name]) + table_exists = cursor.fetchone()[0] + + cursor.execute(""" + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = %s + ) + """, [event_table_name]) + event_table_exists = cursor.fetchone()[0] + + return table_exists, event_table_exists + + +def process_model_backfill( + model_name, + batch_size=10000, + *, + dry_run=False, + progress_callback=None, +): + """ + Process a single model's backfill using PostgreSQL COPY. + + Args: + model_name: Name of the model to backfill + batch_size: Number of records to process in each batch + dry_run: If True, only show what would be done without creating events + progress_callback: Optional callable that receives (message, style) tuples + for progress updates. If None, uses logger.info + + Returns: + tuple: (processed_count, records_per_second) + + """ + if progress_callback is None: + def progress_callback(msg, style=None): + logger.info(msg) + + try: + table_name, event_table_name = get_table_names(model_name) + + # Check if tables exist + table_exists, event_table_exists = check_tables_exist(table_name, event_table_name) + + if not table_exists: + progress_callback(f" Table {table_name} not found") + return 0, 0.0 + + if not event_table_exists: + progress_callback( + f" Event table {event_table_name} not found. " + f"Is {model_name} tracked by pghistory?", + "ERROR", + ) + return 0, 0.0 + + # Get total count using raw SQL + with connection.cursor() as cursor: + cursor.execute(f"SELECT COUNT(*) FROM {table_name}") + total_count = cursor.fetchone()[0] + + if total_count == 0: + progress_callback(f" No records found for {model_name}") + return 0, 0.0 + + progress_callback(f" Found {total_count:,} records") + + # Get excluded fields + excluded_fields = get_excluded_fields(model_name) + + # Check if records already have initial_backfill events using raw SQL + with connection.cursor() as cursor: + cursor.execute(f"SELECT COUNT(*) FROM {event_table_name} WHERE pgh_label = 'initial_backfill'") + existing_count = cursor.fetchone()[0] + + # Get records that need backfill using raw SQL + with connection.cursor() as cursor: + cursor.execute(f""" + SELECT COUNT(*) FROM {table_name} t + WHERE NOT EXISTS ( + SELECT 1 FROM {event_table_name} e + WHERE e.pgh_obj_id = t.id AND e.pgh_label = 'initial_backfill' + ) + """) + backfill_count = cursor.fetchone()[0] + + # Log the breakdown + progress_callback(f" Records with initial_backfill events: {existing_count:,}") + progress_callback(f" Records needing initial_backfill events: {backfill_count:,}") + + if backfill_count == 0: + progress_callback(f" ✓ All {total_count:,} records already have initial_backfill events", "SUCCESS") + return total_count, 0.0 + + if dry_run: + progress_callback(f" Would process {backfill_count:,} records using COPY...") + return backfill_count, 0.0 + + # Get event table columns using raw SQL (excluding auto-generated pgh_id) + with connection.cursor() as cursor: + cursor.execute(""" + SELECT column_name + FROM information_schema.columns + WHERE table_name = %s AND column_name != 'pgh_id' + ORDER BY ordinal_position + """, [event_table_name]) + event_columns = [row[0] for row in cursor.fetchall()] + + # Get all IDs that need backfill first + with connection.cursor() as cursor: + cursor.execute(f""" + SELECT t.id FROM {table_name} t + WHERE NOT EXISTS ( + SELECT 1 FROM {event_table_name} e + WHERE e.pgh_obj_id = t.id AND e.pgh_label = 'initial_backfill' + ) + ORDER BY t.id + """) + ids_to_process = [row[0] for row in cursor.fetchall()] + + if not ids_to_process: + progress_callback(" No records need backfill") + return 0, 0.0 + + # Process records in batches using raw SQL + processed = 0 + batch_start_time = time.time() + model_start_time = time.time() # Track model start time + + # Get column names for the source table + with connection.cursor() as cursor: + cursor.execute(""" + SELECT column_name + FROM information_schema.columns + WHERE table_name = %s + ORDER BY ordinal_position + """, [table_name]) + source_columns = [row[0] for row in cursor.fetchall()] + + # Filter out excluded fields from source columns + source_columns = [col for col in source_columns if col not in excluded_fields] + + # Find the index of the 'id' column for pgh_obj_id mapping + try: + id_column_index = source_columns.index("id") + except ValueError: + # If id is excluded (shouldn't happen), fall back to first column + id_column_index = 0 + progress_callback(" Warning: 'id' column not found in source columns, using first column", "WARNING") + + # Process in batches + consecutive_failures = 0 + max_failures = 3 + + for i in range(0, len(ids_to_process), batch_size): + batch_ids = ids_to_process[i:i + batch_size] + + # Log progress every 10 batches + if i > 0 and i % (batch_size * 10) == 0: + progress_callback(f" Processing batch starting at index {i:,}...") + + # Get batch of records using raw SQL with specific IDs + columns_str = ", ".join(source_columns) + placeholders = ", ".join(["%s"] * len(batch_ids)) + query = f""" + SELECT {columns_str} FROM {table_name} t + WHERE t.id IN ({placeholders}) + ORDER BY t.id + """ + + with connection.cursor() as cursor: + cursor.execute(query, batch_ids) + batch_rows = cursor.fetchall() + + if not batch_rows: + progress_callback(f" No records found for batch at index {i}") + continue + + # Use PostgreSQL COPY + try: + # Use PostgreSQL COPY with psycopg3 syntax + with connection.cursor() as cursor: + # Get the underlying raw cursor to bypass Django's wrapper + raw_cursor = cursor.cursor + # Use the copy method (psycopg3 syntax) + copy_sql = f"COPY {event_table_name} ({', '.join(event_columns)}) FROM STDIN WITH (FORMAT text, DELIMITER E'\\t')" + + # Use psycopg3 copy syntax as per documentation + # Prepare data as list of tuples for write_row() + records = [] + for row in batch_rows: + row_data = [] + + # Create a mapping of source columns to values + source_values = {} + for idx, value in enumerate(row): + field_name = source_columns[idx] + source_values[field_name] = value + + # Build row data in the order of event_columns + for col in event_columns: + if col == "pgh_created_at": + row_data.append(timezone.now()) + elif col == "pgh_label": + row_data.append("initial_backfill") + elif col == "pgh_obj_id": + # Use the id column index instead of assuming position + row_data.append(row[id_column_index] if row[id_column_index] is not None else None) + elif col == "pgh_context_id": + row_data.append(None) # Empty for backfilled events + elif col in source_values: + row_data.append(source_values[col]) + else: + row_data.append(None) # Default NULL value + + records.append(tuple(row_data)) + + # Use COPY with write_row() as per psycopg3 docs + with raw_cursor.copy(copy_sql) as copy: + for record in records: + copy.write_row(record) + progress_callback(" COPY operation completed using write_row") + + # Commit the transaction to persist the data + raw_cursor.connection.commit() + + # Debug: Check if data was inserted + raw_cursor.execute(f"SELECT COUNT(*) FROM {event_table_name} WHERE pgh_label = 'initial_backfill'") + count = raw_cursor.fetchone()[0] + progress_callback(f" Records in event table after batch: {count}") + + batch_processed = len(batch_rows) + processed += batch_processed + consecutive_failures = 0 # Reset failure counter on success + + # Calculate timing + batch_end_time = time.time() + batch_duration = batch_end_time - batch_start_time + batch_records_per_second = batch_processed / batch_duration if batch_duration > 0 else 0 + + # Log progress + progress = (processed / backfill_count) * 100 + progress_callback( + f" Processed {processed:,}/{backfill_count:,} records ({progress:.1f}%) - " + f"Last batch: {batch_duration:.2f}s ({batch_records_per_second:.1f} records/sec)", + ) + + batch_start_time = time.time() # Reset for next batch + + except Exception as e: + consecutive_failures += 1 + logger.error(f"Bulk insert failed for {model_name} batch: {e}") + progress_callback(f" Bulk insert failed: {e}", "ERROR") + # Log more details about the error + progress_callback(f" Processed {processed:,} records before failure") + + if consecutive_failures >= max_failures: + progress_callback(f" Too many consecutive failures ({consecutive_failures}), stopping processing", "ERROR") + break + + # Continue with next batch instead of breaking + continue + + # Calculate total timing + model_end_time = time.time() + total_duration = model_end_time - model_start_time + records_per_second = processed / total_duration if total_duration > 0 else 0 + + progress_callback( + f" ✓ Completed {model_name}: {processed:,} records in {total_duration:.2f}s " + f"({records_per_second:.1f} records/sec)", + "SUCCESS", + ) + except Exception as e: + progress_callback(f" ✗ Failed to process {model_name}: {e}", "ERROR") + logger.exception(f"Error processing {model_name}") + return 0, 0.0 + else: + return processed, records_per_second + + +def get_tracked_models(): + """Get the list of models tracked by pghistory.""" + return [ + "Dojo_User", "Endpoint", "Engagement", "Finding", "Finding_Group", + "Product_Type", "Product", "Test", "Risk_Acceptance", + "Finding_Template", "Cred_User", "Notification_Webhooks", + "FindingReviewers", # M2M through table for Finding.reviewers + ] diff --git a/dojo/db_migrations/0249_findingreviewers_findingreviewersevent_and_more.py b/dojo/db_migrations/0249_findingreviewers_findingreviewersevent_and_more.py new file mode 100644 index 00000000000..c338138055c --- /dev/null +++ b/dojo/db_migrations/0249_findingreviewers_findingreviewersevent_and_more.py @@ -0,0 +1,64 @@ +# Generated by Django 5.1.13 on 2025-11-01 17:04 + +import django.db.models.deletion +import pgtrigger.compiler +import pgtrigger.migrations +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("dojo", "0248_alter_general_survey_expiration") + ] + + operations = [ + migrations.CreateModel( + name="FindingReviewers", + fields=[ + ], + options={ + "proxy": True, + "indexes": [], + "constraints": [], + }, + bases=("dojo.finding_reviewers",), + ), + migrations.CreateModel( + name="FindingReviewersEvent", + fields=[ + ("pgh_id", models.AutoField(primary_key=True, serialize=False)), + ("pgh_created_at", models.DateTimeField(auto_now_add=True)), + ("pgh_label", models.TextField(help_text="The event label.")), + ("id", models.IntegerField()), + ("dojo_user", models.ForeignKey(db_constraint=False, db_index=False, db_tablespace="", on_delete=django.db.models.deletion.DO_NOTHING, related_name="+", related_query_name="+", to="dojo.dojo_user")), + ("finding", models.ForeignKey(db_constraint=False, db_index=False, db_tablespace="", on_delete=django.db.models.deletion.DO_NOTHING, related_name="+", related_query_name="+", to="dojo.finding")), + ("pgh_context", models.ForeignKey(db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name="+", to="pghistory.context")), + ("pgh_obj", models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.DO_NOTHING, related_name="events", to="dojo.findingreviewers")), + ], + options={ + "abstract": False, + "db_table": "dojo_finding_reviewersevent", + }, + ), + migrations.AddIndex( + model_name="findingreviewersevent", + index=models.Index(fields=["pgh_created_at"], name="dojo_findin_pgh_cre_d5e5b4_idx"), + ), + migrations.AddIndex( + model_name="findingreviewersevent", + index=models.Index(fields=["pgh_label"], name="dojo_findin_pgh_lab_5517f9_idx"), + ), + migrations.AddIndex( + model_name="findingreviewersevent", + index=models.Index(fields=["pgh_context_id"], name="dojo_findin_pgh_con_06229b_idx"), + ), + pgtrigger.migrations.AddTrigger( + model_name="findingreviewers", + trigger=pgtrigger.compiler.Trigger(name="insert_insert", sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "dojo_finding_reviewersevent" ("dojo_user_id", "finding_id", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id") VALUES (NEW."dojo_user_id", NEW."finding_id", NEW."id", _pgh_attach_context(), NOW(), \'insert\', NEW."id"); RETURN NULL;', hash="5c1fd440159e49c929122cbb590f96983a1c934e", operation="INSERT", pgid="pgtrigger_insert_insert_0808c", table="dojo_finding_reviewers", when="AFTER")), + ), + pgtrigger.migrations.AddTrigger( + model_name="findingreviewers", + trigger=pgtrigger.compiler.Trigger(name="delete_delete", sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "dojo_finding_reviewersevent" ("dojo_user_id", "finding_id", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id") VALUES (OLD."dojo_user_id", OLD."finding_id", OLD."id", _pgh_attach_context(), NOW(), \'delete\', OLD."id"); RETURN NULL;', hash="23a4e01eaea469f708679392a6a92a6e16b21181", operation="DELETE", pgid="pgtrigger_delete_delete_40083", table="dojo_finding_reviewers", when="AFTER")), + ), + ] diff --git a/dojo/db_migrations/0250_pghistory_backfill.py b/dojo/db_migrations/0250_pghistory_backfill.py new file mode 100644 index 00000000000..5d09d77c0e2 --- /dev/null +++ b/dojo/db_migrations/0250_pghistory_backfill.py @@ -0,0 +1,85 @@ +# Generated manually for pghistory initial backfill + +import logging + +from django.conf import settings +from django.db import migrations + +from dojo.auditlog import ( + get_tracked_models, + process_model_backfill, +) + +logger = logging.getLogger(__name__) + + +def backfill_pghistory_tables(apps, schema_editor): + """ + Backfill pghistory tables with initial snapshots of existing records. + + This migration is fail-safe: if it fails for some reason, it will continue + where it left off on the next run, as it only processes records that don't + already have initial_backfill events. + """ + # Skip if auditlog is not enabled + if not settings.ENABLE_AUDITLOG: + logger.info("pghistory is not enabled. Skipping backfill.") + return + + # Check if we can use COPY (PostgreSQL only) + if settings.DATABASES["default"]["ENGINE"] != "django.db.backends.postgresql": + logger.warning( + "COPY operations only available with PostgreSQL. " + "Skipping backfill. Use the pghistory_backfill command instead.", + ) + return + + # Progress callback for migration logging + def progress_callback(msg, style=None): + """Progress callback that logs to Django's logger.""" + if style == "ERROR": + logger.error(msg) + elif style == "WARNING": + logger.warning(msg) + elif style == "SUCCESS": + logger.info(msg) + else: + logger.info(msg) + + # Get all tracked models + tracked_models = get_tracked_models() + + logger.info(f"Starting pghistory backfill for {len(tracked_models)} model(s)...") + + total_processed = 0 + for model_name in tracked_models: + logger.info(f"Processing {model_name}...") + try: + processed, _ = process_model_backfill( + model_name=model_name, + batch_size=10000, + dry_run=False, + progress_callback=progress_callback, + ) + total_processed += processed + except Exception as e: + logger.error(f"Failed to backfill {model_name}: {e}", exc_info=True) + # Continue with other models even if one fails + continue + + logger.info(f"Pghistory backfill complete: Processed {total_processed:,} records") + + +class Migration(migrations.Migration): + + dependencies = [ + ("dojo", "0249_findingreviewers_findingreviewersevent_and_more"), + ] + + operations = [ + migrations.RunPython( + backfill_pghistory_tables, + reverse_code=migrations.RunPython.noop, + ), + ] + diff --git a/dojo/endpoint/signals.py b/dojo/endpoint/signals.py index 50251c5a80a..aebc348c003 100644 --- a/dojo/endpoint/signals.py +++ b/dojo/endpoint/signals.py @@ -1,9 +1,7 @@ import contextlib -from auditlog.models import LogEntry from django.conf import settings from django.contrib.auth import get_user_model -from django.contrib.contenttypes.models import ContentType from django.db.models.signals import post_delete from django.dispatch import receiver from django.urls import reverse @@ -23,7 +21,7 @@ def endpoint_post_delete(sender, instance, using, origin, **kwargs): user = None if settings.ENABLE_AUDITLOG: - # First try to find deletion author in pghistory events + # Find deletion author in pghistory events # Look for delete events for this specific endpoint instance pghistory_delete_events = DojoEvents.objects.filter( pgh_obj_model="dojo.Endpoint", @@ -39,15 +37,6 @@ def endpoint_post_delete(sender, instance, using, origin, **kwargs): with contextlib.suppress(User.DoesNotExist): user = User.objects.get(id=latest_delete.user) - # Fall back to django-auditlog if no user found in pghistory - if not user: - if le := LogEntry.objects.filter( - action=LogEntry.Action.DELETE, - content_type=ContentType.objects.get(app_label="dojo", model="endpoint"), - object_id=instance.id, - ).order_by("-id").first(): - user = le.actor - # Update description with user if found if user: description = _('The endpoint "%(name)s" was deleted by %(user)s') % { diff --git a/dojo/engagement/signals.py b/dojo/engagement/signals.py index 144094a3264..0d6b8916dd2 100644 --- a/dojo/engagement/signals.py +++ b/dojo/engagement/signals.py @@ -1,9 +1,7 @@ import contextlib -from auditlog.models import LogEntry from django.conf import settings from django.contrib.auth import get_user_model -from django.contrib.contenttypes.models import ContentType from django.db.models.signals import post_delete, post_save, pre_delete, pre_save from django.dispatch import receiver from django.urls import reverse @@ -50,7 +48,7 @@ def engagement_post_delete(sender, instance, using, origin, **kwargs): user = None if settings.ENABLE_AUDITLOG: - # First try to find deletion author in pghistory events + # Find deletion author in pghistory events # Look for delete events for this specific engagement instance pghistory_delete_events = DojoEvents.objects.filter( pgh_obj_model="dojo.Engagement", @@ -66,15 +64,6 @@ def engagement_post_delete(sender, instance, using, origin, **kwargs): with contextlib.suppress(User.DoesNotExist): user = User.objects.get(id=latest_delete.user) - # Fall back to django-auditlog if no user found in pghistory - if not user: - if le := LogEntry.objects.filter( - action=LogEntry.Action.DELETE, - content_type=ContentType.objects.get(app_label="dojo", model="engagement"), - object_id=instance.id, - ).order_by("-id").first(): - user = le.actor - # Update description with user if found if user: description = _('The engagement "%(name)s" was deleted by %(user)s') % { diff --git a/dojo/filters.py b/dojo/filters.py index 620783f98de..4ae5224dab6 100644 --- a/dojo/filters.py +++ b/dojo/filters.py @@ -3607,7 +3607,7 @@ class PgHistoryFilter(DojoFilter): ("insert", "Insert"), ("update", "Update"), ("delete", "Delete"), - ("initial_import", "Initial Import"), + ("initial_backfill", "Initial Backfill"), ], ) diff --git a/dojo/finding_group/signals.py b/dojo/finding_group/signals.py index 3e7ffe7c7b7..4b41b838983 100644 --- a/dojo/finding_group/signals.py +++ b/dojo/finding_group/signals.py @@ -1,9 +1,7 @@ import contextlib -from auditlog.models import LogEntry from django.conf import settings from django.contrib.auth import get_user_model -from django.contrib.contenttypes.models import ContentType from django.db.models.signals import post_delete from django.dispatch import receiver from django.urls import reverse @@ -21,7 +19,7 @@ def finding_group_post_delete(sender, instance, using, origin, **kwargs): user = None if settings.ENABLE_AUDITLOG: - # First try to find deletion author in pghistory events + # Find deletion author in pghistory events # Look for delete events for this specific finding_group instance pghistory_delete_events = DojoEvents.objects.filter( pgh_obj_model="dojo.Finding_Group", @@ -37,15 +35,6 @@ def finding_group_post_delete(sender, instance, using, origin, **kwargs): with contextlib.suppress(User.DoesNotExist): user = User.objects.get(id=latest_delete.user) - # Fall back to django-auditlog if no user found in pghistory - if not user: - if le := LogEntry.objects.filter( - action=LogEntry.Action.DELETE, - content_type=ContentType.objects.get(app_label="dojo", model="finding_group"), - object_id=instance.id, - ).order_by("-id").first(): - user = le.actor - # Update description with user if found if user: description = _('The finding group "%(name)s" was deleted by %(user)s') % { diff --git a/dojo/management/commands/pghistory_backfill.py b/dojo/management/commands/pghistory_backfill.py index 456cbe75c5d..751799831a2 100644 --- a/dojo/management/commands/pghistory_backfill.py +++ b/dojo/management/commands/pghistory_backfill.py @@ -37,12 +37,7 @@ def add_arguments(self, parser): parser.add_argument( "--log-queries", action="store_true", - help="Enable database query logging (default: enabled)", - ) - parser.add_argument( - "--no-log-queries", - action="store_true", - help="Disable database query logging", + help="Enable database query logging (default: disabled)", ) def get_excluded_fields(self, model_name): @@ -140,31 +135,27 @@ def disable_db_logging(self): ) def handle(self, *args, **options): - if not settings.ENABLE_AUDITLOG or settings.AUDITLOG_TYPE != "django-pghistory": + if not settings.ENABLE_AUDITLOG: self.stdout.write( self.style.WARNING( - "pghistory is not enabled. Set DD_ENABLE_AUDITLOG=True and " - "DD_AUDITLOG_TYPE=django-pghistory", + "pghistory is not enabled. Set DD_ENABLE_AUDITLOG=True", ), ) return # Enable database query logging based on options - # Default to enabled unless explicitly disabled - enable_query_logging = not options.get("no_log_queries") + # Default to disabled unless explicitly enabled + enable_query_logging = options.get("log_queries", False) if enable_query_logging: self.enable_db_logging() - else: - self.stdout.write( - self.style.WARNING("Database query logging disabled"), - ) # Models that are tracked by pghistory tracked_models = [ "Dojo_User", "Endpoint", "Engagement", "Finding", "Finding_Group", "Product_Type", "Product", "Test", "Risk_Acceptance", "Finding_Template", "Cred_User", "Notification_Webhooks", + "FindingReviewers", # M2M through table for Finding.reviewers ] specific_model = options.get("model") @@ -220,23 +211,23 @@ def handle(self, *args, **options): ) continue - # Get IDs of records that already have initial_import events - existing_initial_import_ids = set( - EventModel.objects.filter(pgh_label="initial_import").values_list("pgh_obj_id", flat=True), + # Get IDs of records that already have initial_backfill events + existing_initial_backfill_ids = set( + EventModel.objects.filter(pgh_label="initial_backfill").values_list("pgh_obj_id", flat=True), ) - # Filter to only get records that don't have initial_import events - records_needing_backfill = Model.objects.exclude(id__in=existing_initial_import_ids) + # Filter to only get records that don't have initial_backfill events + records_needing_backfill = Model.objects.exclude(id__in=existing_initial_backfill_ids) backfill_count = records_needing_backfill.count() - existing_count = len(existing_initial_import_ids) + existing_count = len(existing_initial_backfill_ids) # Log the breakdown - self.stdout.write(f" Records with initial_import events: {existing_count:,}") - self.stdout.write(f" Records needing initial_import events: {backfill_count:,}") + self.stdout.write(f" Records with initial_backfill events: {existing_count:,}") + self.stdout.write(f" Records needing initial_backfill events: {backfill_count:,}") if backfill_count == 0: self.stdout.write( - self.style.SUCCESS(f" ✓ All {total_count:,} records already have initial_import events"), + self.style.SUCCESS(f" ✓ All {total_count:,} records already have initial_backfill events"), ) processed = total_count continue @@ -284,7 +275,7 @@ def handle(self, *args, **options): # Add pghistory-specific fields event_data.update({ - "pgh_label": "initial_import", + "pgh_label": "initial_backfill", "pgh_obj": instance, # ForeignKey to the original object "pgh_context": None, # No context for backfilled events }) diff --git a/dojo/management/commands/pghistory_backfill_fast.py b/dojo/management/commands/pghistory_backfill_fast.py index a2f1921fc74..23682ad6c62 100644 --- a/dojo/management/commands/pghistory_backfill_fast.py +++ b/dojo/management/commands/pghistory_backfill_fast.py @@ -4,14 +4,16 @@ This command creates initial snapshots for all existing records in tracked models using PostgreSQL COPY for maximum performance. """ -import io import logging import time from django.conf import settings from django.core.management.base import BaseCommand -from django.db import connection -from django.utils import timezone + +from dojo.auditlog import ( + get_tracked_models, + process_model_backfill, +) logger = logging.getLogger(__name__) @@ -39,377 +41,28 @@ def add_arguments(self, parser): parser.add_argument( "--log-queries", action="store_true", - help="Enable database query logging (default: enabled)", + help="Enable database query logging (default: disabled)", ) - parser.add_argument( - "--no-log-queries", - action="store_true", - help="Disable database query logging", - ) - - def get_excluded_fields(self, model_name): - """Get the list of excluded fields for a specific model from pghistory configuration.""" - # Define excluded fields for each model (matching auditlog.py) - excluded_fields_map = { - "Dojo_User": ["password"], - "Product": ["updated"], # This is the key change - "Cred_User": ["password"], - "Notification_Webhooks": ["header_name", "header_value"], - } - return excluded_fields_map.get(model_name, []) def process_model_with_copy(self, model_name, batch_size, dry_run): """Process a single model using COPY operations with raw SQL.""" - try: - # Get table names using raw SQL - # Handle special cases for table naming - if model_name == "Dojo_User": - table_name = "dojo_dojo_user" - event_table_name = "dojo_dojo_userevent" - elif model_name == "Product_Type": - table_name = "dojo_product_type" - event_table_name = "dojo_product_typeevent" - elif model_name == "Finding_Group": - table_name = "dojo_finding_group" - event_table_name = "dojo_finding_groupevent" - elif model_name == "Risk_Acceptance": - table_name = "dojo_risk_acceptance" - event_table_name = "dojo_risk_acceptanceevent" - elif model_name == "Finding_Template": - table_name = "dojo_finding_template" - event_table_name = "dojo_finding_templateevent" - elif model_name == "Cred_User": - table_name = "dojo_cred_user" - event_table_name = "dojo_cred_userevent" - elif model_name == "Notification_Webhooks": - table_name = "dojo_notification_webhooks" - event_table_name = "dojo_notification_webhooksevent" + def progress_callback(msg, style=None): + """Progress callback that uses self.stdout.write with styling.""" + if style == "SUCCESS": + self.stdout.write(self.style.SUCCESS(msg)) + elif style == "ERROR": + self.stdout.write(self.style.ERROR(msg)) + elif style == "WARNING": + self.stdout.write(self.style.WARNING(msg)) else: - table_name = f"dojo_{model_name.lower()}" - event_table_name = f"dojo_{model_name.lower()}event" - - # Check if tables exist - with connection.cursor() as cursor: - cursor.execute(""" - SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_name = %s - ) - """, [table_name]) - table_exists = cursor.fetchone()[0] - - cursor.execute(""" - SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_name = %s - ) - """, [event_table_name]) - event_table_exists = cursor.fetchone()[0] - - if not table_exists: - self.stdout.write(f" Table {table_name} not found") - return 0, 0.0 - - if not event_table_exists: - self.stdout.write( - self.style.ERROR( - f" Event table {event_table_name} not found. " - f"Is {model_name} tracked by pghistory?", - ), - ) - return 0, 0.0 - - # Get total count using raw SQL - with connection.cursor() as cursor: - cursor.execute(f"SELECT COUNT(*) FROM {table_name}") - total_count = cursor.fetchone()[0] - - if total_count == 0: - self.stdout.write(f" No records found for {model_name}") - return 0, 0.0 - - self.stdout.write(f" Found {total_count:,} records") - - # Get excluded fields - excluded_fields = self.get_excluded_fields(model_name) - - # Check if records already have initial_import events using raw SQL - with connection.cursor() as cursor: - cursor.execute(f"SELECT COUNT(*) FROM {event_table_name} WHERE pgh_label = 'initial_import'") - existing_count = cursor.fetchone()[0] - - # Get records that need backfill using raw SQL - with connection.cursor() as cursor: - cursor.execute(f""" - SELECT COUNT(*) FROM {table_name} t - WHERE NOT EXISTS ( - SELECT 1 FROM {event_table_name} e - WHERE e.pgh_obj_id = t.id AND e.pgh_label = 'initial_import' - ) - """) - backfill_count = cursor.fetchone()[0] - - # Log the breakdown - self.stdout.write(f" Records with initial_import events: {existing_count:,}") - self.stdout.write(f" Records needing initial_import events: {backfill_count:,}") - - if backfill_count == 0: - self.stdout.write( - self.style.SUCCESS(f" ✓ All {total_count:,} records already have initial_import events"), - ) - return total_count, 0.0 - - if dry_run: - self.stdout.write(f" Would process {backfill_count:,} records using COPY...") - return backfill_count, 0.0 - - # Get event table columns using raw SQL (excluding auto-generated pgh_id) - with connection.cursor() as cursor: - cursor.execute(""" - SELECT column_name - FROM information_schema.columns - WHERE table_name = %s AND column_name != 'pgh_id' - ORDER BY ordinal_position - """, [event_table_name]) - event_columns = [row[0] for row in cursor.fetchall()] - - # Get all IDs that need backfill first - with connection.cursor() as cursor: - cursor.execute(f""" - SELECT t.id FROM {table_name} t - WHERE NOT EXISTS ( - SELECT 1 FROM {event_table_name} e - WHERE e.pgh_obj_id = t.id AND e.pgh_label = 'initial_import' - ) - ORDER BY t.id - """) - ids_to_process = [row[0] for row in cursor.fetchall()] - - if not ids_to_process: - self.stdout.write(" No records need backfill") - return 0, 0.0 - - # Process records in batches using raw SQL - processed = 0 - batch_start_time = time.time() - model_start_time = time.time() # Track model start time - - # Get column names for the source table - with connection.cursor() as cursor: - cursor.execute(""" - SELECT column_name - FROM information_schema.columns - WHERE table_name = %s - ORDER BY ordinal_position - """, [table_name]) - source_columns = [row[0] for row in cursor.fetchall()] - - # Filter out excluded fields from source columns - source_columns = [col for col in source_columns if col not in excluded_fields] - - # Process in batches - consecutive_failures = 0 - max_failures = 3 - - for i in range(0, len(ids_to_process), batch_size): - batch_ids = ids_to_process[i:i + batch_size] - - # Log progress every 10 batches - if i > 0 and i % (batch_size * 10) == 0: - self.stdout.write(f" Processing batch starting at index {i:,}...") - - # Get batch of records using raw SQL with specific IDs - columns_str = ", ".join(source_columns) - placeholders = ", ".join(["%s"] * len(batch_ids)) - query = f""" - SELECT {columns_str} FROM {table_name} t - WHERE t.id IN ({placeholders}) - ORDER BY t.id - """ - - with connection.cursor() as cursor: - cursor.execute(query, batch_ids) - batch_rows = cursor.fetchall() - - if not batch_rows: - self.stdout.write(f" No records found for batch at index {i}") - continue - - # Use PostgreSQL COPY as described in the article - try: - # Prepare data for COPY using a custom file-like object - class FileLikeObject: - def __init__(self): - self.data = io.BytesIO() - - def write(self, data): - return self.data.write(data) - - def read(self, size=-1): - return self.data.read(size) - - def seek(self, pos): - return self.data.seek(pos) - - def tell(self): - return self.data.tell() - - def __len__(self): - return len(self.data.getvalue()) - - def getvalue(self): - return self.data.getvalue() - - copy_buffer = FileLikeObject() - - for row in batch_rows: - row_data = [] - - # Create a mapping of source columns to values - source_values = {} - for idx, value in enumerate(row): - field_name = source_columns[idx] - # Convert value to string for COPY - if value is None: - source_values[field_name] = "" - elif isinstance(value, bool): - source_values[field_name] = "t" if value else "f" - elif hasattr(value, "isoformat"): # datetime objects - source_values[field_name] = value.isoformat() - else: - source_values[field_name] = str(value) - - # Build row data in the order of event_columns - for col in event_columns: - if col == "pgh_created_at": - row_data.append(timezone.now().isoformat()) - elif col == "pgh_label": - row_data.append("initial_import") - elif col == "pgh_obj_id": - row_data.append(str(row[0]) if row[0] is not None else "") # Assuming first column is id - elif col == "pgh_context_id": - row_data.append("") # Empty for backfilled events - elif col in source_values: - row_data.append(source_values[col]) - else: - row_data.append("") # Default empty value - - # Write tab-separated row to buffer as bytes - copy_buffer.write(("\t".join(row_data) + "\n").encode("utf-8")) - - copy_buffer.seek(0) - - # Debug: Show what we're about to copy - self.stdout.write(f" Batch {i // batch_size + 1}: Writing to table: {event_table_name}") - - # Use PostgreSQL COPY with psycopg3 syntax - with connection.cursor() as cursor: - # Get the underlying raw cursor to bypass Django's wrapper - raw_cursor = cursor.cursor - # Use the copy method (psycopg3 syntax) - copy_sql = f"COPY {event_table_name} ({', '.join(event_columns)}) FROM STDIN WITH (FORMAT text, DELIMITER E'\\t')" - - try: - # Use psycopg3 copy syntax as per documentation - # Prepare data as list of tuples for write_row() - records = [] - for row in batch_rows: - row_data = [] - - # Create a mapping of source columns to values - source_values = {} - for idx, value in enumerate(row): - field_name = source_columns[idx] - source_values[field_name] = value - - # Build row data in the order of event_columns - for col in event_columns: - if col == "pgh_created_at": - row_data.append(timezone.now()) - elif col == "pgh_label": - row_data.append("initial_import") - elif col == "pgh_obj_id": - row_data.append(row[0]) # Assuming first column is id - elif col == "pgh_context_id": - row_data.append(None) # Empty for backfilled events - elif col in source_values: - row_data.append(source_values[col]) - else: - row_data.append(None) # Default NULL value - - records.append(tuple(row_data)) - - # Use COPY with write_row() as per psycopg3 docs - with raw_cursor.copy(copy_sql) as copy: - for record in records: - copy.write_row(record) - self.stdout.write(" COPY operation completed using write_row") - - # Commit the transaction to persist the data - raw_cursor.connection.commit() - - # Debug: Check if data was inserted - raw_cursor.execute(f"SELECT COUNT(*) FROM {event_table_name} WHERE pgh_label = 'initial_import'") - count = raw_cursor.fetchone()[0] - self.stdout.write(f" Records in event table after batch: {count}") - - except Exception as copy_error: - self.stdout.write(f" COPY error: {copy_error}") - # Try to get more details about the error - raw_cursor.execute("SELECT * FROM pg_stat_activity WHERE state = 'active'") - self.stdout.write(f" Active queries: {raw_cursor.fetchall()}") - raise - - batch_processed = len(batch_rows) - processed += batch_processed - consecutive_failures = 0 # Reset failure counter on success - - # Calculate timing - batch_end_time = time.time() - batch_duration = batch_end_time - batch_start_time - batch_records_per_second = batch_processed / batch_duration if batch_duration > 0 else 0 - - # Log progress - progress = (processed / backfill_count) * 100 - self.stdout.write(f" Processed {processed:,}/{backfill_count:,} records ({progress:.1f}%) - " - f"Last batch: {batch_duration:.2f}s ({batch_records_per_second:.1f} records/sec)") - - batch_start_time = time.time() # Reset for next batch - - except Exception as e: - consecutive_failures += 1 - logger.error(f"Bulk insert failed for {model_name} batch: {e}") - self.stdout.write(f" Bulk insert failed: {e}") - # Log more details about the error - self.stdout.write(f" Processed {processed:,} records before failure") - - if consecutive_failures >= max_failures: - self.stdout.write(f" Too many consecutive failures ({consecutive_failures}), stopping processing") - break - - # Continue with next batch instead of breaking - continue - - # Calculate total timing - model_end_time = time.time() - total_duration = model_end_time - model_start_time - records_per_second = processed / total_duration if total_duration > 0 else 0 - - self.stdout.write( - self.style.SUCCESS( - f" ✓ Completed {model_name}: {processed:,} records in {total_duration:.2f}s " - f"({records_per_second:.1f} records/sec)", - ), - ) + self.stdout.write(msg) - return processed, records_per_second # noqa: TRY300 - - except Exception as e: - self.stdout.write( - self.style.ERROR(f" ✗ Failed to process {model_name}: {e}"), - ) - logger.exception(f"Error processing {model_name}") - return 0, 0.0 + return process_model_backfill( + model_name=model_name, + batch_size=batch_size, + dry_run=dry_run, + progress_callback=progress_callback, + ) def enable_db_logging(self): """Enable database query logging for this command.""" @@ -457,11 +110,10 @@ def disable_db_logging(self): ) def handle(self, *args, **options): - if not settings.ENABLE_AUDITLOG or settings.AUDITLOG_TYPE != "django-pghistory": + if not settings.ENABLE_AUDITLOG: self.stdout.write( self.style.WARNING( - "pghistory is not enabled. Set DD_ENABLE_AUDITLOG=True and " - "DD_AUDITLOG_TYPE=django-pghistory", + "pghistory is not enabled. Set DD_ENABLE_AUDITLOG=True", ), ) return @@ -477,7 +129,7 @@ def handle(self, *args, **options): return # Enable database query logging based on options - enable_query_logging = not options.get("no_log_queries") + enable_query_logging = options.get("log_queries") if enable_query_logging: self.enable_db_logging() @@ -487,11 +139,7 @@ def handle(self, *args, **options): ) # Models that are tracked by pghistory - tracked_models = [ - "Dojo_User", "Endpoint", "Engagement", "Finding", "Finding_Group", - "Product_Type", "Product", "Test", "Risk_Acceptance", - "Finding_Template", "Cred_User", "Notification_Webhooks", - ] + tracked_models = get_tracked_models() specific_model = options.get("model") if specific_model: @@ -518,7 +166,6 @@ def handle(self, *args, **options): self.stdout.write(f"Starting backfill for {len(tracked_models)} model(s) using PostgreSQL COPY...") for model_name in tracked_models: - time.time() self.stdout.write(f"\nProcessing {model_name}...") processed, _ = self.process_model_with_copy( diff --git a/dojo/management/commands/pghistory_backfill_simple.py b/dojo/management/commands/pghistory_backfill_simple.py index 0203b5506a7..ebc4d452473 100644 --- a/dojo/management/commands/pghistory_backfill_simple.py +++ b/dojo/management/commands/pghistory_backfill_simple.py @@ -119,7 +119,7 @@ def process_model_simple(self, model_name, batch_size, dry_run): SELECT COUNT(*) FROM {table_name} t WHERE NOT EXISTS ( SELECT 1 FROM {event_table_name} e - WHERE e.pgh_obj_id = t.id AND e.pgh_label = 'initial_import' + WHERE e.pgh_obj_id = t.id AND e.pgh_label = 'initial_backfill' ) """) backfill_count = cursor.fetchone()[0] @@ -165,7 +165,7 @@ def process_model_simple(self, model_name, batch_size, dry_run): if col == "pgh_created_at": select_columns.append("NOW() as pgh_created_at") elif col == "pgh_label": - select_columns.append("'initial_import' as pgh_label") + select_columns.append("'initial_backfill' as pgh_label") elif col == "pgh_obj_id": select_columns.append("t.id as pgh_obj_id") elif col == "pgh_context_id": @@ -181,7 +181,7 @@ def process_model_simple(self, model_name, batch_size, dry_run): SELECT t.id FROM {table_name} t WHERE NOT EXISTS ( SELECT 1 FROM {event_table_name} e - WHERE e.pgh_obj_id = t.id AND e.pgh_label = 'initial_import' + WHERE e.pgh_obj_id = t.id AND e.pgh_label = 'initial_backfill' ) ORDER BY t.id """) diff --git a/dojo/management/commands/pghistory_clear.py b/dojo/management/commands/pghistory_clear.py index a2593ac25ca..97b938293d7 100644 --- a/dojo/management/commands/pghistory_clear.py +++ b/dojo/management/commands/pghistory_clear.py @@ -35,11 +35,10 @@ def add_arguments(self, parser): ) def handle(self, *args, **options): - if not settings.ENABLE_AUDITLOG or settings.AUDITLOG_TYPE != "django-pghistory": + if not settings.ENABLE_AUDITLOG: self.stdout.write( self.style.WARNING( - "pghistory is not enabled. Set DD_ENABLE_AUDITLOG=True and " - "DD_AUDITLOG_TYPE=django-pghistory", + "pghistory is not enabled. Set DD_ENABLE_AUDITLOG=True", ), ) return diff --git a/dojo/management/commands/stamp_finding_last_reviewed.py b/dojo/management/commands/stamp_finding_last_reviewed.py deleted file mode 100644 index ade2bcb6bc3..00000000000 --- a/dojo/management/commands/stamp_finding_last_reviewed.py +++ /dev/null @@ -1,78 +0,0 @@ - -from auditlog.models import LogEntry -from django.contrib.contenttypes.models import ContentType -from django.core.management.base import BaseCommand - -from dojo.models import Finding - -""" -Authors: Jay Paz -New fields last_reviewed, last_reviewed_by, mitigated_by have been added to the Finding model -This script will update all findings with a last_reviewed date of the most current date from: -1. Finding Date if no other evidence of activity is found -2. Last note added date if a note is found -3. Mitigation Date if finding is mitigated -4. Last action_log entry date if Finding has been updated - -It will update the last_reviewed_by with the current reporter. - -If mitigated it will update the mitigated_by with last_reviewed_by or current reporter if last_reviewed_by is None -""" - - -class Command(BaseCommand): - help = ( - "A new field last_reviewed has been added to the Finding model \n" - "This script will update all findings with a last_reviewed date of the most current date from: \n" - "1. Finding Date if no other evidence of activity is found \n" - "2. Last note added date if a note is found \n" - "3. Mitigation Date if finding is mitigated \n" - "4. Last action_log entry date if Finding has been updated \n" - ) - - def handle(self, *args, **options): - findings = Finding.objects.all().order_by("id") - for finding in findings: - save = False - if not finding.last_reviewed: - date_discovered = finding.date - last_note_date = finding.date - - if finding.notes.all(): - last_note_date = finding.notes.order_by("-date")[ - 0].date.date() - - mitigation_date = finding.date - - if finding.mitigated: - mitigation_date = finding.mitigated.date() - - last_action_date = finding.date - - try: - ct = ContentType.objects.get_for_id( - ContentType.objects.get_for_model(finding).id) - obj = ct.get_object_for_this_type(pk=finding.id) - log_entries = LogEntry.objects.filter(content_type=ct, - object_pk=obj.id).order_by( - "-timestamp") - if log_entries: - last_action_date = log_entries[0].timestamp.date() - except KeyError: - pass - - finding.last_reviewed = max( - [date_discovered, last_note_date, mitigation_date, - last_action_date]) - save = True - - if not finding.last_reviewed_by: - finding.last_reviewed_by = finding.reporter - save = True - - if finding.mitigated: - if not finding.mitigated_by: - finding.mitigated_by = finding.last_reviewed_by or finding.reporter - save = True - if save: - finding.save() diff --git a/dojo/middleware.py b/dojo/middleware.py index 275cfa09e71..f2c6c1f908a 100644 --- a/dojo/middleware.py +++ b/dojo/middleware.py @@ -7,15 +7,12 @@ import pghistory.middleware import requests -from auditlog.context import set_actor -from auditlog.middleware import AuditlogMiddleware as _AuditlogMiddleware from django.conf import settings from django.contrib import messages from django.db import models from django.http import HttpResponseRedirect from django.shortcuts import redirect from django.urls import reverse -from django.utils.functional import SimpleLazyObject from social_core.exceptions import AuthCanceled, AuthFailed, AuthForbidden, AuthTokenError from social_django.middleware import SocialAuthExceptionMiddleware from watson.middleware import SearchContextMiddleware @@ -211,20 +208,6 @@ def __call__(self, request): return self.get_response(request) -# This solution comes from https://github.com/jazzband/django-auditlog/issues/115#issuecomment-1539262735 -# It fix situation when TokenAuthentication is used in API. Otherwise, actor in AuditLog would be set to None -class AuditlogMiddleware(_AuditlogMiddleware): - def __call__(self, request): - remote_addr = self._get_remote_addr(request) - - user = SimpleLazyObject(lambda: getattr(request, "user", None)) - - context = set_actor(actor=user, remote_addr=remote_addr) - - with context: - return self.get_response(request) - - class PgHistoryMiddleware(pghistory.middleware.HistoryMiddleware): """ diff --git a/dojo/product/signals.py b/dojo/product/signals.py index 0ed9a62747c..efcf23da5aa 100644 --- a/dojo/product/signals.py +++ b/dojo/product/signals.py @@ -1,9 +1,7 @@ import contextlib -from auditlog.models import LogEntry from django.conf import settings from django.contrib.auth import get_user_model -from django.contrib.contenttypes.models import ContentType from django.db.models.signals import post_delete, post_save from django.dispatch import receiver from django.urls import reverse @@ -37,7 +35,7 @@ def product_post_delete(sender, instance, **kwargs): user = None if settings.ENABLE_AUDITLOG: - # First try to find deletion author in pghistory events + # Find deletion author in pghistory events # Look for delete events for this specific product instance pghistory_delete_events = DojoEvents.objects.filter( pgh_obj_model="dojo.Product", @@ -53,15 +51,7 @@ def product_post_delete(sender, instance, **kwargs): with contextlib.suppress(User.DoesNotExist): user = User.objects.get(id=latest_delete.user) - # Fall back to django-auditlog if no user found in pghistory - if not user: - if le := LogEntry.objects.filter( - action=LogEntry.Action.DELETE, - content_type=ContentType.objects.get(app_label="dojo", model="product"), - object_id=instance.id, - ).order_by("-id").first(): - user = le.actor - + # Fallback to the current user of the request (Which might be not available for ASYNC_OBJECT_DELETE scenario's) if not user: current_user = get_current_user() user = current_user diff --git a/dojo/product_type/signals.py b/dojo/product_type/signals.py index 523e7dcedc4..8bb435751d5 100644 --- a/dojo/product_type/signals.py +++ b/dojo/product_type/signals.py @@ -1,10 +1,8 @@ import contextlib -from auditlog.models import LogEntry from crum import get_current_user from django.conf import settings from django.contrib.auth import get_user_model -from django.contrib.contenttypes.models import ContentType from django.db.models.signals import post_delete, post_save from django.dispatch import receiver from django.urls import reverse @@ -37,7 +35,7 @@ def product_type_post_delete(sender, instance, **kwargs): user = None if settings.ENABLE_AUDITLOG: - # First try to find deletion author in pghistory events + # Find deletion author in pghistory events # Look for delete events for this specific product_type instance pghistory_delete_events = DojoEvents.objects.filter( pgh_obj_model="dojo.Product_Type", @@ -53,16 +51,6 @@ def product_type_post_delete(sender, instance, **kwargs): with contextlib.suppress(User.DoesNotExist): user = User.objects.get(id=latest_delete.user) - # Fall back to django-auditlog if no user found in pghistory - if not user: - if le := LogEntry.objects.filter( - action=LogEntry.Action.DELETE, - content_type=ContentType.objects.get(app_label="dojo", model="product_type"), - object_id=instance.id, - ).order_by("-id").first(): - user = le.actor - - # Since adding pghistory as auditlog option, this signal here runs before the django-auditlog signal # Fallback to the current user of the request (Which might be not available for ASYNC_OBJECT_DELETE scenario's) if not user: current_user = get_current_user() diff --git a/dojo/settings/settings.dist.py b/dojo/settings/settings.dist.py index 2ef1835b08e..0f90261afcf 100644 --- a/dojo/settings/settings.dist.py +++ b/dojo/settings/settings.dist.py @@ -31,7 +31,7 @@ DD_SITE_URL=(str, "http://localhost:8080"), DD_DEBUG=(bool, False), DD_DJANGO_DEBUG_TOOLBAR_ENABLED=(bool, False), - # django-auditlog imports django-jsonfield-backport raises a warning that can be ignored, + # django-jsonfield-backport raises a warning that can be ignored, # see https://github.com/laymonage/django-jsonfield-backport # debug_toolbar.E001 is raised when running tests in dev mode via run-unittests.sh DD_SILENCED_SYSTEM_CHECKS=(list, ["debug_toolbar.E001", "django_jsonfield_backport.W001"]), @@ -340,12 +340,9 @@ DD_DEDUPLICATION_ALGORITHM_PER_PARSER=(str, ""), # Dictates whether cloud banner is created or not DD_CREATE_CLOUD_BANNER=(bool, True), - # With this setting turned on, Dojo maintains an audit log of changes made to entities (Findings, Tests, Engagements, Procuts, ...) - # If you run big import you may want to disable this because the way django-auditlog currently works, there's - # a big performance hit. Especially during (re-)imports. + # With this setting turned on, Dojo maintains an audit log of changes made to entities (Findings, Tests, Engagements, Products, ...) + # If you run big import you may want to disable this because there's a performance hit during (re-)imports. DD_ENABLE_AUDITLOG=(bool, True), - # Audit logging system: "django-auditlog" (default) or "django-pghistory" - DD_AUDITLOG_TYPE=(str, "django-auditlog"), # Specifies whether the "first seen" date of a given report should be used over the "last seen" date DD_USE_FIRST_SEEN=(bool, False), # When set to True, use the older version of the qualys parser that is a more heavy handed in setting severity @@ -714,6 +711,7 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param SIMILAR_FINDINGS_MAX_RESULTS = env("DD_SIMILAR_FINDINGS_MAX_RESULTS") MAX_REQRESP_FROM_API = env("DD_MAX_REQRESP_FROM_API") MAX_AUTOCOMPLETE_WORDS = env("DD_MAX_AUTOCOMPLETE_WORDS") +ENABLE_AUDITLOG = env("DD_ENABLE_AUDITLOG") LOGIN_EXEMPT_URLS = ( rf"^{URL_PREFIX}static/", @@ -971,7 +969,6 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param "dojo.middleware.AdditionalHeaderMiddleware", "dojo.middleware.CustomSocialAuthExceptionMiddleware", "crum.CurrentRequestUserMiddleware", - "dojo.middleware.AuditlogMiddleware", "dojo.middleware.AsyncSearchContextMiddleware", "dojo.request_cache.middleware.RequestCacheMiddleware", "dojo.middleware.LongRunningRequestAlertMiddleware", @@ -979,6 +976,15 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param MIDDLEWARE = DJANGO_MIDDLEWARE_CLASSES +if ENABLE_AUDITLOG: + middleware_list = list(MIDDLEWARE) + crum_index = middleware_list.index("crum.CurrentRequestUserMiddleware") + + # Insert pghistory HistoryMiddleware before CurrentRequestUserMiddleware + middleware_list.insert(crum_index, "dojo.middleware.PgHistoryMiddleware") + + MIDDLEWARE = middleware_list + # WhiteNoise allows your web app to serve its own static files, # making it a self-contained unit that can be deployed anywhere without relying on nginx if env("DD_WHITENOISE"): @@ -1986,10 +1992,6 @@ def saml2_attrib_map_format(din): # Auditlog # ------------------------------------------------------------------------------ AUDITLOG_FLUSH_RETENTION_PERIOD = env("DD_AUDITLOG_FLUSH_RETENTION_PERIOD") -ENABLE_AUDITLOG = env("DD_ENABLE_AUDITLOG") -AUDITLOG_TYPE = env("DD_AUDITLOG_TYPE") -AUDITLOG_TWO_STEP_MIGRATION = False -AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT = False AUDITLOG_FLUSH_BATCH_SIZE = env("DD_AUDITLOG_FLUSH_BATCH_SIZE") AUDITLOG_FLUSH_MAX_BATCHES = env("DD_AUDITLOG_FLUSH_MAX_BATCHES") @@ -2081,19 +2083,6 @@ def show_toolbar(request): # Auditlog configuration # ######################################################################################################### -if ENABLE_AUDITLOG: - middleware_list = list(MIDDLEWARE) - crum_index = middleware_list.index("crum.CurrentRequestUserMiddleware") - - if AUDITLOG_TYPE == "django-auditlog": - # Insert AuditlogMiddleware before CurrentRequestUserMiddleware - middleware_list.insert(crum_index, "dojo.middleware.AuditlogMiddleware") - elif AUDITLOG_TYPE == "django-pghistory": - # Insert pghistory HistoryMiddleware before CurrentRequestUserMiddleware - middleware_list.insert(crum_index, "dojo.middleware.PgHistoryMiddleware") - - MIDDLEWARE = middleware_list - PGHISTORY_FOREIGN_KEY_FIELD = pghistory.ForeignKey(db_index=False) PGHISTORY_CONTEXT_FIELD = pghistory.ContextForeignKey(db_index=True) PGHISTORY_OBJ_FIELD = pghistory.ObjForeignKey(db_index=True) diff --git a/dojo/templates/dojo/action_history.html b/dojo/templates/dojo/action_history.html index f3867024943..904347b7c11 100644 --- a/dojo/templates/dojo/action_history.html +++ b/dojo/templates/dojo/action_history.html @@ -70,8 +70,8 @@

{{ h.pgh_obj_id|default:"N/A" }} - {% if h.pgh_label == "initial_import" %} - Initial Import + {% if h.pgh_label == "initial_backfill" %} + Initial Backfill {% elif h.pgh_diff %}
{% for field, values in h.pgh_diff.items %} diff --git a/dojo/test/signals.py b/dojo/test/signals.py index 22669a2a040..cc180c5d55e 100644 --- a/dojo/test/signals.py +++ b/dojo/test/signals.py @@ -1,9 +1,7 @@ import contextlib -from auditlog.models import LogEntry from django.conf import settings from django.contrib.auth import get_user_model -from django.contrib.contenttypes.models import ContentType from django.db.models.signals import post_delete, pre_delete, pre_save from django.dispatch import receiver from django.urls import reverse @@ -25,7 +23,7 @@ def test_post_delete(sender, instance, using, origin, **kwargs): user = None if settings.ENABLE_AUDITLOG: - # First try to find deletion author in pghistory events + # Find deletion author in pghistory events # Look for delete events for this specific test instance pghistory_delete_events = DojoEvents.objects.filter( pgh_obj_model="dojo.Test", @@ -41,15 +39,6 @@ def test_post_delete(sender, instance, using, origin, **kwargs): with contextlib.suppress(User.DoesNotExist): user = User.objects.get(id=latest_delete.user) - # Fall back to django-auditlog if no user found in pghistory - if not user: - if le := LogEntry.objects.filter( - action=LogEntry.Action.DELETE, - content_type=ContentType.objects.get(app_label="dojo", model="test"), - object_id=instance.id, - ).order_by("-id").first(): - user = le.actor - # Update description with user if found if user: description = _('The test "%(name)s" was deleted by %(user)s') % { diff --git a/dojo/views.py b/dojo/views.py index ae2303e3ea4..d6b33f8c08b 100644 --- a/dojo/views.py +++ b/dojo/views.py @@ -135,7 +135,7 @@ def action_history(request, cid, oid): else: product_tab.setEngagement(object_value.engagement) - # Get audit history from both systems separately + # Get audit history from pghistory (and legacy django-auditlog entries if available) auditlog_history = [] pghistory_history = [] diff --git a/unittests/test_auditlog.py b/unittests/test_auditlog.py index c748758cc67..2af68cf76c6 100644 --- a/unittests/test_auditlog.py +++ b/unittests/test_auditlog.py @@ -1,23 +1,18 @@ """ Unit tests for audit configuration functionality. -Tests the dual-audit system where both django-auditlog and django-pghistory -can coexist, allowing users to see historical data from both systems. +Tests pghistory audit system configuration and event creation. """ -from unittest.mock import MagicMock, patch +import os +from unittest.mock import patch -from auditlog.models import LogEntry from django.apps import apps +from django.conf import settings from django.test import TestCase, override_settings from dojo.auditlog import ( configure_audit_system, configure_pghistory_triggers, - disable_django_auditlog, - disable_django_pghistory, - enable_django_auditlog, - enable_django_pghistory, - register_django_pghistory_models, ) from dojo.models import Product_Type @@ -26,103 +21,39 @@ class TestAuditConfig(TestCase): """Test audit configuration functionality.""" - @patch("auditlog.registry.auditlog") - def test_enable_django_auditlog(self, mock_auditlog): - """Test that enable_django_auditlog registers models.""" - # Mock the auditlog registry - mock_auditlog.register = MagicMock() - - enable_django_auditlog() - - # Verify that register was called multiple times (once for each model) - self.assertTrue(mock_auditlog.register.called) - self.assertGreater(mock_auditlog.register.call_count, 5) - - def test_disable_django_auditlog(self): - """Test that disable_django_auditlog runs without error.""" - # This should not raise an exception - disable_django_auditlog() - - @patch("dojo.auditlog.pghistory") - def test_register_django_pghistory_models(self, mock_pghistory): - """Test that register_django_pghistory_models registers all models.""" - # Mock pghistory.track - mock_pghistory.track = MagicMock() - mock_pghistory.InsertEvent = MagicMock() - mock_pghistory.UpdateEvent = MagicMock() - mock_pghistory.DeleteEvent = MagicMock() - mock_pghistory.ManualEvent = MagicMock() - - register_django_pghistory_models() - - # Verify that track was called multiple times (once for each model) - self.assertTrue(mock_pghistory.track.called) - self.assertGreater(mock_pghistory.track.call_count, 5) - @patch("dojo.auditlog.call_command") - def test_enable_django_pghistory(self, mock_call_command): - """Test that enable_django_pghistory enables triggers only.""" - enable_django_pghistory() + def test_configure_pghistory_triggers_enabled(self, mock_call_command): + """Test that configure_pghistory_triggers enables triggers when audit logging is enabled.""" + with override_settings(ENABLE_AUDITLOG=True): + configure_pghistory_triggers() # Verify that pgtrigger enable command was called mock_call_command.assert_called_with("pgtrigger", "enable") @patch("dojo.auditlog.call_command") - def test_disable_django_pghistory(self, mock_call_command): - """Test that disable_django_pghistory disables triggers.""" - disable_django_pghistory() + def test_configure_pghistory_triggers_disabled(self, mock_call_command): + """Test that configure_pghistory_triggers disables triggers when audit logging is disabled.""" + with override_settings(ENABLE_AUDITLOG=False): + configure_pghistory_triggers() # Verify that pgtrigger disable command was called - mock_call_command.assert_called_once_with("pgtrigger", "disable") - - @override_settings(ENABLE_AUDITLOG=True, AUDITLOG_TYPE="invalid-type") - @patch("dojo.auditlog.disable_django_auditlog") - @patch("dojo.auditlog.call_command") - def test_invalid_audit_type_warning(self, mock_call_command, mock_disable_auditlog): - """Test that invalid audit types disable both audit systems.""" - # Call the main configuration function with invalid type - configure_audit_system() - configure_pghistory_triggers() - - # Verify that auditlog is disabled for invalid type - mock_disable_auditlog.assert_called_once() - # Verify that pghistory triggers are also disabled for invalid type mock_call_command.assert_called_with("pgtrigger", "disable") - # This test mainly ensures no exceptions are raised - - @override_settings(ENABLE_AUDITLOG=True, AUDITLOG_TYPE="django-pghistory") - @patch("dojo.auditlog.enable_django_auditlog") - @patch("dojo.auditlog.disable_django_auditlog") - @patch("dojo.auditlog.call_command") - def test_dual_audit_system_coexistence(self, mock_call_command, mock_disable_auditlog, mock_enable_auditlog): - """Test that audit system configuration handles pghistory type correctly.""" - # Call the main configuration function + @override_settings(ENABLE_AUDITLOG=True) + def test_configure_audit_system_enabled(self): + """Test that configure_audit_system configures pghistory when audit logging is enabled.""" + # Should not raise an exception configure_audit_system() - configure_pghistory_triggers() - - # Verify auditlog is disabled when pghistory is the chosen type - mock_disable_auditlog.assert_called_once() - # Verify auditlog is not enabled when pghistory is chosen - mock_enable_auditlog.assert_not_called() - # Verify that pghistory triggers are enabled when pghistory is the chosen type - mock_call_command.assert_called_with("pgtrigger", "enable") - - # This demonstrates that the system correctly chooses the configured audit type - - def test_separate_history_lists_approach(self): - """Test that the dual-history approach creates separate lists correctly.""" - # This test verifies the new approach where we maintain separate history lists - # instead of mixing audit data from different systems - - # Import the view function to test the separation logic - # This is more of a structural test to ensure the approach is sound - # The actual view testing would require more complex setup + @override_settings(ENABLE_AUDITLOG=False) + def test_configure_audit_system_disabled(self): + """Test that configure_audit_system handles disabled audit logging.""" + # Should not raise an exception + configure_audit_system() - @override_settings(ENABLE_AUDITLOG=True, AUDITLOG_TYPE="django-pghistory") + @override_settings(ENABLE_AUDITLOG=True) def test_pghistory_insert_event_creation(self): - """Test that pghistory creates insert events when a Product_Type is created and auditlog does not.""" + """Test that pghistory creates insert events when a Product_Type is created.""" # Configure audit system for pghistory configure_audit_system() configure_pghistory_triggers() @@ -133,9 +64,6 @@ def test_pghistory_insert_event_creation(self): # Count existing events before creating new Product_Type initial_event_count = ProductTypeEvent.objects.count() - # Clear any existing audit log entries for Product_Type - LogEntry.objects.filter(content_type__model="product_type").delete() - # Create a new Product_Type product_type = Product_Type.objects.create( name="Test Product Type for pghistory", @@ -159,139 +87,41 @@ def test_pghistory_insert_event_creation(self): "Event should contain the Product_Type description") # Verify it's an insert event (check if pgh_label indicates creation) - # The label could be 'insert' or contain insert-related information self.assertIsNotNone(latest_event.pgh_created_at, "Event should have a creation timestamp") - # Verify that NO auditlog entries were created (mutual exclusivity) - audit_entries = LogEntry.objects.filter( - content_type__model="product_type", - object_id=product_type.id, - ) - self.assertEqual(audit_entries.count(), 0, - "Expected NO auditlog entries when pghistory is enabled") - # Clean up product_type.delete() - @override_settings(ENABLE_AUDITLOG=True, AUDITLOG_TYPE="django-auditlog") - @patch("dojo.auditlog.enable_django_auditlog") - @patch("dojo.auditlog.call_command") - def test_configure_audit_system_auditlog_enabled(self, mock_call_command, mock_enable_auditlog): - """Test that configure_audit_system enables auditlog and configures pghistory triggers correctly.""" - configure_audit_system() - configure_pghistory_triggers() - - # Verify that auditlog is enabled - mock_enable_auditlog.assert_called_once() - # Verify that pghistory triggers are disabled when auditlog is the chosen type - mock_call_command.assert_called_with("pgtrigger", "disable") - - @override_settings(ENABLE_AUDITLOG=True, AUDITLOG_TYPE="django-pghistory") - @patch("dojo.auditlog.disable_django_auditlog") - @patch("dojo.auditlog.call_command") - def test_configure_audit_system_pghistory_enabled(self, mock_call_command, mock_disable_auditlog): - """Test that configure_audit_system disables auditlog and enables pghistory triggers correctly.""" - configure_audit_system() - configure_pghistory_triggers() - - # Verify that auditlog is disabled when pghistory is the chosen type - mock_disable_auditlog.assert_called_once() - # Verify that pghistory triggers are enabled when pghistory is the chosen type - mock_call_command.assert_called_with("pgtrigger", "enable") - - @override_settings(ENABLE_AUDITLOG=False) - @patch("dojo.auditlog.disable_django_auditlog") - @patch("dojo.auditlog.call_command") - def test_configure_audit_system_all_disabled(self, mock_call_command, mock_disable_auditlog): - """Test that configure_audit_system disables both auditlog and pghistory when audit is disabled.""" - configure_audit_system() - configure_pghistory_triggers() - - # Verify that auditlog is disabled when audit logging is disabled - mock_disable_auditlog.assert_called_once() - # Verify that pghistory triggers are also disabled when audit logging is disabled - mock_call_command.assert_called_with("pgtrigger", "disable") - - @override_settings(ENABLE_AUDITLOG=True, AUDITLOG_TYPE="unknown-type") - @patch("dojo.auditlog.disable_django_auditlog") - @patch("dojo.auditlog.call_command") - def test_configure_audit_system_unknown_type(self, mock_call_command, mock_disable_auditlog): - """Test that configure_audit_system disables both systems for unknown audit types.""" - configure_audit_system() - configure_pghistory_triggers() - - # Verify that auditlog is disabled for unknown types - mock_disable_auditlog.assert_called_once() - # Verify that pghistory triggers are also disabled for unknown types - mock_call_command.assert_called_with("pgtrigger", "disable") - - @patch("dojo.auditlog.call_command") - def test_disable_pghistory_command_failure(self, mock_call_command): - """Test that disable_django_pghistory handles command failures gracefully.""" - # Simulate command failure - mock_call_command.side_effect = Exception("Command failed") - - # This should not raise an exception - disable_django_pghistory() - - # Verify that call_command was attempted - mock_call_command.assert_called_once_with("pgtrigger", "disable") - - @patch("dojo.auditlog.call_command") - def test_enable_pghistory_command_failure(self, mock_call_command): - """Test that enable_django_pghistory handles command failures gracefully.""" - # Simulate command failure for trigger enable - mock_call_command.side_effect = Exception("Command failed") - - # This should not raise an exception - enable_django_pghistory() - - # Verify that call_command was attempted - mock_call_command.assert_called_with("pgtrigger", "enable") - - @override_settings(ENABLE_AUDITLOG=True, AUDITLOG_TYPE="django-auditlog") - def test_auditlog_insert_event_creation(self): - """Test that django-auditlog creates audit log entries when a Product_Type is created and pghistory does not.""" - # Configure audit system for auditlog - configure_audit_system() - configure_pghistory_triggers() - - # Get the Product_Type event model for pghistory check - ProductTypeEvent = apps.get_model("dojo", "Product_TypeEvent") - - # Clear any existing audit log entries for Product_Type - LogEntry.objects.filter(content_type__model="product_type").delete() - - # Count existing pghistory events - initial_pghistory_count = ProductTypeEvent.objects.count() - - # Create a new Product_Type - product_type = Product_Type.objects.create( - name="Test Product Type for Auditlog", - description="Test description for auditlog verification", - ) - - # Verify that an audit log entry was created - audit_entries = LogEntry.objects.filter( - content_type__model="product_type", - object_id=product_type.id, - action=LogEntry.Action.CREATE, - ) - - self.assertEqual(audit_entries.count(), 1, - "Expected exactly one audit log entry for Product_Type creation") - - audit_entry = audit_entries.first() - self.assertEqual(audit_entry.object_repr, str(product_type), - "Audit entry should represent the created object") - self.assertIsNotNone(audit_entry.timestamp, - "Audit entry should have a timestamp") - - # Verify that NO pghistory events were created (mutual exclusivity) - final_pghistory_count = ProductTypeEvent.objects.count() - self.assertEqual(final_pghistory_count, initial_pghistory_count, - "Expected NO new pghistory events when auditlog is enabled") - - # Clean up - product_type.delete() + def test_configure_audit_system_fails_with_dd_auditlog_type_env(self): + """Test that configure_audit_system fails if DD_AUDITLOG_TYPE environment variable is set.""" + # Temporarily set the environment variable + original_value = os.environ.get("DD_AUDITLOG_TYPE") + try: + os.environ["DD_AUDITLOG_TYPE"] = "django-pghistory" + with self.assertRaises(ValueError) as context: + configure_audit_system() + self.assertIn("DD_AUDITLOG_TYPE", str(context.exception)) + finally: + # Restore original value + if original_value is None: + os.environ.pop("DD_AUDITLOG_TYPE", None) + else: + os.environ["DD_AUDITLOG_TYPE"] = original_value + + def test_configure_audit_system_fails_with_auditlog_type_setting(self): + """Test that configure_audit_system fails if AUDITLOG_TYPE setting is manually set.""" + # Temporarily add the setting + original_value = getattr(settings, "AUDITLOG_TYPE", None) + try: + settings.AUDITLOG_TYPE = "django-pghistory" + with self.assertRaises(ValueError) as context: + configure_audit_system() + self.assertIn("AUDITLOG_TYPE", str(context.exception)) + finally: + # Restore original value + if original_value is None: + if hasattr(settings, "AUDITLOG_TYPE"): + delattr(settings, "AUDITLOG_TYPE") + else: + settings.AUDITLOG_TYPE = original_value diff --git a/unittests/test_flush_auditlog.py b/unittests/test_flush_auditlog.py index 1c7f5ef08df..da347d952d5 100644 --- a/unittests/test_flush_auditlog.py +++ b/unittests/test_flush_auditlog.py @@ -1,11 +1,16 @@ +""" +Unit tests for flush_auditlog functionality. + +Tests the flush_auditlog management command and task that removes old audit log entries. +""" import logging -from datetime import UTC, date, datetime +from datetime import UTC, datetime -from auditlog.models import LogEntry from dateutil.relativedelta import relativedelta +from django.apps import apps from django.test import override_settings -from dojo.models import Finding +from dojo.models import Product_Type from dojo.tasks import flush_auditlog from .dojo_test_case import DojoTestCase @@ -18,32 +23,71 @@ class TestFlushAuditlog(DojoTestCase): @override_settings(AUDITLOG_FLUSH_RETENTION_PERIOD=-1) def test_flush_auditlog_disabled(self): - entries_before = LogEntry.objects.all().count() + """Test that flush_auditlog does nothing when retention period is -1 (disabled).""" + # Get pghistory event model + ProductTypeEvent = apps.get_model("dojo", "Product_TypeEvent") + entries_before = ProductTypeEvent.objects.count() + flush_auditlog() - entries_after = LogEntry.objects.all().count() + + entries_after = ProductTypeEvent.objects.count() self.assertEqual(entries_before, entries_after) @override_settings(AUDITLOG_FLUSH_RETENTION_PERIOD=0) def test_delete_all_entries(self): - entries_before = LogEntry.objects.filter(timestamp__date__lt=date.today()).count() + """Test that flush_auditlog deletes all entries when retention period is 0.""" + # Get pghistory event model + ProductTypeEvent = apps.get_model("dojo", "Product_TypeEvent") + + # Create a test product type to generate events + product_type = Product_Type.objects.create( + name="Test Product Type for Flush", + description="Test description", + ) + + # Flush with retention period 0 (delete all) flush_auditlog() - entries_after = LogEntry.objects.filter(timestamp__date__lt=date.today()).count() - # we have three old log entries in our testdata - self.assertEqual(entries_before - 3, entries_after) + + # All entries should be deleted + entries_after = ProductTypeEvent.objects.count() + self.assertEqual(entries_after, 0, "All entries should be deleted when retention period is 0") + + # Clean up + product_type.delete() @override_settings(AUDITLOG_FLUSH_RETENTION_PERIOD=1) def test_delete_entries_with_retention_period(self): - entries_before = LogEntry.objects.filter(timestamp__date__lt=datetime.now(UTC)).count() - two_weeks_ago = datetime.now(UTC) - relativedelta(weeks=2) - log_entry = LogEntry.objects.log_create( - instance=Finding.objects.all()[0], - timestamp=two_weeks_ago, - changes="foo", - action=LogEntry.Action.UPDATE, + """Test that flush_auditlog deletes entries older than retention period.""" + # Get pghistory event model + ProductTypeEvent = apps.get_model("dojo", "Product_TypeEvent") + + # Create a test product type + product_type = Product_Type.objects.create( + name="Test Product Type for Retention", + description="Test description", ) - log_entry.timestamp = two_weeks_ago - log_entry.save() + + # Get the event created by the creation + recent_event = ProductTypeEvent.objects.filter(pgh_obj_id=product_type.id).first() + + # Manually create an old event by updating the timestamp + # Set it to 2 months ago so it will be deleted with retention period of 1 month + if recent_event: + two_months_ago = datetime.now(UTC) - relativedelta(months=2) + # Update the created_at timestamp to make it old + ProductTypeEvent.objects.filter(pk=recent_event.pk).update(pgh_created_at=two_months_ago) + + # Count events before flush + entries_before = ProductTypeEvent.objects.count() + + # Flush with retention period of 1 month flush_auditlog() - entries_after = LogEntry.objects.filter(timestamp__date__lt=datetime.now(UTC)).count() - # we have three old log entries in our testdata and added a new one - self.assertEqual(entries_before - 3 + 1, entries_after) + + # Count events after flush + entries_after = ProductTypeEvent.objects.count() + + # The old event should be deleted (2 months old > 1 month retention) + self.assertLess(entries_after, entries_before, "Old entries should be deleted") + + # Clean up + product_type.delete() diff --git a/unittests/test_importers_performance.py b/unittests/test_importers_performance.py index 9da777ccecc..481693ff6ce 100644 --- a/unittests/test_importers_performance.py +++ b/unittests/test_importers_performance.py @@ -168,22 +168,7 @@ def _import_reimport_performance(self, expected_num_queries1, expected_num_async reimporter = DefaultReImporter(**reimport_options) test, _, _len_new_findings, _len_closed_findings, _, _, _ = reimporter.process_scan(scan) - @override_settings(ENABLE_AUDITLOG=True, AUDITLOG_TYPE="django-auditlog") - def test_import_reimport_reimport_performance_async(self): - # Ensure django-auditlog is properly configured for this test - configure_audit_system() - configure_pghistory_triggers() - - self._import_reimport_performance( - expected_num_queries1=340, - expected_num_async_tasks1=7, - expected_num_queries2=288, - expected_num_async_tasks2=18, - expected_num_queries3=175, - expected_num_async_tasks3=17, - ) - - @override_settings(ENABLE_AUDITLOG=True, AUDITLOG_TYPE="django-pghistory") + @override_settings(ENABLE_AUDITLOG=True) def test_import_reimport_reimport_performance_pghistory_async(self): """ This test checks the performance of the importers when using django-pghistory with async enabled. @@ -201,31 +186,7 @@ def test_import_reimport_reimport_performance_pghistory_async(self): expected_num_async_tasks3=17, ) - @override_settings(ENABLE_AUDITLOG=True, AUDITLOG_TYPE="django-auditlog") - def test_import_reimport_reimport_performance_no_async(self): - """ - This test checks the performance of the importers when they are run in sync mode. - The reason for this is that we also want to be aware of when a PR affects the number of queries - or async tasks created by a background task. - The impersonate context manager above does not work as expected for disabling async, - so we patch the we_want_async decorator to always return False. - """ - configure_audit_system() - configure_pghistory_triggers() - - testuser = User.objects.get(username="admin") - testuser.usercontactinfo.block_execution = True - testuser.usercontactinfo.save() - self._import_reimport_performance( - expected_num_queries1=345, - expected_num_async_tasks1=6, - expected_num_queries2=293, - expected_num_async_tasks2=17, - expected_num_queries3=180, - expected_num_async_tasks3=16, - ) - - @override_settings(ENABLE_AUDITLOG=True, AUDITLOG_TYPE="django-pghistory") + @override_settings(ENABLE_AUDITLOG=True) def test_import_reimport_reimport_performance_pghistory_no_async(self): """ This test checks the performance of the importers when using django-pghistory with async disabled. @@ -247,33 +208,7 @@ def test_import_reimport_reimport_performance_pghistory_no_async(self): expected_num_async_tasks3=16, ) - @override_settings(ENABLE_AUDITLOG=True, AUDITLOG_TYPE="django-auditlog") - def test_import_reimport_reimport_performance_no_async_with_product_grading(self): - """ - This test checks the performance of the importers when they are run in sync mode. - The reason for this is that we also want to be aware of when a PR affects the number of queries - or async tasks created by a background task. - The impersonate context manager above does not work as expected for disabling async, - so we patch the we_want_async decorator to always return False. - """ - configure_audit_system() - configure_pghistory_triggers() - - testuser = User.objects.get(username="admin") - testuser.usercontactinfo.block_execution = True - testuser.usercontactinfo.save() - self.system_settings(enable_product_grade=True) - - self._import_reimport_performance( - expected_num_queries1=347, - expected_num_async_tasks1=8, - expected_num_queries2=295, - expected_num_async_tasks2=19, - expected_num_queries3=182, - expected_num_async_tasks3=18, - ) - - @override_settings(ENABLE_AUDITLOG=True, AUDITLOG_TYPE="django-pghistory") + @override_settings(ENABLE_AUDITLOG=True) def test_import_reimport_reimport_performance_pghistory_no_async_with_product_grading(self): """ This test checks the performance of the importers when using django-pghistory with async disabled and product grading enabled. @@ -398,27 +333,7 @@ def _deduplication_performance(self, expected_num_queries1, expected_num_async_t total_findings = Finding.objects.filter(test__engagement=engagement).count() self.assertEqual(total_findings, 12, f"Expected 12 total findings, got {total_findings}") - @override_settings(ENABLE_AUDITLOG=True, AUDITLOG_TYPE="django-auditlog") - def test_deduplication_performance_async(self): - """ - Test deduplication performance with async tasks enabled. - This test imports the same scan twice to measure deduplication query and task overhead. - """ - configure_audit_system() - configure_pghistory_triggers() - - # Enable deduplication - self.system_settings(enable_deduplication=True) - - self._deduplication_performance( - expected_num_queries1=311, - expected_num_async_tasks1=8, - expected_num_queries2=204, - expected_num_async_tasks2=8, - check_duplicates=False, # Async mode - deduplication happens later - ) - - @override_settings(ENABLE_AUDITLOG=True, AUDITLOG_TYPE="django-pghistory") + @override_settings(ENABLE_AUDITLOG=True) def test_deduplication_performance_pghistory_async(self): """Test deduplication performance with django-pghistory and async tasks enabled.""" configure_audit_system() @@ -435,27 +350,7 @@ def test_deduplication_performance_pghistory_async(self): check_duplicates=False, # Async mode - deduplication happens later ) - @override_settings(ENABLE_AUDITLOG=True, AUDITLOG_TYPE="django-auditlog") - def test_deduplication_performance_no_async(self): - """Test deduplication performance with async tasks disabled.""" - configure_audit_system() - configure_pghistory_triggers() - - # Enable deduplication - self.system_settings(enable_deduplication=True) - - testuser = User.objects.get(username="admin") - testuser.usercontactinfo.block_execution = True - testuser.usercontactinfo.save() - - self._deduplication_performance( - expected_num_queries1=316, - expected_num_async_tasks1=7, - expected_num_queries2=287, - expected_num_async_tasks2=7, - ) - - @override_settings(ENABLE_AUDITLOG=True, AUDITLOG_TYPE="django-pghistory") + @override_settings(ENABLE_AUDITLOG=True) def test_deduplication_performance_pghistory_no_async(self): """Test deduplication performance with django-pghistory and async tasks disabled.""" configure_audit_system() From 33a25c859f991942eeb2af5c8ef44e0f1d35c4e2 Mon Sep 17 00:00:00 2001 From: DefectDojo release bot Date: Mon, 15 Dec 2025 16:52:48 +0000 Subject: [PATCH 049/126] Update versions in application files --- components/package.json | 2 +- dojo/__init__.py | 2 +- helm/defectdojo/Chart.yaml | 12 ++++-------- helm/defectdojo/README.md | 2 +- 4 files changed, 7 insertions(+), 11 deletions(-) diff --git a/components/package.json b/components/package.json index d91b75e32dd..d9500b421b6 100644 --- a/components/package.json +++ b/components/package.json @@ -1,6 +1,6 @@ { "name": "defectdojo", - "version": "2.53.2", + "version": "2.54.0-dev", "license" : "BSD-3-Clause", "private": true, "dependencies": { diff --git a/dojo/__init__.py b/dojo/__init__.py index e4af428e118..7337d10b9c1 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.53.2" +__version__ = "2.54.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 38568c07cfe..6d6ebd391eb 100644 --- a/helm/defectdojo/Chart.yaml +++ b/helm/defectdojo/Chart.yaml @@ -1,8 +1,8 @@ apiVersion: v2 -appVersion: "2.53.2" +appVersion: "2.54.0-dev" description: A Helm chart for Kubernetes to install DefectDojo name: defectdojo -version: 1.9.2 +version: 1.9.3-dev icon: https://defectdojo.com/hubfs/DefectDojo_favicon.png maintainers: - name: madchap @@ -33,9 +33,5 @@ dependencies: # - kind: security # description: Critical bug annotations: - artifacthub.io/prerelease: "false" - artifacthub.io/changes: | - - kind: fixed - description: Use non-tilde notation of versions in subcharts - - kind: changed - description: Bump DefectDojo to 2.53.2 + artifacthub.io/prerelease: "true" + artifacthub.io/changes: "" diff --git a/helm/defectdojo/README.md b/helm/defectdojo/README.md index 385a80e18ad..c72407336f0 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.2](https://img.shields.io/badge/Version-1.9.2-informational?style=flat-square) ![AppVersion: 2.53.2](https://img.shields.io/badge/AppVersion-2.53.2-informational?style=flat-square) +![Version: 1.9.3-dev](https://img.shields.io/badge/Version-1.9.3--dev-informational?style=flat-square) ![AppVersion: 2.54.0-dev](https://img.shields.io/badge/AppVersion-2.54.0--dev-informational?style=flat-square) A Helm chart for Kubernetes to install DefectDojo From 363e4bedb5f008a450aaf595abf25ea39814cc4e Mon Sep 17 00:00:00 2001 From: DefectDojo release bot Date: Mon, 15 Dec 2025 20:53:38 +0000 Subject: [PATCH 050/126] 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 77178a7ad3e..d9500b421b6 100644 --- a/components/package.json +++ b/components/package.json @@ -1,6 +1,6 @@ { "name": "defectdojo", - "version": "2.53.3", + "version": "2.54.0-dev", "license" : "BSD-3-Clause", "private": true, "dependencies": { diff --git a/dojo/__init__.py b/dojo/__init__.py index 41954f47d2f..7337d10b9c1 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.53.3" +__version__ = "2.54.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 cf9d539c013..0bbd413b257 100644 --- a/helm/defectdojo/Chart.yaml +++ b/helm/defectdojo/Chart.yaml @@ -1,8 +1,8 @@ apiVersion: v2 -appVersion: "2.53.3" +appVersion: "2.54.0-dev" description: A Helm chart for Kubernetes to install DefectDojo name: defectdojo -version: 1.9.3 +version: 1.9.4-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: Bump DefectDojo to 2.53.3\n" + artifacthub.io/prerelease: "true" + artifacthub.io/changes: "" diff --git a/helm/defectdojo/README.md b/helm/defectdojo/README.md index f68686a0916..e54f00ce161 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.3](https://img.shields.io/badge/Version-1.9.3-informational?style=flat-square) ![AppVersion: 2.53.3](https://img.shields.io/badge/AppVersion-2.53.3-informational?style=flat-square) +![Version: 1.9.4-dev](https://img.shields.io/badge/Version-1.9.4--dev-informational?style=flat-square) ![AppVersion: 2.54.0-dev](https://img.shields.io/badge/AppVersion-2.54.0--dev-informational?style=flat-square) A Helm chart for Kubernetes to install DefectDojo From f3a93ced8391c05894175d7b8a32d8cb55dd23d4 Mon Sep 17 00:00:00 2001 From: valentijnscholten Date: Tue, 16 Dec 2025 04:26:38 +0100 Subject: [PATCH 051/126] api tokens: allow admins to reset user tokens (#13885) * apiv2: allow admins to reset tokens * token reset: send notification upon reset * token reset: rename method * revert ruff shenanigans * add ui elements * cleanup * openapi type hints --- dojo/api_v2/permissions.py | 6 + dojo/api_v2/serializers.py | 14 +++ dojo/api_v2/views.py | 14 +++ dojo/authorization/authorization.py | 29 +++++ .../0249_usercontactinfo_reset_timestamps.py | 33 ++++++ dojo/forms.py | 27 ++++- dojo/management/commands/force_token_reset.py | 46 +++++++ dojo/models.py | 2 + dojo/templates/dojo/users.html | 4 + dojo/templates/dojo/view_user.html | 18 ++- dojo/user/authentication.py | 59 +++++++++ dojo/user/urls.py | 2 +- dojo/user/views.py | 74 ++++++++++-- unittests/test_apiv2_user.py | 112 ++++++++++++++++++ unittests/test_user_ui_timestamps.py | 92 ++++++++++++++ 15 files changed, 513 insertions(+), 19 deletions(-) create mode 100644 dojo/db_migrations/0249_usercontactinfo_reset_timestamps.py create mode 100644 dojo/management/commands/force_token_reset.py create mode 100644 dojo/user/authentication.py create mode 100644 unittests/test_user_ui_timestamps.py diff --git a/dojo/api_v2/permissions.py b/dojo/api_v2/permissions.py index a0a25e5364f..421fb87b526 100644 --- a/dojo/api_v2/permissions.py +++ b/dojo/api_v2/permissions.py @@ -12,6 +12,7 @@ user_has_configuration_permission, user_has_global_permission, user_has_permission, + user_is_superuser_or_global_owner, ) from dojo.authorization.roles_permissions import Permissions from dojo.importers.auto_create_context import AutoCreateContextManager @@ -872,6 +873,11 @@ def has_permission(self, request, view): return request.user and request.user.is_superuser +class IsSuperUserOrGlobalOwner(permissions.BasePermission): + def has_permission(self, request, view): + return user_is_superuser_or_global_owner(request.user) + + class UserHasEngagementPresetPermission(permissions.BasePermission): def has_permission(self, request, view): return check_post_permission( diff --git a/dojo/api_v2/serializers.py b/dojo/api_v2/serializers.py index 2f884b3bb4a..5eee3f44e69 100644 --- a/dojo/api_v2/serializers.py +++ b/dojo/api_v2/serializers.py @@ -487,6 +487,8 @@ class UserSerializer(serializers.ModelSerializer): date_joined = serializers.DateTimeField(read_only=True) last_login = serializers.DateTimeField(read_only=True, allow_null=True) email = serializers.EmailField(required=True) + token_last_reset = serializers.SerializerMethodField() + password_last_reset = serializers.SerializerMethodField() password = serializers.CharField( write_only=True, style={"input_type": "password"}, @@ -515,10 +517,22 @@ class Meta: "last_login", "is_active", "is_superuser", + "token_last_reset", + "password_last_reset", "password", "configuration_permissions", ) + @extend_schema_field(serializers.DateTimeField(allow_null=True)) + def get_token_last_reset(self, instance): + uci = getattr(instance, "usercontactinfo", None) + return getattr(uci, "token_last_reset", None) + + @extend_schema_field(serializers.DateTimeField(allow_null=True)) + def get_password_last_reset(self, instance): + uci = getattr(instance, "usercontactinfo", None) + return getattr(uci, "password_last_reset", None) + def to_representation(self, instance): ret = super().to_representation(instance) diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index bdde57955f2..5b2b0edd51e 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -171,6 +171,7 @@ from dojo.risk_acceptance.queries import get_authorized_risk_acceptances from dojo.test.queries import get_authorized_test_imports, get_authorized_tests from dojo.tool_product.queries import get_authorized_tool_product_settings +from dojo.user.authentication import reset_token_for_user from dojo.user.utils import get_configuration_permissions_codenames from dojo.utils import ( async_delete, @@ -2410,6 +2411,19 @@ def destroy(self, request, *args, **kwargs): self.perform_destroy(instance) return Response(status=status.HTTP_204_NO_CONTENT) + @action( + detail=True, + methods=["post"], + url_path="reset_api_token", + permission_classes=(IsAuthenticated, permissions.IsSuperUserOrGlobalOwner), + filter_backends=[], + pagination_class=None, + ) + def reset_api_token(self, request, pk=None): + target_user = self.get_object() + reset_token_for_user(acting_user=request.user, target_user=target_user) + return Response(status=status.HTTP_204_NO_CONTENT) + # Authorization: superuser @extend_schema_view(**schema_with_prefetch()) diff --git a/dojo/authorization/authorization.py b/dojo/authorization/authorization.py index 4110b965db1..b410bb3a95d 100644 --- a/dojo/authorization/authorization.py +++ b/dojo/authorization/authorization.py @@ -40,6 +40,35 @@ def user_has_configuration_permission(user, permission): return user.has_perm(permission) +def user_is_superuser_or_global_owner(user): + """ + Returns True if the user is a superuser or has a global role (directly or + via group membership) whose Role.is_owner is True. + """ + if not user or getattr(user, "is_anonymous", False): + return False + + if user.is_superuser: + return True + + if ( + hasattr(user, "global_role") + and user.global_role.role is not None + and user.global_role.role.is_owner + ): + return True + + for group in get_groups(user): + if ( + hasattr(group, "global_role") + and group.global_role.role is not None + and group.global_role.role.is_owner + ): + return True + + return False + + def user_has_permission(user, obj, permission): if user.is_anonymous: return False diff --git a/dojo/db_migrations/0249_usercontactinfo_reset_timestamps.py b/dojo/db_migrations/0249_usercontactinfo_reset_timestamps.py new file mode 100644 index 00000000000..47d56eef8b2 --- /dev/null +++ b/dojo/db_migrations/0249_usercontactinfo_reset_timestamps.py @@ -0,0 +1,33 @@ +# Generated by Django 3.1.13 on 2025-12-12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("dojo", "0248_alter_general_survey_expiration"), + ] + + operations = [ + migrations.AddField( + model_name="usercontactinfo", + name="token_last_reset", + field=models.DateTimeField( + blank=True, + help_text="Timestamp of the most recent API token reset for this user.", + null=True, + ), + ), + migrations.AddField( + model_name="usercontactinfo", + name="password_last_reset", + field=models.DateTimeField( + blank=True, + help_text="Timestamp of the most recent password reset for this user.", + null=True, + ), + ), + ] + + diff --git a/dojo/forms.py b/dojo/forms.py index 636ce1be9c4..8cf90935cd1 100644 --- a/dojo/forms.py +++ b/dojo/forms.py @@ -31,7 +31,7 @@ from tagulous.forms import TagField import dojo.jira_link.helper as jira_helper -from dojo.authorization.authorization import user_has_configuration_permission +from dojo.authorization.authorization import user_has_configuration_permission, user_is_superuser_or_global_owner from dojo.authorization.roles_permissions import Permissions from dojo.endpoint.utils import endpoint_filter, endpoint_get_or_create, validate_endpoints_to_add from dojo.engagement.queries import get_authorized_engagements @@ -2420,13 +2420,32 @@ class Meta: class UserContactInfoForm(forms.ModelForm): + reset_api_token = forms.BooleanField( + required=False, + label=_("Reset API token"), + help_text=_("Upon saving, a new token will be generated and a notification of category 'Other' is triggered."), + ) + class Meta: model = UserContactInfo exclude = ["user", "slack_user_id"] + # Swap order: password_last_reset before token_last_reset + field_order = [ + "title", "phone_number", "cell_number", "twitter_username", "github_username", + "slack_username", "block_execution", "force_password_reset", "reset_api_token", + "password_last_reset", "token_last_reset", + ] def __init__(self, *args, **kwargs): user = kwargs.pop("user", None) super().__init__(*args, **kwargs) + # Make timestamp fields readonly. + # NOTE: `disabled=True` is enforced server-side by Django forms: posted values for disabled fields + # are ignored during binding/cleaning, so these timestamps cannot be modified via this form. + if "password_last_reset" in self.fields: + self.fields["password_last_reset"].disabled = True + if "token_last_reset" in self.fields: + self.fields["token_last_reset"].disabled = True # Do not expose force password reset if the current user does not have a password to reset if user is not None: if not user.has_usable_password(): @@ -2437,11 +2456,15 @@ def __init__(self, *args, **kwargs): if not current_user.is_superuser: if not user_has_configuration_permission(current_user, "auth.change_user") and \ not user_has_configuration_permission(current_user, "auth.add_user"): - del self.fields["force_password_reset"] + self.fields.pop("force_password_reset", None) if not get_system_setting("enable_user_profile_editable"): for field in self.fields: self.fields[field].disabled = True + # Only show reset_api_token to superusers or global owners, and only if API tokens are enabled + if not settings.API_TOKENS_ENABLED or not user_is_superuser_or_global_owner(current_user): + self.fields.pop("reset_api_token", None) + class GlobalRoleForm(forms.ModelForm): class Meta: diff --git a/dojo/management/commands/force_token_reset.py b/dojo/management/commands/force_token_reset.py new file mode 100644 index 00000000000..fc00dc8c2e3 --- /dev/null +++ b/dojo/management/commands/force_token_reset.py @@ -0,0 +1,46 @@ +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand, CommandError + +from dojo.user.authentication import reset_token_for_user + + +class Command(BaseCommand): + help = "Rotate (reset) the DRF API token for a target user. Requires an acting user (superuser or global owner)." + + def add_arguments(self, parser): + parser.add_argument("--acting-user", required=True, help="Username of the acting user performing the reset.") + parser.add_argument("--username", help="Username of the target user.") + parser.add_argument("--user-id", type=int, help="ID of the target user.") + + def handle(self, *args, **options): + User = get_user_model() + + acting_username = options["acting_user"] + target_username = options.get("username") + target_user_id = options.get("user_id") + + if bool(target_username) == bool(target_user_id): + msg = "Provide exactly one of --username or --user-id." + raise CommandError(msg) + + try: + acting_user = User.objects.get(username=acting_username) + except User.DoesNotExist as exc: + msg = f"Acting user '{acting_username}' does not exist." + raise CommandError(msg) from exc + + try: + if target_username: + target_user = User.objects.get(username=target_username) + else: + target_user = User.objects.get(id=target_user_id) + except User.DoesNotExist as exc: + msg = "Target user does not exist." + raise CommandError(msg) from exc + + try: + reset_token_for_user(acting_user=acting_user, target_user=target_user) + except Exception as exc: + raise CommandError(str(exc)) from exc + + self.stdout.write(self.style.SUCCESS("API token reset successfully.")) diff --git a/dojo/models.py b/dojo/models.py index 48c274ae006..1160fb8b608 100644 --- a/dojo/models.py +++ b/dojo/models.py @@ -268,6 +268,8 @@ class UserContactInfo(models.Model): slack_user_id = models.CharField(blank=True, null=True, max_length=25) block_execution = models.BooleanField(default=False, help_text=_("Instead of async deduping a finding the findings will be deduped synchronously and will 'block' the user until completion.")) force_password_reset = models.BooleanField(default=False, help_text=_("Forces this user to reset their password on next login.")) + token_last_reset = models.DateTimeField(null=True, blank=True, help_text=_("Timestamp of the most recent API token reset for this user.")) + password_last_reset = models.DateTimeField(null=True, blank=True, help_text=_("Timestamp of the most recent password reset for this user.")) class Dojo_Group(models.Model): diff --git a/dojo/templates/dojo/users.html b/dojo/templates/dojo/users.html index e05b7346915..dedbcc2c8cb 100644 --- a/dojo/templates/dojo/users.html +++ b/dojo/templates/dojo/users.html @@ -69,6 +69,8 @@

{% trans "Global Role" %} {% trans "Date Joined" %} {% trans "Last Login" %} + {% trans "Token Last Reset" %} + {% trans "Password Last Reset" %} {% block users_table_extra_header_rows %} {% endblock users_table_extra_header_rows %} @@ -127,6 +129,8 @@

{% if u.global_role.role %} {{ u.global_role.role }} {% endif %} {{ u.date_joined }} {% if u.last_login %}{{ u.last_login }}{% else %}{% trans "Never" %}{% endif %} + {% if u.usercontactinfo.token_last_reset %}{{ u.usercontactinfo.token_last_reset }}{% else %}{% trans "Never" %}{% endif %} + {% if u.usercontactinfo.password_last_reset %}{{ u.usercontactinfo.password_last_reset }}{% else %}{% trans "Never" %}{% endif %} {% block users_table_extra_data_rows %} {% endblock users_table_extra_data_rows %} diff --git a/dojo/templates/dojo/view_user.html b/dojo/templates/dojo/view_user.html index 74a377e8999..7c9876572ab 100644 --- a/dojo/templates/dojo/view_user.html +++ b/dojo/templates/dojo/view_user.html @@ -34,7 +34,7 @@

{% trans "Default Information" %}

{% if "auth.delete_user"|has_configuration_permission:request and user.id != request.user.id %}
  • - + {% trans "Delete" %}
  • {% endif %} @@ -374,6 +374,14 @@

    {% trans "Last Login" %} {% if user.last_login %} {{ user.last_login }} {% else %} {% trans "Never" %} {% endif %} + + {% trans "Token Last Reset" %} + {% if user.usercontactinfo.token_last_reset %} {{ user.usercontactinfo.token_last_reset }} {% else %} {% trans "Never" %} {% endif %} + + + {% trans "Password Last Reset" %} + {% if user.usercontactinfo.password_last_reset %} {{ user.usercontactinfo.password_last_reset }} {% else %} {% trans "Never" %} {% endif %} +

    @@ -399,11 +407,11 @@

    {% if field.view_codename %} - {% endif %} diff --git a/dojo/user/authentication.py b/dojo/user/authentication.py new file mode 100644 index 00000000000..11f8fce104c --- /dev/null +++ b/dojo/user/authentication.py @@ -0,0 +1,59 @@ +from django.conf import settings +from django.urls import reverse +from django.utils import timezone +from rest_framework.authtoken.models import Token +from rest_framework.exceptions import PermissionDenied, ValidationError + +from dojo.authorization.authorization import user_is_superuser_or_global_owner +from dojo.models import Dojo_User, UserContactInfo +from dojo.notifications.helper import create_notification + + +def reset_token_for_user(*, acting_user: Dojo_User, target_user: Dojo_User, allow_self_reset: bool = False) -> None: + if not settings.API_TOKENS_ENABLED: + msg = "API tokens are disabled." + raise PermissionDenied(msg) + + if acting_user is None or getattr(acting_user, "is_anonymous", False): + msg = "Authentication required." + raise PermissionDenied(msg) + + if acting_user == target_user and not allow_self_reset: + msg = "Resetting your own API token via this endpoint is not allowed." + raise ValidationError(msg) + + # Only check permissions if not self-reset (self-reset is always allowed when allow_self_reset=True) + if acting_user != target_user and not user_is_superuser_or_global_owner(acting_user): + msg = "Insufficient permissions to reset API tokens." + raise PermissionDenied(msg) + + # Rotate token: delete existing token (if any), then create a new one. + Token.objects.filter(user=target_user).delete() + Token.objects.create(user=target_user) + + uci, _ = UserContactInfo.objects.get_or_create(user=target_user) + uci.token_last_reset = timezone.now() + uci.save(update_fields=["token_last_reset"]) + + # Send notification to the target user + if acting_user == target_user: + # Self-reset notification + description = f"A new API token has been generated for user {target_user.username}." + requested_by = None + else: + # Admin reset notification + description = ( + f"Your API token has been reset by {acting_user.get_full_name() or acting_user.username}. " + f"Please retrieve the new API token via the UI to keep using the API." + ) + requested_by = acting_user + + create_notification( + event="other", + title="API Token Reset", + description=description, + recipients=[target_user], + url=reverse("api_v2_key"), + requested_by=requested_by, + icon="key", + ) diff --git a/dojo/user/urls.py b/dojo/user/urls.py index 01971b808d1..d560bdb5f55 100644 --- a/dojo/user/urls.py +++ b/dojo/user/urls.py @@ -35,7 +35,7 @@ re_path(r"^password_reset/done/$", auth_views.PasswordResetDoneView.as_view( template_name="login/password_reset_done.html", ), name="password_reset_done"), - re_path(r"^reset/(?P[0-9A-Za-z_\-]+)/(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,40})/$", auth_views.PasswordResetConfirmView.as_view( + re_path(r"^reset/(?P[0-9A-Za-z_\-]+)/(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,40})/$", views.DojoPasswordResetConfirmView.as_view( template_name="login/password_reset_confirm.html", ), name="password_reset_confirm"), re_path(r"^reset/done/$", auth_views.PasswordResetCompleteView.as_view( diff --git a/dojo/user/views.py b/dojo/user/views.py index f4e2539d659..dd732b5c91e 100644 --- a/dojo/user/views.py +++ b/dojo/user/views.py @@ -10,7 +10,7 @@ from django.contrib.auth import logout from django.contrib.auth.decorators import login_required, user_passes_test from django.contrib.auth.forms import AuthenticationForm, PasswordResetForm -from django.contrib.auth.views import LoginView, PasswordResetView +from django.contrib.auth.views import LoginView, PasswordResetConfirmView, PasswordResetView from django.contrib.humanize.templatetags.humanize import naturaltime from django.core import serializers from django.core.exceptions import PermissionDenied, ValidationError @@ -26,7 +26,10 @@ from django.utils.timezone import now from django.utils.translation import gettext as _ from rest_framework.authtoken.models import Token +from rest_framework.exceptions import PermissionDenied as RFPermissionDenied +from rest_framework.exceptions import ValidationError as RFValidationError +from dojo.authorization.authorization import user_is_superuser_or_global_owner from dojo.authorization.authorization_decorators import user_is_configuration_authorized from dojo.authorization.roles_permissions import Permissions from dojo.decorators import dojo_ratelimit @@ -47,9 +50,10 @@ ) from dojo.group.queries import get_authorized_group_members_for_user from dojo.labels import get_labels -from dojo.models import Alerts, Dojo_Group_Member, Dojo_User, Product_Member, Product_Type_Member +from dojo.models import Alerts, Dojo_Group_Member, Dojo_User, Product_Member, Product_Type_Member, UserContactInfo from dojo.product.queries import get_authorized_product_members_for_user from dojo.product_type.queries import get_authorized_product_type_members_for_user +from dojo.user.authentication import reset_token_for_user from dojo.utils import add_breadcrumb, get_page_items, get_setting, get_system_setting logger = logging.getLogger(__name__) @@ -91,15 +95,17 @@ def api_v2_key(request): form = APIKeyForm(request.POST, instance=request.user) if form.is_valid() and form.cleaned_data["id"] == request.user.id: try: - api_key = Token.objects.get(user=request.user) - api_key.delete() - api_key = Token.objects.create(user=request.user) - except Token.DoesNotExist: - api_key = Token.objects.create(user=request.user) - messages.add_message(request, - messages.SUCCESS, - _("API Key generated successfully."), - extra_tags="alert-success") + reset_token_for_user(acting_user=request.user, target_user=request.user, allow_self_reset=True) + except (RFPermissionDenied, RFValidationError) as e: + messages.add_message(request, + messages.ERROR, + _("API Key generation failed: %s") % str(e), + extra_tags="alert-danger") + else: + messages.add_message(request, + messages.SUCCESS, + _("API Key generated successfully."), + extra_tags="alert-success") else: raise PermissionDenied else: @@ -289,6 +295,12 @@ def change_password(request): user.set_password(new_password) user.disable_force_password_reset() user.save() + # Case: user is logged in and changes their password via the profile UI. + # We stamp password_last_reset here so this flow is tracked independently from + # the "forgot password" reset flow (handled in DojoPasswordResetConfirmView). + uci, _created = UserContactInfo.objects.get_or_create(user=user) + uci.password_last_reset = now() + uci.save(update_fields=["password_last_reset"]) messages.add_message(request, messages.SUCCESS, @@ -433,10 +445,37 @@ def edit_user(request, uid): global_role = global_role_form.save(commit=False) global_role.user = user global_role.save() + + # Handle API token reset if checkbox is checked + # Only allow superusers or global owners to reset tokens + token_reset_success = False + if user_is_superuser_or_global_owner(request.user): + reset_token = contact_form.cleaned_data.get("reset_api_token", False) + if reset_token: + try: + reset_token_for_user(acting_user=request.user, target_user=user) + token_reset_success = True + messages.add_message(request, + messages.SUCCESS, + _("API token reset successfully."), + extra_tags="alert-success") + except (RFPermissionDenied, RFValidationError) as e: + # If permission denied or validation error, log but don't fail the user save + messages.add_message(request, + messages.WARNING, + _("User saved successfully, but API token reset failed: %s") % str(e), + extra_tags="alert-warning") + messages.add_message(request, messages.SUCCESS, _("User saved successfully."), extra_tags="alert-success") + + # Re-instantiate forms to uncheck the checkbox after successful save + if token_reset_success: + # Reload contact from database to get updated token_last_reset timestamp + contact.refresh_from_db() + contact_form = UserContactInfoForm(instance=contact, user=user) else: messages.add_message(request, messages.ERROR, @@ -669,3 +708,16 @@ class DojoPasswordResetView(PasswordResetView): class DojoForgotUsernameView(PasswordResetView): form_class = DojoForgotUsernameForm + + +class DojoPasswordResetConfirmView(PasswordResetConfirmView): + def form_valid(self, form): + response = super().form_valid(form) + # Flow: user resets password via the emailed "forgot password" link. + # This uses PasswordResetConfirmView, so we stamp password_last_reset here + # because this flow does not pass through change_password(). + user = form.user + uci, _created = UserContactInfo.objects.get_or_create(user=user) + uci.password_last_reset = now() + uci.save(update_fields=["password_last_reset"]) + return response diff --git a/unittests/test_apiv2_user.py b/unittests/test_apiv2_user.py index d781a4ea384..6a5b1998659 100644 --- a/unittests/test_apiv2_user.py +++ b/unittests/test_apiv2_user.py @@ -1,7 +1,10 @@ from django.urls import reverse +from django.utils import timezone from rest_framework.authtoken.models import Token from rest_framework.test import APIClient, APITestCase +from dojo.models import Global_Role, Role, User, UserContactInfo + class UserTest(APITestCase): @@ -126,3 +129,112 @@ def test_user_deactivate(self): "password": password, }, format="json") self.assertEqual(r.status_code, 400, r.content[:1000]) + + def test_user_reset_api_token_as_superuser(self): + r = self.client.post(reverse("user-list"), { + "username": "api-user-reset-1", + "email": "admin@dojo.com", + "password": "testTEST1234!@#$", + }, format="json") + self.assertEqual(r.status_code, 201, r.content[:1000]) + user_id = r.json()["id"] + + target_user = User.objects.get(id=user_id) + # Tokens aren't created automatically for new users; ensure one exists. + old_token = Token.objects.get_or_create(user=target_user)[0].key + + url = "{}{}/reset_api_token/".format(reverse("user-list"), user_id) + r = self.client.post(url) + self.assertEqual(r.status_code, 204, r.content[:1000]) + + new_token = Token.objects.get(user=target_user).key + self.assertNotEqual(old_token, new_token) + + uci = UserContactInfo.objects.get(user=target_user) + self.assertIsNotNone(uci.token_last_reset) + self.assertLess(abs((timezone.now() - uci.token_last_reset).total_seconds()), 30) + + def test_user_reset_api_token_denies_self(self): + admin_user = User.objects.get(username="admin") + admin_token_before = Token.objects.get(user=admin_user).key + + url = "{}{}/reset_api_token/".format(reverse("user-list"), admin_user.id) + r = self.client.post(url) + self.assertIn(r.status_code, {400, 403}, r.content[:1000]) + + admin_token_after = Token.objects.get(user=admin_user).key + self.assertEqual(admin_token_before, admin_token_after) + + def test_user_reset_api_token_denies_non_privileged(self): + # Create a target + r = self.client.post(reverse("user-list"), { + "username": "api-user-reset-target", + "email": "admin@dojo.com", + "password": "testTEST1234!@#$", + }, format="json") + self.assertEqual(r.status_code, 201, r.content[:1000]) + target_id = r.json()["id"] + + # Create a non-privileged user and authenticate as them + password = "testTEST1234!@#$" + r = self.client.post(reverse("user-list"), { + "username": "api-user-nonpriv", + "email": "admin@dojo.com", + "password": password, + }, format="json") + self.assertEqual(r.status_code, 201, r.content[:1000]) + + token_resp = self.client.post(reverse("api-token-auth"), { + "username": "api-user-nonpriv", + "password": password, + }, format="json") + self.assertEqual(token_resp.status_code, 200, token_resp.content[:1000]) + nonpriv_token = token_resp.json()["token"] + + nonpriv_client = APIClient() + nonpriv_client.credentials(HTTP_AUTHORIZATION="Token " + nonpriv_token) + + url = "{}{}/reset_api_token/".format(reverse("user-list"), target_id) + r = nonpriv_client.post(url) + self.assertEqual(r.status_code, 403, r.content[:1000]) + + def test_user_reset_api_token_allows_global_owner(self): + # Create a global-owner user (not superuser) + password = "testTEST1234!@#$" + r = self.client.post(reverse("user-list"), { + "username": "api-user-global-owner", + "email": "admin@dojo.com", + "password": password, + }, format="json") + self.assertEqual(r.status_code, 201, r.content[:1000]) + global_owner = User.objects.get(username="api-user-global-owner") + + owner_role, _ = Role.objects.get_or_create(name="Owner", defaults={"is_owner": True}) + if not owner_role.is_owner: + owner_role.is_owner = True + owner_role.save(update_fields=["is_owner"]) + Global_Role.objects.update_or_create(user=global_owner, defaults={"role": owner_role}) + + # Create target + r = self.client.post(reverse("user-list"), { + "username": "api-user-reset-2", + "email": "admin@dojo.com", + "password": "testTEST1234!@#$", + }, format="json") + self.assertEqual(r.status_code, 201, r.content[:1000]) + target_id = r.json()["id"] + + # Authenticate as global owner + token_resp = self.client.post(reverse("api-token-auth"), { + "username": "api-user-global-owner", + "password": password, + }, format="json") + self.assertEqual(token_resp.status_code, 200, token_resp.content[:1000]) + go_token = token_resp.json()["token"] + + go_client = APIClient() + go_client.credentials(HTTP_AUTHORIZATION="Token " + go_token) + + url = "{}{}/reset_api_token/".format(reverse("user-list"), target_id) + r = go_client.post(url) + self.assertEqual(r.status_code, 204, r.content[:1000]) diff --git a/unittests/test_user_ui_timestamps.py b/unittests/test_user_ui_timestamps.py new file mode 100644 index 00000000000..367619466db --- /dev/null +++ b/unittests/test_user_ui_timestamps.py @@ -0,0 +1,92 @@ +from datetime import UTC, datetime +from unittest.mock import patch + +from django.contrib.auth.tokens import default_token_generator +from django.test import TestCase +from django.urls import reverse +from django.utils.encoding import force_bytes +from django.utils.http import urlsafe_base64_encode + +from dojo.models import Dojo_User, User, UserContactInfo +from dojo.user.authentication import reset_token_for_user + + +class TestUserUITimestamps(TestCase): + fixtures = ["dojo_testdata.json"] + + @patch("dojo.user.authentication.create_notification") + def test_view_user_contains_timestamps(self, mock_create_notification): + fixed = datetime(2025, 12, 12, 12, 0, 0, tzinfo=UTC) + admin = Dojo_User.objects.get(username="admin") + + # Create a target user and rotate their token at a fixed time. + target = User.objects.create(username="ui-ts-target", email="ui-ts-target@dojo.com") + with patch("dojo.user.authentication.timezone.now", return_value=fixed): + reset_token_for_user(acting_user=admin, target_user=target) + + # Ensure the UI can render and the timestamps match what we wrote. + self.client.force_login(admin) + resp = self.client.get(f"/user/{target.id}") + self.assertEqual(resp.status_code, 200) + viewed_user = resp.context["user"] + self.assertEqual(viewed_user.usercontactinfo.token_last_reset, fixed) + + # Now set password_last_reset to a fixed timestamp and assert it is exposed too. + uci, _ = UserContactInfo.objects.get_or_create(user=target) + uci.password_last_reset = fixed + uci.save(update_fields=["password_last_reset"]) + resp = self.client.get(f"/user/{target.id}") + viewed_user = resp.context["user"] + self.assertEqual(viewed_user.usercontactinfo.password_last_reset, fixed) + + def test_change_password_stamps_password_last_reset(self): + fixed = datetime(2025, 12, 12, 12, 0, 0, tzinfo=UTC) + user = User.objects.create(username="pwd-change-user", email="pwd-change-user@dojo.com", is_active=True) + user.set_password("OldPass123!@#") + user.save() + + self.client.force_login(user) + with patch("dojo.user.views.now", return_value=fixed): + resp = self.client.post( + reverse("change_password"), + data={ + "current_password": "OldPass123!@#", + "new_password": "NewPass123!@#", + "confirm_password": "NewPass123!@#", + }, + ) + # Successful change redirects to profile + self.assertEqual(resp.status_code, 302) + + uci = UserContactInfo.objects.get(user=user) + self.assertEqual(uci.password_last_reset, fixed) + + def test_password_reset_confirm_stamps_password_last_reset(self): + fixed = datetime(2025, 12, 12, 12, 0, 0, tzinfo=UTC) + user = User.objects.create(username="pwd-reset-user", email="pwd-reset-user@dojo.com", is_active=True) + user.set_password("OldPass123!@#") + user.save() + + uidb64 = urlsafe_base64_encode(force_bytes(user.pk)) + token = default_token_generator.make_token(user) + url = reverse("password_reset_confirm", kwargs={"uidb64": uidb64, "token": token}) + + with patch("dojo.user.views.now", return_value=fixed): + # Django's PasswordResetConfirmView typically requires a GET to the tokenized URL, + # which sets a session token and redirects to the "set-password" URL. + resp_get = self.client.get(url) + self.assertEqual(resp_get.status_code, 302) + set_password_url = resp_get["Location"] + + resp = self.client.post( + set_password_url, + data={ + "new_password1": "NewPass123!@#", + "new_password2": "NewPass123!@#", + }, + ) + + # PasswordResetConfirmView redirects to reset done on success + self.assertEqual(resp.status_code, 302) + uci = UserContactInfo.objects.get(user=user) + self.assertEqual(uci.password_last_reset, fixed) From b967bddb941328d3689f688a336b76d72c1c36bb Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 21:29:21 -0600 Subject: [PATCH 052/126] chore(deps): update dependency vite from 7.2.7 to v7.3.0 (docs/package.json) (#13902) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docs/package-lock.json | 224 ++++++++++++++++++++--------------------- docs/package.json | 2 +- 2 files changed, 113 insertions(+), 113 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index 3d3f3a548b8..d23639304b6 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -20,7 +20,7 @@ }, "devDependencies": { "prettier": "3.7.4", - "vite": "7.2.7" + "vite": "7.3.0" }, "engines": { "node": ">=20.11.0" @@ -1497,9 +1497,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz", - "integrity": "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.1.tgz", + "integrity": "sha512-HHB50pdsBX6k47S4u5g/CaLjqS3qwaOVE5ILsq64jyzgMhLuCuZ8rGzM9yhsAjfjkbgUPMzZEPa7DAp7yz6vuA==", "cpu": [ "ppc64" ], @@ -1514,9 +1514,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.11.tgz", - "integrity": "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.1.tgz", + "integrity": "sha512-kFqa6/UcaTbGm/NncN9kzVOODjhZW8e+FRdSeypWe6j33gzclHtwlANs26JrupOntlcWmB0u8+8HZo8s7thHvg==", "cpu": [ "arm" ], @@ -1531,9 +1531,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.11.tgz", - "integrity": "sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.1.tgz", + "integrity": "sha512-45fuKmAJpxnQWixOGCrS+ro4Uvb4Re9+UTieUY2f8AEc+t7d4AaZ6eUJ3Hva7dtrxAAWHtlEFsXFMAgNnGU9uQ==", "cpu": [ "arm64" ], @@ -1548,9 +1548,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.11.tgz", - "integrity": "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.1.tgz", + "integrity": "sha512-LBEpOz0BsgMEeHgenf5aqmn/lLNTFXVfoWMUox8CtWWYK9X4jmQzWjoGoNb8lmAYml/tQ/Ysvm8q7szu7BoxRQ==", "cpu": [ "x64" ], @@ -1565,9 +1565,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz", - "integrity": "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.1.tgz", + "integrity": "sha512-veg7fL8eMSCVKL7IW4pxb54QERtedFDfY/ASrumK/SbFsXnRazxY4YykN/THYqFnFwJ0aVjiUrVG2PwcdAEqQQ==", "cpu": [ "arm64" ], @@ -1582,9 +1582,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.11.tgz", - "integrity": "sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.1.tgz", + "integrity": "sha512-+3ELd+nTzhfWb07Vol7EZ+5PTbJ/u74nC6iv4/lwIU99Ip5uuY6QoIf0Hn4m2HoV0qcnRivN3KSqc+FyCHjoVQ==", "cpu": [ "x64" ], @@ -1599,9 +1599,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.11.tgz", - "integrity": "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.1.tgz", + "integrity": "sha512-/8Rfgns4XD9XOSXlzUDepG8PX+AVWHliYlUkFI3K3GB6tqbdjYqdhcb4BKRd7C0BhZSoaCxhv8kTcBrcZWP+xg==", "cpu": [ "arm64" ], @@ -1616,9 +1616,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.11.tgz", - "integrity": "sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.1.tgz", + "integrity": "sha512-GITpD8dK9C+r+5yRT/UKVT36h/DQLOHdwGVwwoHidlnA168oD3uxA878XloXebK4Ul3gDBBIvEdL7go9gCUFzQ==", "cpu": [ "x64" ], @@ -1633,9 +1633,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.11.tgz", - "integrity": "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.1.tgz", + "integrity": "sha512-ieMID0JRZY/ZeCrsFQ3Y3NlHNCqIhTprJfDgSB3/lv5jJZ8FX3hqPyXWhe+gvS5ARMBJ242PM+VNz/ctNj//eA==", "cpu": [ "arm" ], @@ -1650,9 +1650,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.11.tgz", - "integrity": "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.1.tgz", + "integrity": "sha512-W9//kCrh/6in9rWIBdKaMtuTTzNj6jSeG/haWBADqLLa9P8O5YSRDzgD5y9QBok4AYlzS6ARHifAb75V6G670Q==", "cpu": [ "arm64" ], @@ -1667,9 +1667,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.11.tgz", - "integrity": "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.1.tgz", + "integrity": "sha512-VIUV4z8GD8rtSVMfAj1aXFahsi/+tcoXXNYmXgzISL+KB381vbSTNdeZHHHIYqFyXcoEhu9n5cT+05tRv13rlw==", "cpu": [ "ia32" ], @@ -1684,9 +1684,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.11.tgz", - "integrity": "sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.1.tgz", + "integrity": "sha512-l4rfiiJRN7sTNI//ff65zJ9z8U+k6zcCg0LALU5iEWzY+a1mVZ8iWC1k5EsNKThZ7XCQ6YWtsZ8EWYm7r1UEsg==", "cpu": [ "loong64" ], @@ -1701,9 +1701,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.11.tgz", - "integrity": "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.1.tgz", + "integrity": "sha512-U0bEuAOLvO/DWFdygTHWY8C067FXz+UbzKgxYhXC0fDieFa0kDIra1FAhsAARRJbvEyso8aAqvPdNxzWuStBnA==", "cpu": [ "mips64el" ], @@ -1718,9 +1718,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.11.tgz", - "integrity": "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.1.tgz", + "integrity": "sha512-NzdQ/Xwu6vPSf/GkdmRNsOfIeSGnh7muundsWItmBsVpMoNPVpM61qNzAVY3pZ1glzzAxLR40UyYM23eaDDbYQ==", "cpu": [ "ppc64" ], @@ -1735,9 +1735,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.11.tgz", - "integrity": "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.1.tgz", + "integrity": "sha512-7zlw8p3IApcsN7mFw0O1Z1PyEk6PlKMu18roImfl3iQHTnr/yAfYv6s4hXPidbDoI2Q0pW+5xeoM4eTCC0UdrQ==", "cpu": [ "riscv64" ], @@ -1752,9 +1752,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.11.tgz", - "integrity": "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.1.tgz", + "integrity": "sha512-cGj5wli+G+nkVQdZo3+7FDKC25Uh4ZVwOAK6A06Hsvgr8WqBBuOy/1s+PUEd/6Je+vjfm6stX0kmib5b/O2Ykw==", "cpu": [ "s390x" ], @@ -1769,9 +1769,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.11.tgz", - "integrity": "sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.1.tgz", + "integrity": "sha512-z3H/HYI9MM0HTv3hQZ81f+AKb+yEoCRlUby1F80vbQ5XdzEMyY/9iNlAmhqiBKw4MJXwfgsh7ERGEOhrM1niMA==", "cpu": [ "x64" ], @@ -1786,9 +1786,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.11.tgz", - "integrity": "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.1.tgz", + "integrity": "sha512-wzC24DxAvk8Em01YmVXyjl96Mr+ecTPyOuADAvjGg+fyBpGmxmcr2E5ttf7Im8D0sXZihpxzO1isus8MdjMCXQ==", "cpu": [ "arm64" ], @@ -1803,9 +1803,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.11.tgz", - "integrity": "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.1.tgz", + "integrity": "sha512-1YQ8ybGi2yIXswu6eNzJsrYIGFpnlzEWRl6iR5gMgmsrR0FcNoV1m9k9sc3PuP5rUBLshOZylc9nqSgymI+TYg==", "cpu": [ "x64" ], @@ -1820,9 +1820,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.11.tgz", - "integrity": "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.1.tgz", + "integrity": "sha512-5Z+DzLCrq5wmU7RDaMDe2DVXMRm2tTDvX2KU14JJVBN2CT/qov7XVix85QoJqHltpvAOZUAc3ndU56HSMWrv8g==", "cpu": [ "arm64" ], @@ -1837,9 +1837,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.11.tgz", - "integrity": "sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.1.tgz", + "integrity": "sha512-Q73ENzIdPF5jap4wqLtsfh8YbYSZ8Q0wnxplOlZUOyZy7B4ZKW8DXGWgTCZmF8VWD7Tciwv5F4NsRf6vYlZtqg==", "cpu": [ "x64" ], @@ -1854,9 +1854,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.11.tgz", - "integrity": "sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.1.tgz", + "integrity": "sha512-ajbHrGM/XiK+sXM0JzEbJAen+0E+JMQZ2l4RR4VFwvV9JEERx+oxtgkpoKv1SevhjavK2z2ReHk32pjzktWbGg==", "cpu": [ "arm64" ], @@ -1871,9 +1871,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz", - "integrity": "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.1.tgz", + "integrity": "sha512-IPUW+y4VIjuDVn+OMzHc5FV4GubIwPnsz6ubkvN8cuhEqH81NovB53IUlrlBkPMEPxvNnf79MGBoz8rZ2iW8HA==", "cpu": [ "x64" ], @@ -1888,9 +1888,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.11.tgz", - "integrity": "sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.1.tgz", + "integrity": "sha512-RIVRWiljWA6CdVu8zkWcRmGP7iRRIIwvhDKem8UMBjPql2TXM5PkDVvvrzMtj1V+WFPB4K7zkIGM7VzRtFkjdg==", "cpu": [ "arm64" ], @@ -1905,9 +1905,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.11.tgz", - "integrity": "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.1.tgz", + "integrity": "sha512-2BR5M8CPbptC1AK5JbJT1fWrHLvejwZidKx3UMSF0ecHMa+smhi16drIrCEggkgviBwLYd5nwrFLSl5Kho96RQ==", "cpu": [ "ia32" ], @@ -1922,9 +1922,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.11.tgz", - "integrity": "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.1.tgz", + "integrity": "sha512-d5X6RMYv6taIymSk8JBP+nxv8DQAMY6A51GPgusqLdK9wBz5wWIXy1KjTck6HnjE9hqJzJRdk+1p/t5soSbCtw==", "cpu": [ "x64" ], @@ -2962,9 +2962,9 @@ "license": "MIT" }, "node_modules/esbuild": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.11.tgz", - "integrity": "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.1.tgz", + "integrity": "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2975,32 +2975,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.11", - "@esbuild/android-arm": "0.25.11", - "@esbuild/android-arm64": "0.25.11", - "@esbuild/android-x64": "0.25.11", - "@esbuild/darwin-arm64": "0.25.11", - "@esbuild/darwin-x64": "0.25.11", - "@esbuild/freebsd-arm64": "0.25.11", - "@esbuild/freebsd-x64": "0.25.11", - "@esbuild/linux-arm": "0.25.11", - "@esbuild/linux-arm64": "0.25.11", - "@esbuild/linux-ia32": "0.25.11", - "@esbuild/linux-loong64": "0.25.11", - "@esbuild/linux-mips64el": "0.25.11", - "@esbuild/linux-ppc64": "0.25.11", - "@esbuild/linux-riscv64": "0.25.11", - "@esbuild/linux-s390x": "0.25.11", - "@esbuild/linux-x64": "0.25.11", - "@esbuild/netbsd-arm64": "0.25.11", - "@esbuild/netbsd-x64": "0.25.11", - "@esbuild/openbsd-arm64": "0.25.11", - "@esbuild/openbsd-x64": "0.25.11", - "@esbuild/openharmony-arm64": "0.25.11", - "@esbuild/sunos-x64": "0.25.11", - "@esbuild/win32-arm64": "0.25.11", - "@esbuild/win32-ia32": "0.25.11", - "@esbuild/win32-x64": "0.25.11" + "@esbuild/aix-ppc64": "0.27.1", + "@esbuild/android-arm": "0.27.1", + "@esbuild/android-arm64": "0.27.1", + "@esbuild/android-x64": "0.27.1", + "@esbuild/darwin-arm64": "0.27.1", + "@esbuild/darwin-x64": "0.27.1", + "@esbuild/freebsd-arm64": "0.27.1", + "@esbuild/freebsd-x64": "0.27.1", + "@esbuild/linux-arm": "0.27.1", + "@esbuild/linux-arm64": "0.27.1", + "@esbuild/linux-ia32": "0.27.1", + "@esbuild/linux-loong64": "0.27.1", + "@esbuild/linux-mips64el": "0.27.1", + "@esbuild/linux-ppc64": "0.27.1", + "@esbuild/linux-riscv64": "0.27.1", + "@esbuild/linux-s390x": "0.27.1", + "@esbuild/linux-x64": "0.27.1", + "@esbuild/netbsd-arm64": "0.27.1", + "@esbuild/netbsd-x64": "0.27.1", + "@esbuild/openbsd-arm64": "0.27.1", + "@esbuild/openbsd-x64": "0.27.1", + "@esbuild/openharmony-arm64": "0.27.1", + "@esbuild/sunos-x64": "0.27.1", + "@esbuild/win32-arm64": "0.27.1", + "@esbuild/win32-ia32": "0.27.1", + "@esbuild/win32-x64": "0.27.1" } }, "node_modules/escalade": { @@ -4572,13 +4572,13 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.2.7", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz", - "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", + "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.25.0", + "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", diff --git a/docs/package.json b/docs/package.json index d18b4b456e8..dd66cead668 100644 --- a/docs/package.json +++ b/docs/package.json @@ -27,7 +27,7 @@ }, "devDependencies": { "prettier": "3.7.4", - "vite": "7.2.7" + "vite": "7.3.0" }, "engines": { "node": ">=20.11.0" From 4c5de62c8f3ba5ea434c6b5ee2d1d95f870e5b52 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 21:31:25 -0600 Subject: [PATCH 053/126] chore(deps): bump django-polymorphic from 4.3.0 to 4.4.0 (#13901) Bumps [django-polymorphic](https://github.com/jazzband/django-polymorphic) from 4.3.0 to 4.4.0. - [Release notes](https://github.com/jazzband/django-polymorphic/releases) - [Changelog](https://github.com/jazzband/django-polymorphic/blob/master/docs/changelog.rst) - [Commits](https://github.com/jazzband/django-polymorphic/compare/v4.3.0...v4.4.0) --- updated-dependencies: - dependency-name: django-polymorphic dependency-version: 4.4.0 dependency-type: direct:production update-type: version-update:semver-minor ... 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 e32ad0aa8f0..e843402526d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ django-environ==0.12.0 django-filter==25.1 django-imagekit==6.0.0 django-multiselectfield==1.0.1 -django-polymorphic==4.3.0 +django-polymorphic==4.4.0 django-crispy-forms==2.5 django_extensions==4.1 django-slack==5.19.0 From a56e83227e546bff5867a543c3bde40fa0501bd7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 16 Dec 2025 08:44:13 -0700 Subject: [PATCH 054/126] chore(deps): update valkey docker tag from 0.10.2 to v0.13.0 (helm/defectdojo/chart.yaml) (#13907) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- helm/defectdojo/Chart.lock | 6 +++--- helm/defectdojo/Chart.yaml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/helm/defectdojo/Chart.lock b/helm/defectdojo/Chart.lock index 1d693b06484..ecf2eea23ec 100644 --- a/helm/defectdojo/Chart.lock +++ b/helm/defectdojo/Chart.lock @@ -4,6 +4,6 @@ dependencies: version: 16.7.27 - name: valkey repository: oci://registry-1.docker.io/cloudpirates - version: 0.10.2 -digest: sha256:ffaad5072467386baa7b7142dde88375c007fd72f6bd7e97008107d1fc37751d -generated: "2025-12-15T10:35:03.656421-06:00" + version: 0.13.0 +digest: sha256:2ea1c1bef68a7e7fd2dee4262506c880e48c9703cb7ba9908d91b891fc630048 +generated: "2025-12-15T17:19:18.528341139Z" diff --git a/helm/defectdojo/Chart.yaml b/helm/defectdojo/Chart.yaml index 0bbd413b257..eb3beff0993 100644 --- a/helm/defectdojo/Chart.yaml +++ b/helm/defectdojo/Chart.yaml @@ -14,7 +14,7 @@ dependencies: repository: "oci://us-docker.pkg.dev/os-public-container-registry/defectdojo" condition: postgresql.enabled - name: valkey - version: 0.10.2 + version: 0.13.0 repository: "oci://registry-1.docker.io/cloudpirates" condition: valkey.enabled # For correct syntax, check https://artifacthub.io/docs/topics/annotations/helm/ From 5e4531a8df638fd8f232859ae2eabafdd671d63d Mon Sep 17 00:00:00 2001 From: dogboat Date: Tue, 16 Dec 2025 17:53:10 -0500 Subject: [PATCH 055/126] rename/reorder migrations after recent merges (#13915) --- ...t_timestamps.py => 0251_usercontactinfo_reset_timestamps.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename dojo/db_migrations/{0249_usercontactinfo_reset_timestamps.py => 0251_usercontactinfo_reset_timestamps.py} (93%) diff --git a/dojo/db_migrations/0249_usercontactinfo_reset_timestamps.py b/dojo/db_migrations/0251_usercontactinfo_reset_timestamps.py similarity index 93% rename from dojo/db_migrations/0249_usercontactinfo_reset_timestamps.py rename to dojo/db_migrations/0251_usercontactinfo_reset_timestamps.py index 47d56eef8b2..88d071182f3 100644 --- a/dojo/db_migrations/0249_usercontactinfo_reset_timestamps.py +++ b/dojo/db_migrations/0251_usercontactinfo_reset_timestamps.py @@ -6,7 +6,7 @@ class Migration(migrations.Migration): dependencies = [ - ("dojo", "0248_alter_general_survey_expiration"), + ("dojo", "0250_pghistory_backfill"), ] operations = [ From 67ac006fa2b17ec117184b417c65ab321dc09470 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 17 Dec 2025 20:32:14 -0600 Subject: [PATCH 056/126] chore(deps): update stefanzweifel/git-auto-commit-action action from v7.0.0 to v7.1.0 (.github/workflows/release-3-master-into-dev.yml) (#13920) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/release-1-create-pr.yml | 2 +- .github/workflows/release-3-master-into-dev.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release-1-create-pr.yml b/.github/workflows/release-1-create-pr.yml index 9dca5715225..39d7d4453e4 100644 --- a/.github/workflows/release-1-create-pr.yml +++ b/.github/workflows/release-1-create-pr.yml @@ -98,7 +98,7 @@ jobs: chart-search-root: "helm/defectdojo" - name: Push version changes - uses: stefanzweifel/git-auto-commit-action@28e16e81777b558cc906c8750092100bbb34c5e3 # v7.0.0 + uses: stefanzweifel/git-auto-commit-action@04702edda442b2e678b25b537cec683a1493fcb9 # v7.1.0 with: commit_user_name: "${{ env.GIT_USERNAME }}" commit_user_email: "${{ env.GIT_EMAIL }}" diff --git a/.github/workflows/release-3-master-into-dev.yml b/.github/workflows/release-3-master-into-dev.yml index 14f3e532706..ce757d875a4 100644 --- a/.github/workflows/release-3-master-into-dev.yml +++ b/.github/workflows/release-3-master-into-dev.yml @@ -86,7 +86,7 @@ jobs: chart-search-root: "helm/defectdojo" - name: Push version changes - uses: stefanzweifel/git-auto-commit-action@28e16e81777b558cc906c8750092100bbb34c5e3 # v7.0.0 + uses: stefanzweifel/git-auto-commit-action@04702edda442b2e678b25b537cec683a1493fcb9 # v7.1.0 with: commit_user_name: "${{ env.GIT_USERNAME }}" commit_user_email: "${{ env.GIT_EMAIL }}" @@ -162,7 +162,7 @@ jobs: chart-search-root: "helm/defectdojo" - name: Push version changes - uses: stefanzweifel/git-auto-commit-action@28e16e81777b558cc906c8750092100bbb34c5e3 # v7.0.0 + uses: stefanzweifel/git-auto-commit-action@04702edda442b2e678b25b537cec683a1493fcb9 # v7.1.0 with: commit_user_name: "${{ env.GIT_USERNAME }}" commit_user_email: "${{ env.GIT_EMAIL }}" From 63143621c7d6877b5e633fcafa4f42547cdf1512 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Dec 2025 20:33:18 -0600 Subject: [PATCH 057/126] chore(deps): bump django-polymorphic from 4.4.0 to 4.4.1 (#13917) Bumps [django-polymorphic](https://github.com/jazzband/django-polymorphic) from 4.4.0 to 4.4.1. - [Release notes](https://github.com/jazzband/django-polymorphic/releases) - [Changelog](https://github.com/jazzband/django-polymorphic/blob/master/docs/changelog.rst) - [Commits](https://github.com/jazzband/django-polymorphic/compare/v4.4.0...v4.4.1) --- updated-dependencies: - dependency-name: django-polymorphic dependency-version: 4.4.1 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 e843402526d..45af1c5f1cb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ django-environ==0.12.0 django-filter==25.1 django-imagekit==6.0.0 django-multiselectfield==1.0.1 -django-polymorphic==4.4.0 +django-polymorphic==4.4.1 django-crispy-forms==2.5 django_extensions==4.1 django-slack==5.19.0 From 5c05c231e677ded7a7c80bb7d9172d4ed3bc6349 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 18 Dec 2025 09:55:17 -0700 Subject: [PATCH 058/126] chore(deps): update dependency kubernetes from 1.32.10 to v1.32.11 (.github/workflows/k8s-tests.yml) (#13923) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/k8s-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/k8s-tests.yml b/.github/workflows/k8s-tests.yml index 1a2ae525e26..478fba8cbe0 100644 --- a/.github/workflows/k8s-tests.yml +++ b/.github/workflows/k8s-tests.yml @@ -18,7 +18,7 @@ jobs: # are tested (https://kubernetes.io/releases/) - k8s: 'v1.34.3' # renovate: datasource=github-releases depName=kubernetes/kubernetes versioning=loose os: debian - - k8s: '1.32.10' # renovate: datasource=custom.endoflife-oldest-maintained depName=kubernetes + - k8s: '1.32.11' # renovate: datasource=custom.endoflife-oldest-maintained depName=kubernetes os: debian steps: - name: Checkout From b8231e1a54f0a993952eb85c8a679b84db4331d8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 18 Dec 2025 16:29:32 -0600 Subject: [PATCH 059/126] chore(deps): update postgres:18.1-alpine docker digest from 18.1 to 18.1-alpine (docker-compose.yml) (#13925) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 5825cd3875e..b364bb823b4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -120,7 +120,7 @@ services: source: ./docker/extra_settings target: /app/docker/extra_settings postgres: - image: postgres:18.1-alpine@sha256:eca6fb2d91fda290eb8cfb8ba53dd0dcbf3508a08011e30adb039ea7c8e1e9f2 + image: postgres:18.1-alpine@sha256:b5f0dfb46e028c156ff4e3dd2908fdf3474e6bd7837902d0e0151e4e30ad711f environment: POSTGRES_DB: ${DD_DATABASE_NAME:-defectdojo} POSTGRES_USER: ${DD_DATABASE_USER:-defectdojo} From e2a111e1c4c7be250c9fd562ddeaaf0c07ecb44c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 18 Dec 2025 16:30:20 -0600 Subject: [PATCH 060/126] chore(deps): bump asteval from 1.0.7 to 1.0.8 (#13927) Bumps [asteval](https://github.com/lmfit/asteval) from 1.0.7 to 1.0.8. - [Release notes](https://github.com/lmfit/asteval/releases) - [Commits](https://github.com/lmfit/asteval/compare/1.0.7...1.0.8) --- updated-dependencies: - dependency-name: asteval dependency-version: 1.0.8 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 45af1c5f1cb..6fae8f183b0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ # requirements.txt for DefectDojo using Python 3.x -asteval==1.0.7 +asteval==1.0.8 bleach==6.3.0 bleach[css] celery==5.6.0 From a1478fb093c1f171e69f9e855fa78fdf3be96c8a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 18 Dec 2025 20:08:39 -0600 Subject: [PATCH 061/126] chore(deps): bump django-dbbackup from 5.0.1 to 5.1.0 (#13926) Bumps [django-dbbackup](https://github.com/Archmonger/django-dbbackup) from 5.0.1 to 5.1.0. - [Release notes](https://github.com/Archmonger/django-dbbackup/releases) - [Changelog](https://github.com/Archmonger/django-dbbackup/blob/master/CHANGELOG.md) - [Commits](https://github.com/Archmonger/django-dbbackup/compare/5.0.1...5.1.0) --- updated-dependencies: - dependency-name: django-dbbackup dependency-version: 5.1.0 dependency-type: direct:production update-type: version-update:semver-minor ... 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 6fae8f183b0..37989f0eba0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ defusedxml==0.7.1 django_celery_results==2.6.0 django-auditlog==3.2.1 django-pghistory==3.9.1 -django-dbbackup==5.0.1 +django-dbbackup==5.1.0 django-environ==0.12.0 django-filter==25.1 django-imagekit==6.0.0 From 698ece0ef71a9d52b78e4bf4c4201eba060170e7 Mon Sep 17 00:00:00 2001 From: valentijnscholten Date: Fri, 19 Dec 2025 17:59:57 +0100 Subject: [PATCH 062/126] reimport: match findings in batches (#13889) * fix logger NoneType * reimport: use batch matching * tests: always show all mismatched counts * reimport: add test for internal duplicates during matching * reimport: fix handling of duplicates inside the same report/batch * add id * reimport: optimize vulnerability_id processing * vulnerability_id processing: remove duplicate delete, fix bug, add tests * vulnerability_id processing: separate import/reimport * reimport: add management command to reimport sample scans * reimport: prefetch endpoint status * reimport: bullk update endpoint statuses * add script to update performance test counts easily * add script to update performance test counts easily * 8a57c38c87615300c616f4115a66bee22a1836b2 * add upgrade notes * add upgrade notes * remove obsolete method * remove obsolete method * reimport: extract method to get candidates * reimport: remove fallback to non-batch * reimport: prep for Pro overrides * add comment * add comment * update counts and script * update counts and script --------- Co-authored-by: Valentijn Scholten --- docs/content/en/open_source/upgrading/2.54.md | 18 +- dojo/finding/deduplication.py | 180 ++++- dojo/finding/helper.py | 9 +- dojo/importers/base_importer.py | 22 +- dojo/importers/default_importer.py | 2 +- dojo/importers/default_reimporter.py | 439 +++++++++--- dojo/importers/endpoint_manager.py | 43 +- dojo/management/commands/list_top_tests.py | 119 ++++ dojo/settings/settings.dist.py | 3 + ruff.toml | 11 +- scripts/update_performance_test_counts.py | 667 ++++++++++++++++++ ...k_all_fields_different_ids_fabricated.json | 167 +++++ .../check_all_fields_no_ids_fabricated.json | 122 ++++ .../check_all_fields_with_ids_fabricated.json | 158 +++++ unittests/test_import_reimport.py | 420 +++++++++++ unittests/test_importers_importer.py | 106 ++- unittests/test_importers_performance.py | 345 +++++---- 17 files changed, 2514 insertions(+), 317 deletions(-) create mode 100644 dojo/management/commands/list_top_tests.py create mode 100644 scripts/update_performance_test_counts.py create mode 100644 unittests/scans/anchore_grype/check_all_fields_different_ids_fabricated.json create mode 100644 unittests/scans/anchore_grype/check_all_fields_no_ids_fabricated.json create mode 100644 unittests/scans/anchore_grype/check_all_fields_with_ids_fabricated.json diff --git a/docs/content/en/open_source/upgrading/2.54.md b/docs/content/en/open_source/upgrading/2.54.md index 625198c3162..c6843812cb7 100644 --- a/docs/content/en/open_source/upgrading/2.54.md +++ b/docs/content/en/open_source/upgrading/2.54.md @@ -1,8 +1,8 @@ --- title: 'Upgrading to DefectDojo Version 2.54.x' toc_hide: true -weight: -20251201 -description: Removal of django-auditlog and exclusive use of django-pghistory for audit logging & Dropped support for DD_PARSER_EXCLUDE +weight: -20250804 +description: Removal of django-auditlog & Dropped support for DD_PARSER_EXCLUDE & Reimport performance improvements --- ## Breaking Change: Removal of django-auditlog @@ -44,4 +44,16 @@ The backfill migration is not mandatory to succeed. If it fails for some reason, To simplify the management of the DefectDojo application, parser exclusions are no longer controlled via the environment variable DD_PARSER_EXCLUDE or application settings. This variable is now unsupported. From now on, you should use the active flag in the Test_Type model to enable or disable parsers. Only parsers associated with active Test_Type entries will be available for use. -Check the [Release Notes](https://github.com/DefectDojo/django-DefectDojo/releases/tag/2.54.0) for the contents of the release. \ No newline at end of file +## Import/reimport performance improvements + +DefectDojo 2.54.x includes performance improvements for reimporting scan results, especially for large scans: + +- **Faster reimports** due to fewer database queries and more bulk operations. +- **Reduced database load** during reimport matching and post-processing (helps avoid slowdowns/timeouts under heavy scan volume). +- **More efficient endpoint status updates** during reimport of dynamic findings. +- **Less churn when updating vulnerability IDs**, avoiding unnecessary deletes/writes when nothing changed. + +No action is required after upgrading. (Optional tuning knobs exist via `DD_IMPORT_REIMPORT_MATCH_BATCH_SIZE` and `DD_IMPORT_REIMPORT_DEDUPE_BATCH_SIZE`.) + +There are other instructions for upgrading to 2.54.x. Check the Release Notes for the contents of the release: `https://github.com/DefectDojo/django-DefectDojo/releases/tag/2.54.0` +Check the [Release Notes](https://github.com/DefectDojo/django-DefectDojo/releases/tag/2.54.0) for the contents of the release. diff --git a/dojo/finding/deduplication.py b/dojo/finding/deduplication.py index 7297e55fef4..16dffc100f5 100644 --- a/dojo/finding/deduplication.py +++ b/dojo/finding/deduplication.py @@ -232,59 +232,123 @@ def are_endpoints_duplicates(new_finding, to_duplicate_finding): return False -def build_dedupe_scope_queryset(test): - scope_on_engagement = test.engagement.deduplication_on_engagement - if scope_on_engagement: - scope_q = Q(test__engagement=test.engagement) - else: - # Product scope limited to current product, but exclude engagements that opted into engagement-scoped dedupe - scope_q = Q(test__engagement__product=test.engagement.product) & ( - Q(test__engagement=test.engagement) - | Q(test__engagement__deduplication_on_engagement=False) - ) +def build_candidate_scope_queryset(test, mode="deduplication", service=None): + """ + Build a queryset for candidate finding. + + Args: + test: The test to scope from + mode: "deduplication" (can match across tests) or "reimport" (same test only) + service: Optional service filter (for deduplication mode, not used for reimport since service is in hash) + + """ + if mode == "reimport": + # For reimport, only filter by test. Service filtering is not needed because + # service is included in hash_code calculation (HASH_CODE_FIELDS_ALWAYS = ["service"]), + # so matching by hash_code automatically ensures correct service match. + queryset = Finding.objects.filter(test=test) + else: # deduplication mode + scope_on_engagement = test.engagement.deduplication_on_engagement + if scope_on_engagement: + scope_q = Q(test__engagement=test.engagement) + else: + # Product scope limited to current product, but exclude engagements that opted into engagement-scoped dedupe + scope_q = Q(test__engagement__product=test.engagement.product) & ( + Q(test__engagement=test.engagement) + | Q(test__engagement__deduplication_on_engagement=False) + ) + queryset = Finding.objects.filter(scope_q) + + # Base prefetches for both modes + prefetch_list = ["endpoints", "vulnerability_id_set", "found_by"] + + # Additional prefetches for reimport mode + if mode == "reimport": + prefetch_list.extend([ + "status_finding", + "status_finding__endpoint", + ]) return ( - Finding.objects.filter(scope_q) + queryset .select_related("test", "test__engagement", "test__test_type") - .prefetch_related("endpoints", "found_by") + .prefetch_related(*prefetch_list) ) -def find_candidates_for_deduplication_hash(test, findings): - base_queryset = build_dedupe_scope_queryset(test) +def find_candidates_for_deduplication_hash(test, findings, mode="deduplication", service=None): + """ + Find candidates by hash_code. Works for both deduplication and reimport. + + Args: + test: The test to scope from + findings: List of findings to find candidates for + mode: "deduplication" or "reimport" + service: Optional service filter (for deduplication mode, not used for reimport since service is in hash) + + """ + base_queryset = build_candidate_scope_queryset(test, mode=mode, service=service) hash_codes = {f.hash_code for f in findings if getattr(f, "hash_code", None) is not None} if not hash_codes: return {} - existing_qs = ( - base_queryset.filter(hash_code__in=hash_codes) - .exclude(hash_code=None) - .exclude(duplicate=True) - .order_by("id") - ) + + existing_qs = base_queryset.filter(hash_code__in=hash_codes).exclude(hash_code=None) + if mode == "deduplication": + existing_qs = existing_qs.exclude(duplicate=True) + existing_qs = existing_qs.order_by("id") + existing_by_hash = {} for ef in existing_qs: existing_by_hash.setdefault(ef.hash_code, []).append(ef) - deduplicationLogger.debug(f"Found {len(existing_by_hash)} existing findings by hash codes") + + log_msg = "for reimport" if mode == "reimport" else "" + deduplicationLogger.debug(f"Found {len(existing_by_hash)} existing findings by hash codes {log_msg}") return existing_by_hash -def find_candidates_for_deduplication_unique_id(test, findings): - base_queryset = build_dedupe_scope_queryset(test) +def find_candidates_for_deduplication_unique_id(test, findings, mode="deduplication", service=None): + """ + Find candidates by unique_id_from_tool. Works for both deduplication and reimport. + + Args: + test: The test to scope from + findings: List of findings to find candidates for + mode: "deduplication" or "reimport" + service: Optional service filter (for deduplication mode, not used for reimport since service is in hash) + + """ + base_queryset = build_candidate_scope_queryset(test, mode=mode, service=service) unique_ids = {f.unique_id_from_tool for f in findings if getattr(f, "unique_id_from_tool", None) is not None} if not unique_ids: return {} - existing_qs = base_queryset.filter(unique_id_from_tool__in=unique_ids).exclude(unique_id_from_tool=None).exclude(duplicate=True).order_by("id") + + existing_qs = base_queryset.filter(unique_id_from_tool__in=unique_ids).exclude(unique_id_from_tool=None) + if mode == "deduplication": + existing_qs = existing_qs.exclude(duplicate=True) # unique_id_from_tool can only apply to the same test_type because it is parser dependent - existing_qs = existing_qs.filter(test__test_type=test.test_type) + existing_qs = existing_qs.filter(test__test_type=test.test_type).order_by("id") + existing_by_uid = {} for ef in existing_qs: existing_by_uid.setdefault(ef.unique_id_from_tool, []).append(ef) - deduplicationLogger.debug(f"Found {len(existing_by_uid)} existing findings by unique IDs") + + log_msg = "for reimport" if mode == "reimport" else "" + deduplicationLogger.debug(f"Found {len(existing_by_uid)} existing findings by unique IDs {log_msg}") return existing_by_uid -def find_candidates_for_deduplication_uid_or_hash(test, findings): - base_queryset = build_dedupe_scope_queryset(test) +def find_candidates_for_deduplication_uid_or_hash(test, findings, mode="deduplication", service=None): + """ + Find candidates by unique_id_from_tool or hash_code. Works for both deduplication and reimport. + + Args: + test: The test to scope from + findings: List of findings to find candidates for + mode: "deduplication" or "reimport" + service: Optional service filter (for deduplication mode, not used for reimport since service is in hash) + + """ + base_queryset = build_candidate_scope_queryset(test, mode=mode, service=service) hash_codes = {f.hash_code for f in findings if getattr(f, "hash_code", None) is not None} unique_ids = {f.unique_id_from_tool for f in findings if getattr(f, "unique_id_from_tool", None) is not None} if not hash_codes and not unique_ids: @@ -298,7 +362,11 @@ def find_candidates_for_deduplication_uid_or_hash(test, findings): uid_q = Q(unique_id_from_tool__isnull=False, unique_id_from_tool__in=unique_ids) & Q(test__test_type=test.test_type) cond |= uid_q - existing_qs = base_queryset.filter(cond).exclude(duplicate=True).order_by("id") + existing_qs = base_queryset.filter(cond) + if mode == "deduplication": + # reimport matching will match against duplicates, import/deduplication doesn't. + existing_qs = existing_qs.exclude(duplicate=True) + existing_qs = existing_qs.order_by("id") existing_by_hash = {} existing_by_uid = {} @@ -307,13 +375,15 @@ def find_candidates_for_deduplication_uid_or_hash(test, findings): existing_by_hash.setdefault(ef.hash_code, []).append(ef) if ef.unique_id_from_tool is not None: existing_by_uid.setdefault(ef.unique_id_from_tool, []).append(ef) - deduplicationLogger.debug(f"Found {len(existing_by_uid)} existing findings by unique IDs") - deduplicationLogger.debug(f"Found {len(existing_by_hash)} existing findings by hash codes") + + log_msg = "for reimport" if mode == "reimport" else "" + deduplicationLogger.debug(f"Found {len(existing_by_uid)} existing findings by unique IDs {log_msg}") + deduplicationLogger.debug(f"Found {len(existing_by_hash)} existing findings by hash codes {log_msg}") return existing_by_uid, existing_by_hash def find_candidates_for_deduplication_legacy(test, findings): - base_queryset = build_dedupe_scope_queryset(test) + base_queryset = build_candidate_scope_queryset(test, mode="deduplication") titles = {f.title for f in findings if getattr(f, "title", None)} cwes = {f.cwe for f in findings if getattr(f, "cwe", 0)} cwes.discard(0) @@ -335,6 +405,52 @@ def find_candidates_for_deduplication_legacy(test, findings): return by_title, by_cwe +# TODO: should we align this with deduplication? +def find_candidates_for_reimport_legacy(test, findings, service=None): + """ + Find all existing findings in the test that match any of the given findings by title and severity. + Used for batch reimport to avoid 1+N query problem. + Legacy reimport matches by title (case-insensitive), severity, and numerical_severity. + Note: This function is kept separate because legacy reimport has fundamentally different matching logic + than legacy deduplication (title+severity vs title+CWE). + Note: service parameter is kept for backward compatibility but not used since service is in hash_code. + """ + base_queryset = build_candidate_scope_queryset(test, mode="reimport", service=None) + + # Collect all unique title/severity combinations + title_severity_pairs = set() + for finding in findings: + if finding.title: + title_severity_pairs.add(( + finding.title.lower(), # Case-insensitive matching + finding.severity, + Finding.get_numerical_severity(finding.severity), + )) + + if not title_severity_pairs: + return {} + + # Build query to find all matching findings + conditions = Q() + for title_lower, severity, numerical_severity in title_severity_pairs: + conditions |= ( + Q(title__iexact=title_lower) & + Q(severity=severity) & + Q(numerical_severity=numerical_severity) + ) + + existing_qs = base_queryset.filter(conditions).order_by("id") + + # Build dictionary keyed by (title_lower, severity) for quick lookup + existing_by_key = {} + for ef in existing_qs: + key = (ef.title.lower(), ef.severity) + existing_by_key.setdefault(key, []).append(ef) + + deduplicationLogger.debug(f"Found {sum(len(v) for v in existing_by_key.values())} existing findings by legacy matching for reimport") + return existing_by_key + + def _is_candidate_older(new_finding, candidate): # Ensure the newer finding is marked as duplicate of the older finding is_older = candidate.id < new_finding.id diff --git a/dojo/finding/helper.py b/dojo/finding/helper.py index 19bf9ee6d99..8689212a835 100644 --- a/dojo/finding/helper.py +++ b/dojo/finding/helper.py @@ -765,12 +765,15 @@ def add_endpoints(new_finding, form): endpoint=endpoint, defaults={"date": form.cleaned_data["date"] or timezone.now()}) -def save_vulnerability_ids(finding, vulnerability_ids): +def save_vulnerability_ids(finding, vulnerability_ids, *, delete_existing: bool = True): # Remove duplicates vulnerability_ids = list(dict.fromkeys(vulnerability_ids)) - # Remove old vulnerability ids - Vulnerability_Id.objects.filter(finding=finding).delete() + # Remove old vulnerability ids if requested + # Callers can set delete_existing=False when they know there are no existing IDs + # to avoid an unnecessary delete query (e.g., for new findings) + if delete_existing: + Vulnerability_Id.objects.filter(finding=finding).delete() # Save new vulnerability ids # Using bulk create throws Django 50 warnings about unsaved models... diff --git a/dojo/importers/base_importer.py b/dojo/importers/base_importer.py index 14e5b567712..cac02755022 100644 --- a/dojo/importers/base_importer.py +++ b/dojo/importers/base_importer.py @@ -32,7 +32,6 @@ Test_Import, Test_Import_Finding_Action, Test_Type, - Vulnerability_Id, ) from dojo.notifications.helper import create_notification from dojo.tag_utils import bulk_add_tags_to_instances @@ -278,6 +277,7 @@ def determine_process_method( def determine_deduplication_algorithm(self) -> str: """ Determines what dedupe algorithm to use for the Test being processed. + Overridden in Pro. :return: A string representing the dedupe algorithm to use. """ return self.test.deduplication_algorithm @@ -793,21 +793,23 @@ def process_cve( return finding - def process_vulnerability_ids( + def store_vulnerability_ids( self, finding: Finding, ) -> Finding: """ - Parse the `unsaved_vulnerability_ids` field from findings after they are parsed - to create `Vulnerability_Id` objects with the finding associated correctly - """ - if finding.unsaved_vulnerability_ids: - # Remove old vulnerability ids - keeping this call only because of flake8 - Vulnerability_Id.objects.filter(finding=finding).delete() + Store vulnerability IDs for a finding. + Reads from finding.unsaved_vulnerability_ids and saves them overwriting existing ones. + + Args: + finding: The finding to store vulnerability IDs for - # user the helper function - finding_helper.save_vulnerability_ids(finding, finding.unsaved_vulnerability_ids) + Returns: + The finding object + """ + vulnerability_ids_to_process = finding.unsaved_vulnerability_ids or [] + finding_helper.save_vulnerability_ids(finding, vulnerability_ids_to_process, delete_existing=False) return finding def process_files( diff --git a/dojo/importers/default_importer.py b/dojo/importers/default_importer.py index 63f41b8f744..e1e3f6f4556 100644 --- a/dojo/importers/default_importer.py +++ b/dojo/importers/default_importer.py @@ -234,7 +234,7 @@ def process_findings( # Process any files self.process_files(finding) # Process vulnerability IDs - finding = self.process_vulnerability_ids(finding) + finding = self.store_vulnerability_ids(finding) # Categorize this finding as a new one new_findings.append(finding) # all data is already saved on the finding, we only need to trigger post processing in batches diff --git a/dojo/importers/default_reimporter.py b/dojo/importers/default_reimporter.py index 10b3ac7148a..14a7da7e21d 100644 --- a/dojo/importers/default_reimporter.py +++ b/dojo/importers/default_reimporter.py @@ -1,5 +1,6 @@ import logging +from django.conf import settings from django.core.files.uploadedfile import TemporaryUploadedFile from django.core.serializers import serialize from django.db.models.query_utils import Q @@ -7,6 +8,12 @@ import dojo.finding.helper as finding_helper import dojo.jira_link.helper as jira_helper from dojo.decorators import we_want_async +from dojo.finding.deduplication import ( + find_candidates_for_deduplication_hash, + find_candidates_for_deduplication_uid_or_hash, + find_candidates_for_deduplication_unique_id, + find_candidates_for_reimport_legacy, +) from dojo.importers.base_importer import BaseImporter, Parser from dojo.importers.options import ImporterOptions from dojo.models import ( @@ -152,6 +159,93 @@ def process_scan( test_import_history, ) + def get_reimport_match_candidates_for_batch( + self, + batch_findings: list[Finding], + ) -> tuple[dict, dict, dict]: + """ + Fetch candidate matches for a batch of *unsaved* findings during reimport. + + This is intentionally a separate method so downstream editions (e.g. Dojo Pro) + can override candidate retrieval without copying the full `process_findings()` + implementation. + + Is overridden in Pro. + + Returns: + (candidates_by_hash, candidates_by_uid, candidates_by_key) + + """ + candidates_by_hash: dict = {} + candidates_by_uid: dict = {} + candidates_by_key: dict = {} + + if self.deduplication_algorithm == "hash_code": + candidates_by_hash = find_candidates_for_deduplication_hash( + self.test, + batch_findings, + mode="reimport", + ) + elif self.deduplication_algorithm == "unique_id_from_tool": + candidates_by_uid = find_candidates_for_deduplication_unique_id( + self.test, + batch_findings, + mode="reimport", + ) + elif self.deduplication_algorithm == "unique_id_from_tool_or_hash_code": + candidates_by_uid, candidates_by_hash = find_candidates_for_deduplication_uid_or_hash( + self.test, + batch_findings, + mode="reimport", + ) + elif self.deduplication_algorithm == "legacy": + candidates_by_key = find_candidates_for_reimport_legacy(self.test, batch_findings) + + return candidates_by_hash, candidates_by_uid, candidates_by_key + + def add_new_finding_to_candidates( + self, + finding: Finding, + candidates_by_hash: dict, + candidates_by_uid: dict, + candidates_by_key: dict, + ) -> None: + """ + Add a newly created finding to candidate dictionaries for subsequent findings in the same batch. + + This allows duplicates within the same scan report to be detected even when they're processed + in the same batch. When a new finding is created (no match found), it is added to the candidate + dictionaries so that subsequent findings in the same batch can match against it. + + Is overriden in Pro + + Args: + finding: The newly created finding to add to candidates + candidates_by_hash: Dictionary mapping hash_code to list of findings (modified in-place) + candidates_by_uid: Dictionary mapping unique_id_from_tool to list of findings (modified in-place) + candidates_by_key: Dictionary mapping (title_lower, severity) to list of findings (modified in-place) + + """ + if not finding: + return + + if finding.hash_code: + candidates_by_hash.setdefault(finding.hash_code, []).append(finding) + deduplicationLogger.debug( + f"Added finding {finding.id} (hash_code: {finding.hash_code}) to candidates for next findings in this report", + ) + if finding.unique_id_from_tool: + candidates_by_uid.setdefault(finding.unique_id_from_tool, []).append(finding) + deduplicationLogger.debug( + f"Added finding {finding.id} (unique_id_from_tool: {finding.unique_id_from_tool}) to candidates for next findings in this report", + ) + if finding.title: + legacy_key = (finding.title.lower(), finding.severity) + candidates_by_key.setdefault(legacy_key, []).append(finding) + deduplicationLogger.debug( + f"Added finding {finding.id} (title: {finding.title}, severity: {finding.severity}) to candidates for next findings in this report", + ) + def process_findings( self, parsed_findings: list[Finding], @@ -182,8 +276,6 @@ def process_findings( self.reactivated_items = [] self.unchanged_items = [] self.group_names_to_findings_dict = {} - # Progressive batching for chord execution - # No chord: we dispatch per 1000 findings or on the final finding logger.debug(f"starting reimport of {len(parsed_findings) if parsed_findings else 0} items.") logger.debug("STEP 1: looping over findings from the reimported report and trying to match them to existing findings") @@ -204,86 +296,136 @@ def process_findings( cleaned_findings.append(sanitized) batch_finding_ids: list[int] = [] - batch_max_size = 1000 - - for idx, unsaved_finding in enumerate(cleaned_findings): - is_final = idx == len(cleaned_findings) - 1 - # Some parsers provide "mitigated" field but do not set timezone (because they are probably not available in the report) - # Finding.mitigated is DateTimeField and it requires timezone - if unsaved_finding.mitigated and not unsaved_finding.mitigated.tzinfo: - unsaved_finding.mitigated = unsaved_finding.mitigated.replace(tzinfo=self.now.tzinfo) - # Override the test if needed - if not hasattr(unsaved_finding, "test"): - unsaved_finding.test = self.test - # Set the service supplied at import time - if self.service is not None: - unsaved_finding.service = self.service - # Clean any endpoints that are on the finding - self.endpoint_manager.clean_unsaved_endpoints(unsaved_finding.unsaved_endpoints) - # Calculate the hash code to be used to identify duplicates - unsaved_finding.hash_code = self.calculate_unsaved_finding_hash_code(unsaved_finding) - deduplicationLogger.debug(f"unsaved finding's hash_code: {unsaved_finding.hash_code}") - # Match any findings to this new one coming in - matched_findings = self.match_new_finding_to_existing_finding(unsaved_finding) - deduplicationLogger.debug(f"found {len(matched_findings)} findings matching with current new finding") - # Determine how to proceed based on whether matches were found or not - if matched_findings: - existing_finding = matched_findings[0] - finding, force_continue = self.process_matched_finding( + # Batch size for deduplication/post-processing (only new findings) + dedupe_batch_max_size = getattr(settings, "IMPORT_REIMPORT_DEDUPE_BATCH_SIZE", 1000) + # Batch size for candidate matching (all findings, before matching) + match_batch_max_size = getattr(settings, "IMPORT_REIMPORT_MATCH_BATCH_SIZE", 1000) + + # Process findings in batches to enable batch candidate fetching + # This avoids the 1+N query problem by fetching all candidates for a batch at once + for batch_start in range(0, len(cleaned_findings), match_batch_max_size): + batch_end = min(batch_start + match_batch_max_size, len(cleaned_findings)) + batch_findings = cleaned_findings[batch_start:batch_end] + is_final_batch = batch_end == len(cleaned_findings) + + logger.debug(f"Processing reimport batch {batch_start}-{batch_end} of {len(cleaned_findings)} findings") + + # Prepare findings in batch: set test, service, calculate hash codes + for unsaved_finding in batch_findings: + # Some parsers provide "mitigated" field but do not set timezone (because they are probably not available in the report) + # Finding.mitigated is DateTimeField and it requires timezone + if unsaved_finding.mitigated and not unsaved_finding.mitigated.tzinfo: + unsaved_finding.mitigated = unsaved_finding.mitigated.replace(tzinfo=self.now.tzinfo) + # Override the test if needed + if not hasattr(unsaved_finding, "test"): + unsaved_finding.test = self.test + # Set the service supplied at import time + if self.service is not None: + unsaved_finding.service = self.service + # Clean any endpoints that are on the finding + self.endpoint_manager.clean_unsaved_endpoints(unsaved_finding.unsaved_endpoints) + # Calculate the hash code to be used to identify duplicates + unsaved_finding.hash_code = self.calculate_unsaved_finding_hash_code(unsaved_finding) + deduplicationLogger.debug(f"unsaved finding's hash_code: {unsaved_finding.hash_code}") + + # Fetch all candidates for this batch at once (batch candidate finding) + candidates_by_hash, candidates_by_uid, candidates_by_key = self.get_reimport_match_candidates_for_batch( + batch_findings, + ) + + # Process each finding in the batch using pre-fetched candidates + for idx, unsaved_finding in enumerate(batch_findings): + is_final = is_final_batch and idx == len(batch_findings) - 1 + + # Match any findings to this new one coming in using pre-fetched candidates + matched_findings = self.match_finding_to_candidate_reimport( unsaved_finding, - existing_finding, + candidates_by_hash=candidates_by_hash, + candidates_by_uid=candidates_by_uid, + candidates_by_key=candidates_by_key, ) - # Determine if we should skip the rest of the loop - if force_continue: - continue - # Update endpoints on the existing finding with those on the new finding - if finding.dynamic_finding: - logger.debug( - "Re-import found an existing dynamic finding for this new " - "finding. Checking the status of endpoints", - ) - self.endpoint_manager.update_endpoint_status( - existing_finding, + deduplicationLogger.debug(f"found {len(matched_findings)} findings matching with current new finding") + # Determine how to proceed based on whether matches were found or not + if matched_findings: + existing_finding = matched_findings[0] + finding, force_continue = self.process_matched_finding( unsaved_finding, - self.user, + existing_finding, ) - else: - finding = self.process_finding_that_was_not_matched(unsaved_finding) - # This condition __appears__ to always be true, but am afraid to remove it - if finding: - # Process the rest of the items on the finding - finding = self.finding_post_processing( - finding, - unsaved_finding, - ) - # all data is already saved on the finding, we only need to trigger post processing in batches - push_to_jira = self.push_to_jira and (not self.findings_groups_enabled or not self.group_by) - batch_finding_ids.append(finding.id) - - # If batch is full or we're at the end, dispatch one batched task - if len(batch_finding_ids) >= batch_max_size or is_final: - finding_ids_batch = list(batch_finding_ids) - batch_finding_ids.clear() - if we_want_async(async_user=self.user): - finding_helper.post_process_findings_batch_signature( - finding_ids_batch, - dedupe_option=True, - rules_option=True, - product_grading_option=True, - issue_updater_option=True, - push_to_jira=push_to_jira, - )() - else: - finding_helper.post_process_findings_batch( - finding_ids_batch, - dedupe_option=True, - rules_option=True, - product_grading_option=True, - issue_updater_option=True, - push_to_jira=push_to_jira, + # Determine if we should skip the rest of the loop + if force_continue: + continue + # Update endpoints on the existing finding with those on the new finding + if finding.dynamic_finding: + logger.debug( + "Re-import found an existing dynamic finding for this new " + "finding. Checking the status of endpoints", + ) + self.endpoint_manager.update_endpoint_status( + existing_finding, + unsaved_finding, + self.user, ) + else: + finding = self.process_finding_that_was_not_matched(unsaved_finding) + + # Add newly created finding to candidates for subsequent findings in this batch + self.add_new_finding_to_candidates( + finding, + candidates_by_hash, + candidates_by_uid, + candidates_by_key, + ) - # No chord: tasks are dispatched immediately above per batch + # This condition __appears__ to always be true, but am afraid to remove it + if finding: + # Process the rest of the items on the finding + finding = self.finding_post_processing( + finding, + unsaved_finding, + ) + # all data is already saved on the finding, we only need to trigger post processing in batches + push_to_jira = self.push_to_jira and (not self.findings_groups_enabled or not self.group_by) + batch_finding_ids.append(finding.id) + + # Post-processing batches (deduplication, rules, etc.) are separate from matching batches. + # These batches only contain "new" findings that were saved (not matched to existing findings). + # In reimport scenarios, typically most findings match existing ones, so only a small fraction + # of findings in each matching batch become new findings that need deduplication. + # + # We accumulate finding IDs across matching batches rather than dispatching at the end of each + # matching batch. This ensures deduplication batches stay close to the intended batch size + # (e.g., 1000 findings) for optimal bulk operation efficiency, even when only ~10% of findings + # in matching batches are new. If we dispatched at the end of each matching batch, we would + # end up with many small deduplication batches (e.g., ~100 findings each), reducing efficiency. + # + # The two batch types serve different purposes: + # - Matching batches: optimize candidate fetching (solve 1+N query problem) + # - Deduplication batches: optimize bulk operations (larger batches = fewer queries) + # They don't need to be aligned since they optimize different operations. + if len(batch_finding_ids) >= dedupe_batch_max_size or is_final: + finding_ids_batch = list(batch_finding_ids) + batch_finding_ids.clear() + if we_want_async(async_user=self.user): + finding_helper.post_process_findings_batch_signature( + finding_ids_batch, + dedupe_option=True, + rules_option=True, + product_grading_option=True, + issue_updater_option=True, + push_to_jira=push_to_jira, + )() + else: + finding_helper.post_process_findings_batch( + finding_ids_batch, + dedupe_option=True, + rules_option=True, + product_grading_option=True, + issue_updater_option=True, + push_to_jira=push_to_jira, + ) + + # No chord: tasks are dispatched immediately above per batch self.to_mitigate = (set(self.original_items) - set(self.reactivated_items) - set(self.unchanged_items)) # due to #3958 we can have duplicates inside the same report @@ -375,48 +517,73 @@ def parse_findings_dynamic_test_type( logger.debug("REIMPORT_SCAN parser v2: Create parse findings") return super().parse_findings_dynamic_test_type(scan, parser) - def match_new_finding_to_existing_finding( + def match_finding_to_candidate_reimport( self, unsaved_finding: Finding, + candidates_by_hash: dict | None = None, + candidates_by_uid: dict | None = None, + candidates_by_key: dict | None = None, ) -> list[Finding]: - """Matches a single new finding to N existing findings and then returns those matches""" - # This code should match the logic used for deduplication out of the re-import feature. - # See utils.py deduplicate_* functions - deduplicationLogger.debug("return findings bases on algorithm: %s", self.deduplication_algorithm) + """ + Matches a single new finding to existing findings using pre-fetched candidate dictionaries. + This avoids individual database queries by using batch-fetched candidates. + + Args: + unsaved_finding: The finding to match + candidates_by_hash: Dictionary mapping hash_code to list of findings (for hash_code algorithm) + candidates_by_uid: Dictionary mapping unique_id_from_tool to list of findings (for unique_id algorithms) + candidates_by_key: Dictionary mapping (title_lower, severity) to list of findings (for legacy algorithm) + + Returns: + List of matching findings, ordered by id + + """ + deduplicationLogger.debug("matching finding for reimport using algorithm: %s", self.deduplication_algorithm) + if self.deduplication_algorithm == "hash_code": - return Finding.objects.filter( - test=self.test, - hash_code=unsaved_finding.hash_code, - ).exclude(hash_code=None).order_by("id") + if candidates_by_hash is None or unsaved_finding.hash_code is None: + return [] + matches = candidates_by_hash.get(unsaved_finding.hash_code, []) + return sorted(matches, key=lambda f: f.id) + if self.deduplication_algorithm == "unique_id_from_tool": - deduplicationLogger.debug(f"unique_id_from_tool: {unsaved_finding.unique_id_from_tool}") - return Finding.objects.filter( - test=self.test, - unique_id_from_tool=unsaved_finding.unique_id_from_tool, - ).exclude(unique_id_from_tool=None).order_by("id") + if candidates_by_uid is None or unsaved_finding.unique_id_from_tool is None: + return [] + matches = candidates_by_uid.get(unsaved_finding.unique_id_from_tool, []) + return sorted(matches, key=lambda f: f.id) + if self.deduplication_algorithm == "unique_id_from_tool_or_hash_code": - deduplicationLogger.debug(f"unique_id_from_tool: {unsaved_finding.unique_id_from_tool}") - deduplicationLogger.debug(f"hash_code: {unsaved_finding.hash_code}") - query = Finding.objects.filter( - Q(test=self.test), - (Q(hash_code__isnull=False) & Q(hash_code=unsaved_finding.hash_code)) - | (Q(unique_id_from_tool__isnull=False) & Q(unique_id_from_tool=unsaved_finding.unique_id_from_tool)), - ).order_by("id") - deduplicationLogger.debug(query.query) - return query + if candidates_by_hash is None and candidates_by_uid is None: + return [] + + if unsaved_finding.hash_code is None and unsaved_finding.unique_id_from_tool is None: + return [] + + # Collect matches from both hash_code and unique_id_from_tool + matches_by_id = {} + + if unsaved_finding.hash_code is not None: + hash_matches = candidates_by_hash.get(unsaved_finding.hash_code, []) + for match in hash_matches: + matches_by_id[match.id] = match + + if unsaved_finding.unique_id_from_tool is not None: + uid_matches = candidates_by_uid.get(unsaved_finding.unique_id_from_tool, []) + for match in uid_matches: + matches_by_id[match.id] = match + + matches = list(matches_by_id.values()) + return sorted(matches, key=lambda f: f.id) + if self.deduplication_algorithm == "legacy": - # This is the legacy reimport behavior. Although it's pretty flawed and doesn't match the legacy algorithm for deduplication, - # this is left as is for simplicity. - # Re-writing the legacy deduplication here would be complicated and counter-productive. - # If you have use cases going through this section, you're advised to create a deduplication configuration for your parser - logger.warning("Legacy reimport. In case of issue, you're advised to create a deduplication configuration in order not to go through this section") - return Finding.objects.filter( - title__iexact=unsaved_finding.title, - test=self.test, - severity=unsaved_finding.severity, - numerical_severity=Finding.get_numerical_severity(unsaved_finding.severity)).order_by("id") + if candidates_by_key is None or not unsaved_finding.title: + return [] + key = (unsaved_finding.title.lower(), unsaved_finding.severity) + matches = candidates_by_key.get(key, []) + return sorted(matches, key=lambda f: f.id) + logger.error(f'Internal error: unexpected deduplication_algorithm: "{self.deduplication_algorithm}"') - return None + return [] def process_matched_finding( self, @@ -691,11 +858,46 @@ def process_finding_that_was_not_matched( self.process_request_response_pairs(unsaved_finding) return unsaved_finding + def reconcile_vulnerability_ids( + self, + finding: Finding, + ) -> Finding: + """ + Reconcile vulnerability IDs for an existing finding. + Checks if IDs have changed before updating to avoid unnecessary database operations. + Uses prefetched data if available, otherwise fetches efficiently. + + Args: + finding: The existing finding to reconcile vulnerability IDs for. + Must have unsaved_vulnerability_ids set. + + Returns: + The finding object + + """ + vulnerability_ids_to_process = finding.unsaved_vulnerability_ids or [] + + # Use prefetched data directly without triggering queries + existing_vuln_ids = {v.vulnerability_id for v in finding.vulnerability_id_set.all()} + new_vuln_ids = set(vulnerability_ids_to_process) + + # Early exit if unchanged + if existing_vuln_ids == new_vuln_ids: + logger.debug( + f"Skipping vulnerability_ids update for finding {finding.id} - " + f"vulnerability_ids unchanged: {sorted(existing_vuln_ids)}", + ) + return finding + + # Update if changed + finding_helper.save_vulnerability_ids(finding, vulnerability_ids_to_process, delete_existing=True) + return finding + def finding_post_processing( self, finding: Finding, finding_from_report: Finding, - ) -> None: + ) -> Finding: """ Save all associated objects to the finding after it has been saved for the purpose of foreign key restrictions @@ -715,10 +917,19 @@ def finding_post_processing( finding.unsaved_files = finding_from_report.unsaved_files self.process_files(finding) # Process vulnerability IDs - if finding_from_report.unsaved_vulnerability_ids: - finding.unsaved_vulnerability_ids = finding_from_report.unsaved_vulnerability_ids + # Copy unsaved_vulnerability_ids from the report finding to the existing finding + # so reconcile_vulnerability_ids can process them + # Always set it (even if empty list) so we can clear existing IDs when report has none + finding.unsaved_vulnerability_ids = finding_from_report.unsaved_vulnerability_ids or [] + # Store the current cve value to check if it changes + old_cve = finding.cve # legacy cve field has already been processed/set earlier - return self.process_vulnerability_ids(finding) + finding = self.reconcile_vulnerability_ids(finding) + # Save the finding only if the cve field was changed by save_vulnerability_ids + # This is temporary as the cve field will be phased out + if finding.cve != old_cve: + finding.save() + return finding def process_groups_for_all_findings( self, diff --git a/dojo/importers/endpoint_manager.py b/dojo/importers/endpoint_manager.py index ccfff345c40..f4b277d49fa 100644 --- a/dojo/importers/endpoint_manager.py +++ b/dojo/importers/endpoint_manager.py @@ -68,6 +68,7 @@ def mitigate_endpoint_status( ) -> None: """Mitigates all endpoint status objects that are supplied""" now = timezone.now() + to_update = [] for endpoint_status in endpoint_status_list: # Only mitigate endpoints that are actually active if endpoint_status.mitigated is False: @@ -75,7 +76,14 @@ def mitigate_endpoint_status( endpoint_status.last_modified = now endpoint_status.mitigated_by = user endpoint_status.mitigated = True - endpoint_status.save() + to_update.append(endpoint_status) + + if to_update: + Endpoint_Status.objects.bulk_update( + to_update, + ["mitigated", "mitigated_time", "last_modified", "mitigated_by"], + batch_size=1000, + ) @dojo_async_task @app.task() @@ -85,6 +93,8 @@ def reactivate_endpoint_status( **kwargs: dict, ) -> None: """Reactivate all endpoint status objects that are supplied""" + now = timezone.now() + to_update = [] for endpoint_status in endpoint_status_list: # Only reactivate endpoints that are actually mitigated if endpoint_status.mitigated: @@ -92,8 +102,15 @@ def reactivate_endpoint_status( endpoint_status.mitigated_by = None endpoint_status.mitigated_time = None endpoint_status.mitigated = False - endpoint_status.last_modified = timezone.now() - endpoint_status.save() + endpoint_status.last_modified = now + to_update.append(endpoint_status) + + if to_update: + Endpoint_Status.objects.bulk_update( + to_update, + ["mitigated", "mitigated_time", "mitigated_by", "last_modified"], + batch_size=1000, + ) def chunk_endpoints_and_disperse( self, @@ -149,17 +166,17 @@ def update_endpoint_status( # New finding is mitigated, so mitigate all old endpoints endpoint_status_to_mitigate = existing_finding_endpoint_status_list else: + # Convert to set for O(1) lookups instead of O(n) linear search + new_finding_endpoints_set = set(new_finding_endpoints_list) # Mitigate any endpoints in the old finding not found in the new finding - endpoint_status_to_mitigate = list( - filter( - lambda existing_finding_endpoint_status: existing_finding_endpoint_status.endpoint not in new_finding_endpoints_list, - existing_finding_endpoint_status_list), - ) + endpoint_status_to_mitigate = [ + eps for eps in existing_finding_endpoint_status_list + if eps.endpoint not in new_finding_endpoints_set + ] # Re-activate any endpoints in the old finding that are in the new finding - endpoint_status_to_reactivate = list( - filter( - lambda existing_finding_endpoint_status: existing_finding_endpoint_status.endpoint in new_finding_endpoints_list, - existing_finding_endpoint_status_list), - ) + endpoint_status_to_reactivate = [ + eps for eps in existing_finding_endpoint_status_list + if eps.endpoint in new_finding_endpoints_set + ] self.chunk_endpoints_and_reactivate(endpoint_status_to_reactivate) self.chunk_endpoints_and_mitigate(endpoint_status_to_mitigate, user) diff --git a/dojo/management/commands/list_top_tests.py b/dojo/management/commands/list_top_tests.py new file mode 100644 index 00000000000..7666d707101 --- /dev/null +++ b/dojo/management/commands/list_top_tests.py @@ -0,0 +1,119 @@ +from django.core.management.base import BaseCommand +from django.db.models import Count, Q + +from dojo.models import Test + + +class Command(BaseCommand): + help = "List the top 25 tests with the most findings" + + def add_arguments(self, parser): + parser.add_argument( + "--limit", + type=int, + default=25, + help="Number of tests to display (default: 25)", + ) + + def handle(self, *args, **options): + limit = options["limit"] + + # Annotate tests with finding counts + tests = ( + Test.objects.annotate( + total_findings=Count("finding", distinct=True), + active_findings=Count("finding", filter=Q(finding__active=True), distinct=True), + duplicate_findings=Count("finding", filter=Q(finding__duplicate=True), distinct=True), + ) + .filter(total_findings__gt=0) + .select_related("engagement", "engagement__product", "test_type") + .order_by("-total_findings")[:limit] + ) + + if not tests: + self.stdout.write(self.style.WARNING("No tests with findings found.")) + return + + # Calculate column widths + max_test_id_len = max( + (len(str(test.id)) for test in tests), + default=8, + ) + max_product_len = max( + (len(str(test.engagement.product.name)) for test in tests), + default=20, + ) + max_engagement_len = max( + (len(str(test.engagement.name)) for test in tests), + default=20, + ) + max_test_len = max( + (len(str(test.title or test.id)) for test in tests), + default=20, + ) + max_test_type_len = max( + (len(str(test.test_type.name)) for test in tests), + default=20, + ) + max_dedup_algo_len = max( + (len(str(test.deduplication_algorithm)) for test in tests), + default=20, + ) + + # Ensure minimum widths for readability + max_test_id_len = max(max_test_id_len, 8) + max_product_len = max(max_product_len, 20) + max_engagement_len = max(max_engagement_len, 20) + max_test_len = max(max_test_len, 20) + max_test_type_len = max(max_test_type_len, 20) + max_dedup_algo_len = max(max_dedup_algo_len, 20) + + # Header + header = ( + f"{'Test ID':<{max_test_id_len}} | " + f"{'Product':<{max_product_len}} | " + f"{'Engagement':<{max_engagement_len}} | " + f"{'Test':<{max_test_len}} | " + f"{'Test Type':<{max_test_type_len}} | " + f"{'Dedup Algorithm':<{max_dedup_algo_len}} | " + f"{'Total':>8} | " + f"{'Active':>8} | " + f"{'Duplicate':>10}" + ) + separator = "-" * len(header) + + self.stdout.write(self.style.SUCCESS(header)) + self.stdout.write(separator) + + # Data rows + for test in tests: + test_id = str(test.id) + product_name = str(test.engagement.product.name) + engagement_name = str(test.engagement.name) + test_name = str(test.title or f"Test #{test.id}") + test_type_name = str(test.test_type.name) + dedup_algo = str(test.deduplication_algorithm) + total = test.total_findings + active = test.active_findings + duplicate = test.duplicate_findings + + row = ( + f"{test_id:<{max_test_id_len}} | " + f"{product_name:<{max_product_len}} | " + f"{engagement_name:<{max_engagement_len}} | " + f"{test_name:<{max_test_len}} | " + f"{test_type_name:<{max_test_type_len}} | " + f"{dedup_algo:<{max_dedup_algo_len}} | " + f"{total:>8} | " + f"{active:>8} | " + f"{duplicate:>10}" + ) + self.stdout.write(row) + + # Summary + self.stdout.write(separator) + self.stdout.write( + self.style.SUCCESS( + f"\nDisplayed top {len(tests)} tests with findings.", + ), + ) diff --git a/dojo/settings/settings.dist.py b/dojo/settings/settings.dist.py index 1f24bd653f1..e57e5dcdbee 100644 --- a/dojo/settings/settings.dist.py +++ b/dojo/settings/settings.dist.py @@ -283,6 +283,8 @@ DD_EDITABLE_MITIGATED_DATA=(bool, False), # new feature that tracks history across multiple reimports for the same test DD_TRACK_IMPORT_HISTORY=(bool, True), + # Batch size for reimport candidate matching (finding existing findings) + DD_IMPORT_REIMPORT_MATCH_BATCH_SIZE=(int, 1000), # Batch size for import/reimport deduplication processing DD_IMPORT_REIMPORT_DEDUPE_BATCH_SIZE=(int, 1000), # Delete Auditlogs older than x month; -1 to keep all logs @@ -1716,6 +1718,7 @@ def saml2_attrib_map_format(din): DISABLE_FINDING_MERGE = env("DD_DISABLE_FINDING_MERGE") TRACK_IMPORT_HISTORY = env("DD_TRACK_IMPORT_HISTORY") +IMPORT_REIMPORT_MATCH_BATCH_SIZE = env("DD_IMPORT_REIMPORT_MATCH_BATCH_SIZE") IMPORT_REIMPORT_DEDUPE_BATCH_SIZE = env("DD_IMPORT_REIMPORT_DEDUPE_BATCH_SIZE") # ------------------------------------------------------------------------------ diff --git a/ruff.toml b/ruff.toml index 670a95b7b99..942bd47a00a 100644 --- a/ruff.toml +++ b/ruff.toml @@ -84,7 +84,7 @@ select = [ "PGH", "PLC", "PLE", - "PLR01", "PLR02", "PLR04", "PLR0915", "PLR1711", "PLR1704", "PLR1714", "PLR1716", "PLR172", "PLR173", "PLR2044", "PLR5", "PLR6104", "PLR6201", + "PLR01", "PLR02", "PLR04", "PLR0915", "PLR1711", "PLR1704", "PLR1714", "PLR1716", "PLR172", "PLR173", "PLR2044", "PLR5", "PLR6104", "PLR6201", "PLW", "UP", "FURB", @@ -118,6 +118,15 @@ preview = true "dojo/filters.py" = [ "A003", # ruff upgrade to 0.13.3 ] +"scripts/update_performance_test_counts.py" = [ + "S603", # subprocess.run without shell=True is safe for this script + "S604", # subprocess.run without shell=True is safe for this script + "S605", # subprocess.run without shell=True is safe for this script + "S606", # subprocess.run without shell=True is safe for this script + "S607", # subprocess.run without shell=True is safe for this script + "T201", # print statements are fine for console output scripts + "EXE001", # git won't commit the executable flag I don't know why +] [lint.flake8-boolean-trap] extend-allowed-calls = ["dojo.utils.get_system_setting"] diff --git a/scripts/update_performance_test_counts.py b/scripts/update_performance_test_counts.py new file mode 100644 index 00000000000..f7cfaae2859 --- /dev/null +++ b/scripts/update_performance_test_counts.py @@ -0,0 +1,667 @@ +#!/usr/bin/env python3 +""" +Script to update performance test query counts. +I tried to implement this as management command, but it would become very slow on parsing the test output. + +This script: +1. Runs all performance tests and captures actual query counts +2. Compares them with expected counts +3. Generates a report and optionally updates the test file +4. Provides a verification run + +How to run: + + # Default: Update the test file (uses TestDojoImporterPerformanceSmall by default) + python3 scripts/update_performance_test_counts.py + + # Or specify a different test class: + python3 scripts/update_performance_test_counts.py --test-class TestDojoImporterPerformanceSmall + + # Step 1: Run tests and generate report only (without updating) + python3 scripts/update_performance_test_counts.py --report-only + + # Step 2: Verify all tests pass + python3 scripts/update_performance_test_counts.py --verify + +The script defaults to TestDojoImporterPerformanceSmall if --test-class is not provided. +The script defaults to --update behavior if no action flag is provided. +""" + +import argparse +import re +import subprocess +import sys +from pathlib import Path + +# Path to the test file +TEST_FILE = Path(__file__).parent.parent / "unittests" / "test_importers_performance.py" + + +class TestCount: + + """Represents a test's expected and actual counts.""" + + def __init__(self, test_name: str, step: str, metric: str): + self.test_name = test_name + self.step = step + self.metric = metric + self.expected = None + self.actual = None + self.difference = None + + def __repr__(self): + return ( + f"TestCount({self.test_name}, {self.step}, {self.metric}, " + f"expected={self.expected}, actual={self.actual})" + ) + + +def extract_test_methods(test_class: str) -> list[str]: + """Extract all test method names from the test class.""" + if not TEST_FILE.exists(): + msg = f"Test file not found: {TEST_FILE}" + raise FileNotFoundError(msg) + + content = TEST_FILE.read_text() + + # Find the test class definition + class_pattern = re.compile( + rf"class {re.escape(test_class)}.*?(?=class |\Z)", + re.DOTALL, + ) + class_match = class_pattern.search(content) + if not class_match: + return [] + + class_content = class_match.group(0) + + # Find all test methods in this class + test_method_pattern = re.compile(r"def (test_\w+)\(") + return test_method_pattern.findall(class_content) + + +def run_test_method(test_class: str, test_method: str) -> tuple[str, int]: + """Run a specific test method and return the output and return code.""" + print(f"Running {test_class}.{test_method}...") + cmd = [ + "./run-unittest.sh", + "--test-case", + f"unittests.test_importers_performance.{test_class}.{test_method}", + ] + + # Run with real-time output streaming + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + cwd=Path(__file__).parent.parent, + ) + + output_lines = [] + for line in process.stdout: + print(line, end="") # Print in real-time + output_lines.append(line) + + process.wait() + output = "".join(output_lines) + + return output, process.returncode + + +def run_tests(test_class: str) -> tuple[str, int]: + """Run all tests in a test class and return the output and return code.""" + print(f"Running tests for {test_class}...") + cmd = [ + "./run-unittest.sh", + "--test-case", + f"unittests.test_importers_performance.{test_class}", + ] + + # Run with real-time output streaming + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + cwd=Path(__file__).parent.parent, + ) + + output_lines = [] + for line in process.stdout: + print(line, end="") # Print in real-time + output_lines.append(line) + + process.wait() + output = "".join(output_lines) + + return output, process.returncode + + +def check_test_execution_success(output: str, return_code: int) -> tuple[bool, str]: + """Check if tests executed successfully or failed due to other reasons.""" + # Check for migration errors + migration_error_patterns = [ + r"django\.db\.migrations\.exceptions\.", + r"Migration.*failed", + r"django\.core\.management\.base\.CommandError", + r"OperationalError", + r"ProgrammingError", + r"relation.*does not exist", + r"no such table", + ] + + for pattern in migration_error_patterns: + if re.search(pattern, output, re.IGNORECASE): + return False, f"Migration or database error detected: {pattern}" + + # Check if any tests actually ran + test_run_patterns = [ + r"Ran \d+ test", + r"OK", + r"FAILED", + r"FAIL:", + r"test_\w+", + ] + + tests_ran = any(re.search(pattern, output) for pattern in test_run_patterns) + + if not tests_ran and return_code != 0: + return False, "Tests did not run successfully. Check the output above for errors." + + # Check for other critical errors + critical_error_patterns = [ + r"ImportError", + r"ModuleNotFoundError", + r"SyntaxError", + r"IndentationError", + ] + + for pattern in critical_error_patterns: + if re.search(pattern, output): + return False, f"Critical error detected: {pattern}" + + return True, "" + + +def parse_test_output(output: str) -> list[TestCount]: + """Parse test output to extract actual vs expected counts.""" + counts = [] + + # Debug: Save a sample of the output to help diagnose parsing issues + if "FAIL:" in output: + # Extract failure sections for debugging + fail_sections = [] + lines = output.split("\n") + in_fail_section = False + fail_section = [] + for line in lines: + if "FAIL:" in line: + if fail_section: + fail_sections.append("\n".join(fail_section)) + fail_section = [line] + in_fail_section = True + elif in_fail_section: + fail_section.append(line) + # Stop collecting after AssertionError line or after 5 more lines + if "AssertionError:" in line or len(fail_section) > 6: + fail_sections.append("\n".join(fail_section)) + fail_section = [] + in_fail_section = False + if fail_section: + fail_sections.append("\n".join(fail_section)) + + if fail_sections: + print(f"\n🔍 Found {len(fail_sections)} failure section(s) in output") + + # The test output format is: + # FAIL: test_name (step='import1', metric='queries') + # AssertionError: 118 != 120 : 118 queries executed, 120 expected + # OR for async tasks: + # FAIL: test_name (step='import1', metric='async_tasks') + # AssertionError: 7 != 8 : 7 async tasks executed, 8 expected + + # Pattern to match the full failure block: + # FAIL: test_name (full.path.to.test) (step='...', metric='...') + # AssertionError: actual != expected : actual ... executed, expected expected + # The test name may include the full path in parentheses, so we extract just the method name + failure_pattern = re.compile( + r"FAIL:\s+(test_\w+)\s+\([^)]+\)\s+\(step=['\"](\w+)['\"],\s*metric=['\"](\w+)['\"]\)\s*\n" + r".*?AssertionError:\s+(\d+)\s+!=\s+(\d+)\s+:\s+\d+\s+(?:queries|async tasks?)\s+executed,\s+\d+\s+expected", + re.MULTILINE | re.DOTALL, + ) + + for match in failure_pattern.finditer(output): + test_name = match.group(1) + step = match.group(2) + metric = match.group(3) + actual = int(match.group(4)) + expected = int(match.group(5)) + + count = TestCount(test_name, step, metric) + count.actual = actual + count.expected = expected + count.difference = expected - actual + counts.append(count) + + # Also try a simpler pattern in case the format is slightly different + if not counts: + # Look for lines with step/metric followed by AssertionError on nearby lines + lines = output.split("\n") + i = 0 + while i < len(lines): + line = lines[i] + + # Look for FAIL: test_name (may include full path in parentheses) + # Format: FAIL: test_name (full.path) (step='...', metric='...') + fail_match = re.search(r"FAIL:\s+(test_\w+)\s+\([^)]+\)\s+\(step=['\"](\w+)['\"],\s*metric=['\"](\w+)['\"]\)", line) + if fail_match: + test_name = fail_match.group(1) + step = fail_match.group(2) + metric = fail_match.group(3) + # Look ahead for AssertionError + for j in range(i, min(i + 15, len(lines))): + assertion_match = re.search( + r"AssertionError:\s+(\d+)\s+!=\s+(\d+)\s+:\s+\d+\s+(?:queries|async tasks?)\s+executed,\s+\d+\s+expected", + lines[j], + ) + + if assertion_match: + actual = int(assertion_match.group(1)) + expected = int(assertion_match.group(2)) + + count = TestCount(test_name, step, metric) + count.actual = actual + count.expected = expected + count.difference = expected - actual + counts.append(count) + break + i += 1 + + if counts: + print(f"\n📊 Parsed {len(counts)} count mismatch(es) from test output:") + for count in counts: + print(f" {count.test_name} - {count.step} {count.metric}: {count.actual} != {count.expected}") + elif "FAIL:" in output: + print("\n⚠️ WARNING: Found FAIL in output but couldn't parse any count mismatches!") + print("This might indicate a parsing issue. Check the output above.") + + return counts + + +def extract_expected_counts_from_file(test_class: str) -> dict[str, dict[str, int]]: + """Extract expected counts from the test file.""" + if not TEST_FILE.exists(): + msg = f"Test file not found: {TEST_FILE}" + raise FileNotFoundError(msg) + + content = TEST_FILE.read_text() + + # Pattern to match test method calls with expected counts + # Format: self._import_reimport_performance( + # expected_num_queries1=340, + # expected_num_async_tasks1=7, + # expected_num_queries2=238, + # expected_num_async_tasks2=18, + # expected_num_queries3=120, + # expected_num_async_tasks3=17, + # ) + # More flexible pattern that handles whitespace variations + pattern = re.compile( + r"def (test_\w+)\([^)]*\):.*?" + r"self\._import_reimport_performance\(\s*" + r"expected_num_queries1\s*=\s*(\d+)\s*,\s*" + r"expected_num_async_tasks1\s*=\s*(\d+)\s*,\s*" + r"expected_num_queries2\s*=\s*(\d+)\s*,\s*" + r"expected_num_async_tasks2\s*=\s*(\d+)\s*,\s*" + r"expected_num_queries3\s*=\s*(\d+)\s*,\s*" + r"expected_num_async_tasks3\s*=\s*(\d+)\s*,", + re.DOTALL, + ) + + expected_counts = {} + for match in pattern.finditer(content): + test_name = match.group(1) + expected_counts[test_name] = { + "import1_queries": int(match.group(2)), + "import1_async_tasks": int(match.group(3)), + "reimport1_queries": int(match.group(4)), + "reimport1_async_tasks": int(match.group(5)), + "reimport2_queries": int(match.group(6)), + "reimport2_async_tasks": int(match.group(7)), + } + + return expected_counts + + +def generate_report(counts: list[TestCount], expected_counts: dict[str, dict[str, int]]): + """Generate a report of differences.""" + if not counts: + print("✅ All tests passed! No count differences found.") + return + + print("\n" + "=" * 80) + print("PERFORMANCE TEST COUNT DIFFERENCES REPORT") + print("=" * 80 + "\n") + + # Group by test name + by_test = {} + for count in counts: + if count.test_name not in by_test: + by_test[count.test_name] = [] + by_test[count.test_name].append(count) + + for test_name, test_counts in sorted(by_test.items()): + print(f"Test: {test_name}") + print("-" * 80) + for count in sorted(test_counts, key=lambda x: (x.step, x.metric)): + print( + f" {count.step:12} {count.metric:15} " + f"Expected: {count.expected:4} → Actual: {count.actual:4} " + f"(Difference: {count.difference:+3})", + ) + print() + + print("=" * 80) + print("\nTo update the test file, run:") + print(f" python scripts/update_performance_test_counts.py --test-class {test_name.split('_')[0]} --update") + print() + + +def update_test_file(counts: list[TestCount]): + """Update the test file with new expected counts.""" + if not counts: + print("No counts to update.") + return + + content = TEST_FILE.read_text() + + # Create a mapping of test_name -> step_metric -> new_value + updates = {} + for count in counts: + if count.test_name not in updates: + updates[count.test_name] = {} + step_metric = f"{count.step}_{count.metric}" + updates[count.test_name][step_metric] = count.actual + + # Map step_metric to parameter name for different methods + param_map_import_reimport = { + "import1_queries": "expected_num_queries1", + "import1_async_tasks": "expected_num_async_tasks1", + "reimport1_queries": "expected_num_queries2", + "reimport1_async_tasks": "expected_num_async_tasks2", + "reimport2_queries": "expected_num_queries3", + "reimport2_async_tasks": "expected_num_async_tasks3", + } + param_map_deduplication = { + "first_import_queries": "expected_num_queries1", + "first_import_async_tasks": "expected_num_async_tasks1", + "second_import_queries": "expected_num_queries2", + "second_import_async_tasks": "expected_num_async_tasks2", + } + + # Update each test method + for test_name, test_updates in updates.items(): + print(f" Updating {test_name}...") + # Find the test method boundaries + test_method_pattern = re.compile( + rf"(def {re.escape(test_name)}\([^)]*\):.*?)(?=def test_|\Z)", + re.DOTALL, + ) + test_match = test_method_pattern.search(content) + if not test_match: + print(f"⚠️ Warning: Could not find test method {test_name}") + continue + + test_method_content = test_match.group(1) + test_method_start = test_match.start() + test_method_end = test_match.end() + + # Try to find _import_reimport_performance call first + perf_call_pattern_import_reimport = re.compile( + r"(self\._import_reimport_performance\s*\(\s*)" + r"expected_num_queries1\s*=\s*(\d+)\s*,\s*" + r"expected_num_async_tasks1\s*=\s*(\d+)\s*,\s*" + r"expected_num_queries2\s*=\s*(\d+)\s*,\s*" + r"expected_num_async_tasks2\s*=\s*(\d+)\s*,\s*" + r"expected_num_queries3\s*=\s*(\d+)\s*,\s*" + r"expected_num_async_tasks3\s*=\s*(\d+)\s*," + r"(\s*\))", + re.DOTALL, + ) + + # Try to find _deduplication_performance call + perf_call_pattern_deduplication = re.compile( + r"(self\._deduplication_performance\s*\(\s*)" + r"expected_num_queries1\s*=\s*(\d+)\s*,\s*" + r"expected_num_async_tasks1\s*=\s*(\d+)\s*,\s*" + r"expected_num_queries2\s*=\s*(\d+)\s*,\s*" + r"expected_num_async_tasks2\s*=\s*(\d+)\s*," + r"(\s*\))", + re.DOTALL, + ) + + perf_match = perf_call_pattern_import_reimport.search(test_method_content) + method_type = "import_reimport" + param_map = param_map_import_reimport + param_order = [ + "import1_queries", + "import1_async_tasks", + "reimport1_queries", + "reimport1_async_tasks", + "reimport2_queries", + "reimport2_async_tasks", + ] + + if not perf_match: + perf_match = perf_call_pattern_deduplication.search(test_method_content) + if perf_match: + method_type = "deduplication" + param_map = param_map_deduplication + param_order = [ + "first_import_queries", + "first_import_async_tasks", + "second_import_queries", + "second_import_async_tasks", + ] + else: + print(f"⚠️ Warning: Could not find _import_reimport_performance or _deduplication_performance call in {test_name}") + continue + + # Get the indentation from the original call (first line after opening paren) + call_lines = test_method_content[perf_match.start():perf_match.end()].split("\n") + indent = "" + for line in call_lines: + if "expected_num_queries1" in line: + # Extract indentation (spaces before the parameter) + indent_match = re.match(r"(\s*)expected_num_queries1", line) + if indent_match: + indent = indent_match.group(1) + break + + # If we couldn't find indentation, use a default + if not indent: + indent = " " # 12 spaces default + + replacement_parts = [perf_match.group(1)] # Opening: "self._import_reimport_performance(" + updated_params = [] + for i, step_metric in enumerate(param_order): + param_name = param_map[step_metric] + old_value = int(perf_match.group(i + 2)) # +2 because group 1 is the opening + if step_metric in test_updates: + new_value = test_updates[step_metric] + if old_value != new_value: + updated_params.append(f"{param_name}: {old_value} → {new_value}") + else: + # Keep the existing value + new_value = old_value + + replacement_parts.append(f"{indent}{param_name}={new_value},") + + # Closing parenthesis - group number depends on method type + closing_group = 8 if method_type == "import_reimport" else 6 + replacement_parts.append(perf_match.group(closing_group)) # Closing parenthesis + replacement = "\n".join(replacement_parts) + + if updated_params: + print(f" Updated: {', '.join(updated_params)}") + + # Replace the method call within the test method content + updated_method_content = ( + test_method_content[: perf_match.start()] + + replacement + + test_method_content[perf_match.end() :] + ) + + # Replace the entire test method in the original content + content = content[:test_method_start] + updated_method_content + content[test_method_end:] + + # Write back to file + TEST_FILE.write_text(content) + print(f"✅ Updated {TEST_FILE}") + print(f" Updated {len(counts)} count(s) across {len(updates)} test(s)") + + +def verify_tests(test_class: str) -> bool: + """Run tests to verify they all pass.""" + print(f"Verifying tests for {test_class}...") + output, return_code = run_tests(test_class) + + success, error_msg = check_test_execution_success(output, return_code) + if not success: + print(f"\n❌ Test execution failed: {error_msg}") + return False + + counts = parse_test_output(output) + + if counts: + print("\n❌ Some tests still have count mismatches:") + for count in counts: + print(f" {count.test_name} - {count.step} {count.metric}: " + f"expected {count.expected}, got {count.actual}") + return False + else: # noqa: RET505 + print("\n✅ All tests pass!") + return True + + +def main(): + parser = argparse.ArgumentParser( + description="Update performance test query counts", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + parser.add_argument( + "--test-class", + required=False, + default="TestDojoImporterPerformanceSmall", + help="Test class name (e.g., TestDojoImporterPerformanceSmall). Defaults to TestDojoImporterPerformanceSmall if not provided.", + ) + parser.add_argument( + "--report-only", + action="store_true", + help="Only generate a report, don't update the file", + ) + parser.add_argument( + "--update", + action="store_true", + help="Update the test file with new counts (default behavior if no action flag is provided)", + ) + parser.add_argument( + "--verify", + action="store_true", + help="Run tests to verify they pass", + ) + + args = parser.parse_args() + + if args.report_only: + # Step 1: Run tests and generate report + # Run each test method individually + test_methods = extract_test_methods(args.test_class) + if not test_methods: + print(f"⚠️ No test methods found in {args.test_class}") + sys.exit(1) + + print(f"\nFound {len(test_methods)} test method(s) in {args.test_class}") + print("=" * 80) + + all_counts = [] + for test_method in test_methods: + print(f"\n{'=' * 80}") + output, return_code = run_test_method(args.test_class, test_method) + success, error_msg = check_test_execution_success(output, return_code) + if not success: + print(f"\n⚠️ Test execution failed for {test_method}: {error_msg}") + print("Skipping this test method...") + continue + + counts = parse_test_output(output) + if counts: + all_counts.extend(counts) + + expected_counts = extract_expected_counts_from_file(args.test_class) + generate_report(all_counts, expected_counts) + + elif args.verify: + # Step 3: Verify + success = verify_tests(args.test_class) + sys.exit(0 if success else 1) + + else: + # Default: Update the file (--update is the default behavior) + # Run each test method individually + test_methods = extract_test_methods(args.test_class) + if not test_methods: + print(f"⚠️ No test methods found in {args.test_class}") + sys.exit(1) + + print(f"\nFound {len(test_methods)} test method(s) in {args.test_class}") + print("=" * 80) + + all_counts = [] + for test_method in test_methods: + print(f"\n{'=' * 80}") + output, return_code = run_test_method(args.test_class, test_method) + success, error_msg = check_test_execution_success(output, return_code) + if not success: + print(f"\n⚠️ Test execution failed for {test_method}: {error_msg}") + print("Skipping this test method...") + continue + + counts = parse_test_output(output) + + # Check if test actually passed + test_passed = "OK" in output or ("Ran" in output and "FAILED" not in output and return_code == 0) + + if counts: + all_counts.extend(counts) + # Update immediately after each test + update_test_file(counts) + print(f"⚠️ {test_method}: Found {len(counts)} count mismatch(es) - updated file") + elif test_passed: + print(f"✅ {test_method}: Test passed, all counts match") + elif return_code != 0: + # Test might have failed for other reasons + print(f"⚠️ {test_method}: Test failed (exit code {return_code}) but no count mismatches parsed") + print(" This might indicate a parsing issue or a different type of failure") + # Show a snippet of the output to help debug + fail_lines = [line for line in output.split("\n") if "FAIL" in line or "Error" in line or "Exception" in line] + if fail_lines: + print(" Relevant error lines:") + for line in fail_lines[:5]: + print(f" {line}") + + if all_counts: + print(f"\n{'=' * 80}") + print(f"✅ Updated {len(all_counts)} count(s) across {len({c.test_name for c in all_counts})} test(s)") + print("\nNext step: Run --verify to ensure all tests pass") + else: + print(f"\n{'=' * 80}") + print("\n✅ No differences found. All tests are already up to date.") + + +if __name__ == "__main__": + main() diff --git a/unittests/scans/anchore_grype/check_all_fields_different_ids_fabricated.json b/unittests/scans/anchore_grype/check_all_fields_different_ids_fabricated.json new file mode 100644 index 00000000000..8520e8828dc --- /dev/null +++ b/unittests/scans/anchore_grype/check_all_fields_different_ids_fabricated.json @@ -0,0 +1,167 @@ +{ + "matches": [ + { + "vulnerability": { + "id": "GHSA-v6rh-hp5x-86rv", + "dataSource": "https://github.com/advisories/GHSA-v6rh-hp5x-86rv", + "namespace": "github:python", + "severity": "High", + "urls": [ + "https://github.com/advisories/GHSA-v6rh-hp5x-86rv" + ], + "description": "Potential bypass of an upstream access control based on URL paths in Django", + "cvss": [], + "fix": { + "versions": [ + "3.2.10" + ], + "state": "fixed" + }, + "advisories": [] + }, + "relatedVulnerabilities": [ + { + "id": "CVE-2021-1234", + "dataSource": "https://nvd.nist.gov/vuln/detail/CVE-2021-1234", + "namespace": "nvd", + "severity": "High", + "urls": [ + "https://example.com/cve-2021-1234" + ], + "description": "A different CVE for testing vulnerability ID changes", + "cvss": [ + { + "version": "3.1", + "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + "metrics": { + "baseScore": 9.8, + "exploitabilityScore": 3.9, + "impactScore": 5.9 + }, + "vendorMetadata": {} + } + ] + }, + { + "id": "CVE-2021-5678", + "dataSource": "https://nvd.nist.gov/vuln/detail/CVE-2021-5678", + "namespace": "nvd", + "severity": "Medium", + "urls": [ + "https://example.com/cve-2021-5678" + ], + "description": "Another different CVE for testing vulnerability ID changes", + "cvss": [ + { + "version": "3.1", + "vector": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:L", + "metrics": { + "baseScore": 6.3, + "exploitabilityScore": 3.9, + "impactScore": 2.4 + }, + "vendorMetadata": {} + } + ] + } + ], + "matchDetails": [ + { + "matcher": "python-matcher", + "searchedBy": { + "language": "python", + "namespace": "github:python" + }, + "found": { + "versionConstraint": ">=3.2,<3.2.10 (python)" + } + } + ], + "artifact": { + "name": "Django", + "version": "3.2.9", + "type": "python", + "locations": [ + { + "path": "/usr/local/lib/python3.8/site-packages/Django-3.2.9.dist-info/METADATA", + "layerID": "sha256:b1d4455cf82b15a50b006fe87bd29f694c8f9155456253eb67fdd155b5edcf4a" + } + ], + "language": "python", + "licenses": [ + "BSD-3-Clause" + ], + "cpes": [ + "cpe:2.3:a:django_software_foundation:Django:3.2.9:*:*:*:*:*:*:*" + ], + "purl": "pkg:pypi/Django@3.2.9", + "metadata": null + } + } + ], + "source": { + "type": "image", + "target": { + "userInput": "vulnerable-image:latest", + "imageID": "sha256:ce9898fd214aef9c994a42624b09056bdce3ff4a8e3f68dc242d967b80fcbeee", + "manifestDigest": "sha256:9d8825ab20ac86b40eb71495bece1608a302fb180384740697a28c2b0a5a0fc6", + "mediaType": "application/vnd.docker.distribution.manifest.v2+json", + "tags": [ + "vulnerable-image:latest" + ], + "imageSize": 707381791, + "layers": [] + } + }, + "distro": { + "name": "debian", + "version": "10", + "idLike": "" + }, + "descriptor": { + "name": "grype", + "version": "0.28.0", + "configuration": { + "configPath": "", + "output": "json", + "file": "", + "output-template-file": "", + "quiet": false, + "check-for-app-update": true, + "only-fixed": false, + "scope": "Squashed", + "log": { + "structured": false, + "level": "", + "file": "" + }, + "db": { + "cache-dir": "/home/user/.cache/grype/db", + "update-url": "https://toolbox-data.anchore.io/grype/databases/listing.json", + "ca-cert": "", + "auto-update": true, + "validate-by-hash-on-start": false + }, + "dev": { + "profile-cpu": false, + "profile-mem": false + }, + "fail-on-severity": "", + "registry": { + "insecure-skip-tls-verify": false, + "insecure-use-http": false, + "auth": [] + }, + "ignore": null, + "exclude": [] + }, + "db": { + "built": "2021-12-24T08:14:02Z", + "schemaVersion": 3, + "location": "/home/user/.cache/grype/db/3", + "checksum": "sha256:6c4777e1acea787e5335ccee6b5e4562cd1767b9cca138c07e0802efb2a74162", + "error": null + } + } + } + diff --git a/unittests/scans/anchore_grype/check_all_fields_no_ids_fabricated.json b/unittests/scans/anchore_grype/check_all_fields_no_ids_fabricated.json new file mode 100644 index 00000000000..d4371d3a478 --- /dev/null +++ b/unittests/scans/anchore_grype/check_all_fields_no_ids_fabricated.json @@ -0,0 +1,122 @@ +{ + "matches": [ + { + "vulnerability": { + "id": "GHSA-v6rh-hp5x-86rv", + "dataSource": "https://github.com/advisories/GHSA-v6rh-hp5x-86rv", + "namespace": "github:python", + "severity": "High", + "urls": [ + "https://github.com/advisories/GHSA-v6rh-hp5x-86rv" + ], + "description": "Potential bypass of an upstream access control based on URL paths in Django", + "cvss": [], + "fix": { + "versions": [ + "3.2.10" + ], + "state": "fixed" + }, + "advisories": [] + }, + "relatedVulnerabilities": [], + "matchDetails": [ + { + "matcher": "python-matcher", + "searchedBy": { + "language": "python", + "namespace": "github:python" + }, + "found": { + "versionConstraint": ">=3.2,<3.2.10 (python)" + } + } + ], + "artifact": { + "name": "Django", + "version": "3.2.9", + "type": "python", + "locations": [ + { + "path": "/usr/local/lib/python3.8/site-packages/Django-3.2.9.dist-info/METADATA", + "layerID": "sha256:b1d4455cf82b15a50b006fe87bd29f694c8f9155456253eb67fdd155b5edcf4a" + } + ], + "language": "python", + "licenses": [ + "BSD-3-Clause" + ], + "cpes": [ + "cpe:2.3:a:django_software_foundation:Django:3.2.9:*:*:*:*:*:*:*" + ], + "purl": "pkg:pypi/Django@3.2.9", + "metadata": null + } + } + ], + "source": { + "type": "image", + "target": { + "userInput": "vulnerable-image:latest", + "imageID": "sha256:ce9898fd214aef9c994a42624b09056bdce3ff4a8e3f68dc242d967b80fcbeee", + "manifestDigest": "sha256:9d8825ab20ac86b40eb71495bece1608a302fb180384740697a28c2b0a5a0fc6", + "mediaType": "application/vnd.docker.distribution.manifest.v2+json", + "tags": [ + "vulnerable-image:latest" + ], + "imageSize": 707381791, + "layers": [] + } + }, + "distro": { + "name": "debian", + "version": "10", + "idLike": "" + }, + "descriptor": { + "name": "grype", + "version": "0.28.0", + "configuration": { + "configPath": "", + "output": "json", + "file": "", + "output-template-file": "", + "quiet": false, + "check-for-app-update": true, + "only-fixed": false, + "scope": "Squashed", + "log": { + "structured": false, + "level": "", + "file": "" + }, + "db": { + "cache-dir": "/home/user/.cache/grype/db", + "update-url": "https://toolbox-data.anchore.io/grype/databases/listing.json", + "ca-cert": "", + "auto-update": true, + "validate-by-hash-on-start": false + }, + "dev": { + "profile-cpu": false, + "profile-mem": false + }, + "fail-on-severity": "", + "registry": { + "insecure-skip-tls-verify": false, + "insecure-use-http": false, + "auth": [] + }, + "ignore": null, + "exclude": [] + }, + "db": { + "built": "2021-12-24T08:14:02Z", + "schemaVersion": 3, + "location": "/home/user/.cache/grype/db/3", + "checksum": "sha256:6c4777e1acea787e5335ccee6b5e4562cd1767b9cca138c07e0802efb2a74162", + "error": null + } + } + } + diff --git a/unittests/scans/anchore_grype/check_all_fields_with_ids_fabricated.json b/unittests/scans/anchore_grype/check_all_fields_with_ids_fabricated.json new file mode 100644 index 00000000000..028bd2c7b7b --- /dev/null +++ b/unittests/scans/anchore_grype/check_all_fields_with_ids_fabricated.json @@ -0,0 +1,158 @@ +{ + "matches": [ + { + "vulnerability": { + "id": "GHSA-v6rh-hp5x-86rv", + "dataSource": "https://github.com/advisories/GHSA-v6rh-hp5x-86rv", + "namespace": "github:python", + "severity": "High", + "urls": [ + "https://github.com/advisories/GHSA-v6rh-hp5x-86rv" + ], + "description": "Potential bypass of an upstream access control based on URL paths in Django", + "cvss": [], + "fix": { + "versions": [ + "3.2.10" + ], + "state": "fixed" + }, + "advisories": [] + }, + "relatedVulnerabilities": [ + { + "id": "CVE-2021-44420", + "dataSource": "https://nvd.nist.gov/vuln/detail/CVE-2021-44420", + "namespace": "nvd", + "severity": "High", + "urls": [ + "https://docs.djangoproject.com/en/3.2/releases/security/", + "https://www.openwall.com/lists/oss-security/2021/12/07/1", + "https://www.djangoproject.com/weblog/2021/dec/07/security-releases/", + "https://groups.google.com/forum/#!forum/django-announce" + ], + "description": "In Django 2.2 before 2.2.25, 3.1 before 3.1.14, and 3.2 before 3.2.10, HTTP requests for URLs with trailing newlines could bypass upstream access control based on URL paths.", + "cvss": [ + { + "version": "2.0", + "vector": "AV:N/AC:L/Au:N/C:P/I:P/A:P", + "metrics": { + "baseScore": 7.5, + "exploitabilityScore": 10, + "impactScore": 6.4 + }, + "vendorMetadata": {} + }, + { + "version": "3.1", + "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:L", + "metrics": { + "baseScore": 7.3, + "exploitabilityScore": 3.9, + "impactScore": 3.4 + }, + "vendorMetadata": {} + } + ] + } + ], + "matchDetails": [ + { + "matcher": "python-matcher", + "searchedBy": { + "language": "python", + "namespace": "github:python" + }, + "found": { + "versionConstraint": ">=3.2,<3.2.10 (python)" + } + } + ], + "artifact": { + "name": "Django", + "version": "3.2.9", + "type": "python", + "locations": [ + { + "path": "/usr/local/lib/python3.8/site-packages/Django-3.2.9.dist-info/METADATA", + "layerID": "sha256:b1d4455cf82b15a50b006fe87bd29f694c8f9155456253eb67fdd155b5edcf4a" + } + ], + "language": "python", + "licenses": [ + "BSD-3-Clause" + ], + "cpes": [ + "cpe:2.3:a:django_software_foundation:Django:3.2.9:*:*:*:*:*:*:*" + ], + "purl": "pkg:pypi/Django@3.2.9", + "metadata": null + } + } + ], + "source": { + "type": "image", + "target": { + "userInput": "vulnerable-image:latest", + "imageID": "sha256:ce9898fd214aef9c994a42624b09056bdce3ff4a8e3f68dc242d967b80fcbeee", + "manifestDigest": "sha256:9d8825ab20ac86b40eb71495bece1608a302fb180384740697a28c2b0a5a0fc6", + "mediaType": "application/vnd.docker.distribution.manifest.v2+json", + "tags": [ + "vulnerable-image:latest" + ], + "imageSize": 707381791, + "layers": [] + } + }, + "distro": { + "name": "debian", + "version": "10", + "idLike": "" + }, + "descriptor": { + "name": "grype", + "version": "0.28.0", + "configuration": { + "configPath": "", + "output": "json", + "file": "", + "output-template-file": "", + "quiet": false, + "check-for-app-update": true, + "only-fixed": false, + "scope": "Squashed", + "log": { + "structured": false, + "level": "", + "file": "" + }, + "db": { + "cache-dir": "/home/user/.cache/grype/db", + "update-url": "https://toolbox-data.anchore.io/grype/databases/listing.json", + "ca-cert": "", + "auto-update": true, + "validate-by-hash-on-start": false + }, + "dev": { + "profile-cpu": false, + "profile-mem": false + }, + "fail-on-severity": "", + "registry": { + "insecure-skip-tls-verify": false, + "insecure-use-http": false, + "auth": [] + }, + "ignore": null, + "exclude": [] + }, + "db": { + "built": "2021-12-24T08:14:02Z", + "schemaVersion": 3, + "location": "/home/user/.cache/grype/db/3", + "checksum": "sha256:6c4777e1acea787e5335ccee6b5e4562cd1767b9cca138c07e0802efb2a74162", + "error": null + } + } + } + diff --git a/unittests/test_import_reimport.py b/unittests/test_import_reimport.py index f51a462045a..ed00e015189 100644 --- a/unittests/test_import_reimport.py +++ b/unittests/test_import_reimport.py @@ -102,12 +102,18 @@ def __init__(self, *args, **kwargs): self.anchore_grype_file_name = get_unit_tests_scans_path("anchore_grype") / "check_all_fields.json" self.anchore_grype_file_name_fix_not_available = get_unit_tests_scans_path("anchore_grype") / "fix_not_available.json" self.anchore_grype_file_name_fix_available = get_unit_tests_scans_path("anchore_grype") / "fix_available.json" + self.anchore_grype_file_name_with_ids_fabricated = get_unit_tests_scans_path("anchore_grype") / "check_all_fields_with_ids_fabricated.json" + self.anchore_grype_file_name_no_ids_fabricated = get_unit_tests_scans_path("anchore_grype") / "check_all_fields_no_ids_fabricated.json" + self.anchore_grype_file_name_different_ids_fabricated = get_unit_tests_scans_path("anchore_grype") / "check_all_fields_different_ids_fabricated.json" self.anchore_grype_scan_type = "Anchore Grype" self.checkmarx_one_open_and_false_positive = get_unit_tests_scans_path("checkmarx_one") / "one-open-one-false-positive.json" self.checkmarx_one_two_false_positive = get_unit_tests_scans_path("checkmarx_one") / "two-false-positive.json" self.scan_type_checkmarx_one = "Checkmarx One Scan" + self.bandit_large_file = get_unit_tests_scans_path("bandit") / "many_vulns.json" + self.scan_type_bandit = "Bandit Scan" + # import zap scan, testing: # - import # - active/verifed = True @@ -1693,6 +1699,78 @@ def test_import_reimport_vulnerability_ids(self): self.assertEqual("GHSA-v6rh-hp5x-86rv", findings[3].vulnerability_ids[0]) self.assertEqual("CVE-2021-44420", findings[3].vulnerability_ids[1]) + def test_import_reimport_clear_vulnerability_ids(self): + """Test that vulnerability IDs are cleared when reimporting with no IDs""" + # Import scan with vulnerability IDs + import0 = self.import_scan_with_params(self.anchore_grype_file_name_with_ids_fabricated, scan_type=self.anchore_grype_scan_type) + + test_id = import0["test"] + test = Test.objects.get(id=test_id) + findings = Finding.objects.filter(test=test) + self.assertEqual(1, len(findings)) + finding = findings[0] + self.assertEqual("GHSA-v6rh-hp5x-86rv", finding.cve) + self.assertEqual(2, len(finding.vulnerability_ids)) + self.assertEqual("GHSA-v6rh-hp5x-86rv", finding.vulnerability_ids[0]) + self.assertEqual("CVE-2021-44420", finding.vulnerability_ids[1]) + + # Reimport with no vulnerability IDs - should clear existing IDs + test_type = Test_Type.objects.get(name=self.anchore_grype_scan_type) + reimport_test = Test( + engagement=test.engagement, + test_type=test_type, + scan_type=self.anchore_grype_scan_type, + target_start=datetime.now(timezone.get_current_timezone()), + target_end=datetime.now(timezone.get_current_timezone()), + ) + reimport_test.save() + + self.reimport_scan_with_params(reimport_test.id, self.anchore_grype_file_name_no_ids_fabricated, scan_type=self.anchore_grype_scan_type) + findings = Finding.objects.filter(test=reimport_test) + self.assertEqual(1, len(findings)) + finding = findings[0] + # After clearing, only the primary vuln_id should remain (GHSA-v6rh-hp5x-86rv) + # because the parser always includes the primary vulnerability.id + self.assertEqual("GHSA-v6rh-hp5x-86rv", finding.cve) + self.assertEqual(1, len(finding.vulnerability_ids)) + self.assertEqual("GHSA-v6rh-hp5x-86rv", finding.vulnerability_ids[0]) + + def test_import_reimport_change_vulnerability_ids(self): + """Test that vulnerability IDs are updated when reimporting with different IDs""" + # Import scan with initial vulnerability IDs + import0 = self.import_scan_with_params(self.anchore_grype_file_name_with_ids_fabricated, scan_type=self.anchore_grype_scan_type) + + test_id = import0["test"] + test = Test.objects.get(id=test_id) + findings = Finding.objects.filter(test=test) + self.assertEqual(1, len(findings)) + finding = findings[0] + self.assertEqual("GHSA-v6rh-hp5x-86rv", finding.cve) + self.assertEqual(2, len(finding.vulnerability_ids)) + self.assertEqual("GHSA-v6rh-hp5x-86rv", finding.vulnerability_ids[0]) + self.assertEqual("CVE-2021-44420", finding.vulnerability_ids[1]) + + # Reimport with different vulnerability IDs - should update existing IDs + test_type = Test_Type.objects.get(name=self.anchore_grype_scan_type) + reimport_test = Test( + engagement=test.engagement, + test_type=test_type, + scan_type=self.anchore_grype_scan_type, + target_start=datetime.now(timezone.get_current_timezone()), + target_end=datetime.now(timezone.get_current_timezone()), + ) + reimport_test.save() + + self.reimport_scan_with_params(reimport_test.id, self.anchore_grype_file_name_different_ids_fabricated, scan_type=self.anchore_grype_scan_type) + findings = Finding.objects.filter(test=reimport_test) + self.assertEqual(1, len(findings)) + finding = findings[0] + self.assertEqual("GHSA-v6rh-hp5x-86rv", finding.cve) + self.assertEqual(3, len(finding.vulnerability_ids)) + self.assertEqual("GHSA-v6rh-hp5x-86rv", finding.vulnerability_ids[0]) + self.assertEqual("CVE-2021-1234", finding.vulnerability_ids[1]) + self.assertEqual("CVE-2021-5678", finding.vulnerability_ids[2]) + def test_import_reimport_fix_available(self): import0 = self.import_scan_with_params(self.anchore_grype_file_name_fix_not_available, scan_type=self.anchore_grype_scan_type) test_id = import0["test"] @@ -2033,6 +2111,348 @@ def test_reimport_set_scan_date_parser_sets_date(self): date = findings["results"][0]["date"] self.assertEqual(date, "2006-12-26") + @override_settings( + IMPORT_REIMPORT_DEDUPE_BATCH_SIZE=200, + IMPORT_REIMPORT_MATCH_BATCH_SIZE=200, + ) + def test_batch_reimport_large_bandit_file(self): + """ + Test that batch reimport produces identical results to non-batch mode (which we simulate via large max batch size setting). + + Step 1: Import scan (baseline), assess active count, duplicate count + Step 2: Reimport scan (same test), assess active count, duplicate count + Step 3: Import scan in NEW engagement with batch_size=50, assess active and duplicate equal to step 1 + Step 4: Reimport scan in same new engagement (batch_size=50), assess active and duplicate equal to step 2 + """ + # Step 1: Baseline import (default batch size) + # Create engagement first and set deduplication_on_engagement before import + product_type1, _ = Product_Type.objects.get_or_create(name="PT Bandit Baseline") + product1, _ = Product.objects.get_or_create(name="P Bandit Baseline", prod_type=product_type1) + engagement1 = Engagement.objects.create( + name="E Bandit Baseline", + product=product1, + target_start=timezone.now(), + target_end=timezone.now(), + ) + engagement1.deduplication_on_engagement = True + engagement1.save() + engagement1_id = engagement1.id + + import1 = self.import_scan_with_params( + self.bandit_large_file, + scan_type=self.scan_type_bandit, + engagement=engagement1_id, + product_type_name=None, + product_name=None, + engagement_name=None, + auto_create_context=False, + ) + test1_id = import1["test"] + + # Assess step 1: active count, duplicate count + step1_active = Finding.objects.filter( + test__engagement_id=engagement1_id, + active=True, + duplicate=False, + ).count() + step1_duplicate = Finding.objects.filter( + test__engagement_id=engagement1_id, + duplicate=True, + ).count() + + # Assert step 1 specific counts + # Each assertion is wrapped in its own subTest so that if one fails, the others still run. + # This allows us to see all count mismatches in a single test run, making it easier to fix + # all incorrect expected values at once rather than fixing them one at a time. + with self.subTest(step=1, metric="active"): + self.assertEqual(step1_active, 213, "Step 1 active count") + with self.subTest(step=1, metric="duplicate"): + self.assertEqual(step1_duplicate, 1, "Step 1 duplicate count") + + # Step 2: Reimport scan (same test) + self.reimport_scan_with_params( + test1_id, + self.bandit_large_file, + scan_type=self.scan_type_bandit, + ) + + # Assess step 2: active count, duplicate count + step2_active = Finding.objects.filter( + test__engagement_id=engagement1_id, + active=True, + duplicate=False, + ).count() + step2_duplicate = Finding.objects.filter( + test__engagement_id=engagement1_id, + duplicate=True, + ).count() + + # Assert step 2 specific counts + # Each assertion is wrapped in its own subTest so that if one fails, the others still run. + # This allows us to see all count mismatches in a single test run, making it easier to fix + # all incorrect expected values at once rather than fixing them one at a time. + with self.subTest(step=2, metric="active"): + self.assertEqual(step2_active, 213, "Step 2 active count") + with self.subTest(step=2, metric="duplicate"): + self.assertEqual(step2_duplicate, 1, "Step 2 duplicate count") + + # Step 3: Import scan in NEW engagement with batch_size=50 + with override_settings( + IMPORT_REIMPORT_DEDUPE_BATCH_SIZE=50, + IMPORT_REIMPORT_MATCH_BATCH_SIZE=50, + ): + # Create engagement first and set deduplication_on_engagement before import + product_type2, _ = Product_Type.objects.get_or_create(name="PT Bandit Batch") + product2, _ = Product.objects.get_or_create(name="P Bandit Batch", prod_type=product_type2) + engagement2 = Engagement.objects.create( + name="E Bandit Batch", + product=product2, + target_start=timezone.now(), + target_end=timezone.now(), + ) + engagement2.deduplication_on_engagement = True + engagement2.save() + engagement2_id = engagement2.id + + import2 = self.import_scan_with_params( + self.bandit_large_file, + scan_type=self.scan_type_bandit, + engagement=engagement2_id, + product_type_name=None, + product_name=None, + engagement_name=None, + auto_create_context=False, + ) + test2_id = import2["test"] + + # Assess step 3: active and duplicate should equal step 1 + step3_active = Finding.objects.filter( + test__engagement_id=engagement2_id, + active=True, + duplicate=False, + ).count() + step3_duplicate = Finding.objects.filter( + test__engagement_id=engagement2_id, + duplicate=True, + ).count() + + # Each assertion is wrapped in its own subTest so that if one fails, the others still run. + # This allows us to see all count mismatches in a single test run, making it easier to fix + # all incorrect expected values at once rather than fixing them one at a time. + with self.subTest(step=3, metric="active"): + self.assertEqual( + step3_active, + step1_active, + "Step 3 active count should equal step 1 (baseline import)", + ) + with self.subTest(step=3, metric="duplicate"): + self.assertEqual( + step3_duplicate, + step1_duplicate, + "Step 3 duplicate count should equal step 1 (baseline import)", + ) + + # Step 4: Reimport scan in same new engagement (batch_size=50) + self.reimport_scan_with_params( + test2_id, + self.bandit_large_file, + scan_type=self.scan_type_bandit, + ) + + # Assess step 4: active and duplicate should equal step 2 + step4_active = Finding.objects.filter( + test__engagement_id=engagement2_id, + active=True, + duplicate=False, + ).count() + step4_duplicate = Finding.objects.filter( + test__engagement_id=engagement2_id, + duplicate=True, + ).count() + + # Each assertion is wrapped in its own subTest so that if one fails, the others still run. + # This allows us to see all count mismatches in a single test run, making it easier to fix + # all incorrect expected values at once rather than fixing them one at a time. + with self.subTest(step=4, metric="active"): + self.assertEqual( + step4_active, + step2_active, + "Step 4 active count should equal step 2 (baseline reimport)", + ) + with self.subTest(step=4, metric="duplicate"): + self.assertEqual( + step4_duplicate, + step2_duplicate, + "Step 4 duplicate count should equal step 2 (baseline reimport)", + ) + + def test_batch_deduplication_large_bandit_file(self): + """ + Test that batch deduplication produces identical results to non-batch mode (which we simulate via large max batch size setting). + + Step 1: Import scan (baseline), assess active count, duplicate count + Step 2: Import scan again (same engagement), assess active count, duplicate count + Step 3: Import scan in NEW engagement with batch_size=50, assess active and duplicate equal to step 1 + Step 4: Import scan again in same new engagement (batch_size=50), assess active and duplicate equal to step 2 + """ + # Step 1: Baseline import (default batch size) + # Create engagement first and set deduplication_on_engagement before import + product_type1, _ = Product_Type.objects.get_or_create(name="PT Bandit Baseline Dedupe") + product1, _ = Product.objects.get_or_create(name="P Bandit Baseline Dedupe", prod_type=product_type1) + engagement1 = Engagement.objects.create( + name="E Bandit Baseline Dedupe", + product=product1, + target_start=timezone.now(), + target_end=timezone.now(), + ) + engagement1.deduplication_on_engagement = True + engagement1.save() + engagement1_id = engagement1.id + + self.import_scan_with_params( + self.bandit_large_file, + scan_type=self.scan_type_bandit, + engagement=engagement1_id, + product_type_name=None, + product_name=None, + engagement_name=None, + auto_create_context=False, + ) + + # Assess step 1: active count, duplicate count + step1_active = Finding.objects.filter( + test__engagement_id=engagement1_id, + active=True, + duplicate=False, + ).count() + step1_duplicate = Finding.objects.filter( + test__engagement_id=engagement1_id, + duplicate=True, + ).count() + + # Assert step 1 specific counts + self.assertEqual(step1_active, 213, "Step 1 active count") + self.assertEqual(step1_duplicate, 1, "Step 1 duplicate count") + + # Step 2: Import scan again (same engagement) + self.import_scan_with_params( + self.bandit_large_file, + scan_type=self.scan_type_bandit, + engagement=engagement1_id, + product_type_name=None, + product_name=None, + engagement_name=None, + auto_create_context=False, + ) + + # Assess step 2: active count, duplicate count + step2_active = Finding.objects.filter( + test__engagement_id=engagement1_id, + active=True, + duplicate=False, + ).count() + step2_duplicate = Finding.objects.filter( + test__engagement_id=engagement1_id, + duplicate=True, + ).count() + + # Assert step 2 specific counts + self.assertEqual(step2_active, 213, "Step 2 active count") + self.assertEqual(step2_duplicate, 215, "Step 2 duplicate count") + + # Step 3: Import scan in NEW engagement with batch_size=50 + with override_settings( + IMPORT_REIMPORT_DEDUPE_BATCH_SIZE=50, + IMPORT_REIMPORT_MATCH_BATCH_SIZE=50, + ): + # Create engagement first and set deduplication_on_engagement before import + product_type2, _ = Product_Type.objects.get_or_create(name="PT Bandit Batch Dedupe") + product2, _ = Product.objects.get_or_create(name="P Bandit Batch Dedupe", prod_type=product_type2) + engagement2 = Engagement.objects.create( + name="E Bandit Batch Dedupe", + product=product2, + target_start=timezone.now(), + target_end=timezone.now(), + ) + engagement2.deduplication_on_engagement = True + engagement2.save() + engagement2_id = engagement2.id + + self.import_scan_with_params( + self.bandit_large_file, + scan_type=self.scan_type_bandit, + engagement=engagement2_id, + product_type_name=None, + product_name=None, + engagement_name=None, + auto_create_context=False, + ) + + # Assess step 3: active and duplicate should equal step 1 + step3_active = Finding.objects.filter( + test__engagement_id=engagement2_id, + active=True, + duplicate=False, + ).count() + step3_duplicate = Finding.objects.filter( + test__engagement_id=engagement2_id, + duplicate=True, + ).count() + + # Each assertion is wrapped in its own subTest so that if one fails, the others still run. + # This allows us to see all count mismatches in a single test run, making it easier to fix + # all incorrect expected values at once rather than fixing them one at a time. + with self.subTest(step=3, metric="active"): + self.assertEqual( + step3_active, + step1_active, + "Step 3 active count should equal step 1 (baseline import)", + ) + with self.subTest(step=3, metric="duplicate"): + self.assertEqual( + step3_duplicate, + step1_duplicate, + "Step 3 duplicate count should equal step 1 (baseline import)", + ) + + # Step 4: Import scan again in same new engagement (batch_size=50) + self.import_scan_with_params( + self.bandit_large_file, + scan_type=self.scan_type_bandit, + engagement=engagement2_id, + product_type_name=None, + product_name=None, + engagement_name=None, + auto_create_context=False, + ) + + # Assess step 4: active and duplicate should equal step 2 + step4_active = Finding.objects.filter( + test__engagement_id=engagement2_id, + active=True, + duplicate=False, + ).count() + step4_duplicate = Finding.objects.filter( + test__engagement_id=engagement2_id, + duplicate=True, + ).count() + + # Each assertion is wrapped in its own subTest so that if one fails, the others still run. + # This allows us to see all count mismatches in a single test run, making it easier to fix + # all incorrect expected values at once rather than fixing them one at a time. + with self.subTest(step=4, metric="active"): + self.assertEqual( + step4_active, + step2_active, + "Step 4 active count should equal step 2 (baseline second import)", + ) + with self.subTest(step=4, metric="duplicate"): + self.assertEqual( + step4_duplicate, + step2_duplicate, + "Step 4 duplicate count should equal step 2 (baseline second import)", + ) + @override_settings( IMPORT_REIMPORT_DEDUPE_BATCH_SIZE=50, IMPORT_REIMPORT_MATCH_BATCH_SIZE=50, diff --git a/unittests/test_importers_importer.py b/unittests/test_importers_importer.py index cc5fb342df7..6e526ec0d92 100644 --- a/unittests/test_importers_importer.py +++ b/unittests/test_importers_importer.py @@ -7,7 +7,17 @@ from rest_framework.test import APIClient from dojo.importers.default_importer import DefaultImporter -from dojo.models import Development_Environment, Engagement, Finding, Product, Product_Type, Test, User +from dojo.importers.default_reimporter import DefaultReImporter +from dojo.models import ( + Development_Environment, + Engagement, + Finding, + Product, + Product_Type, + Test, + User, + Vulnerability_Id, +) from dojo.tools.gitlab_sast.parser import GitlabSastParser from dojo.tools.sarif.parser import SarifParser from dojo.utils import get_object_or_none @@ -553,8 +563,7 @@ def create_default_data(self): "scan_type": NPM_AUDIT_SCAN_TYPE, } - @patch("dojo.importers.base_importer.Vulnerability_Id", autospec=True) - def test_handle_vulnerability_ids_references_and_cve(self, mock): + def test_handle_vulnerability_ids_references_and_cve(self): # Why doesn't this test use the test db and query for one? vulnerability_ids = ["CVE", "REF-1", "REF-2"] finding = Finding() @@ -562,7 +571,7 @@ def test_handle_vulnerability_ids_references_and_cve(self, mock): finding.test = self.test finding.reporter = self.testuser finding.save() - DefaultImporter(**self.importer_data).process_vulnerability_ids(finding) + DefaultImporter(**self.importer_data).store_vulnerability_ids(finding) self.assertEqual("CVE", finding.vulnerability_ids[0]) self.assertEqual("CVE", finding.cve) @@ -571,8 +580,7 @@ def test_handle_vulnerability_ids_references_and_cve(self, mock): self.assertEqual("REF-2", finding.vulnerability_ids[2]) finding.delete() - @patch("dojo.importers.base_importer.Vulnerability_Id", autospec=True) - def test_handle_no_vulnerability_ids_references_and_cve(self, mock): + def test_handle_no_vulnerability_ids_references_and_cve(self): vulnerability_ids = ["CVE"] finding = Finding() finding.test = self.test @@ -580,22 +588,21 @@ def test_handle_no_vulnerability_ids_references_and_cve(self, mock): finding.save() finding.unsaved_vulnerability_ids = vulnerability_ids - DefaultImporter(**self.importer_data).process_vulnerability_ids(finding) + DefaultImporter(**self.importer_data).store_vulnerability_ids(finding) self.assertEqual("CVE", finding.vulnerability_ids[0]) self.assertEqual("CVE", finding.cve) self.assertEqual(vulnerability_ids, finding.unsaved_vulnerability_ids) finding.delete() - @patch("dojo.importers.base_importer.Vulnerability_Id", autospec=True) - def test_handle_vulnerability_ids_references_and_no_cve(self, mock): + def test_handle_vulnerability_ids_references_and_no_cve(self): vulnerability_ids = ["REF-1", "REF-2"] finding = Finding() finding.test = self.test finding.reporter = self.testuser finding.save() finding.unsaved_vulnerability_ids = vulnerability_ids - DefaultImporter(**self.importer_data).process_vulnerability_ids(finding) + DefaultImporter(**self.importer_data).store_vulnerability_ids(finding) self.assertEqual("REF-1", finding.vulnerability_ids[0]) self.assertEqual("REF-1", finding.cve) @@ -603,14 +610,87 @@ def test_handle_vulnerability_ids_references_and_no_cve(self, mock): self.assertEqual("REF-2", finding.vulnerability_ids[1]) finding.delete() - @patch("dojo.importers.base_importer.Vulnerability_Id", autospec=True) - def test_no_handle_vulnerability_ids_references_and_no_cve(self, mock): + def test_no_handle_vulnerability_ids_references_and_no_cve(self): finding = Finding() finding.test = self.test finding.reporter = self.testuser finding.save() - DefaultImporter(**self.importer_data).process_vulnerability_ids(finding) + DefaultImporter(**self.importer_data).store_vulnerability_ids(finding) self.assertEqual(finding.cve, None) self.assertEqual(finding.unsaved_vulnerability_ids, None) self.assertEqual(finding.vulnerability_ids, []) finding.delete() + + def test_clear_vulnerability_ids_on_empty_list(self): + """Test that vulnerability IDs are cleared when an empty list is provided""" + # Create a finding with existing vulnerability IDs + finding = Finding() + finding.test = self.test + finding.reporter = self.testuser + finding.save() + + # Add some vulnerability IDs + Vulnerability_Id.objects.create(finding=finding, vulnerability_id="CVE-2020-1234") + Vulnerability_Id.objects.create(finding=finding, vulnerability_id="CVE-2020-5678") + finding.cve = "CVE-2020-1234" + finding.save() + + # Verify initial state + self.assertEqual(2, len(finding.vulnerability_ids)) + self.assertEqual("CVE-2020-1234", finding.cve) + + # Process with empty list - should clear all IDs + finding.unsaved_vulnerability_ids = [] + DefaultReImporter(test=self.test, environment=self.importer_data["environment"], scan_type=self.importer_data["scan_type"]).reconcile_vulnerability_ids(finding) + # Save the finding to persist the cve=None change + finding.save() + + # Get fresh finding from database to avoid cached property issues + finding = Finding.objects.get(pk=finding.pk) + + # Verify IDs are cleared + self.assertEqual(0, len(finding.vulnerability_ids)) + self.assertEqual(None, finding.cve) + # Verify no Vulnerability_Id objects exist for this finding + self.assertEqual(0, Vulnerability_Id.objects.filter(finding=finding).count()) + finding.delete() + + def test_change_vulnerability_ids_on_reimport(self): + """Test that vulnerability IDs are updated when different IDs are provided""" + # Create a finding with existing vulnerability IDs + finding = Finding() + finding.test = self.test + finding.reporter = self.testuser + finding.save() + + # Add initial vulnerability IDs + Vulnerability_Id.objects.create(finding=finding, vulnerability_id="CVE-2020-1234") + Vulnerability_Id.objects.create(finding=finding, vulnerability_id="CVE-2020-5678") + finding.cve = "CVE-2020-1234" + finding.save() + + # Verify initial state + self.assertEqual(2, len(finding.vulnerability_ids)) + self.assertEqual("CVE-2020-1234", finding.vulnerability_ids[0]) + self.assertEqual("CVE-2020-5678", finding.vulnerability_ids[1]) + self.assertEqual("CVE-2020-1234", finding.cve) + + # Process with different IDs - should replace old IDs + new_vulnerability_ids = ["CVE-2021-9999", "GHSA-xxxx-yyyy"] + finding.unsaved_vulnerability_ids = new_vulnerability_ids + DefaultReImporter(test=self.test, environment=self.importer_data["environment"], scan_type=self.importer_data["scan_type"]).reconcile_vulnerability_ids(finding) + # Save the finding to persist the cve change + finding.save() + + # Get fresh finding from database to avoid cached property issues + finding = Finding.objects.get(pk=finding.pk) + + # Verify old IDs are removed and new IDs are present + self.assertEqual(2, len(finding.vulnerability_ids)) + self.assertEqual("CVE-2021-9999", finding.vulnerability_ids[0]) + self.assertEqual("GHSA-xxxx-yyyy", finding.vulnerability_ids[1]) + self.assertEqual("CVE-2021-9999", finding.cve) + # 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() diff --git a/unittests/test_importers_performance.py b/unittests/test_importers_performance.py index 1963c522f7e..1e7b05d8fe5 100644 --- a/unittests/test_importers_performance.py +++ b/unittests/test_importers_performance.py @@ -1,3 +1,23 @@ +""" +Performance tests for importers. + +These tests verify that import and reimport operations maintain acceptable query counts +and async task counts to prevent performance regressions. + +Counts can be updated via the Python script at scripts/update_performance_test_counts.py. +However, counts must be verified to ensure no implicit performance regressions are introduced. +When counts change, review the differences carefully to determine if they represent: +- Legitimate optimizations (counts decreasing) +- Acceptable changes due to feature additions (counts increasing with justification) +- Unintended performance regressions (counts increasing without clear reason) + +Always verify updated counts by: +1. Running the update script to see the differences +2. Reviewing the changes to understand why counts changed +3. Running the verification step to ensure all tests pass +4. Investigating any unexpected increases in query or task counts +""" + import logging from contextlib import contextmanager @@ -33,7 +53,9 @@ STACK_HAWK_SCAN_TYPE = "StackHawk HawkScan" -class TestDojoImporterPerformance(DojoTestCase): +class TestDojoImporterPerformanceBase(DojoTestCase): + + """Base class for performance tests with shared setup and helper methods.""" def setUp(self): super().setUp() @@ -77,96 +99,161 @@ def _assertNumAsyncTask(self, num): ) logger.debug(msg) - def _import_reimport_performance(self, expected_num_queries1, expected_num_async_tasks1, expected_num_queries2, expected_num_async_tasks2, expected_num_queries3, expected_num_async_tasks3): - """ - Log output can be quite large as when the assertNumQueries fails, all queries are printed. - It could be usefule to capture the output in `less`: - ./run-unittest.sh --test-case unittests.test_importers_performance.TestDojoImporterPerformance 2>&1 | less - Then search for `expected` to find the lines where the expected number of queries is printed. - Or you can use `grep` to filter the output: - ./run-unittest.sh --test-case unittests.test_importers_performance.TestDojoImporterPerformance 2>&1 | grep expected -B 10 - """ + def _create_test_objects(self, product_name, engagement_name): + """Helper method to create test product, engagement, lead user, and environment.""" product_type, _created = Product_Type.objects.get_or_create(name="test") product, _created = Product.objects.get_or_create( - name="TestDojoDefaultImporter", + name=product_name, prod_type=product_type, ) engagement, _created = Engagement.objects.get_or_create( - name="Test Create Engagement", + name=engagement_name, product=product, target_start=timezone.now(), target_end=timezone.now(), ) lead, _ = User.objects.get_or_create(username="admin") environment, _ = Development_Environment.objects.get_or_create(name="Development") + return product, engagement, lead, environment + + def _import_reimport_performance( + self, + expected_num_queries1, + expected_num_async_tasks1, + expected_num_queries2, + expected_num_async_tasks2, + expected_num_queries3, + expected_num_async_tasks3, + scan_file1, + scan_file2, + scan_file3, + scan_type, + product_name, + engagement_name, + ): + """ + Test import/reimport/reimport performance with specified scan files and scan type. + Log output can be quite large as when the assertNumQueries fails, all queries are printed. + """ + _, engagement, lead, environment = self._create_test_objects( + product_name, + engagement_name, + ) - # first import the subset which missed one finding and a couple of endpoints on some of the findings - with ( + # First import + # Each assertion context manager is wrapped in its own subTest so that if one fails, the others still run. + # This allows us to see all count mismatches in a single test run, making it easier to fix + # all incorrect expected values at once rather than fixing them one at a time. + # Nested with statements are intentional - each assertion needs its own subTest wrapper. + with ( # noqa: SIM117 self.subTest("import1"), impersonate(Dojo_User.objects.get(username="admin")), - self.assertNumQueries(expected_num_queries1), - self._assertNumAsyncTask(expected_num_async_tasks1), - STACK_HAWK_SUBSET_FILENAME.open(encoding="utf-8") as scan, + scan_file1.open(encoding="utf-8") as scan, ): - import_options = { - "user": lead, - "lead": lead, - "scan_date": None, - "environment": environment, - "minimum_severity": "Info", - "active": True, - "verified": True, - "sync": True, - "scan_type": STACK_HAWK_SCAN_TYPE, - "engagement": engagement, - "tags": ["performance-test", "tag-in-param", "go-faster"], - "apply_tags_to_findings": True, - } - importer = DefaultImporter(**import_options) - test, _, _len_new_findings, _len_closed_findings, _, _, _ = importer.process_scan(scan) - - # use reimport with the full report so it add a finding and some endpoints - with ( + with self.subTest(step="import1", metric="queries"): + with self.assertNumQueries(expected_num_queries1): + with self.subTest(step="import1", metric="async_tasks"): + with self._assertNumAsyncTask(expected_num_async_tasks1): + import_options = { + "user": lead, + "lead": lead, + "scan_date": None, + "environment": environment, + "minimum_severity": "Info", + "active": True, + "verified": True, + "sync": True, + "scan_type": scan_type, + "engagement": engagement, + "tags": ["performance-test", "tag-in-param", "go-faster"], + "apply_tags_to_findings": True, + } + importer = DefaultImporter(**import_options) + test, _, _len_new_findings, _len_closed_findings, _, _, _ = importer.process_scan(scan) + + # Second import (reimport) + # Each assertion context manager is wrapped in its own subTest so that if one fails, the others still run. + # This allows us to see all count mismatches in a single test run, making it easier to fix + # all incorrect expected values at once rather than fixing them one at a time. + # Nested with statements are intentional - each assertion needs its own subTest wrapper. + with ( # noqa: SIM117 self.subTest("reimport1"), impersonate(Dojo_User.objects.get(username="admin")), - self.assertNumQueries(expected_num_queries2), - self._assertNumAsyncTask(expected_num_async_tasks2), - STACK_HAWK_FILENAME.open(encoding="utf-8") as scan, + scan_file2.open(encoding="utf-8") as scan, ): - reimport_options = { - "test": test, - "user": lead, - "lead": lead, - "scan_date": None, - "minimum_severity": "Info", - "active": True, - "verified": True, - "sync": True, - "scan_type": STACK_HAWK_SCAN_TYPE, - "tags": ["performance-test-reimport", "reimport-tag-in-param", "reimport-go-faster"], - "apply_tags_to_findings": True, - } - reimporter = DefaultReImporter(**reimport_options) - test, _, _len_new_findings, _len_closed_findings, _, _, _ = reimporter.process_scan(scan) - - # use reimport with the subset again to close a finding and mitigate some endpoints - with ( + with self.subTest(step="reimport1", metric="queries"): + with self.assertNumQueries(expected_num_queries2): + with self.subTest(step="reimport1", metric="async_tasks"): + with self._assertNumAsyncTask(expected_num_async_tasks2): + reimport_options = { + "test": test, + "user": lead, + "lead": lead, + "scan_date": None, + "minimum_severity": "Info", + "active": True, + "verified": True, + "sync": True, + "scan_type": scan_type, + "tags": ["performance-test-reimport", "reimport-tag-in-param", "reimport-go-faster"], + "apply_tags_to_findings": True, + } + reimporter = DefaultReImporter(**reimport_options) + test, _, _len_new_findings, _len_closed_findings, _, _, _ = reimporter.process_scan(scan) + + # Third import (reimport again) + # Each assertion context manager is wrapped in its own subTest so that if one fails, the others still run. + # This allows us to see all count mismatches in a single test run, making it easier to fix + # all incorrect expected values at once rather than fixing them one at a time. + # Nested with statements are intentional - each assertion needs its own subTest wrapper. + with ( # noqa: SIM117 self.subTest("reimport2"), impersonate(Dojo_User.objects.get(username="admin")), - self.assertNumQueries(expected_num_queries3), - self._assertNumAsyncTask(expected_num_async_tasks3), - STACK_HAWK_SUBSET_FILENAME.open(encoding="utf-8") as scan, + scan_file3.open(encoding="utf-8") as scan, ): - reimport_options = { - "test": test, - "user": lead, - "lead": lead, - "scan_date": None, - "minimum_severity": "Info", - "active": True, - "verified": True, - "sync": True, - "scan_type": STACK_HAWK_SCAN_TYPE, - } - reimporter = DefaultReImporter(**reimport_options) - test, _, _len_new_findings, _len_closed_findings, _, _, _ = reimporter.process_scan(scan) + with self.subTest(step="reimport2", metric="queries"): + with self.assertNumQueries(expected_num_queries3): + with self.subTest(step="reimport2", metric="async_tasks"): + with self._assertNumAsyncTask(expected_num_async_tasks3): + reimport_options = { + "test": test, + "user": lead, + "lead": lead, + "scan_date": None, + "minimum_severity": "Info", + "active": True, + "verified": True, + "sync": True, + "scan_type": scan_type, + } + reimporter = DefaultReImporter(**reimport_options) + test, _, _len_new_findings, _len_closed_findings, _, _, _ = reimporter.process_scan(scan) + + +class TestDojoImporterPerformanceSmall(TestDojoImporterPerformanceBase): + + """Performance tests using small sample files (StackHawk, ~6 findings).""" + + def _import_reimport_performance(self, expected_num_queries1, expected_num_async_tasks1, expected_num_queries2, expected_num_async_tasks2, expected_num_queries3, expected_num_async_tasks3): + """ + Log output can be quite large as when the assertNumQueries fails, all queries are printed. + It could be usefule to capture the output in `less`: + ./run-unittest.sh --test-case unittests.test_importers_performance.TestDojoImporterPerformanceSmall 2>&1 | less + Then search for `expected` to find the lines where the expected number of queries is printed. + Or you can use `grep` to filter the output: + ./run-unittest.sh --test-case unittests.test_importers_performance.TestDojoImporterPerformanceSmall 2>&1 | grep expected -B 10 + """ + return super()._import_reimport_performance( + expected_num_queries1, + expected_num_async_tasks1, + expected_num_queries2, + expected_num_async_tasks2, + expected_num_queries3, + expected_num_async_tasks3, + scan_file1=STACK_HAWK_SUBSET_FILENAME, + scan_file2=STACK_HAWK_FILENAME, + scan_file3=STACK_HAWK_SUBSET_FILENAME, + scan_type=STACK_HAWK_SCAN_TYPE, + product_name="TestDojoDefaultImporter", + engagement_name="Test Create Engagement", + ) @override_settings(ENABLE_AUDITLOG=True) def test_import_reimport_reimport_performance_pghistory_async(self): @@ -180,9 +267,9 @@ def test_import_reimport_reimport_performance_pghistory_async(self): self._import_reimport_performance( expected_num_queries1=306, expected_num_async_tasks1=7, - expected_num_queries2=281, + expected_num_queries2=232, expected_num_async_tasks2=18, - expected_num_queries3=170, + expected_num_queries3=114, expected_num_async_tasks3=17, ) @@ -200,11 +287,11 @@ def test_import_reimport_reimport_performance_pghistory_no_async(self): testuser.usercontactinfo.save() self._import_reimport_performance( - expected_num_queries1=312, + expected_num_queries1=313, expected_num_async_tasks1=6, - expected_num_queries2=287, + expected_num_queries2=239, expected_num_async_tasks2=17, - expected_num_queries3=176, + expected_num_queries3=121, expected_num_async_tasks3=16, ) @@ -223,11 +310,11 @@ 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=314, + expected_num_queries1=315, expected_num_async_tasks1=8, - expected_num_queries2=289, + expected_num_queries2=241, expected_num_async_tasks2=19, - expected_num_queries3=178, + expected_num_queries3=123, expected_num_async_tasks3=18, ) @@ -238,61 +325,64 @@ def _deduplication_performance(self, expected_num_queries1, expected_num_async_t The second import should result in all findings being marked as duplicates. This is different from reimport as we create a new test each time. """ - product_type, _created = Product_Type.objects.get_or_create(name="test") - product, _created = Product.objects.get_or_create( - name="TestDojoDeduplicationPerformance", - prod_type=product_type, + _, engagement, lead, environment = self._create_test_objects( + "TestDojoDeduplicationPerformance", + "Test Deduplication Performance Engagement", ) - engagement, _created = Engagement.objects.get_or_create( - name="Test Deduplication Performance Engagement", - product=product, - target_start=timezone.now(), - target_end=timezone.now(), - ) - lead, _ = User.objects.get_or_create(username="admin") - environment, _ = Development_Environment.objects.get_or_create(name="Development") # First import - all findings should be new - with ( + # Each assertion context manager is wrapped in its own subTest so that if one fails, the others still run. + # This allows us to see all count mismatches in a single test run, making it easier to fix + # all incorrect expected values at once rather than fixing them one at a time. + # Nested with statements are intentional - each assertion needs its own subTest wrapper. + with ( # noqa: SIM117 self.subTest("first_import"), impersonate(Dojo_User.objects.get(username="admin")), - self.assertNumQueries(expected_num_queries1), - self._assertNumAsyncTask(expected_num_async_tasks1), STACK_HAWK_FILENAME.open(encoding="utf-8") as scan, ): - import_options = { - "user": lead, - "lead": lead, - "scan_date": None, - "environment": environment, - "minimum_severity": "Info", - "active": True, - "verified": True, - "scan_type": STACK_HAWK_SCAN_TYPE, - "engagement": engagement, - } - importer = DefaultImporter(**import_options) - _, _, len_new_findings1, len_closed_findings1, _, _, _ = importer.process_scan(scan) + with self.subTest(step="first_import", metric="queries"): + with self.assertNumQueries(expected_num_queries1): + with self.subTest(step="first_import", metric="async_tasks"): + with self._assertNumAsyncTask(expected_num_async_tasks1): + import_options = { + "user": lead, + "lead": lead, + "scan_date": None, + "environment": environment, + "minimum_severity": "Info", + "active": True, + "verified": True, + "scan_type": STACK_HAWK_SCAN_TYPE, + "engagement": engagement, + } + importer = DefaultImporter(**import_options) + _, _, len_new_findings1, len_closed_findings1, _, _, _ = importer.process_scan(scan) # Second import - all findings should be duplicates - with ( + # Each assertion context manager is wrapped in its own subTest so that if one fails, the others still run. + # This allows us to see all count mismatches in a single test run, making it easier to fix + # all incorrect expected values at once rather than fixing them one at a time. + # Nested with statements are intentional - each assertion needs its own subTest wrapper. + with ( # noqa: SIM117 self.subTest("second_import"), impersonate(Dojo_User.objects.get(username="admin")), - self.assertNumQueries(expected_num_queries2), - self._assertNumAsyncTask(expected_num_async_tasks2), STACK_HAWK_FILENAME.open(encoding="utf-8") as scan, ): - import_options = { - "user": lead, - "lead": lead, - "scan_date": None, - "environment": environment, - "minimum_severity": "Info", - "active": True, - "verified": True, - "scan_type": STACK_HAWK_SCAN_TYPE, - "engagement": engagement, - } - importer = DefaultImporter(**import_options) - _, _, len_new_findings2, len_closed_findings2, _, _, _ = importer.process_scan(scan) + with self.subTest(step="second_import", metric="queries"): + with self.assertNumQueries(expected_num_queries2): + with self.subTest(step="second_import", metric="async_tasks"): + with self._assertNumAsyncTask(expected_num_async_tasks2): + import_options = { + "user": lead, + "lead": lead, + "scan_date": None, + "environment": environment, + "minimum_severity": "Info", + "active": True, + "verified": True, + "scan_type": STACK_HAWK_SCAN_TYPE, + "engagement": engagement, + } + importer = DefaultImporter(**import_options) + _, _, len_new_findings2, len_closed_findings2, _, _, _ = importer.process_scan(scan) # Log the results for analysis logger.debug(f"First import: {len_new_findings1} new findings, {len_closed_findings1} closed findings") @@ -364,8 +454,9 @@ def test_deduplication_performance_pghistory_no_async(self): testuser.usercontactinfo.save() self._deduplication_performance( - expected_num_queries1=281, + expected_num_queries1=282, expected_num_async_tasks1=7, - expected_num_queries2=245, + expected_num_queries2=246, expected_num_async_tasks2=7, + ) From 76bacbcfc17de754fd49758583089fa49555eaec Mon Sep 17 00:00:00 2001 From: Bryan Leong Date: Sat, 20 Dec 2025 01:00:11 +0800 Subject: [PATCH 063/126] docs: add opening backticks for usage segment (#13924) Signed-off-by: Leong Bryan --- .../finding_deduplication/deduplication_tuning_os.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/content/en/working_with_findings/finding_deduplication/deduplication_tuning_os.md b/docs/content/en/working_with_findings/finding_deduplication/deduplication_tuning_os.md index 2acf22e0e08..d46f9626567 100644 --- a/docs/content/en/working_with_findings/finding_deduplication/deduplication_tuning_os.md +++ b/docs/content/en/working_with_findings/finding_deduplication/deduplication_tuning_os.md @@ -112,7 +112,7 @@ docker compose exec uwsgi /bin/bash -c "python manage.py dedupe --hash_code_only ``` Help/usage: - +``` options: --parser PARSER List of parsers for which hash_code needs recomputing (defaults to all parsers) From 6ab9039cf76954d86855cdd975a80f9008ecf94a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 19 Dec 2025 18:01:51 -0600 Subject: [PATCH 064/126] chore(deps): update postgres:18.1-alpine docker digest from 18.1 to 18.1-alpine (docker-compose.yml) (#13932) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index b364bb823b4..d3022d2c5b4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -120,7 +120,7 @@ services: source: ./docker/extra_settings target: /app/docker/extra_settings postgres: - image: postgres:18.1-alpine@sha256:b5f0dfb46e028c156ff4e3dd2908fdf3474e6bd7837902d0e0151e4e30ad711f + image: postgres:18.1-alpine@sha256:b40d931bd0e7ce6eecc59a5a6ac3b3c04a01e559750e73e7086b6dbd7f8bf545 environment: POSTGRES_DB: ${DD_DATABASE_NAME:-defectdojo} POSTGRES_USER: ${DD_DATABASE_USER:-defectdojo} From 65f4e7ec2d5032c76118304d39a798f3fd1fd32b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 19 Dec 2025 18:03:00 -0600 Subject: [PATCH 065/126] chore(deps): update docker/setup-buildx-action action from v3.11.1 to v3.12.0 (.github/workflows/release-x-manual-tag-as-latest.yml) (#13934) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/build-docker-images-for-testing.yml | 2 +- .github/workflows/release-x-manual-docker-containers.yml | 2 +- .github/workflows/release-x-manual-merge-container-digests.yml | 2 +- .github/workflows/release-x-manual-tag-as-latest.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-docker-images-for-testing.yml b/.github/workflows/build-docker-images-for-testing.yml index 36fb5a2e0c5..4d1f1147f84 100644 --- a/.github/workflows/build-docker-images-for-testing.yml +++ b/.github/workflows/build-docker-images-for-testing.yml @@ -49,7 +49,7 @@ jobs: run: echo "IMAGE_REPOSITORY=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV - name: Set up Docker Buildx - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Build id: docker_build diff --git a/.github/workflows/release-x-manual-docker-containers.yml b/.github/workflows/release-x-manual-docker-containers.yml index 67390b02047..13466030c9e 100644 --- a/.github/workflows/release-x-manual-docker-containers.yml +++ b/.github/workflows/release-x-manual-docker-containers.yml @@ -64,7 +64,7 @@ jobs: - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 # 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 diff --git a/.github/workflows/release-x-manual-merge-container-digests.yml b/.github/workflows/release-x-manual-merge-container-digests.yml index f0ac06dc114..27cafd21731 100644 --- a/.github/workflows/release-x-manual-merge-container-digests.yml +++ b/.github/workflows/release-x-manual-merge-container-digests.yml @@ -54,7 +54,7 @@ jobs: password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 # the alpine and debian images are tagged with the os name - name: Create OS specific manifest list and push diff --git a/.github/workflows/release-x-manual-tag-as-latest.yml b/.github/workflows/release-x-manual-tag-as-latest.yml index 0a3d447edd1..2424dc436a2 100644 --- a/.github/workflows/release-x-manual-tag-as-latest.yml +++ b/.github/workflows/release-x-manual-tag-as-latest.yml @@ -43,7 +43,7 @@ jobs: password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Tag with latest tags run: | From 023dc6c57ca1e230f5b945ae7c1df9f1641e8460 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 19 Dec 2025 18:04:34 -0600 Subject: [PATCH 066/126] chore(deps): update dependency gohugoio/hugo from v0.152.2 to v0.153.0 (.github/workflows/validate_docs_build.yml) (#13937) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .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 cee823706e2..7616cf57cc5 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -18,7 +18,7 @@ jobs: - name: Setup Hugo uses: peaceiris/actions-hugo@75d2e84710de30f6ff7268e08f310b60ef14033f # v3.0.0 with: - hugo-version: '0.152.2' # renovate: datasource=github-releases depName=gohugoio/hugo + hugo-version: '0.153.0' # renovate: datasource=github-releases depName=gohugoio/hugo extended: true - name: Setup Node diff --git a/.github/workflows/validate_docs_build.yml b/.github/workflows/validate_docs_build.yml index e4096677a63..34e5ecd3f1b 100644 --- a/.github/workflows/validate_docs_build.yml +++ b/.github/workflows/validate_docs_build.yml @@ -12,7 +12,7 @@ jobs: - name: Setup Hugo uses: peaceiris/actions-hugo@75d2e84710de30f6ff7268e08f310b60ef14033f # v3.0.0 with: - hugo-version: '0.152.2' # renovate: datasource=github-releases depName=gohugoio/hugo + hugo-version: '0.153.0' # renovate: datasource=github-releases depName=gohugoio/hugo extended: true - name: Setup Node From c0a717a96b40c9155a778e9c8a0813f77b4044a4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Dec 2025 18:05:00 -0600 Subject: [PATCH 067/126] chore(deps): bump social-auth-core from 4.8.1 to 4.8.3 (#13936) Bumps [social-auth-core](https://github.com/python-social-auth/social-core) from 4.8.1 to 4.8.3. - [Release notes](https://github.com/python-social-auth/social-core/releases) - [Changelog](https://github.com/python-social-auth/social-core/blob/master/CHANGELOG.md) - [Commits](https://github.com/python-social-auth/social-core/compare/4.8.1...4.8.3) --- updated-dependencies: - dependency-name: social-auth-core dependency-version: 4.8.3 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 37989f0eba0..ab9f42c8aac 100644 --- a/requirements.txt +++ b/requirements.txt @@ -41,7 +41,7 @@ vobject==0.9.9 whitenoise==5.2.0 titlecase==2.4.1 social-auth-app-django==5.6.0 -social-auth-core==4.8.1 +social-auth-core==4.8.3 gitpython==3.1.45 python-gitlab==7.0.0 cpe==1.3.1 From fe94d22154cd6bf38b185e7f6b5bd1274d9f4cb1 Mon Sep 17 00:00:00 2001 From: manuelsommer <47991713+manuel-sommer@users.noreply.github.com> Date: Mon, 22 Dec 2025 17:02:41 +0100 Subject: [PATCH 068/126] :arrow_up: Bump ruff from 0.14.9 to 0.14.10 (#13938) --- dojo/tools/checkmarx/parser.py | 2 +- dojo/tools/snyk/parser.py | 2 +- dojo/tools/wiz/parser.py | 2 +- requirements-lint.txt | 2 +- unittests/tools/test_api_bugcrowd_parser.py | 3 +-- 5 files changed, 5 insertions(+), 6 deletions(-) diff --git a/dojo/tools/checkmarx/parser.py b/dojo/tools/checkmarx/parser.py index 71ca3e8074c..3cdf42f5560 100644 --- a/dojo/tools/checkmarx/parser.py +++ b/dojo/tools/checkmarx/parser.py @@ -40,7 +40,7 @@ def get_fields(self) -> list[str]: - component_version: Set to value within the "name" returned from the Checkmarx Scanner. """ return [ - "title" + "title", "cwe", "active", "verified", diff --git a/dojo/tools/snyk/parser.py b/dojo/tools/snyk/parser.py index a3cdd43cb1e..f67092153ba 100644 --- a/dojo/tools/snyk/parser.py +++ b/dojo/tools/snyk/parser.py @@ -40,7 +40,7 @@ def get_fields(self) -> list[str]: "severity_justification", "description", "mitigation", - "component_name" + "component_name", "component_version", "false_p", "duplicate", diff --git a/dojo/tools/wiz/parser.py b/dojo/tools/wiz/parser.py index 41a22c3c616..909471311c3 100644 --- a/dojo/tools/wiz/parser.py +++ b/dojo/tools/wiz/parser.py @@ -54,7 +54,7 @@ def parse_findings(self, test: Test, reader: csv.DictReader) -> list[Finding]: "Container Service", "Provider ID", "Risks", - "Threats" + "Threats", "Created At", "Status Changed At", "Updated At", diff --git a/requirements-lint.txt b/requirements-lint.txt index 9d54705a3c1..76dbc2656d3 100644 --- a/requirements-lint.txt +++ b/requirements-lint.txt @@ -1 +1 @@ -ruff==0.14.9 \ No newline at end of file +ruff==0.14.10 \ No newline at end of file diff --git a/unittests/tools/test_api_bugcrowd_parser.py b/unittests/tools/test_api_bugcrowd_parser.py index 4b2c5436a97..ff15fa71820 100644 --- a/unittests/tools/test_api_bugcrowd_parser.py +++ b/unittests/tools/test_api_bugcrowd_parser.py @@ -152,5 +152,4 @@ def test_parse_file_with_broken_bug_url(self): parser = ApiBugcrowdParser() with self.assertLogs("dojo.tools.api_bugcrowd.parser", level="ERROR") as cm: parser.get_findings(testfile, Test()) - self.assertEqual(cm.output, ["ERROR:dojo.tools.api_bugcrowd.parser:" - "Error parsing bugcrowd bug_url : curl https://example.com/"]) + self.assertEqual(cm.output, ["ERROR:dojo.tools.api_bugcrowd.parser:Error parsing bugcrowd bug_url : curl https://example.com/"]) From 5e4aaad0663013f8c4dd490b61a39d8d80458d28 Mon Sep 17 00:00:00 2001 From: DefectDojo release bot Date: Mon, 22 Dec 2025 17:29:20 +0000 Subject: [PATCH 069/126] Update versions in application files --- components/package.json | 2 +- dojo/__init__.py | 2 +- helm/defectdojo/Chart.yaml | 12 ++++-------- helm/defectdojo/README.md | 2 +- 4 files changed, 7 insertions(+), 11 deletions(-) diff --git a/components/package.json b/components/package.json index 385f6754f56..d9500b421b6 100644 --- a/components/package.json +++ b/components/package.json @@ -1,6 +1,6 @@ { "name": "defectdojo", - "version": "2.53.4", + "version": "2.54.0-dev", "license" : "BSD-3-Clause", "private": true, "dependencies": { diff --git a/dojo/__init__.py b/dojo/__init__.py index 894a4f111d5..7337d10b9c1 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.53.4" +__version__ = "2.54.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 515736d9964..119538cc717 100644 --- a/helm/defectdojo/Chart.yaml +++ b/helm/defectdojo/Chart.yaml @@ -1,8 +1,8 @@ apiVersion: v2 -appVersion: "2.53.4" +appVersion: "2.54.0-dev" description: A Helm chart for Kubernetes to install DefectDojo name: defectdojo -version: 1.9.4 +version: 1.9.5-dev icon: https://defectdojo.com/hubfs/DefectDojo_favicon.png maintainers: - name: madchap @@ -33,9 +33,5 @@ dependencies: # - kind: security # description: Critical bug annotations: - artifacthub.io/prerelease: "false" - artifacthub.io/changes: | - - kind: fixed - description: Drop 'replicas' when HPA is in place - - kind: changed - description: Bump DefectDojo to 2.53.4 + artifacthub.io/prerelease: "true" + artifacthub.io/changes: "" diff --git a/helm/defectdojo/README.md b/helm/defectdojo/README.md index 48c668d9eed..e749100dd98 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.4](https://img.shields.io/badge/Version-1.9.4-informational?style=flat-square) ![AppVersion: 2.53.4](https://img.shields.io/badge/AppVersion-2.53.4-informational?style=flat-square) +![Version: 1.9.5-dev](https://img.shields.io/badge/Version-1.9.5--dev-informational?style=flat-square) ![AppVersion: 2.54.0-dev](https://img.shields.io/badge/AppVersion-2.54.0--dev-informational?style=flat-square) A Helm chart for Kubernetes to install DefectDojo From 683ce9dd87880d813a68360c56abb0d684715da3 Mon Sep 17 00:00:00 2001 From: Ross Esposito Date: Mon, 22 Dec 2025 11:41:10 -0600 Subject: [PATCH 070/126] Update Helm chart docs --- helm/defectdojo/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helm/defectdojo/README.md b/helm/defectdojo/README.md index 5751b3ce9b8..5746e30a9ad 100644 --- a/helm/defectdojo/README.md +++ b/helm/defectdojo/README.md @@ -525,7 +525,7 @@ A Helm chart for Kubernetes to install DefectDojo | Repository | Name | Version | |------------|------|---------| -| oci://registry-1.docker.io/cloudpirates | valkey | 0.10.2 | +| oci://registry-1.docker.io/cloudpirates | valkey | 0.13.0 | | oci://us-docker.pkg.dev/os-public-container-registry/defectdojo | postgresql | 16.7.27 | ## Values From 8d02cb23d031bea010fd72f572690513b7570cd1 Mon Sep 17 00:00:00 2001 From: Ross Esposito Date: Mon, 22 Dec 2025 12:09:50 -0600 Subject: [PATCH 071/126] Increasing mem for hugo --- .github/workflows/validate_docs_build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/validate_docs_build.yml b/.github/workflows/validate_docs_build.yml index 34e5ecd3f1b..525bbd3ed3b 100644 --- a/.github/workflows/validate_docs_build.yml +++ b/.github/workflows/validate_docs_build.yml @@ -38,4 +38,5 @@ jobs: env: HUGO_ENVIRONMENT: production HUGO_ENV: production + HUGO_MEMORYLIMIT: 6 run: cd docs && npm ci && hugo --minify --gc --config config/production/hugo.toml From f3ce35685e71e49be0984c0bc7a1a2b76884ea6d Mon Sep 17 00:00:00 2001 From: Ross Esposito Date: Mon, 22 Dec 2025 12:26:58 -0600 Subject: [PATCH 072/126] Bumping hugo version due to memory issue --- .github/workflows/validate_docs_build.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/validate_docs_build.yml b/.github/workflows/validate_docs_build.yml index 525bbd3ed3b..5ccfefbed3a 100644 --- a/.github/workflows/validate_docs_build.yml +++ b/.github/workflows/validate_docs_build.yml @@ -12,7 +12,7 @@ jobs: - name: Setup Hugo uses: peaceiris/actions-hugo@75d2e84710de30f6ff7268e08f310b60ef14033f # v3.0.0 with: - hugo-version: '0.153.0' # renovate: datasource=github-releases depName=gohugoio/hugo + hugo-version: '0.153.1' # renovate: datasource=github-releases depName=gohugoio/hugo extended: true - name: Setup Node @@ -38,5 +38,4 @@ jobs: env: HUGO_ENVIRONMENT: production HUGO_ENV: production - HUGO_MEMORYLIMIT: 6 run: cd docs && npm ci && hugo --minify --gc --config config/production/hugo.toml From 2ffcf978a9885eb5e3ad25f514c941db96592a16 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 24 Dec 2025 21:05:12 -0600 Subject: [PATCH 073/126] chore(deps): update dependency kubernetes/kubernetes from v1.34.3 to v1.35.0 (.github/workflows/k8s-tests.yml) (#13940) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/k8s-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/k8s-tests.yml b/.github/workflows/k8s-tests.yml index 478fba8cbe0..ad67880be21 100644 --- a/.github/workflows/k8s-tests.yml +++ b/.github/workflows/k8s-tests.yml @@ -16,7 +16,7 @@ jobs: # databases, broker and k8s are independent, so we don't need to test each combination # lastest k8s version (https://kubernetes.io/releases/) and the oldest officially supported version # are tested (https://kubernetes.io/releases/) - - k8s: 'v1.34.3' # renovate: datasource=github-releases depName=kubernetes/kubernetes versioning=loose + - k8s: 'v1.35.0' # renovate: datasource=github-releases depName=kubernetes/kubernetes versioning=loose os: debian - k8s: '1.32.11' # renovate: datasource=custom.endoflife-oldest-maintained depName=kubernetes os: debian From 67817c161ca4ae8e2c25011d962b7a9a5c0d3080 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 24 Dec 2025 21:06:15 -0600 Subject: [PATCH 074/126] chore(deps): update dependency gohugoio/hugo to v0.153.2 (.github/workflows/validate_docs_build.yml) (#13944) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .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 7616cf57cc5..2a398aeb441 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -18,7 +18,7 @@ jobs: - name: Setup Hugo uses: peaceiris/actions-hugo@75d2e84710de30f6ff7268e08f310b60ef14033f # v3.0.0 with: - hugo-version: '0.153.0' # renovate: datasource=github-releases depName=gohugoio/hugo + hugo-version: '0.153.2' # renovate: datasource=github-releases depName=gohugoio/hugo extended: true - name: Setup Node diff --git a/.github/workflows/validate_docs_build.yml b/.github/workflows/validate_docs_build.yml index 5ccfefbed3a..9e44661be77 100644 --- a/.github/workflows/validate_docs_build.yml +++ b/.github/workflows/validate_docs_build.yml @@ -12,7 +12,7 @@ jobs: - name: Setup Hugo uses: peaceiris/actions-hugo@75d2e84710de30f6ff7268e08f310b60ef14033f # v3.0.0 with: - hugo-version: '0.153.1' # renovate: datasource=github-releases depName=gohugoio/hugo + hugo-version: '0.153.2' # renovate: datasource=github-releases depName=gohugoio/hugo extended: true - name: Setup Node From 24e74bd3a35fd3f1a06721982ef90f0d6be9d5ab Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 24 Dec 2025 21:11:54 -0600 Subject: [PATCH 075/126] chore(deps): update dependency renovatebot/renovate from 42.52.8 to v42.66.4 (.github/workflows/renovate.yaml) (#13947) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/renovate.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/renovate.yaml b/.github/workflows/renovate.yaml index c86464c03e7..9936c8aa4cb 100644 --- a/.github/workflows/renovate.yaml +++ b/.github/workflows/renovate.yaml @@ -21,4 +21,4 @@ jobs: uses: suzuki-shunsuke/github-action-renovate-config-validator@c22827f47f4f4a5364bdba19e1fe36907ef1318e # v1.1.1 with: strict: "true" - validator_version: 42.52.8 # renovate: datasource=github-releases depName=renovatebot/renovate + validator_version: 42.66.4 # renovate: datasource=github-releases depName=renovatebot/renovate From adef6b00d9c04e86ad621d52edda82353c9ed8ca Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 24 Dec 2025 21:12:17 -0600 Subject: [PATCH 076/126] chore(deps): bump openapitools/openapi-generator-cli (#13948) Bumps openapitools/openapi-generator-cli from v7.17.0 to v7.18.0. --- updated-dependencies: - dependency-name: openapitools/openapi-generator-cli dependency-version: v7.18.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Dockerfile.integration-tests-debian | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile.integration-tests-debian b/Dockerfile.integration-tests-debian index 0b7c1d75b1c..53da53ea736 100644 --- a/Dockerfile.integration-tests-debian +++ b/Dockerfile.integration-tests-debian @@ -1,7 +1,7 @@ # code: language=Dockerfile -FROM openapitools/openapi-generator-cli:v7.17.0@sha256:868b97eb4e5080d2cdfd5b3eeaa4d52e4bbb7c56f14e234b08b0b0bc4f38a78f AS openapitools +FROM openapitools/openapi-generator-cli:v7.18.0@sha256:be5c0a17c978ed4c39985312af3129882407581e07f2e3167cf777c908ffd52b AS openapitools # currently only supports x64, no arm yet due to chrome and selenium dependencies FROM python:3.13.7-slim-trixie@sha256:5f55cdf0c5d9dc1a415637a5ccc4a9e18663ad203673173b8cda8f8dcacef689 AS build WORKDIR /app From 79a58ec9b6a997bbc9493cd0f00729dd8adff98a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 24 Dec 2025 21:18:14 -0600 Subject: [PATCH 077/126] chore(deps): bump humanize from 4.14.0 to 4.15.0 (#13949) Bumps [humanize](https://github.com/python-humanize/humanize) from 4.14.0 to 4.15.0. - [Release notes](https://github.com/python-humanize/humanize/releases) - [Commits](https://github.com/python-humanize/humanize/compare/4.14.0...4.15.0) --- updated-dependencies: - dependency-name: humanize dependency-version: 4.15.0 dependency-type: direct:production update-type: version-update:semver-minor ... 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 ab9f42c8aac..1ad90d4e558 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,7 @@ Django==5.2.9 django-single-session==0.2.0 djangorestframework==3.16.1 html2text==2025.4.15 -humanize==4.14.0 +humanize==4.15.0 jira==3.10.5 PyGithub==2.8.1 lxml==6.0.2 From 52656b62127db134bc3bc35d31ed659d6fc28069 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 24 Dec 2025 21:28:48 -0600 Subject: [PATCH 078/126] chore(deps): update valkey/valkey:7.2.11-alpine docker digest from 7.2.11 to v (docker-compose.yml) (#13966) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index d3022d2c5b4..fb566f29611 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -128,7 +128,7 @@ services: volumes: - defectdojo_postgres:/var/lib/postgresql/data valkey: - image: valkey/valkey:7.2.11-alpine@sha256:36745d7c91d75dde02fa239ebe333fa1c7637249ed76f7da1c5ea838375974ff + image: valkey/valkey:7.2.11-alpine@sha256:9e483e0fe4c98b631b166b41d530c7ff1b8009a44f261bff28e9d1e2e27db58d volumes: # we keep using the redis volume as renaming is not possible and copying data over # would require steps during downtime or complex commands in the intializer From aace359b48ebffe84ccb5d76e1a791b50c60efcf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 24 Dec 2025 21:33:59 -0600 Subject: [PATCH 079/126] chore(deps): bump datatables.net from 2.3.5 to 2.3.6 in /components (#13976) Bumps [datatables.net](https://github.com/DataTables/Dist-DataTables) from 2.3.5 to 2.3.6. - [Release notes](https://github.com/DataTables/Dist-DataTables/releases) - [Commits](https://github.com/DataTables/Dist-DataTables/compare/2.3.5...2.3.6) --- updated-dependencies: - dependency-name: datatables.net dependency-version: 2.3.6 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> --- components/package.json | 2 +- components/yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/components/package.json b/components/package.json index d9500b421b6..58d5dfaef4a 100644 --- a/components/package.json +++ b/components/package.json @@ -12,7 +12,7 @@ "chosen-bootstrap": "https://github.com/dbtek/chosen-bootstrap", "chosen-js": "^1.8.7", "clipboard": "^2.0.11", - "datatables.net": "^2.3.5", + "datatables.net": "^2.3.6", "datatables.net-buttons-bs": "^3.2.5", "datatables.net-colreorder": "^2.1.2", "drmonty-datatables-plugins": "^1.0.0", diff --git a/components/yarn.lock b/components/yarn.lock index 6c1c95ef183..96796efd8ca 100644 --- a/components/yarn.lock +++ b/components/yarn.lock @@ -219,10 +219,10 @@ datatables.net@2.3.2: dependencies: jquery ">=1.7" -datatables.net@^2, datatables.net@^2.3.5: - version "2.3.5" - resolved "https://registry.yarnpkg.com/datatables.net/-/datatables.net-2.3.5.tgz#a35cc1209edb7525ea68ebc3e7d3af6e3f30a758" - integrity sha512-Qrwc+vuw8GHo42u1usWTuriNAMW0VvLPSW3j8g3GxvatiD8wS/ZGW32VAYLLfmF4Hz0C/fo2KB3xZBfcpqqVTQ== +datatables.net@^2, datatables.net@^2.3.6: + version "2.3.6" + resolved "https://registry.yarnpkg.com/datatables.net/-/datatables.net-2.3.6.tgz#a11be57a2b50d7231cae2980a8ff1df3c18b7b17" + integrity sha512-xQ/dCxrjfxM0XY70wSIzakkTZ6ghERwlLmAPyCnu8Sk5cyt9YvOVyOsFNOa/BZ/lM63Q3i2YSSvp/o7GXZGsbg== dependencies: jquery ">=1.7" From bfad5208b122eeb4a4fdc799c052c0a864444a67 Mon Sep 17 00:00:00 2001 From: Jino Tesauro <53376807+Jino-T@users.noreply.github.com> Date: Fri, 26 Dec 2025 17:40:25 -0600 Subject: [PATCH 080/126] Prowler Scan Parser (#13831) * Draft of prowler parser * prowler parser lint changes * Ruff fixes and formatting * D203 fix * D300 fix * added unittests for all cloud types in both file types * added docs for prowler parser * added docs for prowler parser * security and bug resistance for prowler parser * Update prowler unittests --- .../supported_tools/parsers/file/prowler.md | 14 + dojo/fixtures/test_type.json | 7 + dojo/tools/prowler/__init__.py | 0 dojo/tools/prowler/parser.py | 24 + dojo/tools/prowler/parser_csv.py | 122 +++ dojo/tools/prowler/parser_json.py | 137 +++ .../scans/prowler/example_output_aws.csv | 5 + .../prowler/example_output_aws.ocsf.json | 369 +++++++ .../scans/prowler/example_output_azure.csv | 5 + .../prowler/example_output_azure.ocsf.json | 342 +++++++ .../scans/prowler/example_output_gcp.csv | 5 + .../prowler/example_output_gcp.ocsf.json | 373 +++++++ .../prowler/example_output_kubernetes.csv | 5 + .../example_output_kubernetes.ocsf.json | 538 ++++++++++ unittests/scans/prowler/prowler_zero_vul.csv | 1 + unittests/scans/prowler/prowler_zero_vul.json | 3 + unittests/tools/test_prowler_parser.py | 932 ++++++++++++++++++ 17 files changed, 2882 insertions(+) create mode 100644 docs/content/supported_tools/parsers/file/prowler.md create mode 100644 dojo/tools/prowler/__init__.py create mode 100644 dojo/tools/prowler/parser.py create mode 100644 dojo/tools/prowler/parser_csv.py create mode 100644 dojo/tools/prowler/parser_json.py create mode 100644 unittests/scans/prowler/example_output_aws.csv create mode 100644 unittests/scans/prowler/example_output_aws.ocsf.json create mode 100644 unittests/scans/prowler/example_output_azure.csv create mode 100644 unittests/scans/prowler/example_output_azure.ocsf.json create mode 100644 unittests/scans/prowler/example_output_gcp.csv create mode 100644 unittests/scans/prowler/example_output_gcp.ocsf.json create mode 100644 unittests/scans/prowler/example_output_kubernetes.csv create mode 100644 unittests/scans/prowler/example_output_kubernetes.ocsf.json create mode 100644 unittests/scans/prowler/prowler_zero_vul.csv create mode 100644 unittests/scans/prowler/prowler_zero_vul.json create mode 100644 unittests/tools/test_prowler_parser.py diff --git a/docs/content/supported_tools/parsers/file/prowler.md b/docs/content/supported_tools/parsers/file/prowler.md new file mode 100644 index 00000000000..5bf1dff2202 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/prowler.md @@ -0,0 +1,14 @@ +--- +title: "Prowler Scan" +toc_hide: true +--- +This parser imports Prowler Scan files in JSON and CSV format. The AWS, GCP, Azure, and Kubernetes could types are supported by this parser. + +### Sample Scan Data +Sample Prowler scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/prowler). + +### Default Deduplication Hashcode Fields +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- description diff --git a/dojo/fixtures/test_type.json b/dojo/fixtures/test_type.json index d1a9fa60726..898560e9d98 100644 --- a/dojo/fixtures/test_type.json +++ b/dojo/fixtures/test_type.json @@ -47,5 +47,12 @@ }, "model": "dojo.test_type", "pk": 7 + }, + { + "fields": { + "name": "Prowler" + }, + "model": "dojo.test_type", + "pk": 8 } ] \ No newline at end of file diff --git a/dojo/tools/prowler/__init__.py b/dojo/tools/prowler/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/prowler/parser.py b/dojo/tools/prowler/parser.py new file mode 100644 index 00000000000..f211c3e4c18 --- /dev/null +++ b/dojo/tools/prowler/parser.py @@ -0,0 +1,24 @@ +from dojo.tools.prowler.parser_csv import ProwlerParserCSV +from dojo.tools.prowler.parser_json import ProwlerParserJSON + + +class ProwlerParser: + + """Prowler is an Open Cloud Security that automates security and compliance in cloud environments. This parser is for Prowler JSON files and Prowler CSV files.""" + + def get_scan_types(self): + return ["Prowler Scan"] + + def get_label_for_scan_types(self, scan_type): + return "Prowler Scan" + + def get_description_for_scan_types(self, scan_type): + return "Prowler report file can be imported in JSON format or in CSV format." + + def get_findings(self, filename, test): + name = getattr(filename, "name", str(filename)).lower() + if name.endswith(".csv"): + return ProwlerParserCSV().get_findings(filename, test) + if name.endswith(".json"): + return ProwlerParserJSON().get_findings(filename, test) + return [] diff --git a/dojo/tools/prowler/parser_csv.py b/dojo/tools/prowler/parser_csv.py new file mode 100644 index 00000000000..0a9e286050d --- /dev/null +++ b/dojo/tools/prowler/parser_csv.py @@ -0,0 +1,122 @@ +import csv +import hashlib +import io + +from dojo.models import Finding + + +class ProwlerParserCSV: + + """Parser for Prowler CSV (semicolon-separated).""" + + def get_findings(self, filename, test): + if filename is None: + return [] + + content = filename.read() + if isinstance(content, bytes): + content = content.decode("utf-8") + reader = csv.DictReader(io.StringIO(content), delimiter=";") + csvarray = [] + for row in reader: + csvarray.append(row) + + dupes = {} + for row in csvarray: + # Skip vulnerability if the status is "PASS", continue parsing is status is "FAIL" or "MANUAL" + if row.get("STATUS") == "PASS": + continue + + provider = row.get("PROVIDER", "N/A").upper() + + description = ( + "**Cloud Type** : " + + provider + + "\n\n" + + "**Description** : " + + row.get("DESCRIPTION", "N/A") + + "\n\n" + + "**Service Name** : " + + row.get("SERVICE_NAME", "N/A") + + "\n\n" + + "**Status Detail** : " + + row.get("STATUS_EXTENDED", "N/A") + + "\n\n" + + "**Finding Created Time** : " + + row.get("TIMESTAMP", "N/A") + + "\n\n" + + "**Region** : " + + row.get("REGION", "N/A") + + "\n\n" + + "**Notes** : " + + row.get("NOTES", "N/A") + ) + + related = row.get("RELATED_URL", "") + additional = row.get("ADDITIONAL_URLS", "") + if related: + description += "\n\n**Related URL** : " + related + if additional: + description += "\n\n**Additional URLs** : " + additional + + mitigation = ( + "**Remediation Recommendation** : " + + row.get("REMEDIATION_RECOMMENDATION_TEXT", "N/A") + + "\n\n" + + "**Remediation Recommendation URL** : " + + row.get("REMEDIATION_RECOMMENDATION_URL", "N/A") + + "\n\n" + + "**Remediation Code Native IaC** : " + + row.get("REMEDIATION_CODE_NATIVEIAC", "N/A") + + "\n\n" + + "**Remediation Code Terraform** : " + + row.get("REMEDIATION_CODE_TERRAFORM", "N/A") + + "\n\n" + + "**Remediation Code CLI** : " + + row.get("REMEDIATION_CODE_CLI", "N/A") + + "\n\n" + + "**Other Remediation Info** : " + + row.get("REMEDIATION_CODE_OTHER", "N/A") + ) + + title = row.get("CHECK_TITLE", "") + severity = self.convert_severity(row.get("SEVERITY")) + impact = row.get("RISK", "") + compliance = row.get("COMPLIANCE", "N/A") + + # If compliance is not 'N/A', break info into multiple lines + references = "\n".join(part.strip() for part in compliance.split("|")) if compliance != "N/A" else "N/A" + + finding = Finding( + title=title, + test=test, + description=description, + severity=severity, + references=references, + mitigation=mitigation, + impact=impact, + static_finding=False, + dynamic_finding=True, + ) + + key = hashlib.sha256((finding.title + "|" + finding.description).encode("utf-8")).hexdigest() + if key not in dupes: + dupes[key] = finding + + return list(dupes.values()) + + def convert_severity(self, severity: str) -> str: + """Convert severity value""" + if not severity: + return "Info" + + s = severity.lower() + if s == "critical": + return "Critical" + if s == "high": + return "High" + if s == "medium": + return "Medium" + if s == "low": + return "Low" + return "Info" diff --git a/dojo/tools/prowler/parser_json.py b/dojo/tools/prowler/parser_json.py new file mode 100644 index 00000000000..82a62f9ba43 --- /dev/null +++ b/dojo/tools/prowler/parser_json.py @@ -0,0 +1,137 @@ +import hashlib +import json + +from dojo.models import Finding + + +class ProwlerParserJSON: + + """This parser is for Prowler JSON files.""" + + def get_findings(self, file, test): + data = json.load(file) + + dupes = {} + for node in data: + # Skip vulnerability if the status is "PASS", continue parsing is status is "FAIL" or "MANUAL" + if node.get("status_code") == "PASS": + continue + + cloudtype = self.get_cloud_type(node) + description = ( + "**Cloud Type** : " + + cloudtype + + "\n\n" + + "**Finding Description** : " + + node.get("finding_info", {}).get("desc", "N/A") + + "\n\n" + + "**Product Name** : " + + node.get("metadata", {}).get("product", {}).get("name", "N/A") + + "\n\n" + + "**Status Detail** : " + + node.get("status_detail", "N/A") + + "\n\n" + + "**Finding Created Time** : " + + node.get("finding_info", {}).get("created_time_dt", "N/A") + ) + # Add cloud type sepecific information to description + description = self.add_cloud_type_metadata(node, cloudtype, description) + + title = node.get("message", "") + severity = self.convert_severity(node.get("severity")) + mitigation = ( + "**Remediation Description** : " + + node.get("remediation", {}).get("desc", "N/A") + + "\n\n" + + "**Remediation References** : " + + ", ".join(node.get("remediation", {}).get("references", [])) + ) + impact = node.get("risk_details", "") + compliance = node.get("unmapped", {}).get("compliance", {}) + references = "**Related URL** : " + node.get("unmapped", {}).get("related_url", "") + # Add data presnet in scan to References + for key, values in compliance.items(): + joined = ", ".join(values) + # Ex: CIS-1.10 : 1.2.16 + references += f"\n\n**{key}** : {joined}" + + finding = Finding( + title=title, + test=test, + description=description, + severity=severity, + references=references, + mitigation=mitigation, + impact=impact, + static_finding=False, + dynamic_finding=True, + ) + + # internal de-duplication + dupe_key = hashlib.sha256(str(description + title).encode("utf-8")).hexdigest() + if dupe_key in dupes: + find = dupes[dupe_key] + if finding.description: + find.description += "\n" + finding.description + # find.unsaved_endpoints.extend(finding.unsaved_endpoints) + dupes[dupe_key] = find + else: + dupes[dupe_key] = finding + + return list(dupes.values()) + + def convert_severity(self, severity: str) -> str: + """Convert severity value""" + if not severity: + return "Info" + + s = severity.lower() + if s == "critical": + return "Critical" + if s == "high": + return "High" + if s == "medium": + return "Medium" + if s == "low": + return "Low" + return "Info" + + def get_cloud_type(self, node: dict) -> str: + """Determine the cloud type of a Prowler JSON finding. Returns one of: AWS, Azure, Kubernetes, GCP, or N/A""" + # Check for GCP, AWS, or Azure + account_type = node.get("cloud", {}).get("provider") + if account_type: + account_type.lower() + if account_type == "gcp": + return "GCP" + if account_type == "aws": + return "AWS" + if account_type == "azure": + return "AZURE" + + # Check for Kubernetes + for resource in node.get("resources", []): + namespace = resource.get("data", {}).get("metadata", {}).get("namespace") + if namespace is not None: + return "KUBERNETES" + + # No Cloud Type information was found + return "N/A" + + def add_cloud_type_metadata(self, node: dict, cloudtype: str, description: str) -> str: + # Add metadata for GCP, AWS, and Azure + if cloudtype in {"GCP", "AWS", "AZURE"}: + description += "\n\n" + "**" + cloudtype + " Region** : " + node.get("cloud", {}).get("region", "N/A") + return description + + # Add metadata for Kubernetes + if cloudtype == "KUBERNETES": + for resource in node.get("resources", []): + pod = resource.get("data", {}).get("metadata", {}).get("name") + namespace = resource.get("data", {}).get("metadata", {}).get("namespace") + if pod is not None: + description += "\n\n" + "**Pod Name** : " + pod + if namespace is not None: + description += "\n\n" + "**Namespace** : " + namespace + return description + return description diff --git a/unittests/scans/prowler/example_output_aws.csv b/unittests/scans/prowler/example_output_aws.csv new file mode 100644 index 00000000000..ebd601536c9 --- /dev/null +++ b/unittests/scans/prowler/example_output_aws.csv @@ -0,0 +1,5 @@ +AUTH_METHOD;TIMESTAMP;ACCOUNT_UID;ACCOUNT_NAME;ACCOUNT_EMAIL;ACCOUNT_ORGANIZATION_UID;ACCOUNT_ORGANIZATION_NAME;ACCOUNT_TAGS;FINDING_UID;PROVIDER;CHECK_ID;CHECK_TITLE;CHECK_TYPE;STATUS;STATUS_EXTENDED;MUTED;SERVICE_NAME;SUBSERVICE_NAME;SEVERITY;RESOURCE_TYPE;RESOURCE_UID;RESOURCE_NAME;RESOURCE_DETAILS;RESOURCE_TAGS;PARTITION;REGION;DESCRIPTION;RISK;RELATED_URL;REMEDIATION_RECOMMENDATION_TEXT;REMEDIATION_RECOMMENDATION_URL;REMEDIATION_CODE_NATIVEIAC;REMEDIATION_CODE_TERRAFORM;REMEDIATION_CODE_CLI;REMEDIATION_CODE_OTHER;COMPLIANCE;CATEGORIES;DEPENDS_ON;RELATED_TO;NOTES;PROWLER_VERSION;ADDITIONAL_URLS +;2025-02-14 14:27:03.913874;;;;;;;;aws;accessanalyzer_enabled;Check if IAM Access Analyzer is enabled;IAM;FAIL;IAM Access Analyzer in account is not enabled.;False;accessanalyzer;;low;Other;;;;;aws;;Check if IAM Access Analyzer is enabled;AWS IAM Access Analyzer helps you identify the resources in your organization and accounts, such as Amazon S3 buckets or IAM roles, that are shared with an external entity. This lets you identify unintended access to your resources and data, which is a security risk. IAM Access Analyzer uses a form of mathematical analysis called automated reasoning, which applies logic and mathematical inference to determine all possible access paths allowed by a resource policy.;https://docs.aws.amazon.com/IAM/latest/UserGuide/what-is-access-analyzer.html;Enable IAM Access Analyzer for all accounts, create analyzer and take action over it is recommendations (IAM Access Analyzer is available at no additional cost).;https://docs.aws.amazon.com/IAM/latest/UserGuide/what-is-access-analyzer.html;;;aws accessanalyzer create-analyzer --analyzer-name --type ;;CIS-1.4: 1.20 | CIS-1.5: 1.20 | KISA-ISMS-P-2023: 2.5.6, 2.6.4, 2.8.1, 2.8.2 | CIS-2.0: 1.20 | KISA-ISMS-P-2023-korean: 2.5.6, 2.6.4, 2.8.1, 2.8.2 | AWS-Account-Security-Onboarding: Enabled security services, Create analyzers in each active regions, Verify that events are present in SecurityHub aggregated view | CIS-3.0: 1.20;;;;;;https://docs.aws.amazon.com/IAM/latest/UserGuide/access-analyzer-getting-started.html | https://aws.amazon.com/iam/features/analyze-access/ +;2025-02-14 14:27:03.913874;;;;;;;;aws;account_maintain_current_contact_details;Maintain current contact details.;IAM;MANUAL;Login to the AWS Console. Choose your account name on the top right of the window -> My Account -> Contact Information.;False;account;;medium;Other;;;;;aws;;Maintain current contact details.;Ensure contact email and telephone details for AWS accounts are current and map to more than one individual in your organization. An AWS account supports a number of contact details, and AWS will use these to contact the account owner if activity judged to be in breach of Acceptable Use Policy. If an AWS account is observed to be behaving in a prohibited or suspicious manner, AWS will attempt to contact the account owner by email and phone using the contact details listed. If this is unsuccessful and the account behavior needs urgent mitigation, proactive measures may be taken, including throttling of traffic between the account exhibiting suspicious behavior and the AWS API endpoints and the Internet. This will result in impaired service to and from the account in question.;;Using the Billing and Cost Management console complete contact details.;https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-update-contact.html;;;No command available.;https://docs.prowler.com/checks/aws/iam-policies/iam_18-maintain-contact-details#aws-console;CIS-1.4: 1.1 | CIS-1.5: 1.1 | KISA-ISMS-P-2023: 2.1.3 | CIS-2.0: 1.1 | KISA-ISMS-P-2023-korean: 2.1.3 | AWS-Well-Architected-Framework-Security-Pillar: SEC03-BP03, SEC10-BP01 | AWS-Account-Security-Onboarding: Billing, emergency, security contacts | CIS-3.0: 1.1 | ENS-RD2022: op.ext.7.aws.am.1;;;;;;https://docs.aws.amazon.com/IAM/latest/UserGuide/access-analyzer-getting-started.html | https://aws.amazon.com/iam/features/analyze-access/ +;2025-02-14 14:27:03.913874;;;;;;;;aws;account_maintain_different_contact_details_to_security_billing_and_operations;Maintain different contact details to security, billing and operations.;IAM;FAIL;SECURITY, BILLING and OPERATIONS contacts not found or they are not different between each other and between ROOT contact.;False;account;;medium;Other;;;;;aws;;Maintain different contact details to security, billing and operations.;Ensure contact email and telephone details for AWS accounts are current and map to more than one individual in your organization. An AWS account supports a number of contact details, and AWS will use these to contact the account owner if activity judged to be in breach of Acceptable Use Policy. If an AWS account is observed to be behaving in a prohibited or suspicious manner, AWS will attempt to contact the account owner by email and phone using the contact details listed. If this is unsuccessful and the account behavior needs urgent mitigation, proactive measures may be taken, including throttling of traffic between the account exhibiting suspicious behavior and the AWS API endpoints and the Internet. This will result in impaired service to and from the account in question.;https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-update-contact.html;Using the Billing and Cost Management console complete contact details.;https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-update-contact.html;;;;https://docs.prowler.com/checks/aws/iam-policies/iam_18-maintain-contact-details#aws-console;KISA-ISMS-P-2023: 2.1.3 | KISA-ISMS-P-2023-korean: 2.1.3;;;;;;https://docs.aws.amazon.com/IAM/latest/UserGuide/access-analyzer-getting-started.html | https://aws.amazon.com/iam/features/analyze-access/ +;2025-02-14 14:27:03.913874;;;;;;;;aws;account_security_contact_information_is_registered;Ensure security contact information is registered.;IAM;MANUAL;Login to the AWS Console. Choose your account name on the top right of the window -> My Account -> Alternate Contacts -> Security Section.;False;account;;medium;Other;:root;;;;aws;;Ensure security contact information is registered.;AWS provides customers with the option of specifying the contact information for accounts security team. It is recommended that this information be provided. Specifying security-specific contact information will help ensure that security advisories sent by AWS reach the team in your organization that is best equipped to respond to them.;;Go to the My Account section and complete alternate contacts.;https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-update-contact.html;;;No command available.;https://docs.prowler.com/checks/aws/iam-policies/iam_19#aws-console;CIS-1.4: 1.2 | CIS-1.5: 1.2 | AWS-Foundational-Security-Best-Practices: account, acm | KISA-ISMS-P-2023: 2.1.3, 2.2.1 | CIS-2.0: 1.2 | KISA-ISMS-P-2023-korean: 2.1.3, 2.2.1 | AWS-Well-Architected-Framework-Security-Pillar: SEC03-BP03, SEC10-BP01 | AWS-Account-Security-Onboarding: Billing, emergency, security contacts | CIS-3.0: 1.2 | ENS-RD2022: op.ext.7.aws.am.1;;;;;;https://docs.aws.amazon.com/IAM/latest/UserGuide/access-analyzer-getting-started.html | https://aws.amazon.com/iam/features/analyze-access/ diff --git a/unittests/scans/prowler/example_output_aws.ocsf.json b/unittests/scans/prowler/example_output_aws.ocsf.json new file mode 100644 index 00000000000..0de6818e2fb --- /dev/null +++ b/unittests/scans/prowler/example_output_aws.ocsf.json @@ -0,0 +1,369 @@ +[ + { + "message": "IAM Access Analyzer in account is not enabled.", + "metadata": { + "event_code": "accessanalyzer_enabled", + "product": { + "name": "Prowler", + "uid": "prowler", + "vendor_name": "Prowler", + "version": "" + }, + "profiles": [ + "cloud", + "datetime" + ], + "tenant_uid": "", + "version": "1.4.0" + }, + "severity_id": 2, + "severity": "Low", + "status": "New", + "status_code": "FAIL", + "status_detail": "IAM Access Analyzer in account is not enabled.", + "status_id": 1, + "unmapped": { + "related_url": "https://docs.aws.amazon.com/IAM/latest/UserGuide/what-is-access-analyzer.html", + "categories": [], + "depends_on": [], + "related_to": [], + "additional_urls": [], + "notes": "", + "compliance": { + "CIS-1.4": [ + "1.20" + ], + "CIS-1.5": [ + "1.20" + ], + "KISA-ISMS-P-2023": [ + "2.5.6", + "2.6.4", + "2.8.1", + "2.8.2" + ], + "CIS-2.0": [ + "1.20" + ], + "KISA-ISMS-P-2023-korean": [ + "2.5.6", + "2.6.4", + "2.8.1", + "2.8.2" + ], + "AWS-Account-Security-Onboarding": [ + "Enabled security services", + "Create analyzers in each active regions", + "Verify that events are present in SecurityHub aggregated view" + ], + "CIS-3.0": [ + "1.20" + ] + } + }, + "activity_name": "Create", + "activity_id": 1, + "finding_info": { + "created_time": 1739539623, + "created_time_dt": "2025-02-14T14:27:03.913874", + "desc": "Check if IAM Access Analyzer is enabled", + "product_uid": "prowler", + "title": "Check if IAM Access Analyzer is enabled", + "types": [ + "IAM" + ], + "uid": "" + }, + "resources": [ + { + "cloud_partition": "aws", + "region": "", + "data": { + "details": "", + "metadata": { + "arn": "", + "name": "", + "status": "NOT_AVAILABLE", + "findings": [], + "tags": [], + "type": "", + "region": "" + } + }, + "group": { + "name": "accessanalyzer" + }, + "labels": [], + "name": "", + "type": "Other", + "uid": "" + } + ], + "category_name": "Findings", + "category_uid": 2, + "class_name": "Detection Finding", + "class_uid": 2004, + "cloud": { + "account": { + "name": "", + "type": "AWS Account", + "type_id": 10, + "uid": "", + "labels": [] + }, + "org": { + "name": "", + "uid": "" + }, + "provider": "aws", + "region": "" + }, + "remediation": { + "desc": "Enable IAM Access Analyzer for all accounts, create analyzer and take action over it is recommendations (IAM Access Analyzer is available at no additional cost).", + "references": [ + "aws accessanalyzer create-analyzer --analyzer-name --type ", + "https://docs.aws.amazon.com/IAM/latest/UserGuide/what-is-access-analyzer.html" + ] + }, + "risk_details": "AWS IAM Access Analyzer helps you identify the resources in your organization and accounts, such as Amazon S3 buckets or IAM roles, that are shared with an external entity. This lets you identify unintended access to your resources and data, which is a security risk. IAM Access Analyzer uses a form of mathematical analysis called automated reasoning, which applies logic and mathematical inference to determine all possible access paths allowed by a resource policy.", + "time": 1739539623, + "time_dt": "2025-02-14T14:27:03.913874", + "type_uid": 200401, + "type_name": "Detection Finding: Create" + }, + { + "message": "Login to the AWS Console. Choose your account name on the top right of the window -> My Account -> Contact Information.", + "metadata": { + "event_code": "account_maintain_current_contact_details", + "product": { + "name": "Prowler", + "uid": "prowler", + "vendor_name": "Prowler", + "version": "" + }, + "profiles": [ + "cloud", + "datetime" + ], + "tenant_uid": "", + "version": "1.4.0" + }, + "severity_id": 3, + "severity": "Medium", + "status": "New", + "status_code": "MANUAL", + "status_detail": "Login to the AWS Console. Choose your account name on the top right of the window -> My Account -> Contact Information.", + "status_id": 1, + "unmapped": { + "related_url": "", + "categories": [], + "depends_on": [], + "related_to": [], + "additional_urls": [], + "notes": "", + "compliance": { + "CIS-1.4": [ + "1.1" + ], + "CIS-1.5": [ + "1.1" + ], + "KISA-ISMS-P-2023": [ + "2.1.3" + ], + "CIS-2.0": [ + "1.1" + ], + "KISA-ISMS-P-2023-korean": [ + "2.1.3" + ], + "AWS-Well-Architected-Framework-Security-Pillar": [ + "SEC03-BP03", + "SEC10-BP01" + ], + "AWS-Account-Security-Onboarding": [ + "Billing, emergency, security contacts" + ], + "CIS-3.0": [ + "1.1" + ], + "ENS-RD2022": [ + "op.ext.7.aws.am.1" + ] + } + }, + "activity_name": "Create", + "activity_id": 1, + "finding_info": { + "created_time": 1739539623, + "created_time_dt": "2025-02-14T14:27:03.913874", + "desc": "Maintain current contact details.", + "product_uid": "prowler", + "title": "Maintain current contact details.", + "types": [ + "IAM" + ], + "uid": "" + }, + "resources": [ + { + "cloud_partition": "aws", + "region": "", + "data": { + "details": "", + "metadata": { + "type": "PRIMARY", + "email": null, + "name": "", + "phone_number": "" + } + }, + "group": { + "name": "account" + }, + "labels": [], + "name": "", + "type": "Other", + "uid": "arn:aws:iam:::root" + } + ], + "category_name": "Findings", + "category_uid": 2, + "class_name": "Detection Finding", + "class_uid": 2004, + "cloud": { + "account": { + "name": "", + "type": "AWS Account", + "type_id": 10, + "uid": "", + "labels": [] + }, + "org": { + "name": "", + "uid": "" + }, + "provider": "aws", + "region": "" + }, + "remediation": { + "desc": "Using the Billing and Cost Management console complete contact details.", + "references": [ + "No command available.", + "https://docs.prowler.com/checks/aws/iam-policies/iam_18-maintain-contact-details#aws-console", + "https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-update-contact.html" + ] + }, + "risk_details": "Ensure contact email and telephone details for AWS accounts are current and map to more than one individual in your organization. An AWS account supports a number of contact details, and AWS will use these to contact the account owner if activity judged to be in breach of Acceptable Use Policy. If an AWS account is observed to be behaving in a prohibited or suspicious manner, AWS will attempt to contact the account owner by email and phone using the contact details listed. If this is unsuccessful and the account behavior needs urgent mitigation, proactive measures may be taken, including throttling of traffic between the account exhibiting suspicious behavior and the AWS API endpoints and the Internet. This will result in impaired service to and from the account in question.", + "time": 1739539623, + "time_dt": "2025-02-14T14:27:03.913874", + "type_uid": 200401, + "type_name": "Detection Finding: Create" + }, + { + "message": "SECURITY, BILLING and OPERATIONS contacts not found or they are not different between each other and between ROOT contact.", + "metadata": { + "event_code": "account_maintain_different_contact_details_to_security_billing_and_operations", + "product": { + "name": "Prowler", + "uid": "prowler", + "vendor_name": "Prowler", + "version": "" + }, + "profiles": [ + "cloud", + "datetime" + ], + "tenant_uid": "", + "version": "1.4.0" + }, + "severity_id": 3, + "severity": "Medium", + "status": "New", + "status_code": "FAIL", + "status_detail": "SECURITY, BILLING and OPERATIONS contacts not found or they are not different between each other and between ROOT contact.", + "status_id": 1, + "unmapped": { + "related_url": "https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-update-contact.html", + "categories": [], + "depends_on": [], + "related_to": [], + "additional_urls": [], + "notes": "", + "compliance": { + "KISA-ISMS-P-2023": [ + "2.1.3" + ], + "KISA-ISMS-P-2023-korean": [ + "2.1.3" + ] + } + }, + "activity_name": "Create", + "activity_id": 1, + "finding_info": { + "created_time": 1739539623, + "created_time_dt": "2025-02-14T14:27:03.913874", + "desc": "Maintain different contact details to security, billing and operations.", + "product_uid": "prowler", + "title": "Maintain different contact details to security, billing and operations.", + "types": [ + "IAM" + ], + "uid": "" + }, + "resources": [ + { + "cloud_partition": "aws", + "region": "", + "data": { + "details": "", + "metadata": { + "type": "PRIMARY", + "email": null, + "name": "", + "phone_number": "" + } + }, + "group": { + "name": "account" + }, + "labels": [], + "name": "", + "type": "Other", + "uid": "arn:aws:iam:::root" + } + ], + "category_name": "Findings", + "category_uid": 2, + "class_name": "Detection Finding", + "class_uid": 2004, + "cloud": { + "account": { + "name": "", + "type": "AWS Account", + "type_id": 10, + "uid": "", + "labels": [] + }, + "org": { + "name": "", + "uid": "" + }, + "provider": "aws", + "region": "" + }, + "remediation": { + "desc": "Using the Billing and Cost Management console complete contact details.", + "references": [ + "https://docs.prowler.com/checks/aws/iam-policies/iam_18-maintain-contact-details#aws-console", + "https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-update-contact.html" + ] + }, + "risk_details": "Ensure contact email and telephone details for AWS accounts are current and map to more than one individual in your organization. An AWS account supports a number of contact details, and AWS will use these to contact the account owner if activity judged to be in breach of Acceptable Use Policy. If an AWS account is observed to be behaving in a prohibited or suspicious manner, AWS will attempt to contact the account owner by email and phone using the contact details listed. If this is unsuccessful and the account behavior needs urgent mitigation, proactive measures may be taken, including throttling of traffic between the account exhibiting suspicious behavior and the AWS API endpoints and the Internet. This will result in impaired service to and from the account in question.", + "time": 1739539623, + "time_dt": "2025-02-14T14:27:03.913874", + "type_uid": 200401, + "type_name": "Detection Finding: Create" + } +] diff --git a/unittests/scans/prowler/example_output_azure.csv b/unittests/scans/prowler/example_output_azure.csv new file mode 100644 index 00000000000..624ecfd2f2e --- /dev/null +++ b/unittests/scans/prowler/example_output_azure.csv @@ -0,0 +1,5 @@ +AUTH_METHOD;TIMESTAMP;ACCOUNT_UID;ACCOUNT_NAME;ACCOUNT_EMAIL;ACCOUNT_ORGANIZATION_UID;ACCOUNT_ORGANIZATION_NAME;ACCOUNT_TAGS;FINDING_UID;PROVIDER;CHECK_ID;CHECK_TITLE;CHECK_TYPE;STATUS;STATUS_EXTENDED;MUTED;SERVICE_NAME;SUBSERVICE_NAME;SEVERITY;RESOURCE_TYPE;RESOURCE_UID;RESOURCE_NAME;RESOURCE_DETAILS;RESOURCE_TAGS;PARTITION;REGION;DESCRIPTION;RISK;RELATED_URL;REMEDIATION_RECOMMENDATION_TEXT;REMEDIATION_RECOMMENDATION_URL;REMEDIATION_CODE_NATIVEIAC;REMEDIATION_CODE_TERRAFORM;REMEDIATION_CODE_CLI;REMEDIATION_CODE_OTHER;COMPLIANCE;CATEGORIES;DEPENDS_ON;RELATED_TO;NOTES;PROWLER_VERSION;ADDITIONAL_URLS +;2025-02-14 14:27:30.710664;;;;;ProwlerPro.onmicrosoft.com;;;azure;aks_cluster_rbac_enabled;Ensure AKS RBAC is enabled;;FAIL;RBAC is enabled for cluster '' in subscription ''.;False;aks;;medium;Microsoft.ContainerService/ManagedClusters;/subscriptions//resourcegroups/_group/providers/Microsoft.ContainerService/managedClusters/;;;;;;Azure Kubernetes Service (AKS) can be configured to use Azure Active Directory (AD) for user authentication. In this configuration, you sign in to an AKS cluster using an Azure AD authentication token. You can also configure Kubernetes role-based access control (Kubernetes RBAC) to limit access to cluster resources based a user's identity or group membership.;Kubernetes RBAC and AKS help you secure your cluster access and provide only the minimum required permissions to developers and operators.;https://learn.microsoft.com/en-us/azure/aks/azure-ad-rbac?tabs=portal;;https://learn.microsoft.com/en-us/security/benchmark/azure/security-controls-v2-privileged-access#pa-7-follow-just-enough-administration-least-privilege-principle;;https://docs.prowler.com/checks/azure/azure-kubernetes-policies/bc_azr_kubernetes_2#terraform;;https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/AKS/enable-role-based-access-control-for-kubernetes-service.html#;ENS-RD2022: op.acc.2.az.r1.eid.1;;;;;;https://learn.microsoft.com/azure/aks/azure-ad-rbac | https://learn.microsoft.com/azure/aks/concepts-identity +;2025-02-14 14:27:30.710664;;;;;ProwlerPro.onmicrosoft.com;;;azure;aks_clusters_created_with_private_nodes;Ensure clusters are created with Private Nodes;;FAIL;Cluster '' was created with private nodes in subscription '';False;aks;;high;Microsoft.ContainerService/ManagedClusters;/subscriptions//resourcegroups/_group/providers/Microsoft.ContainerService/managedClusters/;;;;;;Disable public IP addresses for cluster nodes, so that they only have private IP addresses. Private Nodes are nodes with no public IP addresses.;Disabling public IP addresses on cluster nodes restricts access to only internal networks, forcing attackers to obtain local network access before attempting to compromise the underlying Kubernetes hosts.;https://learn.microsoft.com/en-us/azure/aks/private-clusters;;https://learn.microsoft.com/en-us/azure/aks/access-private-cluster;;;;;ENS-RD2022: mp.com.4.r2.az.aks.1 | MITRE-ATTACK: T1190, T1530;;;;;;https://learn.microsoft.com/azure/aks/azure-ad-rbac | https://learn.microsoft.com/azure/aks/concepts-identity +;2025-02-14 14:27:30.710664;;;;;ProwlerPro.onmicrosoft.com;;;azure;aks_clusters_public_access_disabled;Ensure clusters are created with Private Endpoint Enabled and Public Access Disabled;;FAIL;Public access to nodes is enabled for cluster '' in subscription '';False;aks;;high;Microsoft.ContainerService/ManagedClusters;/subscriptions//resourcegroups/_group/providers/Microsoft.ContainerService/managedClusters/;;;;;;Disable access to the Kubernetes API from outside the node network if it is not required.;In a private cluster, the master node has two endpoints, a private and public endpoint. The private endpoint is the internal IP address of the master, behind an internal load balancer in the master's wirtual network. Nodes communicate with the master using the private endpoint. The public endpoint enables the Kubernetes API to be accessed from outside the master's virtual network. Although Kubernetes API requires an authorized token to perform sensitive actions, a vulnerability could potentially expose the Kubernetes publically with unrestricted access. Additionally, an attacker may be able to identify the current cluster and Kubernetes API version and determine whether it is vulnerable to an attack. Unless required, disabling public endpoint will help prevent such threats, and require the attacker to be on the master's virtual network to perform any attack on the Kubernetes API.;https://learn.microsoft.com/en-us/azure/aks/private-clusters?tabs=azure-portal;To use a private endpoint, create a new private endpoint in your virtual network then create a link between your virtual network and a new private DNS zone;https://learn.microsoft.com/en-us/azure/aks/access-private-cluster?tabs=azure-cli;;;az aks update -n -g --disable-public-fqdn;;ENS-RD2022: mp.com.4.az.aks.2 | MITRE-ATTACK: T1190, T1530;;;;;;https://learn.microsoft.com/azure/aks/azure-ad-rbac | https://learn.microsoft.com/azure/aks/concepts-identity +;2025-02-14 14:27:30.710664;;;;;ProwlerPro.onmicrosoft.com;;;azure;aks_network_policy_enabled;Ensure Network Policy is Enabled and set as appropriate;;FAIL;Network policy is enabled for cluster '' in subscription ''.;False;aks;;medium;Microsoft.ContainerService/managedClusters;/subscriptions//resourcegroups/_group/providers/Microsoft.ContainerService/managedClusters/;;;;;;When you run modern, microservices-based applications in Kubernetes, you often want to control which components can communicate with each other. The principle of least privilege should be applied to how traffic can flow between pods in an Azure Kubernetes Service (AKS) cluster. Let's say you likely want to block traffic directly to back-end applications. The Network Policy feature in Kubernetes lets you define rules for ingress and egress traffic between pods in a cluster.;All pods in an AKS cluster can send and receive traffic without limitations, by default. To improve security, you can define rules that control the flow of traffic. Back-end applications are often only exposed to required front-end services, for example. Or, database components are only accessible to the application tiers that connect to them. Network Policy is a Kubernetes specification that defines access policies for communication between Pods. Using Network Policies, you define an ordered set of rules to send and receive traffic and apply them to a collection of pods that match one or more label selectors. These network policy rules are defined as YAML manifests. Network policies can be included as part of a wider manifest that also creates a deployment or service.;https://learn.microsoft.com/en-us/security/benchmark/azure/security-controls-v2-network-security#ns-2-connect-private-networks-together;;https://learn.microsoft.com/en-us/azure/aks/use-network-policies;;https://docs.prowler.com/checks/azure/azure-kubernetes-policies/bc_azr_kubernetes_4#terraform;;;ENS-RD2022: mp.com.4.r2.az.aks.1;;;;Network Policy requires the Network Policy add-on. This add-on is included automatically when a cluster with Network Policy is created, but for an existing cluster, needs to be added prior to enabling Network Policy. Enabling/Disabling Network Policy causes a rolling update of all cluster nodes, similar to performing a cluster upgrade. This operation is long-running and will block other operations on the cluster (including delete) until it has run to completion. If Network Policy is used, a cluster must have at least 2 nodes of type n1-standard-1 or higher. The recommended minimum size cluster to run Network Policy enforcement is 3 n1-standard-1 instances. Enabling Network Policy enforcement consumes additional resources in nodes. Specifically, it increases the memory footprint of the kube-system process by approximately 128MB, and requires approximately 300 millicores of CPU.;;https://learn.microsoft.com/azure/aks/azure-ad-rbac | https://learn.microsoft.com/azure/aks/concepts-identity diff --git a/unittests/scans/prowler/example_output_azure.ocsf.json b/unittests/scans/prowler/example_output_azure.ocsf.json new file mode 100644 index 00000000000..9c916ef7580 --- /dev/null +++ b/unittests/scans/prowler/example_output_azure.ocsf.json @@ -0,0 +1,342 @@ +[ + { + "message": "There are no AppInsight configured in subscription .", + "metadata": { + "event_code": "appinsights_ensure_is_configured", + "product": { + "name": "Prowler", + "uid": "prowler", + "vendor_name": "Prowler", + "version": "5.4.0" + }, + "profiles": [ + "cloud", + "datetime" + ], + "tenant_uid": "", + "version": "1.4.0" + }, + "severity_id": 2, + "severity": "Low", + "status": "New", + "status_code": "FAIL", + "status_detail": "There are no AppInsight configured in subscription .", + "status_id": 1, + "unmapped": { + "related_url": "https://learn.microsoft.com/en-us/azure/azure-monitor/app/app-insights-overview", + "categories": [], + "depends_on": [], + "related_to": [], + "additional_urls": [], + "notes": "Because Application Insights relies on a Log Analytics Workspace, an organization will incur additional expenses when using this service.", + "compliance": { + "CIS-2.1": [ + "5.3.1" + ], + "ENS-RD2022": [ + "mp.s.4.r1.az.nt.2" + ], + "CIS-3.0": [ + "6.3.1" + ], + "CIS-2.0": [ + "5.3.1" + ] + } + }, + "activity_name": "Create", + "activity_id": 1, + "finding_info": { + "created_time": 1739539650, + "created_time_dt": "2025-02-14T14:27:30.710664", + "desc": "Application Insights within Azure act as an Application Performance Monitoring solution providing valuable data into how well an application performs and additional information when performing incident response. The types of log data collected include application metrics, telemetry data, and application trace logging data providing organizations with detailed information about application activity and application transactions. Both data sets help organizations adopt a proactive and retroactive means to handle security and performance related metrics within their modern applications.", + "product_uid": "prowler", + "title": "Ensure Application Insights are Configured.", + "types": [], + "uid": "" + }, + "resources": [ + { + "cloud_partition": "AzureCloud", + "region": "global", + "data": { + "details": "", + "metadata": {} + }, + "group": { + "name": "appinsights" + }, + "labels": [], + "name": "AppInsights", + "type": "Microsoft.Insights/components", + "uid": "AppInsights" + } + ], + "category_name": "Findings", + "category_uid": 2, + "class_name": "Detection Finding", + "class_uid": 2004, + "cloud": { + "account": { + "name": "", + "type": "Azure AD Account", + "type_id": 6, + "uid": "", + "labels": [] + }, + "org": { + "name": "", + "uid": "" + }, + "provider": "azure", + "region": "global" + }, + "remediation": { + "desc": "1. Navigate to Application Insights 2. Under the Basics tab within the PROJECT DETAILS section, select the Subscription 3. Select the Resource group 4. Within the INSTANCE DETAILS, enter a Name 5. Select a Region 6. Next to Resource Mode, select Workspace-based 7. Within the WORKSPACE DETAILS, select the Subscription for the log analytics workspace 8. Select the appropriate Log Analytics Workspace 9. Click Next:Tags > 10. Enter the appropriate Tags as Name, Value pairs. 11. Click Next:Review+Create 12. Click Create.", + "references": [ + "az monitor app-insights component create --app --resource-group --location --kind 'web' --retention-time --workspace -- subscription ", + "https://www.tenable.com/audits/items/CIS_Microsoft_Azure_Foundations_v2.0.0_L2.audit:8a7a608d180042689ad9d3f16aa359f1" + ] + }, + "risk_details": "Configuring Application Insights provides additional data not found elsewhere within Azure as part of a much larger logging and monitoring program within an organization's Information Security practice. The types and contents of these logs will act as both a potential cost saving measure (application performance) and a means to potentially confirm the source of a potential incident (trace logging). Metrics and Telemetry data provide organizations with a proactive approach to cost savings by monitoring an application's performance, while the trace logging data provides necessary details in a reactive incident response scenario by helping organizations identify the potential source of an incident within their application.", + "time": 1739539650, + "time_dt": "2025-02-14T14:27:30.710664", + "type_uid": 200401, + "type_name": "Detection Finding: Create" + }, + { + "message": "There is not another correct email configured for subscription .", + "metadata": { + "event_code": "defender_additional_email_configured_with_a_security_contact", + "product": { + "name": "Prowler", + "uid": "prowler", + "vendor_name": "Prowler", + "version": "5.4.0" + }, + "profiles": [ + "cloud", + "datetime" + ], + "tenant_uid": "", + "version": "1.4.0" + }, + "severity_id": 3, + "severity": "Medium", + "status": "New", + "status_code": "FAIL", + "status_detail": "There is not another correct email configured for subscription .", + "status_id": 1, + "unmapped": { + "related_url": "https://docs.microsoft.com/en-us/azure/security-center/security-center-provide-security-contact-details", + "categories": [], + "depends_on": [], + "related_to": [], + "additional_urls": [], + "notes": "", + "compliance": { + "CIS-2.1": [ + "2.1.18" + ], + "ENS-RD2022": [ + "op.mon.3.r3.az.de.1" + ], + "CIS-3.0": [ + "3.1.13" + ], + "CIS-2.0": [ + "2.1.19" + ] + } + }, + "activity_name": "Create", + "activity_id": 1, + "finding_info": { + "created_time": 1739539650, + "created_time_dt": "2025-02-14T14:27:30.710664", + "desc": "Microsoft Defender for Cloud emails the subscription owners whenever a high-severity alert is triggered for their subscription. You should provide a security contact email address as an additional email address.", + "product_uid": "prowler", + "title": "Ensure 'Additional email addresses' is Configured with a Security Contact Email", + "types": [], + "uid": "" + }, + "resources": [ + { + "cloud_partition": "AzureCloud", + "region": "global", + "data": { + "details": "", + "metadata": { + "resource_id": "", + "name": "", + "emails": "", + "phone": "", + "alert_notifications_minimal_severity": "High", + "alert_notifications_state": "On", + "notified_roles": [ + "Owner" + ], + "notified_roles_state": "On" + } + }, + "group": { + "name": "defender" + }, + "labels": [], + "name": "", + "type": "AzureEmailNotifications", + "uid": "" + } + ], + "category_name": "Findings", + "category_uid": 2, + "class_name": "Detection Finding", + "class_uid": 2004, + "cloud": { + "account": { + "name": "", + "type": "Azure AD Account", + "type_id": 6, + "uid": "", + "labels": [] + }, + "org": { + "name": "", + "uid": "" + }, + "provider": "azure", + "region": "global" + }, + "remediation": { + "desc": "1. From Azure Home select the Portal Menu 2. Select Microsoft Defender for Cloud 3. Click on Environment Settings 4. Click on the appropriate Management Group, Subscription, or Workspace 5. Click on Email notifications 6. Enter a valid security contact email address (or multiple addresses separated by commas) in the Additional email addresses field 7. Click Save", + "references": [ + "https://docs.prowler.com/checks/azure/azure-general-policies/ensure-that-security-contact-emails-is-set#terraform", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/SecurityCenter/security-contact-email.html", + "https://learn.microsoft.com/en-us/rest/api/defenderforcloud/security-contacts/list?view=rest-defenderforcloud-2020-01-01-preview&tabs=HTTP" + ] + }, + "risk_details": "Microsoft Defender for Cloud emails the Subscription Owner to notify them about security alerts. Adding your Security Contact's email address to the 'Additional email addresses' field ensures that your organization's Security Team is included in these alerts. This ensures that the proper people are aware of any potential compromise in order to mitigate the risk in a timely fashion.", + "time": 1739539650, + "time_dt": "2025-02-14T14:27:30.710664", + "type_uid": 200401, + "type_name": "Detection Finding: Create" + }, + { + "message": "Defender plan Defender for App Services from subscription is set to OFF (pricing tier not standard).", + "metadata": { + "event_code": "defender_ensure_defender_for_app_services_is_on", + "product": { + "name": "Prowler", + "uid": "prowler", + "vendor_name": "Prowler", + "version": "5.4.0" + }, + "profiles": [ + "cloud", + "datetime" + ], + "tenant_uid": "", + "version": "1.4.0" + }, + "severity_id": 4, + "severity": "High", + "status": "New", + "status_code": "FAIL", + "status_detail": "Defender plan Defender for App Services from subscription is set to OFF (pricing tier not standard).", + "status_id": 1, + "unmapped": { + "related_url": "", + "categories": [], + "depends_on": [], + "related_to": [], + "additional_urls": [], + "notes": "", + "compliance": { + "CIS-2.1": [ + "2.1.2" + ], + "ENS-RD2022": [ + "mp.s.4.r1.az.nt.3" + ], + "MITRE-ATTACK": [ + "T1190", + "T1059", + "T1204", + "T1552", + "T1486", + "T1499", + "T1496", + "T1087" + ], + "CIS-3.0": [ + "3.1.6.1" + ] + } + }, + "activity_name": "Create", + "activity_id": 1, + "finding_info": { + "created_time": 1739539650, + "created_time_dt": "2025-02-14T14:27:30.710664", + "desc": "Ensure That Microsoft Defender for App Services Is Set To 'On' ", + "product_uid": "prowler", + "title": "Ensure That Microsoft Defender for App Services Is Set To 'On' ", + "types": [], + "uid": "" + }, + "resources": [ + { + "cloud_partition": "AzureCloud", + "region": "global", + "data": { + "details": "", + "metadata": { + "resource_id": "", + "pricing_tier": "Free", + "free_trial_remaining_time": 2592000.0, + "extensions": {} + } + }, + "group": { + "name": "defender" + }, + "labels": [], + "name": "", + "type": "AzureDefenderPlan", + "uid": "" + } + ], + "category_name": "Findings", + "category_uid": 2, + "class_name": "Detection Finding", + "class_uid": 2004, + "cloud": { + "account": { + "name": "", + "type": "Azure AD Account", + "type_id": 6, + "uid": "", + "labels": [] + }, + "org": { + "name": "", + "uid": "" + }, + "provider": "azure", + "region": "global" + }, + "remediation": { + "desc": "By , Microsoft Defender for Cloud is not enabled for your App Service instances. Enabling the Defender security service for App Service instances allows for advanced security defense using threat detection capabilities provided by Microsoft Security Response Center.", + "references": [ + "https://docs.prowler.com/checks/azure/azure-general-policies/ensure-that-azure-defender-is-set-to-on-for-app-service#terraform", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/SecurityCenter/defender-app-service.html", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/SecurityCenter/defender-app-service.html" + ] + }, + "risk_details": "Turning on Microsoft Defender for App Service enables threat detection for App Service, providing threat intelligence, anomaly detection, and behavior analytics in the Microsoft Defender for Cloud.", + "time": 1739539650, + "time_dt": "2025-02-14T14:27:30.710664", + "type_uid": 200401, + "type_name": "Detection Finding: Create" + } +] diff --git a/unittests/scans/prowler/example_output_gcp.csv b/unittests/scans/prowler/example_output_gcp.csv new file mode 100644 index 00000000000..37e5c00f170 --- /dev/null +++ b/unittests/scans/prowler/example_output_gcp.csv @@ -0,0 +1,5 @@ +AUTH_METHOD;TIMESTAMP;ACCOUNT_UID;ACCOUNT_NAME;ACCOUNT_EMAIL;ACCOUNT_ORGANIZATION_UID;ACCOUNT_ORGANIZATION_NAME;ACCOUNT_TAGS;FINDING_UID;PROVIDER;CHECK_ID;CHECK_TITLE;CHECK_TYPE;STATUS;STATUS_EXTENDED;MUTED;SERVICE_NAME;SUBSERVICE_NAME;SEVERITY;RESOURCE_TYPE;RESOURCE_UID;RESOURCE_NAME;RESOURCE_DETAILS;RESOURCE_TAGS;PARTITION;REGION;DESCRIPTION;RISK;RELATED_URL;REMEDIATION_RECOMMENDATION_TEXT;REMEDIATION_RECOMMENDATION_URL;REMEDIATION_CODE_NATIVEIAC;REMEDIATION_CODE_TERRAFORM;REMEDIATION_CODE_CLI;REMEDIATION_CODE_OTHER;COMPLIANCE;CATEGORIES;DEPENDS_ON;RELATED_TO;NOTES;PROWLER_VERSION;ADDITIONAL_URLS +;2025-02-14 14:27:20.697446;;;;;;;;gcp;apikeys_key_exists;Ensure API Keys Only Exist for Active Services;;PASS;Project does not have active API Keys.;False;apikeys;;medium;API Key;;;;;;;API Keys should only be used for services in cases where other authentication methods are unavailable. Unused keys with their permissions in tact may still exist within a project. Keys are insecure because they can be viewed publicly, such as from within a browser, or they can be accessed on a device where the key resides. It is recommended to use standard authentication flow instead.;Security risks involved in using API-Keys appear below: API keys are simple encrypted strings, API keys do not identify the user or the application making the API request, API keys are typically accessible to clients, making it easy to discover and steal an API key.;;To avoid the security risk in using API keys, it is recommended to use standard authentication flow instead.;https://cloud.google.com/docs/authentication/api-keys;;;gcloud alpha services api-keys delete;;MITRE-ATTACK: T1098 | CIS-2.0: 1.12 | ENS-RD2022: op.acc.2.gcp.rbak.1 | CIS-3.0: 1.12;;;;;;https://cloud.google.com/api-keys/docs/best-practices | https://cloud.google.com/docs/authentication +;2025-02-14 14:27:20.697446;;;;;;;;gcp;artifacts_container_analysis_enabled;Ensure Image Vulnerability Analysis using AR Container Analysis or a third-party provider;Security | Configuration;FAIL;AR Container Analysis is not enabled in project .;False;artifacts;Container Analysis;medium;Service;;;;;;;Scan images stored in Google Container Registry (GCR) for vulnerabilities using AR Container Analysis or a third-party provider. This helps identify and mitigate security risks associated with known vulnerabilities in container images.;Without image vulnerability scanning, container images stored in Artifact Registry may contain known vulnerabilities, increasing the risk of exploitation by malicious actors.;https://cloud.google.com/artifact-analysis/docs;Enable vulnerability scanning for images stored in Artifact Registry using AR Container Analysis or a third-party provider.;https://cloud.google.com/artifact-analysis/docs/container-scanning-overview;;;gcloud services enable containeranalysis.googleapis.com;;MITRE-ATTACK: T1525 | ENS-RD2022: op.exp.4.r4.gcp.log.1, op.mon.3.gcp.scc.1;;;;By default, AR Container Analysis is disabled.;;https://cloud.google.com/api-keys/docs/best-practices | https://cloud.google.com/docs/authentication +;2025-02-14 14:27:20.697446;;;;;;;;gcp;compute_firewall_rdp_access_from_the_internet_allowed;Ensure That RDP Access Is Restricted From the Internet;;FAIL;Firewall does not expose port 3389 (RDP) to the internet.;False;networking;;critical;FirewallRule;;;;;;;GCP `Firewall Rules` are specific to a `VPC Network`. Each rule either `allows` or `denies` traffic when its conditions are met. Its conditions allow users to specify the type of traffic, such as ports and protocols, and the source or destination of the traffic, including IP addresses, subnets, and instances. Firewall rules are defined at the VPC network level and are specific to the network in which they are defined. The rules themselves cannot be shared among networks. Firewall rules only support IPv4 traffic. When specifying a source for an ingress rule or a destination for an egress rule by address, an `IPv4` address or `IPv4 block in CIDR` notation can be used. Generic `(0.0.0.0/0)` incoming traffic from the Internet to a VPC or VM instance using `RDP` on `Port 3389` can be avoided.;Allowing unrestricted Remote Desktop Protocol (RDP) access can increase opportunities for malicious activities such as hacking, Man-In-The-Middle attacks (MITM) and Pass-The-Hash (PTH) attacks.;;Ensure that Google Cloud Virtual Private Cloud (VPC) firewall rules do not allow unrestricted access (i.e. 0.0.0.0/0) on TCP port 3389 in order to restrict Remote Desktop Protocol (RDP) traffic to trusted IP addresses or IP ranges only and reduce the attack surface. TCP port 3389 is used for secure remote GUI login to Windows VM instances by connecting a RDP client application with an RDP server.;https://cloud.google.com/vpc/docs/using-firewalls;;https://docs./checks/gcp/google-cloud-networking-policies/bc_gcp_networking_2#terraform;https://docs./checks/gcp/google-cloud-networking-policies/bc_gcp_networking_2#cli-command;https://www.trendmicro.com/cloudoneconformity/knowledge-base/gcp/CloudVPC/unrestricted-rdp-access.html;MITRE-ATTACK: T1190, T1199, T1048, T1498, T1046 | CIS-2.0: 3.7 | ENS-RD2022: mp.com.1.gcp.fw.1 | CIS-3.0: 3.7;internet-exposed;;;;;https://cloud.google.com/api-keys/docs | https://cloud.google.com/docs/authentication +;2025-02-14 14:27:20.697446;;;;;;;;gcp;compute_firewall_rdp_access_from_the_internet_allowed;Ensure That RDP Access Is Restricted From the Internet;;FAIL;Firewall does not expose port 3389 (RDP) to the internet.;False;networking;;critical;FirewallRule;;;;;;;GCP `Firewall Rules` are specific to a `VPC Network`. Each rule either `allows` or `denies` traffic when its conditions are met. Its conditions allow users to specify the type of traffic, such as ports and protocols, and the source or destination of the traffic, including IP addresses, subnets, and instances. Firewall rules are defined at the VPC network level and are specific to the network in which they are defined. The rules themselves cannot be shared among networks. Firewall rules only support IPv4 traffic. When specifying a source for an ingress rule or a destination for an egress rule by address, an `IPv4` address or `IPv4 block in CIDR` notation can be used. Generic `(0.0.0.0/0)` incoming traffic from the Internet to a VPC or VM instance using `RDP` on `Port 3389` can be avoided.;Allowing unrestricted Remote Desktop Protocol (RDP) access can increase opportunities for malicious activities such as hacking, Man-In-The-Middle attacks (MITM) and Pass-The-Hash (PTH) attacks.;;Ensure that Google Cloud Virtual Private Cloud (VPC) firewall rules do not allow unrestricted access (i.e. 0.0.0.0/0) on TCP port 3389 in order to restrict Remote Desktop Protocol (RDP) traffic to trusted IP addresses or IP ranges only and reduce the attack surface. TCP port 3389 is used for secure remote GUI login to Windows VM instances by connecting a RDP client application with an RDP server.;https://cloud.google.com/vpc/docs/using-firewalls;;https://docs./checks/gcp/google-cloud-networking-policies/bc_gcp_networking_2#terraform;https://docs./checks/gcp/google-cloud-networking-policies/bc_gcp_networking_2#cli-command;https://www.trendmicro.com/cloudoneconformity/knowledge-base/gcp/CloudVPC/unrestricted-rdp-access.html;MITRE-ATTACK: T1190, T1199, T1048, T1498, T1046 | CIS-2.0: 3.7 | ENS-RD2022: mp.com.1.gcp.fw.1 | CIS-3.0: 3.7;internet-exposed;;;;;https://cloud.google.com/api-keys/docs | https://cloud.google.com/docs/authentication diff --git a/unittests/scans/prowler/example_output_gcp.ocsf.json b/unittests/scans/prowler/example_output_gcp.ocsf.json new file mode 100644 index 00000000000..3267abff4b6 --- /dev/null +++ b/unittests/scans/prowler/example_output_gcp.ocsf.json @@ -0,0 +1,373 @@ +[ + { + "message": "Project does not have active API Keys.", + "metadata": { + "event_code": "apikeys_key_exists", + "product": { + "name": "Prowler", + "uid": "prowler", + "vendor_name": "Prowler", + "version": "5.4.0" + }, + "profiles": [ + "cloud", + "datetime" + ], + "tenant_uid": "", + "version": "1.4.0" + }, + "severity_id": 3, + "severity": "Medium", + "status": "New", + "status_code": "FAIL", + "status_detail": "Project does not have active API Keys.", + "status_id": 1, + "unmapped": { + "related_url": "", + "categories": [], + "depends_on": [], + "related_to": [], + "additional_urls": [], + "notes": "", + "compliance": { + "MITRE-ATTACK": [ + "T1098" + ], + "CIS-2.0": [ + "1.12" + ], + "ENS-RD2022": [ + "op.acc.2.gcp.rbak.1" + ], + "CIS-3.0": [ + "1.12" + ] + } + }, + "activity_name": "Create", + "activity_id": 1, + "finding_info": { + "created_time": 1739539640, + "created_time_dt": "2025-02-14T14:27:20.697446", + "desc": "API Keys should only be used for services in cases where other authentication methods are unavailable. Unused keys with their permissions in tact may still exist within a project. Keys are insecure because they can be viewed publicly, such as from within a browser, or they can be accessed on a device where the key resides. It is recommended to use standard authentication flow instead.", + "product_uid": "prowler", + "title": "Ensure API Keys Only Exist for Active Services", + "types": [], + "uid": "" + }, + "resources": [ + { + "region": "global", + "data": { + "details": "", + "metadata": { + "number": "", + "id": "", + "name": "", + "organization": { + "id": "", + "name": "organizations/", + "display_name": "prowler.com" + }, + "labels": { + "tag": "test", + "tag2": "test2", + "generative-language": "enabled" + }, + "lifecycle_state": "ACTIVE" + } + }, + "group": { + "name": "apikeys" + }, + "labels": [], + "name": "", + "type": "API Key", + "uid": "" + } + ], + "category_name": "Findings", + "category_uid": 2, + "class_name": "Detection Finding", + "class_uid": 2004, + "cloud": { + "account": { + "name": "", + "type": "GCP Account", + "type_id": 5, + "uid": "", + "labels": [ + "tag:test" + ] + }, + "org": { + "name": "prowler.com", + "uid": "" + }, + "provider": "gcp", + "region": "global" + }, + "remediation": { + "desc": "To avoid the security risk in using API keys, it is recommended to use standard authentication flow instead.", + "references": [ + "gcloud alpha services api-keys delete", + "https://cloud.google.com/docs/authentication/api-keys" + ] + }, + "risk_details": "Security risks involved in using API-Keys appear below: API keys are simple encrypted strings, API keys do not identify the user or the application making the API request, API keys are typically accessible to clients, making it easy to discover and steal an API key.", + "time": 1739539640, + "time_dt": "2025-02-14T14:27:20.697446", + "type_uid": 200401, + "type_name": "Detection Finding: Create" + }, + { + "message": "AR Container Analysis is not enabled in project .", + "metadata": { + "event_code": "artifacts_container_analysis_enabled", + "product": { + "name": "Prowler", + "uid": "prowler", + "vendor_name": "Prowler", + "version": "5.4.0" + }, + "profiles": [ + "cloud", + "datetime" + ], + "tenant_uid": "", + "version": "1.4.0" + }, + "severity_id": 3, + "severity": "Medium", + "status": "New", + "status_code": "FAIL", + "status_detail": "AR Container Analysis is not enabled in project .", + "status_id": 1, + "unmapped": { + "related_url": "https://cloud.google.com/artifact-analysis/docs", + "categories": [], + "depends_on": [], + "related_to": [], + "additional_urls": [], + "notes": "By default, AR Container Analysis is disabled.", + "compliance": { + "MITRE-ATTACK": [ + "T1525" + ], + "ENS-RD2022": [ + "op.exp.4.r4.gcp.log.1", + "op.mon.3.gcp.scc.1" + ] + } + }, + "activity_name": "Create", + "activity_id": 1, + "finding_info": { + "created_time": 1739539640, + "created_time_dt": "2025-02-14T14:27:20.697446", + "desc": "Scan images stored in Google Container Registry (GCR) for vulnerabilities using AR Container Analysis or a third-party provider. This helps identify and mitigate security risks associated with known vulnerabilities in container images.", + "product_uid": "prowler", + "title": "Ensure Image Vulnerability Analysis using AR Container Analysis or a third-party provider", + "types": [ + "Security", + "Configuration" + ], + "uid": "" + }, + "resources": [ + { + "region": "global", + "data": { + "details": "", + "metadata": { + "number": "538174383574", + "id": "", + "name": "", + "organization": { + "id": "", + "name": "organizations/", + "display_name": "prowler.com" + }, + "labels": { + "tag": "test", + "tag2": "test2", + "generative-language": "enabled" + }, + "lifecycle_state": "ACTIVE" + } + }, + "group": { + "name": "artifacts" + }, + "labels": [], + "name": "AR Container Analysis", + "type": "Service", + "uid": "containeranalysis.googleapis.com" + } + ], + "category_name": "Findings", + "category_uid": 2, + "class_name": "Detection Finding", + "class_uid": 2004, + "cloud": { + "account": { + "name": "", + "type": "GCP Account", + "type_id": 5, + "uid": "", + "labels": [ + "tag:test" + ] + }, + "org": { + "name": "prowler.com", + "uid": "" + }, + "provider": "gcp", + "region": "global" + }, + "remediation": { + "desc": "Enable vulnerability scanning for images stored in Artifact Registry using AR Container Analysis or a third-party provider.", + "references": [ + "gcloud services enable containeranalysis.googleapis.com", + "https://cloud.google.com/artifact-analysis/docs/container-scanning-overview" + ] + }, + "risk_details": "Without image vulnerability scanning, container images stored in Artifact Registry may contain known vulnerabilities, increasing the risk of exploitation by malicious actors.", + "time": 1739539640, + "time_dt": "2025-02-14T14:27:20.697446", + "type_uid": 200401, + "type_name": "Detection Finding: Create" + }, + { + "message": "Firewall does exposes port 3389 (RDP) to the internet.", + "metadata": { + "event_code": "compute_firewall_rdp_access_from_the_internet_allowed", + "product": { + "name": "Prowler", + "uid": "prowler", + "vendor_name": "Prowler", + "version": "5.4.0" + }, + "profiles": [ + "cloud", + "datetime" + ], + "tenant_uid": "", + "version": "1.4.0" + }, + "severity_id": 5, + "severity": "Critical", + "status": "New", + "status_code": "FAIL", + "status_detail": "Firewall does exposes port 3389 (RDP) to the internet.", + "status_id": 1, + "unmapped": { + "related_url": "", + "categories": [ + "internet-exposed" + ], + "depends_on": [], + "related_to": [], + "additional_urls": [], + "notes": "", + "compliance": { + "MITRE-ATTACK": [ + "T1190", + "T1199", + "T1048", + "T1498", + "T1046" + ], + "CIS-2.0": [ + "3.7" + ], + "ENS-RD2022": [ + "mp.com.1.gcp.fw.1" + ], + "CIS-3.0": [ + "3.7" + ] + } + }, + "activity_name": "Create", + "activity_id": 1, + "finding_info": { + "created_time": 1739539640, + "created_time_dt": "2025-02-14T14:27:20.697446", + "desc": "GCP `Firewall Rules` are specific to a `VPC Network`. Each rule either `allows` or `denies` traffic when its conditions are met. Its conditions allow users to specify the type of traffic, such as ports and protocols, and the source or destination of the traffic, including IP addresses, subnets, and instances. Firewall rules are defined at the VPC network level and are specific to the network in which they are defined. The rules themselves cannot be shared among networks. Firewall rules only support IPv4 traffic. When specifying a source for an ingress rule or a destination for an egress rule by address, an `IPv4` address or `IPv4 block in CIDR` notation can be used. Generic `(0.0.0.0/0)` incoming traffic from the Internet to a VPC or VM instance using `RDP` on `Port 3389` can be avoided.", + "product_uid": "prowler", + "title": "Ensure That RDP Access Is Restricted From the Internet", + "types": [], + "uid": "" + }, + "resources": [ + { + "region": "global", + "data": { + "details": "", + "metadata": { + "name": "", + "id": "", + "source_ranges": [ + "" + ], + "direction": "INGRESS", + "allowed_rules": [ + { + "IPProtocol": "tcp", + "ports": [ + "3389" + ] + } + ], + "project_id": "" + } + }, + "group": { + "name": "networking" + }, + "labels": [], + "name": "", + "type": "FirewallRule", + "uid": "" + } + ], + "category_name": "Findings", + "category_uid": 2, + "class_name": "Detection Finding", + "class_uid": 2004, + "cloud": { + "account": { + "name": "", + "type": "GCP Account", + "type_id": 5, + "uid": "", + "labels": [ + "tag:test", + "tag2:test2" + ] + }, + "org": { + "name": "prowler.com", + "uid": "" + }, + "provider": "gcp", + "region": "global" + }, + "remediation": { + "desc": "Ensure that Google Cloud Virtual Private Cloud (VPC) firewall rules do not allow unrestricted access (i.e. 0.0.0.0/0) on TCP port 3389 in order to restrict Remote Desktop Protocol (RDP) traffic to trusted IP addresses or IP ranges only and reduce the attack surface. TCP port 3389 is used for secure remote GUI login to Windows VM instances by connecting a RDP client application with an RDP server.", + "references": [ + "https://docs.prowler.com/checks/gcp/google-cloud-networking-policies/bc_gcp_networking_2#terraform", + "https://docs.prowler.com/checks/gcp/google-cloud-networking-policies/bc_gcp_networking_2#cli-command", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/gcp/CloudVPC/unrestricted-rdp-access.html", + "https://cloud.google.com/vpc/docs/using-firewalls" + ] + }, + "risk_details": "Allowing unrestricted Remote Desktop Protocol (RDP) access can increase opportunities for malicious activities such as hacking, Man-In-The-Middle attacks (MITM) and Pass-The-Hash (PTH) attacks.", + "time": 1739539640, + "time_dt": "2025-02-14T14:27:20.697446", + "type_uid": 200401, + "type_name": "Detection Finding: Create" + } +] diff --git a/unittests/scans/prowler/example_output_kubernetes.csv b/unittests/scans/prowler/example_output_kubernetes.csv new file mode 100644 index 00000000000..57b532fbda5 --- /dev/null +++ b/unittests/scans/prowler/example_output_kubernetes.csv @@ -0,0 +1,5 @@ +AUTH_METHOD;TIMESTAMP;ACCOUNT_UID;ACCOUNT_NAME;ACCOUNT_EMAIL;ACCOUNT_ORGANIZATION_UID;ACCOUNT_ORGANIZATION_NAME;ACCOUNT_TAGS;FINDING_UID;PROVIDER;CHECK_ID;CHECK_TITLE;CHECK_TYPE;STATUS;STATUS_EXTENDED;MUTED;SERVICE_NAME;SUBSERVICE_NAME;SEVERITY;RESOURCE_TYPE;RESOURCE_UID;RESOURCE_NAME;RESOURCE_DETAILS;RESOURCE_TAGS;PARTITION;REGION;DESCRIPTION;RISK;RELATED_URL;REMEDIATION_RECOMMENDATION_TEXT;REMEDIATION_RECOMMENDATION_URL;REMEDIATION_CODE_NATIVEIAC;REMEDIATION_CODE_TERRAFORM;REMEDIATION_CODE_CLI;REMEDIATION_CODE_OTHER;COMPLIANCE;CATEGORIES;DEPENDS_ON;RELATED_TO;NOTES;PROWLER_VERSION;ADDITIONAL_URLS +;2025-02-14 14:27:38.533897;;context: ;;;;;;kubernetes;apiserver_always_pull_images_plugin;Ensure that the admission control plugin AlwaysPullImages is set;;FAIL;AlwaysPullImages admission control plugin is not set in pod ;False;apiserver;;medium;KubernetesAPIServer;;;;;;namespace: kube-system;This check verifies that the AlwaysPullImages admission control plugin is enabled in the Kubernetes API server. This plugin ensures that every new pod always pulls the required images, enforcing image access control and preventing the use of possibly outdated or altered images.;Without AlwaysPullImages, once an image is pulled to a node, any pod can use it without any authorization check, potentially leading to security risks.;https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#alwayspullimages;Configure the API server to use the AlwaysPullImages admission control plugin to ensure image security and integrity.;https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers;https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-admission-control-plugin-alwayspullimages-is-set#kubernetes;;--enable-admission-plugins=...,AlwaysPullImages,...;;CIS-1.10: 1.2.11 | CIS-1.8: 1.2.11;cluster-security;;;Enabling AlwaysPullImages can increase network and registry load and decrease container startup speed. It may not be suitable for all environments.;;https://kubernetes.io/docs/concepts/containers/images/ | https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/ +;2025-02-14 14:27:38.533897;;context: ;;;;;;kubernetes;apiserver_anonymous_requests;Ensure that the --anonymous-auth argument is set to false;;FAIL;API Server does not have anonymous-auth enabled in pod ;False;apiserver;;high;KubernetesAPIServer;;;;;;namespace: kube-system;Disable anonymous requests to the API server. When enabled, requests that are not rejected by other configured authentication methods are treated as anonymous requests, which are then served by the API server. Disallowing anonymous requests strengthens security by ensuring all access is authenticated.;Enabling anonymous access to the API server can expose the cluster to unauthorized access and potential security vulnerabilities.;https://kubernetes.io/docs/admin/authentication/#anonymous-requests;Ensure the --anonymous-auth argument in the API server is set to false. This will reject all anonymous requests, enforcing authenticated access to the server.;https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/;https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-anonymous-auth-argument-is-set-to-false-1#kubernetes;;--anonymous-auth=false;;CIS-1.10: 1.2.1 | CIS-1.8: 1.2.1;trustboundaries;;;While anonymous access can be useful for health checks and discovery, consider the security implications for your specific environment.;;https://kubernetes.io/docs/concepts/containers/images/ | https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/ +;2025-02-14 14:27:38.533897;;context: ;;;;;;kubernetes;apiserver_audit_log_maxage_set;Ensure that the --audit-log-maxage argument is set to 30 or as appropriate;;FAIL;Audit log max age is not set to 30 or as appropriate in pod ;False;apiserver;;medium;KubernetesAPIServer;;;;;;namespace: kube-system;This check ensures that the Kubernetes API server is configured with an appropriate audit log retention period. Setting --audit-log-maxage to 30 or as per business requirements helps in maintaining logs for sufficient time to investigate past events.;Without an adequate log retention period, there may be insufficient audit history to investigate and analyze past events or security incidents.;https://kubernetes.io/docs/concepts/cluster-administration/audit/;Configure the API server audit log retention period to retain logs for at least 30 days or as per your organization's requirements.;https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/;https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-audit-log-maxage-argument-is-set-to-30-or-as-appropriate#kubernetes;;--audit-log-maxage=30;;CIS-1.10: 1.2.17 | CIS-1.8: 1.2.18;logging;;;Ensure the audit log retention period is set appropriately to balance between storage constraints and the need for historical data.;;https://kubernetes.io/docs/concepts/containers/images/ | https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/ +;2025-02-14 14:27:38.533897;;context: ;;;;;;kubernetes;apiserver_audit_log_maxbackup_set;Ensure that the --audit-log-maxbackup argument is set to 10 or as appropriate;;FAIL;Audit log max backup is not set to 10 or as appropriate in pod ;False;apiserver;;medium;KubernetesAPIServer;;;;;;namespace: kube-system;This check ensures that the Kubernetes API server is configured with an appropriate number of audit log backups. Setting --audit-log-maxbackup to 10 or as per business requirements helps maintain a sufficient log backup for investigations or analysis.;Without an adequate number of audit log backups, there may be insufficient log history to investigate past events or security incidents.;https://kubernetes.io/docs/concepts/cluster-administration/audit/;Configure the API server audit log backup retention to 10 or as per your organization's requirements.;https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/;https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-audit-log-maxbackup-argument-is-set-to-10-or-as-appropriate#kubernetes;;--audit-log-maxbackup=10;;CIS-1.10: 1.2.18 | CIS-1.8: 1.2.19;logging;;;Ensure the audit log backup retention period is set appropriately to balance between storage constraints and the need for historical data.;;https://kubernetes.io/docs/concepts/containers/images/ | https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/ diff --git a/unittests/scans/prowler/example_output_kubernetes.ocsf.json b/unittests/scans/prowler/example_output_kubernetes.ocsf.json new file mode 100644 index 00000000000..6e7dffb48a2 --- /dev/null +++ b/unittests/scans/prowler/example_output_kubernetes.ocsf.json @@ -0,0 +1,538 @@ +[ + { + "message": "AlwaysPullImages admission control plugin is not set in pod .", + "metadata": { + "event_code": "apiserver_always_pull_images_plugin", + "product": { + "name": "Prowler", + "uid": "prowler", + "vendor_name": "Prowler", + "version": "5.4.0" + }, + "profiles": [ + "container", + "datetime" + ], + "version": "1.4.0" + }, + "severity_id": 3, + "severity": "Medium", + "status": "New", + "status_code": "FAIL", + "status_detail": "AlwaysPullImages admission control plugin is not set in pod .", + "status_id": 1, + "unmapped": { + "related_url": "https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#alwayspullimages", + "categories": [ + "cluster-security" + ], + "depends_on": [], + "related_to": [], + "additional_urls": [], + "notes": "Enabling AlwaysPullImages can increase network and registry load and decrease container startup speed. It may not be suitable for all environments.", + "compliance": { + "CIS-1.10": [ + "1.2.11" + ], + "CIS-1.8": [ + "1.2.11" + ] + } + }, + "activity_name": "Create", + "activity_id": 1, + "finding_info": { + "created_time": 1739539658, + "created_time_dt": "2025-02-14T14:27:38.533897", + "desc": "This check verifies that the AlwaysPullImages admission control plugin is enabled in the Kubernetes API server. This plugin ensures that every new pod always pulls the required images, enforcing image access control and preventing the use of possibly outdated or altered images.", + "product_uid": "prowler", + "title": "Ensure that the admission control plugin AlwaysPullImages is set", + "types": [], + "uid": "" + }, + "resources": [ + { + "data": { + "details": "", + "metadata": { + "name": "", + "uid": "", + "namespace": "", + "labels": { + "component": "kube-apiserver", + "tier": "control-plane" + }, + "annotations": { + "kubernetes.io/config.source": "file" + }, + "node_name": "", + "service_account": null, + "status_phase": "Running", + "pod_ip": "", + "host_ip": "", + "host_pid": null, + "host_ipc": null, + "host_network": "True", + "security_context": { + "app_armor_profile": null, + "fs_group": null, + "fs_group_change_policy": null, + "run_as_group": null, + "run_as_non_root": null, + "run_as_user": null, + "se_linux_change_policy": null, + "se_linux_options": null, + "seccomp_profile": { + "localhost_profile": null, + "type": "RuntimeDefault" + }, + "supplemental_groups": null, + "supplemental_groups_policy": null, + "sysctls": null, + "windows_options": null + }, + "containers": { + "kube-apiserver": { + "name": "kube-apiserver", + "image": "", + "command": [ + "" + ], + "ports": null, + "env": null, + "security_context": {} + } + } + } + }, + "group": { + "name": "apiserver" + }, + "labels": [], + "name": "", + "namespace": "", + "type": "KubernetesAPIServer", + "uid": "" + } + ], + "category_name": "Findings", + "category_uid": 2, + "class_name": "Detection Finding", + "class_uid": 2004, + "remediation": { + "desc": "Configure the API server to use the AlwaysPullImages admission control plugin to ensure image security and integrity.", + "references": [ + "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-admission-control-plugin-alwayspullimages-is-set#kubernetes", + "--enable-admission-plugins=...,AlwaysPullImages,...", + "https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers" + ] + }, + "risk_details": "Without AlwaysPullImages, once an image is pulled to a node, any pod can use it without any authorization check, potentially leading to security risks.", + "time": 1739539658, + "time_dt": "2025-02-14T14:27:38.533897", + "type_uid": 200401, + "type_name": "Detection Finding: Create" + }, + { + "message": "Audit log max age is not set to 30 or as appropriate in pod .", + "metadata": { + "event_code": "apiserver_audit_log_maxage_set", + "product": { + "name": "Prowler", + "uid": "prowler", + "vendor_name": "Prowler", + "version": "5.4.0" + }, + "profiles": [ + "container", + "datetime" + ], + "version": "1.4.0" + }, + "severity_id": 3, + "severity": "Medium", + "status": "New", + "status_code": "FAIL", + "status_detail": "Audit log max age is not set to 30 or as appropriate in pod .", + "status_id": 1, + "unmapped": { + "related_url": "https://kubernetes.io/docs/concepts/cluster-administration/audit/", + "categories": [ + "logging" + ], + "depends_on": [], + "related_to": [], + "additional_urls": [], + "notes": "Ensure the audit log retention period is set appropriately to balance between storage constraints and the need for historical data.", + "compliance": { + "CIS-1.10": [ + "1.2.17" + ], + "CIS-1.8": [ + "1.2.18" + ] + } + }, + "activity_name": "Create", + "activity_id": 1, + "finding_info": { + "created_time": 1739539658, + "created_time_dt": "2025-02-14T14:27:38.533897", + "desc": "This check ensures that the Kubernetes API server is configured with an appropriate audit log retention period. Setting --audit-log-maxage to 30 or as per business requirements helps in maintaining logs for sufficient time to investigate past events.", + "product_uid": "prowler", + "title": "Ensure that the --audit-log-maxage argument is set to 30 or as appropriate", + "types": [], + "uid": "" + }, + "resources": [ + { + "data": { + "details": "", + "metadata": { + "name": "", + "uid": "", + "namespace": "", + "labels": { + "component": "kube-apiserver", + "tier": "control-plane" + }, + "annotations": { + "kubernetes.io/config.source": "file" + }, + "node_name": "", + "service_account": null, + "status_phase": "Running", + "pod_ip": "", + "host_ip": "", + "host_pid": null, + "host_ipc": null, + "host_network": "True", + "security_context": { + "app_armor_profile": null, + "fs_group": null, + "fs_group_change_policy": null, + "run_as_group": null, + "run_as_non_root": null, + "run_as_user": null, + "se_linux_change_policy": null, + "se_linux_options": null, + "seccomp_profile": { + "localhost_profile": null, + "type": "RuntimeDefault" + }, + "supplemental_groups": null, + "supplemental_groups_policy": null, + "sysctls": null, + "windows_options": null + }, + "containers": { + "kube-apiserver": { + "name": "kube-apiserver", + "image": "", + "command": [ + "" + ], + "ports": null, + "env": null, + "security_context": {} + } + } + } + }, + "group": { + "name": "apiserver" + }, + "labels": [], + "name": "", + "namespace": "", + "type": "KubernetesAPIServer", + "uid": "" + } + ], + "category_name": "Findings", + "category_uid": 2, + "class_name": "Detection Finding", + "class_uid": 2004, + "remediation": { + "desc": "Configure the API server audit log retention period to retain logs for at least 30 days or as per your organization's requirements.", + "references": [ + "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-audit-log-maxage-argument-is-set-to-30-or-as-appropriate#kubernetes", + "--audit-log-maxage=30", + "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/" + ] + }, + "risk_details": "Without an adequate log retention period, there may be insufficient audit history to investigate and analyze past events or security incidents.", + "time": 1739539658, + "time_dt": "2025-02-14T14:27:38.533897", + "type_uid": 200401, + "type_name": "Detection Finding: Create" + }, + { + "message": "Audit log max backup is not set to 10 or as appropriate in pod .", + "metadata": { + "event_code": "apiserver_audit_log_maxbackup_set", + "product": { + "name": "Prowler", + "uid": "prowler", + "vendor_name": "Prowler", + "version": "5.4.0" + }, + "profiles": [ + "container", + "datetime" + ], + "version": "1.4.0" + }, + "severity_id": 3, + "severity": "Medium", + "status": "New", + "status_code": "FAIL", + "status_detail": "Audit log max backup is not set to 10 or as appropriate in pod .", + "status_id": 1, + "unmapped": { + "related_url": "https://kubernetes.io/docs/concepts/cluster-administration/audit/", + "categories": [ + "logging" + ], + "depends_on": [], + "related_to": [], + "additional_urls": [], + "notes": "Ensure the audit log backup retention period is set appropriately to balance between storage constraints and the need for historical data.", + "compliance": { + "CIS-1.10": [ + "1.2.18" + ], + "CIS-1.8": [ + "1.2.19" + ] + } + }, + "activity_name": "Create", + "activity_id": 1, + "finding_info": { + "created_time": 1739539658, + "created_time_dt": "2025-02-14T14:27:38.533897", + "desc": "This check ensures that the Kubernetes API server is configured with an appropriate number of audit log backups. Setting --audit-log-maxbackup to 10 or as per business requirements helps maintain a sufficient log backup for investigations or analysis.", + "product_uid": "prowler", + "title": "Ensure that the --audit-log-maxbackup argument is set to 10 or as appropriate", + "types": [], + "uid": "" + }, + "resources": [ + { + "data": { + "details": "", + "metadata": { + "name": "", + "uid": "", + "namespace": "", + "labels": { + "component": "kube-apiserver", + "tier": "control-plane" + }, + "annotations": { + "kubernetes.io/config.source": "file" + }, + "node_name": "", + "service_account": null, + "status_phase": "Running", + "pod_ip": "", + "host_ip": "", + "host_pid": null, + "host_ipc": null, + "host_network": "True", + "security_context": { + "app_armor_profile": null, + "fs_group": null, + "fs_group_change_policy": null, + "run_as_group": null, + "run_as_non_root": null, + "run_as_user": null, + "se_linux_change_policy": null, + "se_linux_options": null, + "seccomp_profile": { + "localhost_profile": null, + "type": "RuntimeDefault" + }, + "supplemental_groups": null, + "supplemental_groups_policy": null, + "sysctls": null, + "windows_options": null + }, + "containers": { + "kube-apiserver": { + "name": "kube-apiserver", + "image": "", + "command": [ + "" + ], + "ports": null, + "env": null, + "security_context": {} + } + } + } + }, + "group": { + "name": "apiserver" + }, + "labels": [], + "name": "", + "namespace": "", + "type": "KubernetesAPIServer", + "uid": "" + } + ], + "category_name": "Findings", + "category_uid": 2, + "class_name": "Detection Finding", + "class_uid": 2004, + "remediation": { + "desc": "Configure the API server audit log backup retention to 10 or as per your organization's requirements.", + "references": [ + "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-audit-log-maxbackup-argument-is-set-to-10-or-as-appropriate#kubernetes", + "--audit-log-maxbackup=10", + "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/" + ] + }, + "risk_details": "Without an adequate number of audit log backups, there may be insufficient log history to investigate past events or security incidents.", + "time": 1739539658, + "time_dt": "2025-02-14T14:27:38.533897", + "type_uid": 200401, + "type_name": "Detection Finding: Create" + }, + { + "message": "Audit log max backup is not set to 10 or as appropriate in pod .", + "metadata": { + "event_code": "apiserver_audit_log_maxbackup_set", + "product": { + "name": "Prowler", + "uid": "prowler", + "vendor_name": "Prowler", + "version": "5.4.0" + }, + "profiles": [ + "container", + "datetime" + ], + "version": "1.4.0" + }, + "severity_id": 3, + "severity": "Medium", + "status": "New", + "status_code": "FAIL", + "status_detail": "Audit log max backup is not set to 10 or as appropriate in pod .", + "status_id": 1, + "unmapped": { + "related_url": "https://kubernetes.io/docs/concepts/cluster-administration/audit/", + "categories": [ + "logging" + ], + "depends_on": [], + "related_to": [], + "additional_urls": [], + "notes": "Ensure the audit log backup retention period is set appropriately to balance between storage constraints and the need for historical data.", + "compliance": { + "CIS-1.10": [ + "1.2.18" + ], + "CIS-1.8": [ + "1.2.19" + ] + } + }, + "activity_name": "Create", + "activity_id": 1, + "finding_info": { + "created_time": 1739539658, + "created_time_dt": "2025-02-14T14:27:38.533897", + "desc": "This check ensures that the Kubernetes API server is configured with an appropriate number of audit log backups. Setting --audit-log-maxbackup to 10 or as per business requirements helps maintain a sufficient log backup for investigations or analysis.", + "product_uid": "prowler", + "title": "Ensure that the --audit-log-maxbackup argument is set to 10 or as appropriate", + "types": [], + "uid": "" + }, + "resources": [ + { + "data": { + "details": "", + "metadata": { + "name": "", + "uid": "", + "namespace": "", + "labels": { + "component": "kube-apiserver", + "tier": "control-plane" + }, + "annotations": { + "kubernetes.io/config.source": "file" + }, + "node_name": "", + "service_account": null, + "status_phase": "Running", + "pod_ip": "", + "host_ip": "", + "host_pid": null, + "host_ipc": null, + "host_network": "True", + "security_context": { + "app_armor_profile": null, + "fs_group": null, + "fs_group_change_policy": null, + "run_as_group": null, + "run_as_non_root": null, + "run_as_user": null, + "se_linux_change_policy": null, + "se_linux_options": null, + "seccomp_profile": { + "localhost_profile": null, + "type": "RuntimeDefault" + }, + "supplemental_groups": null, + "supplemental_groups_policy": null, + "sysctls": null, + "windows_options": null + }, + "containers": { + "kube-apiserver": { + "name": "kube-apiserver", + "image": "", + "command": [ + "" + ], + "ports": null, + "env": null, + "security_context": {} + } + } + } + }, + "group": { + "name": "apiserver" + }, + "labels": [], + "name": "", + "namespace": "", + "type": "KubernetesAPIServer", + "uid": "" + } + ], + "category_name": "Findings", + "category_uid": 2, + "class_name": "Detection Finding", + "class_uid": 2004, + "remediation": { + "desc": "Configure the API server audit log backup retention to 10 or as per your organization's requirements.", + "references": [ + "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-audit-log-maxbackup-argument-is-set-to-10-or-as-appropriate#kubernetes", + "--audit-log-maxbackup=10", + "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/" + ] + }, + "risk_details": "Without an adequate number of audit log backups, there may be insufficient log history to investigate past events or security incidents.", + "time": 1739539658, + "time_dt": "2025-02-14T14:27:38.533897", + "type_uid": 200401, + "type_name": "Detection Finding: Create" + } +] diff --git a/unittests/scans/prowler/prowler_zero_vul.csv b/unittests/scans/prowler/prowler_zero_vul.csv new file mode 100644 index 00000000000..04a160e85fe --- /dev/null +++ b/unittests/scans/prowler/prowler_zero_vul.csv @@ -0,0 +1 @@ +AUTH_METHOD;TIMESTAMP;ACCOUNT_UID;ACCOUNT_NAME;ACCOUNT_EMAIL;ACCOUNT_ORGANIZATION_UID;ACCOUNT_ORGANIZATION_NAME;ACCOUNT_TAGS;FINDING_UID;PROVIDER;CHECK_ID;CHECK_TITLE;CHECK_TYPE;STATUS;STATUS_EXTENDED;MUTED;SERVICE_NAME;SUBSERVICE_NAME;SEVERITY;RESOURCE_TYPE;RESOURCE_UID;RESOURCE_NAME;RESOURCE_DETAILS;RESOURCE_TAGS;PARTITION;REGION;DESCRIPTION;RISK;RELATED_URL;REMEDIATION_RECOMMENDATION_TEXT;REMEDIATION_RECOMMENDATION_URL;REMEDIATION_CODE_NATIVEIAC;REMEDIATION_CODE_TERRAFORM;REMEDIATION_CODE_CLI;REMEDIATION_CODE_OTHER;COMPLIANCE;CATEGORIES;DEPENDS_ON;RELATED_TO;NOTES;PROWLER_VERSION;ADDITIONAL_URLS diff --git a/unittests/scans/prowler/prowler_zero_vul.json b/unittests/scans/prowler/prowler_zero_vul.json new file mode 100644 index 00000000000..c9c513b9b8c --- /dev/null +++ b/unittests/scans/prowler/prowler_zero_vul.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/unittests/tools/test_prowler_parser.py b/unittests/tools/test_prowler_parser.py new file mode 100644 index 00000000000..0efc86dfbc6 --- /dev/null +++ b/unittests/tools/test_prowler_parser.py @@ -0,0 +1,932 @@ +from django.test import TestCase + +from dojo.models import Test +from dojo.tools.prowler.parser import ProwlerParser +from unittests.dojo_test_case import get_unit_tests_scans_path + + +class TestProwlerParser(TestCase): + + def test_prowler_parser_json_with_no_vuln_has_no_findings(self): + with (get_unit_tests_scans_path("prowler") / "prowler_zero_vul.json").open(encoding="utf-8") as testfile: + parser = ProwlerParser() + findings = parser.get_findings(testfile, Test()) + testfile.close() + self.assertEqual(0, len(findings)) + + def test_prowler_parser_csv_with_no_vuln_has_no_findings(self): + with (get_unit_tests_scans_path("prowler") / "prowler_zero_vul.csv").open(encoding="utf-8") as testfile: + parser = ProwlerParser() + findings = parser.get_findings(testfile, Test()) + testfile.close() + self.assertEqual(0, len(findings)) + + def test_prowler_parser_aws_csv_file_with_multiple_vulnerabilities(self): + with (get_unit_tests_scans_path("prowler") / "example_output_aws.csv").open(encoding="utf-8") as testfile: + parser = ProwlerParser() + findings = parser.get_findings(testfile, Test()) + testfile.close() + self.assertEqual(4, len(findings)) + with self.subTest(i=0): + description = ( + "**Cloud Type** : AWS\n\n" + "**Description** : Check if IAM Access Analyzer is enabled\n\n" + "**Service Name** : accessanalyzer\n\n" + "**Status Detail** : IAM Access Analyzer in account is not enabled.\n\n" + "**Finding Created Time** : 2025-02-14 14:27:03.913874\n\n" + "**Region** : \n\n" + "**Notes** : \n\n" + "**Related URL** : https://docs.aws.amazon.com/IAM/latest/UserGuide/what-is-access-analyzer.html\n\n" + "**Additional URLs** : https://docs.aws.amazon.com/IAM/latest/UserGuide/access-analyzer-getting-started.html | https://aws.amazon.com/iam/features/analyze-access/" + ) + mitigation = ( + "**Remediation Recommendation** : Enable IAM Access Analyzer for all accounts, create analyzer and take action over it is recommendations (IAM Access Analyzer is available at no additional cost).\n\n" + "**Remediation Recommendation URL** : https://docs.aws.amazon.com/IAM/latest/UserGuide/what-is-access-analyzer.html\n\n" + "**Remediation Code Native IaC** : \n\n" + "**Remediation Code Terraform** : \n\n" + "**Remediation Code CLI** : aws accessanalyzer create-analyzer --analyzer-name --type \n\n" + "**Other Remediation Info** : " + ) + references = ( + "CIS-1.4: 1.20\n" + "CIS-1.5: 1.20\n" + "KISA-ISMS-P-2023: 2.5.6, 2.6.4, 2.8.1, 2.8.2\n" + "CIS-2.0: 1.20\n" + "KISA-ISMS-P-2023-korean: 2.5.6, 2.6.4, 2.8.1, 2.8.2\n" + "AWS-Account-Security-Onboarding: Enabled security services, Create analyzers in each active regions, Verify that events are present in SecurityHub aggregated view\n" + "CIS-3.0: 1.20" + ) + i = 0 + self.assertEqual(findings[i].description, description) + self.assertEqual(findings[i].mitigation, mitigation) + self.assertEqual(findings[i].title, "Check if IAM Access Analyzer is enabled") + self.assertEqual(findings[i].severity, "Low") + self.assertEqual(findings[i].impact, "AWS IAM Access Analyzer helps you identify the resources in your organization and accounts, such as Amazon S3 buckets or IAM roles, that are shared with an external entity. This lets you identify unintended access to your resources and data, which is a security risk. IAM Access Analyzer uses a form of mathematical analysis called automated reasoning, which applies logic and mathematical inference to determine all possible access paths allowed by a resource policy.") + self.assertEqual(findings[i].references, references) + + with self.subTest(i=1): + description = ( + "**Cloud Type** : AWS\n\n" + "**Description** : Maintain current contact details.\n\n" + "**Service Name** : account\n\n" + "**Status Detail** : Login to the AWS Console. Choose your account name on the top right of the window -> My Account -> Contact Information.\n\n" + "**Finding Created Time** : 2025-02-14 14:27:03.913874\n\n" + "**Region** : \n\n" + "**Notes** : \n\n" + "**Additional URLs** : https://docs.aws.amazon.com/IAM/latest/UserGuide/access-analyzer-getting-started.html | https://aws.amazon.com/iam/features/analyze-access/" + ) + mitigation = ( + "**Remediation Recommendation** : Using the Billing and Cost Management console complete contact details.\n\n" + "**Remediation Recommendation URL** : https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-update-contact.html\n\n" + "**Remediation Code Native IaC** : \n\n" + "**Remediation Code Terraform** : \n\n" + "**Remediation Code CLI** : No command available.\n\n" + "**Other Remediation Info** : https://docs.prowler.com/checks/aws/iam-policies/iam_18-maintain-contact-details#aws-console" + ) + references = ( + "CIS-1.4: 1.1\n" + "CIS-1.5: 1.1\n" + "KISA-ISMS-P-2023: 2.1.3\n" + "CIS-2.0: 1.1\n" + "KISA-ISMS-P-2023-korean: 2.1.3\n" + "AWS-Well-Architected-Framework-Security-Pillar: SEC03-BP03, SEC10-BP01\n" + "AWS-Account-Security-Onboarding: Billing, emergency, security contacts\n" + "CIS-3.0: 1.1\n" + "ENS-RD2022: op.ext.7.aws.am.1" + ) + i = 1 + self.assertEqual(findings[i].description, description) + self.assertEqual(findings[i].mitigation, mitigation) + self.assertEqual(findings[i].title, "Maintain current contact details.") + self.assertEqual(findings[i].severity, "Medium") + self.assertEqual(findings[i].impact, "Ensure contact email and telephone details for AWS accounts are current and map to more than one individual in your organization. An AWS account supports a number of contact details, and AWS will use these to contact the account owner if activity judged to be in breach of Acceptable Use Policy. If an AWS account is observed to be behaving in a prohibited or suspicious manner, AWS will attempt to contact the account owner by email and phone using the contact details listed. If this is unsuccessful and the account behavior needs urgent mitigation, proactive measures may be taken, including throttling of traffic between the account exhibiting suspicious behavior and the AWS API endpoints and the Internet. This will result in impaired service to and from the account in question.") + self.assertEqual(findings[i].references, references) + with self.subTest(i=2): + description = ( + "**Cloud Type** : AWS\n\n" + "**Description** : Maintain different contact details to security, billing and operations.\n\n" + "**Service Name** : account\n\n" + "**Status Detail** : SECURITY, BILLING and OPERATIONS contacts not found or they are not different between each other and between ROOT contact.\n\n" + "**Finding Created Time** : 2025-02-14 14:27:03.913874\n\n" + "**Region** : \n\n" + "**Notes** : \n\n" + "**Related URL** : https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-update-contact.html\n\n" + "**Additional URLs** : https://docs.aws.amazon.com/IAM/latest/UserGuide/access-analyzer-getting-started.html | https://aws.amazon.com/iam/features/analyze-access/" + ) + + mitigation = ( + "**Remediation Recommendation** : Using the Billing and Cost Management console complete contact details.\n\n" + "**Remediation Recommendation URL** : https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-update-contact.html\n\n" + "**Remediation Code Native IaC** : \n\n" + "**Remediation Code Terraform** : \n\n" + "**Remediation Code CLI** : \n\n" + "**Other Remediation Info** : https://docs.prowler.com/checks/aws/iam-policies/iam_18-maintain-contact-details#aws-console" + ) + + references = ( + "KISA-ISMS-P-2023: 2.1.3\n" + "KISA-ISMS-P-2023-korean: 2.1.3" + ) + + i = 2 + self.assertEqual(findings[i].description, description) + self.assertEqual(findings[i].mitigation, mitigation) + self.assertEqual(findings[i].title, "Maintain different contact details to security, billing and operations.") + self.assertEqual(findings[i].severity, "Medium") + self.assertEqual(findings[i].impact, "Ensure contact email and telephone details for AWS accounts are current and map to more than one individual in your organization. An AWS account supports a number of contact details, and AWS will use these to contact the account owner if activity judged to be in breach of Acceptable Use Policy. If an AWS account is observed to be behaving in a prohibited or suspicious manner, AWS will attempt to contact the account owner by email and phone using the contact details listed. If this is unsuccessful and the account behavior needs urgent mitigation, proactive measures may be taken, including throttling of traffic between the account exhibiting suspicious behavior and the AWS API endpoints and the Internet. This will result in impaired service to and from the account in question.") + self.assertEqual(findings[i].references, references) + with self.subTest(i=3): + description = ( + "**Cloud Type** : AWS\n\n" + "**Description** : Ensure security contact information is registered.\n\n" + "**Service Name** : account\n\n" + "**Status Detail** : Login to the AWS Console. Choose your account name on the top right of the window -> My Account -> Alternate Contacts -> Security Section.\n\n" + "**Finding Created Time** : 2025-02-14 14:27:03.913874\n\n" + "**Region** : \n\n" + "**Notes** : \n\n" + "**Additional URLs** : https://docs.aws.amazon.com/IAM/latest/UserGuide/access-analyzer-getting-started.html | https://aws.amazon.com/iam/features/analyze-access/" + ) + + mitigation = ( + "**Remediation Recommendation** : Go to the My Account section and complete alternate contacts.\n\n" + "**Remediation Recommendation URL** : https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-update-contact.html\n\n" + "**Remediation Code Native IaC** : \n\n" + "**Remediation Code Terraform** : \n\n" + "**Remediation Code CLI** : No command available.\n\n" + "**Other Remediation Info** : https://docs.prowler.com/checks/aws/iam-policies/iam_19#aws-console" + ) + + references = ( + "CIS-1.4: 1.2\n" + "CIS-1.5: 1.2\n" + "AWS-Foundational-Security-Best-Practices: account, acm\n" + "KISA-ISMS-P-2023: 2.1.3, 2.2.1\n" + "CIS-2.0: 1.2\n" + "KISA-ISMS-P-2023-korean: 2.1.3, 2.2.1\n" + "AWS-Well-Architected-Framework-Security-Pillar: SEC03-BP03, SEC10-BP01\n" + "AWS-Account-Security-Onboarding: Billing, emergency, security contacts\n" + "CIS-3.0: 1.2\n" + "ENS-RD2022: op.ext.7.aws.am.1" + ) + + i = 3 + self.assertEqual(findings[i].description, description) + self.assertEqual(findings[i].mitigation, mitigation) + self.assertEqual(findings[i].title, "Ensure security contact information is registered.") + self.assertEqual(findings[i].severity, "Medium") + self.assertEqual(findings[i].impact, "AWS provides customers with the option of specifying the contact information for accounts security team. It is recommended that this information be provided. Specifying security-specific contact information will help ensure that security advisories sent by AWS reach the team in your organization that is best equipped to respond to them.") + self.assertEqual(findings[i].references, references) + + def test_prowler_parser_azure_csv_file_with_multiple_vulnerabilities(self): + with (get_unit_tests_scans_path("prowler") / "example_output_azure.csv").open(encoding="utf-8") as testfile: + parser = ProwlerParser() + findings = parser.get_findings(testfile, Test()) + testfile.close() + self.assertEqual(4, len(findings)) + with self.subTest(i=0): + description = ( + "**Cloud Type** : AZURE\n\n" + "**Description** : Azure Kubernetes Service (AKS) can be configured to use Azure Active Directory (AD) for user authentication. In this configuration, you sign in to an AKS cluster using an Azure AD authentication token. You can also configure Kubernetes role-based access control (Kubernetes RBAC) to limit access to cluster resources based a user's identity or group membership.\n\n" + "**Service Name** : aks\n\n" + "**Status Detail** : RBAC is enabled for cluster '' in subscription ''.\n\n" + "**Finding Created Time** : 2025-02-14 14:27:30.710664\n\n" + "**Region** : \n\n" + "**Notes** : \n\n" + "**Related URL** : https://learn.microsoft.com/en-us/azure/aks/azure-ad-rbac?tabs=portal\n\n" + "**Additional URLs** : https://learn.microsoft.com/azure/aks/azure-ad-rbac | https://learn.microsoft.com/azure/aks/concepts-identity" + ) + + mitigation = ( + "**Remediation Recommendation** : \n\n" + "**Remediation Recommendation URL** : https://learn.microsoft.com/en-us/security/benchmark/azure/security-controls-v2-privileged-access#pa-7-follow-just-enough-administration-least-privilege-principle\n\n" + "**Remediation Code Native IaC** : \n\n" + "**Remediation Code Terraform** : https://docs.prowler.com/checks/azure/azure-kubernetes-policies/bc_azr_kubernetes_2#terraform\n\n" + "**Remediation Code CLI** : \n\n" + "**Other Remediation Info** : https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/AKS/enable-role-based-access-control-for-kubernetes-service.html#" + ) + + references = ( + "ENS-RD2022: op.acc.2.az.r1.eid.1" + ) + + i = 0 + self.assertEqual(findings[i].description, description) + self.assertEqual(findings[i].mitigation, mitigation) + self.assertEqual(findings[i].title, "Ensure AKS RBAC is enabled") + self.assertEqual(findings[i].severity, "Medium") + self.assertEqual(findings[i].impact, "Kubernetes RBAC and AKS help you secure your cluster access and provide only the minimum required permissions to developers and operators.") + self.assertEqual(findings[i].references, references) + with self.subTest(i=1): + description = ( + "**Cloud Type** : AZURE\n\n" + "**Description** : Disable public IP addresses for cluster nodes, so that they only have private IP addresses. Private Nodes are nodes with no public IP addresses.\n\n" + "**Service Name** : aks\n\n" + "**Status Detail** : Cluster '' was created with private nodes in subscription ''\n\n" + "**Finding Created Time** : 2025-02-14 14:27:30.710664\n\n" + "**Region** : \n\n" + "**Notes** : \n\n" + "**Related URL** : https://learn.microsoft.com/en-us/azure/aks/private-clusters\n\n" + "**Additional URLs** : https://learn.microsoft.com/azure/aks/azure-ad-rbac | https://learn.microsoft.com/azure/aks/concepts-identity" + ) + mitigation = ( + "**Remediation Recommendation** : \n\n" + "**Remediation Recommendation URL** : https://learn.microsoft.com/en-us/azure/aks/access-private-cluster\n\n" + "**Remediation Code Native IaC** : \n\n" + "**Remediation Code Terraform** : \n\n" + "**Remediation Code CLI** : \n\n" + "**Other Remediation Info** : " + ) + references = ( + "ENS-RD2022: mp.com.4.r2.az.aks.1\n" + "MITRE-ATTACK: T1190, T1530" + ) + + i = 1 + self.assertEqual(findings[i].description, description) + self.assertEqual(findings[i].mitigation, mitigation) + self.assertEqual(findings[i].title, "Ensure clusters are created with Private Nodes") + self.assertEqual(findings[i].severity, "High") + self.assertEqual(findings[i].impact, "Disabling public IP addresses on cluster nodes restricts access to only internal networks, forcing attackers to obtain local network access before attempting to compromise the underlying Kubernetes hosts.") + self.assertEqual(findings[i].references, references) + with self.subTest(i=2): + description = ( + "**Cloud Type** : AZURE\n\n" + "**Description** : Disable access to the Kubernetes API from outside the node network if it is not required.\n\n" + "**Service Name** : aks\n\n" + "**Status Detail** : Public access to nodes is enabled for cluster '' in subscription ''\n\n" + "**Finding Created Time** : 2025-02-14 14:27:30.710664\n\n" + "**Region** : \n\n" + "**Notes** : \n\n" + "**Related URL** : https://learn.microsoft.com/en-us/azure/aks/private-clusters?tabs=azure-portal\n\n" + "**Additional URLs** : https://learn.microsoft.com/azure/aks/azure-ad-rbac | https://learn.microsoft.com/azure/aks/concepts-identity" + ) + + mitigation = ( + "**Remediation Recommendation** : To use a private endpoint, create a new private endpoint in your virtual network then create a link between your virtual network and a new private DNS zone\n\n" + "**Remediation Recommendation URL** : https://learn.microsoft.com/en-us/azure/aks/access-private-cluster?tabs=azure-cli\n\n" + "**Remediation Code Native IaC** : \n\n" + "**Remediation Code Terraform** : \n\n" + "**Remediation Code CLI** : az aks update -n -g --disable-public-fqdn\n\n" + "**Other Remediation Info** : " + ) + + references = ( + "ENS-RD2022: mp.com.4.az.aks.2\n" + "MITRE-ATTACK: T1190, T1530" + ) + + i = 2 + self.assertEqual(findings[i].description, description) + self.assertEqual(findings[i].mitigation, mitigation) + self.assertEqual(findings[i].title, "Ensure clusters are created with Private Endpoint Enabled and Public Access Disabled") + self.assertEqual(findings[i].severity, "High") + self.assertEqual(findings[i].impact, "In a private cluster, the master node has two endpoints, a private and public endpoint. The private endpoint is the internal IP address of the master, behind an internal load balancer in the master's wirtual network. Nodes communicate with the master using the private endpoint. The public endpoint enables the Kubernetes API to be accessed from outside the master's virtual network. Although Kubernetes API requires an authorized token to perform sensitive actions, a vulnerability could potentially expose the Kubernetes publically with unrestricted access. Additionally, an attacker may be able to identify the current cluster and Kubernetes API version and determine whether it is vulnerable to an attack. Unless required, disabling public endpoint will help prevent such threats, and require the attacker to be on the master's virtual network to perform any attack on the Kubernetes API.") + self.assertEqual(findings[i].references, references) + with self.subTest(i=3): + description = ( + "**Cloud Type** : AZURE\n\n" + "**Description** : When you run modern, microservices-based applications in Kubernetes, you often want to control which components can communicate with each other. The principle of least privilege should be applied to how traffic can flow between pods in an Azure Kubernetes Service (AKS) cluster. Let's say you likely want to block traffic directly to back-end applications. The Network Policy feature in Kubernetes lets you define rules for ingress and egress traffic between pods in a cluster.\n\n" + "**Service Name** : aks\n\n" + "**Status Detail** : Network policy is enabled for cluster '' in subscription ''.\n\n" + "**Finding Created Time** : 2025-02-14 14:27:30.710664\n\n" + "**Region** : \n\n" + "**Notes** : Network Policy requires the Network Policy add-on. This add-on is included automatically when a cluster with Network Policy is created, but for an existing cluster, needs to be added prior to enabling Network Policy. Enabling/Disabling Network Policy causes a rolling update of all cluster nodes, similar to performing a cluster upgrade. This operation is long-running and will block other operations on the cluster (including delete) until it has run to completion. If Network Policy is used, a cluster must have at least 2 nodes of type n1-standard-1 or higher. The recommended minimum size cluster to run Network Policy enforcement is 3 n1-standard-1 instances. Enabling Network Policy enforcement consumes additional resources in nodes. Specifically, it increases the memory footprint of the kube-system process by approximately 128MB, and requires approximately 300 millicores of CPU.\n\n" + "**Related URL** : https://learn.microsoft.com/en-us/security/benchmark/azure/security-controls-v2-network-security#ns-2-connect-private-networks-together\n\n" + "**Additional URLs** : https://learn.microsoft.com/azure/aks/azure-ad-rbac | https://learn.microsoft.com/azure/aks/concepts-identity" + ) + + mitigation = ( + "**Remediation Recommendation** : \n\n" + "**Remediation Recommendation URL** : https://learn.microsoft.com/en-us/azure/aks/use-network-policies\n\n" + "**Remediation Code Native IaC** : \n\n" + "**Remediation Code Terraform** : https://docs.prowler.com/checks/azure/azure-kubernetes-policies/bc_azr_kubernetes_4#terraform\n\n" + "**Remediation Code CLI** : \n\n" + "**Other Remediation Info** : " + ) + + references = ( + "ENS-RD2022: mp.com.4.r2.az.aks.1" + ) + + i = 3 + self.assertEqual(findings[i].description, description) + self.assertEqual(findings[i].mitigation, mitigation) + self.assertEqual(findings[i].title, "Ensure Network Policy is Enabled and set as appropriate") + self.assertEqual(findings[i].severity, "Medium") + self.assertEqual(findings[i].impact, "All pods in an AKS cluster can send and receive traffic without limitations, by default. To improve security, you can define rules that control the flow of traffic. Back-end applications are often only exposed to required front-end services, for example. Or, database components are only accessible to the application tiers that connect to them. Network Policy is a Kubernetes specification that defines access policies for communication between Pods. Using Network Policies, you define an ordered set of rules to send and receive traffic and apply them to a collection of pods that match one or more label selectors. These network policy rules are defined as YAML manifests. Network policies can be included as part of a wider manifest that also creates a deployment or service.") + self.assertEqual(findings[i].references, references) + + def test_prowler_parser_gcp_csv_file_with_multiple_vulnerabilities(self): + with (get_unit_tests_scans_path("prowler") / "example_output_gcp.csv").open(encoding="utf-8") as testfile: + parser = ProwlerParser() + findings = parser.get_findings(testfile, Test()) + testfile.close() + self.assertEqual(2, len(findings)) + with self.subTest(i=0): + description = ( + "**Cloud Type** : GCP\n\n" + "**Description** : Scan images stored in Google Container Registry (GCR) for vulnerabilities using AR Container Analysis or a third-party provider. This helps identify and mitigate security risks associated with known vulnerabilities in container images.\n\n" + "**Service Name** : artifacts\n\n" + "**Status Detail** : AR Container Analysis is not enabled in project .\n\n" + "**Finding Created Time** : 2025-02-14 14:27:20.697446\n\n" + "**Region** : \n\n" + "**Notes** : By default, AR Container Analysis is disabled.\n\n" + "**Related URL** : https://cloud.google.com/artifact-analysis/docs\n\n" + "**Additional URLs** : https://cloud.google.com/api-keys/docs/best-practices | https://cloud.google.com/docs/authentication" + ) + + mitigation = ( + "**Remediation Recommendation** : Enable vulnerability scanning for images stored in Artifact Registry using AR Container Analysis or a third-party provider.\n\n" + "**Remediation Recommendation URL** : https://cloud.google.com/artifact-analysis/docs/container-scanning-overview\n\n" + "**Remediation Code Native IaC** : \n\n" + "**Remediation Code Terraform** : \n\n" + "**Remediation Code CLI** : gcloud services enable containeranalysis.googleapis.com\n\n" + "**Other Remediation Info** : " + ) + + references = ( + "MITRE-ATTACK: T1525\n" + "ENS-RD2022: op.exp.4.r4.gcp.log.1, op.mon.3.gcp.scc.1" + ) + + i = 0 + self.assertEqual(findings[i].description, description) + self.assertEqual(findings[i].mitigation, mitigation) + self.assertEqual(findings[i].title, "Ensure Image Vulnerability Analysis using AR Container Analysis or a third-party provider") + self.assertEqual(findings[i].severity, "Medium") + self.assertEqual(findings[i].impact, "Without image vulnerability scanning, container images stored in Artifact Registry may contain known vulnerabilities, increasing the risk of exploitation by malicious actors.") + self.assertEqual(findings[i].references, references) + with self.subTest(i=1): + description = ( + "**Cloud Type** : GCP\n\n" + "**Description** : GCP `Firewall Rules` are specific to a `VPC Network`. Each rule either `allows` or `denies` traffic when its conditions are met. Its conditions allow users to specify the type of traffic, such as ports and protocols, and the source or destination of the traffic, including IP addresses, subnets, and instances. Firewall rules are defined at the VPC network level and are specific to the network in which they are defined. The rules themselves cannot be shared among networks. Firewall rules only support IPv4 traffic. When specifying a source for an ingress rule or a destination for an egress rule by address, an `IPv4` address or `IPv4 block in CIDR` notation can be used. Generic `(0.0.0.0/0)` incoming traffic from the Internet to a VPC or VM instance using `RDP` on `Port 3389` can be avoided.\n\n" + "**Service Name** : networking\n\n" + "**Status Detail** : Firewall does not expose port 3389 (RDP) to the internet.\n\n" + "**Finding Created Time** : 2025-02-14 14:27:20.697446\n\n" + "**Region** : \n\n" + "**Notes** : \n\n" + "**Additional URLs** : https://cloud.google.com/api-keys/docs | https://cloud.google.com/docs/authentication" + ) + + mitigation = ( + "**Remediation Recommendation** : Ensure that Google Cloud Virtual Private Cloud (VPC) firewall rules do not allow unrestricted access (i.e. 0.0.0.0/0) on TCP port 3389 in order to restrict Remote Desktop Protocol (RDP) traffic to trusted IP addresses or IP ranges only and reduce the attack surface. TCP port 3389 is used for secure remote GUI login to Windows VM instances by connecting a RDP client application with an RDP server.\n\n" + "**Remediation Recommendation URL** : https://cloud.google.com/vpc/docs/using-firewalls\n\n" + "**Remediation Code Native IaC** : \n\n" + "**Remediation Code Terraform** : https://docs./checks/gcp/google-cloud-networking-policies/bc_gcp_networking_2#terraform\n\n" + "**Remediation Code CLI** : https://docs./checks/gcp/google-cloud-networking-policies/bc_gcp_networking_2#cli-command\n\n" + "**Other Remediation Info** : https://www.trendmicro.com/cloudoneconformity/knowledge-base/gcp/CloudVPC/unrestricted-rdp-access.html" + ) + + references = ( + "MITRE-ATTACK: T1190, T1199, T1048, T1498, T1046\n" + "CIS-2.0: 3.7\n" + "ENS-RD2022: mp.com.1.gcp.fw.1\n" + "CIS-3.0: 3.7" + ) + + i = 1 + self.assertEqual(findings[i].description, description) + self.assertEqual(findings[i].mitigation, mitigation) + self.assertEqual(findings[i].title, "Ensure That RDP Access Is Restricted From the Internet") + self.assertEqual(findings[i].severity, "Critical") + self.assertEqual(findings[i].impact, "Allowing unrestricted Remote Desktop Protocol (RDP) access can increase opportunities for malicious activities such as hacking, Man-In-The-Middle attacks (MITM) and Pass-The-Hash (PTH) attacks.") + self.assertEqual(findings[i].references, references) + + def test_prowler_parser_kubernetes_csv_file_with_multiple_vulnerabilities(self): + with (get_unit_tests_scans_path("prowler") / "example_output_kubernetes.csv").open(encoding="utf-8") as testfile: + parser = ProwlerParser() + findings = parser.get_findings(testfile, Test()) + testfile.close() + self.assertEqual(4, len(findings)) + with self.subTest(i=0): + description = ( + "**Cloud Type** : KUBERNETES\n\n" + "**Description** : This check verifies that the AlwaysPullImages admission control plugin is enabled in the Kubernetes API server. This plugin ensures that every new pod always pulls the required images, enforcing image access control and preventing the use of possibly outdated or altered images.\n\n" + "**Service Name** : apiserver\n\n" + "**Status Detail** : AlwaysPullImages admission control plugin is not set in pod \n\n" + "**Finding Created Time** : 2025-02-14 14:27:38.533897\n\n" + "**Region** : namespace: kube-system\n\n" + "**Notes** : Enabling AlwaysPullImages can increase network and registry load and decrease container startup speed. It may not be suitable for all environments.\n\n" + "**Related URL** : https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#alwayspullimages\n\n" + "**Additional URLs** : https://kubernetes.io/docs/concepts/containers/images/ | https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/" + ) + + mitigation = ( + "**Remediation Recommendation** : Configure the API server to use the AlwaysPullImages admission control plugin to ensure image security and integrity.\n\n" + "**Remediation Recommendation URL** : https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers\n\n" + "**Remediation Code Native IaC** : https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-admission-control-plugin-alwayspullimages-is-set#kubernetes\n\n" + "**Remediation Code Terraform** : \n\n" + "**Remediation Code CLI** : --enable-admission-plugins=...,AlwaysPullImages,...\n\n" + "**Other Remediation Info** : " + ) + + references = ( + "CIS-1.10: 1.2.11\n" + "CIS-1.8: 1.2.11" + ) + + i = 0 + self.assertEqual(findings[i].description, description) + self.assertEqual(findings[i].mitigation, mitigation) + self.assertEqual(findings[i].title, "Ensure that the admission control plugin AlwaysPullImages is set") + self.assertEqual(findings[i].severity, "Medium") + self.assertEqual(findings[i].impact, "Without AlwaysPullImages, once an image is pulled to a node, any pod can use it without any authorization check, potentially leading to security risks.") + self.assertEqual(findings[i].references, references) + with self.subTest(i=1): + description = ( + "**Cloud Type** : KUBERNETES\n\n" + "**Description** : Disable anonymous requests to the API server. When enabled, requests that are not rejected by other configured authentication methods are treated as anonymous requests, which are then served by the API server. Disallowing anonymous requests strengthens security by ensuring all access is authenticated.\n\n" + "**Service Name** : apiserver\n\n" + "**Status Detail** : API Server does not have anonymous-auth enabled in pod \n\n" + "**Finding Created Time** : 2025-02-14 14:27:38.533897\n\n" + "**Region** : namespace: kube-system\n\n" + "**Notes** : While anonymous access can be useful for health checks and discovery, consider the security implications for your specific environment.\n\n" + "**Related URL** : https://kubernetes.io/docs/admin/authentication/#anonymous-requests\n\n" + "**Additional URLs** : https://kubernetes.io/docs/concepts/containers/images/ | https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/" + ) + + mitigation = ( + "**Remediation Recommendation** : Ensure the --anonymous-auth argument in the API server is set to false. This will reject all anonymous requests, enforcing authenticated access to the server.\n\n" + "**Remediation Recommendation URL** : https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/\n\n" + "**Remediation Code Native IaC** : https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-anonymous-auth-argument-is-set-to-false-1#kubernetes\n\n" + "**Remediation Code Terraform** : \n\n" + "**Remediation Code CLI** : --anonymous-auth=false\n\n" + "**Other Remediation Info** : " + ) + + references = ( + "CIS-1.10: 1.2.1\n" + "CIS-1.8: 1.2.1" + ) + + i = 1 + self.assertEqual(findings[i].description, description) + self.assertEqual(findings[i].mitigation, mitigation) + self.assertEqual(findings[i].title, "Ensure that the --anonymous-auth argument is set to false") + self.assertEqual(findings[i].severity, "High") + self.assertEqual(findings[i].impact, "Enabling anonymous access to the API server can expose the cluster to unauthorized access and potential security vulnerabilities.") + self.assertEqual(findings[i].references, references) + with self.subTest(i=2): + description = ( + "**Cloud Type** : KUBERNETES\n\n" + "**Description** : This check ensures that the Kubernetes API server is configured with an appropriate audit log retention period. Setting --audit-log-maxage to 30 or as per business requirements helps in maintaining logs for sufficient time to investigate past events.\n\n" + "**Service Name** : apiserver\n\n" + "**Status Detail** : Audit log max age is not set to 30 or as appropriate in pod \n\n" + "**Finding Created Time** : 2025-02-14 14:27:38.533897\n\n" + "**Region** : namespace: kube-system\n\n" + "**Notes** : Ensure the audit log retention period is set appropriately to balance between storage constraints and the need for historical data.\n\n" + "**Related URL** : https://kubernetes.io/docs/concepts/cluster-administration/audit/\n\n" + "**Additional URLs** : https://kubernetes.io/docs/concepts/containers/images/ | https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/" + ) + + mitigation = ( + "**Remediation Recommendation** : Configure the API server audit log retention period to retain logs for at least 30 days or as per your organization's requirements.\n\n" + "**Remediation Recommendation URL** : https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/\n\n" + "**Remediation Code Native IaC** : https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-audit-log-maxage-argument-is-set-to-30-or-as-appropriate#kubernetes\n\n" + "**Remediation Code Terraform** : \n\n" + "**Remediation Code CLI** : --audit-log-maxage=30\n\n" + "**Other Remediation Info** : " + ) + + references = ( + "CIS-1.10: 1.2.17\n" + "CIS-1.8: 1.2.18" + ) + + i = 2 + self.assertEqual(findings[i].description, description) + self.assertEqual(findings[i].mitigation, mitigation) + self.assertEqual(findings[i].title, "Ensure that the --audit-log-maxage argument is set to 30 or as appropriate") + self.assertEqual(findings[i].severity, "Medium") + self.assertEqual(findings[i].impact, "Without an adequate log retention period, there may be insufficient audit history to investigate and analyze past events or security incidents.") + self.assertEqual(findings[i].references, references) + with self.subTest(i=3): + description = ( + "**Cloud Type** : KUBERNETES\n\n" + "**Description** : This check ensures that the Kubernetes API server is configured with an appropriate number of audit log backups. Setting --audit-log-maxbackup to 10 or as per business requirements helps maintain a sufficient log backup for investigations or analysis.\n\n" + "**Service Name** : apiserver\n\n" + "**Status Detail** : Audit log max backup is not set to 10 or as appropriate in pod \n\n" + "**Finding Created Time** : 2025-02-14 14:27:38.533897\n\n" + "**Region** : namespace: kube-system\n\n" + "**Notes** : Ensure the audit log backup retention period is set appropriately to balance between storage constraints and the need for historical data.\n\n" + "**Related URL** : https://kubernetes.io/docs/concepts/cluster-administration/audit/\n\n" + "**Additional URLs** : https://kubernetes.io/docs/concepts/containers/images/ | https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/" + ) + + mitigation = ( + "**Remediation Recommendation** : Configure the API server audit log backup retention to 10 or as per your organization's requirements.\n\n" + "**Remediation Recommendation URL** : https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/\n\n" + "**Remediation Code Native IaC** : https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-audit-log-maxbackup-argument-is-set-to-10-or-as-appropriate#kubernetes\n\n" + "**Remediation Code Terraform** : \n\n" + "**Remediation Code CLI** : --audit-log-maxbackup=10\n\n" + "**Other Remediation Info** : " + ) + + references = ( + "CIS-1.10: 1.2.18\n" + "CIS-1.8: 1.2.19" + ) + + i = 3 + self.assertEqual(findings[i].description, description) + self.assertEqual(findings[i].mitigation, mitigation) + self.assertEqual(findings[i].title, "Ensure that the --audit-log-maxbackup argument is set to 10 or as appropriate") + self.assertEqual(findings[i].severity, "Medium") + self.assertEqual(findings[i].impact, "Without an adequate number of audit log backups, there may be insufficient log history to investigate past events or security incidents.") + self.assertEqual(findings[i].references, references) + + def test_prowler_parser_aws_json_file_with_multiple_vulnerabilities(self): + with (get_unit_tests_scans_path("prowler") / "example_output_aws.ocsf.json").open(encoding="utf-8") as testfile: + parser = ProwlerParser() + findings = parser.get_findings(testfile, Test()) + testfile.close() + self.assertEqual(3, len(findings)) + with self.subTest(i=0): + description = ( + "**Cloud Type** : AWS\n\n" + "**Finding Description** : Check if IAM Access Analyzer is enabled\n\n" + "**Product Name** : Prowler\n\n" + "**Status Detail** : IAM Access Analyzer in account is not enabled.\n\n" + "**Finding Created Time** : 2025-02-14T14:27:03.913874\n\n" + "**AWS Region** : " + ) + + mitigation = ( + "**Remediation Description** : Enable IAM Access Analyzer for all accounts, create analyzer and take action over it is recommendations (IAM Access Analyzer is available at no additional cost).\n\n" + "**Remediation References** : aws accessanalyzer create-analyzer --analyzer-name --type , https://docs.aws.amazon.com/IAM/latest/UserGuide/what-is-access-analyzer.html" + ) + + references = ( + "**Related URL** : https://docs.aws.amazon.com/IAM/latest/UserGuide/what-is-access-analyzer.html\n\n" + "**CIS-1.4** : 1.20\n\n" + "**CIS-1.5** : 1.20\n\n" + "**KISA-ISMS-P-2023** : 2.5.6, 2.6.4, 2.8.1, 2.8.2\n\n" + "**CIS-2.0** : 1.20\n\n" + "**KISA-ISMS-P-2023-korean** : 2.5.6, 2.6.4, 2.8.1, 2.8.2\n\n" + "**AWS-Account-Security-Onboarding** : Enabled security services, Create analyzers in each active regions, Verify that events are present in SecurityHub aggregated view\n\n" + "**CIS-3.0** : 1.20" + ) + + i = 0 + self.assertEqual(findings[i].description, description) + self.assertEqual(findings[i].mitigation, mitigation) + self.assertEqual(findings[i].title, "IAM Access Analyzer in account is not enabled.") + self.assertEqual(findings[i].severity, "Low") + self.assertEqual(findings[i].impact, "AWS IAM Access Analyzer helps you identify the resources in your organization and accounts, such as Amazon S3 buckets or IAM roles, that are shared with an external entity. This lets you identify unintended access to your resources and data, which is a security risk. IAM Access Analyzer uses a form of mathematical analysis called automated reasoning, which applies logic and mathematical inference to determine all possible access paths allowed by a resource policy.") + self.assertEqual(findings[i].references, references) + with self.subTest(i=1): + description = ( + "**Cloud Type** : AWS\n\n" + "**Finding Description** : Maintain current contact details.\n\n" + "**Product Name** : Prowler\n\n" + "**Status Detail** : Login to the AWS Console. Choose your account name on the top right of the window -> My Account -> Contact Information.\n\n" + "**Finding Created Time** : 2025-02-14T14:27:03.913874\n\n" + "**AWS Region** : " + ) + + mitigation = ( + "**Remediation Description** : Using the Billing and Cost Management console complete contact details.\n\n" + "**Remediation References** : No command available., https://docs.prowler.com/checks/aws/iam-policies/iam_18-maintain-contact-details#aws-console, https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-update-contact.html" + ) + + references = ( + "**Related URL** : \n\n" + "**CIS-1.4** : 1.1\n\n" + "**CIS-1.5** : 1.1\n\n" + "**KISA-ISMS-P-2023** : 2.1.3\n\n" + "**CIS-2.0** : 1.1\n\n" + "**KISA-ISMS-P-2023-korean** : 2.1.3\n\n" + "**AWS-Well-Architected-Framework-Security-Pillar** : SEC03-BP03, SEC10-BP01\n\n" + "**AWS-Account-Security-Onboarding** : Billing, emergency, security contacts\n\n" + "**CIS-3.0** : 1.1\n\n" + "**ENS-RD2022** : op.ext.7.aws.am.1" + ) + + i = 1 + self.assertEqual(findings[i].description, description) + self.assertEqual(findings[i].mitigation, mitigation) + self.assertEqual(findings[i].title, "Login to the AWS Console. Choose your account name on the top right of the window -> My Account -> Contact Information.") + self.assertEqual(findings[i].severity, "Medium") + self.assertEqual(findings[i].impact, "Ensure contact email and telephone details for AWS accounts are current and map to more than one individual in your organization. An AWS account supports a number of contact details, and AWS will use these to contact the account owner if activity judged to be in breach of Acceptable Use Policy. If an AWS account is observed to be behaving in a prohibited or suspicious manner, AWS will attempt to contact the account owner by email and phone using the contact details listed. If this is unsuccessful and the account behavior needs urgent mitigation, proactive measures may be taken, including throttling of traffic between the account exhibiting suspicious behavior and the AWS API endpoints and the Internet. This will result in impaired service to and from the account in question.") + self.assertEqual(findings[i].references, references) + with self.subTest(i=2): + description = ( + "**Cloud Type** : AWS\n\n" + "**Finding Description** : Maintain different contact details to security, billing and operations.\n\n" + "**Product Name** : Prowler\n\n" + "**Status Detail** : SECURITY, BILLING and OPERATIONS contacts not found or they are not different between each other and between ROOT contact.\n\n" + "**Finding Created Time** : 2025-02-14T14:27:03.913874\n\n" + "**AWS Region** : " + ) + + mitigation = ( + "**Remediation Description** : Using the Billing and Cost Management console complete contact details.\n\n" + "**Remediation References** : https://docs.prowler.com/checks/aws/iam-policies/iam_18-maintain-contact-details#aws-console, https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-update-contact.html" + ) + + references = ( + "**Related URL** : https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-update-contact.html\n\n" + "**KISA-ISMS-P-2023** : 2.1.3\n\n" + "**KISA-ISMS-P-2023-korean** : 2.1.3" + ) + + i = 2 + self.assertEqual(findings[i].description, description) + self.assertEqual(findings[i].mitigation, mitigation) + self.assertEqual(findings[i].title, "SECURITY, BILLING and OPERATIONS contacts not found or they are not different between each other and between ROOT contact.") + self.assertEqual(findings[i].severity, "Medium") + self.assertEqual(findings[i].impact, "Ensure contact email and telephone details for AWS accounts are current and map to more than one individual in your organization. An AWS account supports a number of contact details, and AWS will use these to contact the account owner if activity judged to be in breach of Acceptable Use Policy. If an AWS account is observed to be behaving in a prohibited or suspicious manner, AWS will attempt to contact the account owner by email and phone using the contact details listed. If this is unsuccessful and the account behavior needs urgent mitigation, proactive measures may be taken, including throttling of traffic between the account exhibiting suspicious behavior and the AWS API endpoints and the Internet. This will result in impaired service to and from the account in question.") + self.assertEqual(findings[i].references, references) + + def test_prowler_parser_azure_json_file_with_multiple_vulnerabilities(self): + with (get_unit_tests_scans_path("prowler") / "example_output_azure.ocsf.json").open(encoding="utf-8") as testfile: + parser = ProwlerParser() + findings = parser.get_findings(testfile, Test()) + testfile.close() + self.assertEqual(3, len(findings)) + with self.subTest(i=0): + description = ( + "**Cloud Type** : AZURE\n\n" + "**Finding Description** : Application Insights within Azure act as an Application Performance Monitoring solution providing valuable data into how well an application performs and additional information when performing incident response. The types of log data collected include application metrics, telemetry data, and application trace logging data providing organizations with detailed information about application activity and application transactions. Both data sets help organizations adopt a proactive and retroactive means to handle security and performance related metrics within their modern applications.\n\n" + "**Product Name** : Prowler\n\n" + "**Status Detail** : There are no AppInsight configured in subscription .\n\n" + "**Finding Created Time** : 2025-02-14T14:27:30.710664\n\n" + "**AZURE Region** : global" + ) + + mitigation = ( + "**Remediation Description** : 1. Navigate to Application Insights 2. Under the Basics tab within the PROJECT DETAILS section, select the Subscription 3. Select the Resource group 4. Within the INSTANCE DETAILS, enter a Name 5. Select a Region 6. Next to Resource Mode, select Workspace-based 7. Within the WORKSPACE DETAILS, select the Subscription for the log analytics workspace 8. Select the appropriate Log Analytics Workspace 9. Click Next:Tags > 10. Enter the appropriate Tags as Name, Value pairs. 11. Click Next:Review+Create 12. Click Create.\n\n" + "**Remediation References** : az monitor app-insights component create --app --resource-group --location --kind 'web' --retention-time --workspace -- subscription , https://www.tenable.com/audits/items/CIS_Microsoft_Azure_Foundations_v2.0.0_L2.audit:8a7a608d180042689ad9d3f16aa359f1" + ) + + references = ( + "**Related URL** : https://learn.microsoft.com/en-us/azure/azure-monitor/app/app-insights-overview\n\n" + "**CIS-2.1** : 5.3.1\n\n" + "**ENS-RD2022** : mp.s.4.r1.az.nt.2\n\n" + "**CIS-3.0** : 6.3.1\n\n" + "**CIS-2.0** : 5.3.1" + ) + + i = 0 + self.assertEqual(findings[i].description, description) + self.assertEqual(findings[i].mitigation, mitigation) + self.assertEqual(findings[i].title, "There are no AppInsight configured in subscription .") + self.assertEqual(findings[i].severity, "Low") + self.assertEqual(findings[i].impact, "Configuring Application Insights provides additional data not found elsewhere within Azure as part of a much larger logging and monitoring program within an organization's Information Security practice. The types and contents of these logs will act as both a potential cost saving measure (application performance) and a means to potentially confirm the source of a potential incident (trace logging). Metrics and Telemetry data provide organizations with a proactive approach to cost savings by monitoring an application's performance, while the trace logging data provides necessary details in a reactive incident response scenario by helping organizations identify the potential source of an incident within their application.") + self.assertEqual(findings[i].references, references) + with self.subTest(i=1): + description = ( + "**Cloud Type** : AZURE\n\n" + "**Finding Description** : Microsoft Defender for Cloud emails the subscription owners whenever a high-severity alert is triggered for their subscription. You should provide a security contact email address as an additional email address.\n\n" + "**Product Name** : Prowler\n\n" + "**Status Detail** : There is not another correct email configured for subscription .\n\n" + "**Finding Created Time** : 2025-02-14T14:27:30.710664\n\n" + "**AZURE Region** : global" + ) + + mitigation = ( + "**Remediation Description** : 1. From Azure Home select the Portal Menu 2. Select Microsoft Defender for Cloud 3. Click on Environment Settings 4. Click on the appropriate Management Group, Subscription, or Workspace 5. Click on Email notifications 6. Enter a valid security contact email address (or multiple addresses separated by commas) in the Additional email addresses field 7. Click Save\n\n" + "**Remediation References** : https://docs.prowler.com/checks/azure/azure-general-policies/ensure-that-security-contact-emails-is-set#terraform, https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/SecurityCenter/security-contact-email.html, https://learn.microsoft.com/en-us/rest/api/defenderforcloud/security-contacts/list?view=rest-defenderforcloud-2020-01-01-preview&tabs=HTTP" + ) + + references = ( + "**Related URL** : https://docs.microsoft.com/en-us/azure/security-center/security-center-provide-security-contact-details\n\n" + "**CIS-2.1** : 2.1.18\n\n" + "**ENS-RD2022** : op.mon.3.r3.az.de.1\n\n" + "**CIS-3.0** : 3.1.13\n\n" + "**CIS-2.0** : 2.1.19" + ) + + i = 1 + self.assertEqual(findings[i].description, description) + self.assertEqual(findings[i].mitigation, mitigation) + self.assertEqual(findings[i].title, "There is not another correct email configured for subscription .") + self.assertEqual(findings[i].severity, "Medium") + self.assertEqual(findings[i].impact, "Microsoft Defender for Cloud emails the Subscription Owner to notify them about security alerts. Adding your Security Contact's email address to the 'Additional email addresses' field ensures that your organization's Security Team is included in these alerts. This ensures that the proper people are aware of any potential compromise in order to mitigate the risk in a timely fashion.") + self.assertEqual(findings[i].references, references) + with self.subTest(i=2): + description = ( + "**Cloud Type** : AZURE\n\n" + "**Finding Description** : Ensure That Microsoft Defender for App Services Is Set To 'On' \n\n" + "**Product Name** : Prowler\n\n" + "**Status Detail** : Defender plan Defender for App Services from subscription is set to OFF (pricing tier not standard).\n\n" + "**Finding Created Time** : 2025-02-14T14:27:30.710664\n\n" + "**AZURE Region** : global" + ) + + mitigation = ( + "**Remediation Description** : By , Microsoft Defender for Cloud is not enabled for your App Service instances. Enabling the Defender security service for App Service instances allows for advanced security defense using threat detection capabilities provided by Microsoft Security Response Center.\n\n" + "**Remediation References** : https://docs.prowler.com/checks/azure/azure-general-policies/ensure-that-azure-defender-is-set-to-on-for-app-service#terraform, https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/SecurityCenter/defender-app-service.html, https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/SecurityCenter/defender-app-service.html" + ) + + references = ( + "**Related URL** : \n\n" + "**CIS-2.1** : 2.1.2\n\n" + "**ENS-RD2022** : mp.s.4.r1.az.nt.3\n\n" + "**MITRE-ATTACK** : T1190, T1059, T1204, T1552, T1486, T1499, T1496, T1087\n\n" + "**CIS-3.0** : 3.1.6.1" + ) + + i = 2 + self.assertEqual(findings[i].description, description) + self.assertEqual(findings[i].mitigation, mitigation) + self.assertEqual(findings[i].title, "Defender plan Defender for App Services from subscription is set to OFF (pricing tier not standard).") + self.assertEqual(findings[i].severity, "High") + self.assertEqual(findings[i].impact, "Turning on Microsoft Defender for App Service enables threat detection for App Service, providing threat intelligence, anomaly detection, and behavior analytics in the Microsoft Defender for Cloud.") + self.assertEqual(findings[i].references, references) + + def test_prowler_parser_gcp_json_file_with_multiple_vulnerabilities(self): + with (get_unit_tests_scans_path("prowler") / "example_output_gcp.ocsf.json").open(encoding="utf-8") as testfile: + parser = ProwlerParser() + findings = parser.get_findings(testfile, Test()) + testfile.close() + self.assertEqual(3, len(findings)) + with self.subTest(i=0): + description = ( + "**Cloud Type** : GCP\n\n" + "**Finding Description** : API Keys should only be used for services in cases where other authentication methods are unavailable. Unused keys with their permissions in tact may still exist within a project. Keys are insecure because they can be viewed publicly, such as from within a browser, or they can be accessed on a device where the key resides. It is recommended to use standard authentication flow instead.\n\n" + "**Product Name** : Prowler\n\n" + "**Status Detail** : Project does not have active API Keys.\n\n" + "**Finding Created Time** : 2025-02-14T14:27:20.697446\n\n" + "**GCP Region** : global" + ) + + mitigation = ( + "**Remediation Description** : To avoid the security risk in using API keys, it is recommended to use standard authentication flow instead.\n\n" + "**Remediation References** : gcloud alpha services api-keys delete, https://cloud.google.com/docs/authentication/api-keys" + ) + + references = ( + "**Related URL** : \n\n" + "**MITRE-ATTACK** : T1098\n\n" + "**CIS-2.0** : 1.12\n\n" + "**ENS-RD2022** : op.acc.2.gcp.rbak.1\n\n" + "**CIS-3.0** : 1.12" + ) + + i = 0 + self.assertEqual(findings[i].description, description) + self.assertEqual(findings[i].mitigation, mitigation) + self.assertEqual(findings[i].title, "Project does not have active API Keys.") + self.assertEqual(findings[i].severity, "Medium") + self.assertEqual(findings[i].impact, "Security risks involved in using API-Keys appear below: API keys are simple encrypted strings, API keys do not identify the user or the application making the API request, API keys are typically accessible to clients, making it easy to discover and steal an API key.") + self.assertEqual(findings[i].references, references) + with self.subTest(i=1): + description = ( + "**Cloud Type** : GCP\n\n" + "**Finding Description** : Scan images stored in Google Container Registry (GCR) for vulnerabilities using AR Container Analysis or a third-party provider. This helps identify and mitigate security risks associated with known vulnerabilities in container images.\n\n" + "**Product Name** : Prowler\n\n" + "**Status Detail** : AR Container Analysis is not enabled in project .\n\n" + "**Finding Created Time** : 2025-02-14T14:27:20.697446\n\n" + "**GCP Region** : global" + ) + + mitigation = ( + "**Remediation Description** : Enable vulnerability scanning for images stored in Artifact Registry using AR Container Analysis or a third-party provider.\n\n" + "**Remediation References** : gcloud services enable containeranalysis.googleapis.com, https://cloud.google.com/artifact-analysis/docs/container-scanning-overview" + ) + + references = ( + "**Related URL** : https://cloud.google.com/artifact-analysis/docs\n\n" + "**MITRE-ATTACK** : T1525\n\n" + "**ENS-RD2022** : op.exp.4.r4.gcp.log.1, op.mon.3.gcp.scc.1" + ) + + i = 1 + self.assertEqual(findings[i].description, description) + self.assertEqual(findings[i].mitigation, mitigation) + self.assertEqual(findings[i].title, "AR Container Analysis is not enabled in project .") + self.assertEqual(findings[i].severity, "Medium") + self.assertEqual(findings[i].impact, "Without image vulnerability scanning, container images stored in Artifact Registry may contain known vulnerabilities, increasing the risk of exploitation by malicious actors.") + self.assertEqual(findings[i].references, references) + with self.subTest(i=2): + description = ( + "**Cloud Type** : GCP\n\n" + "**Finding Description** : GCP `Firewall Rules` are specific to a `VPC Network`. Each rule either `allows` or `denies` traffic when its conditions are met. Its conditions allow users to specify the type of traffic, such as ports and protocols, and the source or destination of the traffic, including IP addresses, subnets, and instances. Firewall rules are defined at the VPC network level and are specific to the network in which they are defined. The rules themselves cannot be shared among networks. Firewall rules only support IPv4 traffic. When specifying a source for an ingress rule or a destination for an egress rule by address, an `IPv4` address or `IPv4 block in CIDR` notation can be used. Generic `(0.0.0.0/0)` incoming traffic from the Internet to a VPC or VM instance using `RDP` on `Port 3389` can be avoided.\n\n" + "**Product Name** : Prowler\n\n" + "**Status Detail** : Firewall does exposes port 3389 (RDP) to the internet.\n\n" + "**Finding Created Time** : 2025-02-14T14:27:20.697446\n\n" + "**GCP Region** : global" + ) + + mitigation = ( + "**Remediation Description** : Ensure that Google Cloud Virtual Private Cloud (VPC) firewall rules do not allow unrestricted access (i.e. 0.0.0.0/0) on TCP port 3389 in order to restrict Remote Desktop Protocol (RDP) traffic to trusted IP addresses or IP ranges only and reduce the attack surface. TCP port 3389 is used for secure remote GUI login to Windows VM instances by connecting a RDP client application with an RDP server.\n\n" + "**Remediation References** : https://docs.prowler.com/checks/gcp/google-cloud-networking-policies/bc_gcp_networking_2#terraform, https://docs.prowler.com/checks/gcp/google-cloud-networking-policies/bc_gcp_networking_2#cli-command, https://www.trendmicro.com/cloudoneconformity/knowledge-base/gcp/CloudVPC/unrestricted-rdp-access.html, https://cloud.google.com/vpc/docs/using-firewalls" + ) + + references = ( + "**Related URL** : \n\n" + "**MITRE-ATTACK** : T1190, T1199, T1048, T1498, T1046\n\n" + "**CIS-2.0** : 3.7\n\n" + "**ENS-RD2022** : mp.com.1.gcp.fw.1\n\n" + "**CIS-3.0** : 3.7" + ) + + i = 2 + self.assertEqual(findings[i].description, description) + self.assertEqual(findings[i].mitigation, mitigation) + self.assertEqual(findings[i].title, "Firewall does exposes port 3389 (RDP) to the internet.") + self.assertEqual(findings[i].severity, "Critical") + self.assertEqual(findings[i].impact, "Allowing unrestricted Remote Desktop Protocol (RDP) access can increase opportunities for malicious activities such as hacking, Man-In-The-Middle attacks (MITM) and Pass-The-Hash (PTH) attacks.") + self.assertEqual(findings[i].references, references) + + def test_prowler_parser_kubernetes_json_file_with_multiple_vulnerabilities(self): + with (get_unit_tests_scans_path("prowler") / "example_output_kubernetes.ocsf.json").open(encoding="utf-8") as testfile: + parser = ProwlerParser() + findings = parser.get_findings(testfile, Test()) + testfile.close() + self.assertEqual(3, len(findings)) + with self.subTest(i=0): + description = ( + "**Cloud Type** : KUBERNETES\n\n" + "**Finding Description** : This check verifies that the AlwaysPullImages admission control plugin is enabled in the Kubernetes API server. This plugin ensures that every new pod always pulls the required images, enforcing image access control and preventing the use of possibly outdated or altered images.\n\n" + "**Product Name** : Prowler\n\n" + "**Status Detail** : AlwaysPullImages admission control plugin is not set in pod .\n\n" + "**Finding Created Time** : 2025-02-14T14:27:38.533897\n\n" + "**Pod Name** : \n\n" + "**Namespace** : " + ) + + mitigation = ( + "**Remediation Description** : Configure the API server to use the AlwaysPullImages admission control plugin to ensure image security and integrity.\n\n" + "**Remediation References** : https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-admission-control-plugin-alwayspullimages-is-set#kubernetes, --enable-admission-plugins=...,AlwaysPullImages,..., https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers" + ) + + references = ( + "**Related URL** : https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#alwayspullimages\n\n" + "**CIS-1.10** : 1.2.11\n\n" + "**CIS-1.8** : 1.2.11" + ) + + i = 0 + self.assertEqual(findings[i].description, description) + self.assertEqual(findings[i].mitigation, mitigation) + self.assertEqual(findings[i].title, "AlwaysPullImages admission control plugin is not set in pod .") + self.assertEqual(findings[i].severity, "Medium") + self.assertEqual(findings[i].impact, "Without AlwaysPullImages, once an image is pulled to a node, any pod can use it without any authorization check, potentially leading to security risks.") + self.assertEqual(findings[i].references, references) + with self.subTest(i=1): + description = ( + "**Cloud Type** : KUBERNETES\n\n" + "**Finding Description** : This check ensures that the Kubernetes API server is configured with an appropriate audit log retention period. Setting --audit-log-maxage to 30 or as per business requirements helps in maintaining logs for sufficient time to investigate past events.\n\n" + "**Product Name** : Prowler\n\n" + "**Status Detail** : Audit log max age is not set to 30 or as appropriate in pod .\n\n" + "**Finding Created Time** : 2025-02-14T14:27:38.533897\n\n" + "**Pod Name** : \n\n" + "**Namespace** : " + ) + + mitigation = ( + "**Remediation Description** : Configure the API server audit log retention period to retain logs for at least 30 days or as per your organization's requirements.\n\n" + "**Remediation References** : https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-audit-log-maxage-argument-is-set-to-30-or-as-appropriate#kubernetes, --audit-log-maxage=30, https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/" + ) + + references = ( + "**Related URL** : https://kubernetes.io/docs/concepts/cluster-administration/audit/\n\n" + "**CIS-1.10** : 1.2.17\n\n" + "**CIS-1.8** : 1.2.18" + ) + + i = 1 + self.assertEqual(findings[i].description, description) + self.assertEqual(findings[i].mitigation, mitigation) + self.assertEqual(findings[i].title, "Audit log max age is not set to 30 or as appropriate in pod .") + self.assertEqual(findings[i].severity, "Medium") + self.assertEqual(findings[i].impact, "Without an adequate log retention period, there may be insufficient audit history to investigate and analyze past events or security incidents.") + self.assertEqual(findings[i].references, references) + with self.subTest(i=2): + description = ( + "**Cloud Type** : KUBERNETES\n\n" + "**Finding Description** : This check ensures that the Kubernetes API server is configured with an appropriate number of audit log backups. Setting --audit-log-maxbackup to 10 or as per business requirements helps maintain a sufficient log backup for investigations or analysis.\n\n" + "**Product Name** : Prowler\n\n" + "**Status Detail** : Audit log max backup is not set to 10 or as appropriate in pod .\n\n" + "**Finding Created Time** : 2025-02-14T14:27:38.533897\n\n" + "**Pod Name** : \n\n" + "**Namespace** : \n" + "**Cloud Type** : KUBERNETES\n\n" + "**Finding Description** : This check ensures that the Kubernetes API server is configured with an appropriate number of audit log backups. Setting --audit-log-maxbackup to 10 or as per business requirements helps maintain a sufficient log backup for investigations or analysis.\n\n" + "**Product Name** : Prowler\n\n" + "**Status Detail** : Audit log max backup is not set to 10 or as appropriate in pod .\n\n" + "**Finding Created Time** : 2025-02-14T14:27:38.533897\n\n" + "**Pod Name** : \n\n" + "**Namespace** : " + ) + + mitigation = ( + "**Remediation Description** : Configure the API server audit log backup retention to 10 or as per your organization's requirements.\n\n" + "**Remediation References** : https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-audit-log-maxbackup-argument-is-set-to-10-or-as-appropriate#kubernetes, --audit-log-maxbackup=10, https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/" + ) + + references = ( + "**Related URL** : https://kubernetes.io/docs/concepts/cluster-administration/audit/\n\n" + "**CIS-1.10** : 1.2.18\n\n" + "**CIS-1.8** : 1.2.19" + ) + + i = 2 + self.assertEqual(findings[i].description, description) + self.assertEqual(findings[i].mitigation, mitigation) + self.assertEqual(findings[i].title, "Audit log max backup is not set to 10 or as appropriate in pod .") + self.assertEqual(findings[i].severity, "Medium") + self.assertEqual(findings[i].impact, "Without an adequate number of audit log backups, there may be insufficient log history to investigate past events or security incidents.") + self.assertEqual(findings[i].references, references) From c663c6512532fc0abb66e488c64d44814ea7b066 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 28 Dec 2025 21:21:50 -0600 Subject: [PATCH 081/126] fix(deps): update dependency @docsearch/css from 4.3.2 to v4.4.0 (docs/package.json) (#13956) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docs/package-lock.json | 8 ++++---- docs/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index d23639304b6..1c507feed08 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -9,7 +9,7 @@ "version": "1.8.0", "license": "MIT", "dependencies": { - "@docsearch/css": "4.3.2", + "@docsearch/css": "4.4.0", "@docsearch/js": "4.3.2", "@tabler/icons": "3.36.0", "@thulite/doks-core": "1.8.3", @@ -1482,9 +1482,9 @@ } }, "node_modules/@docsearch/css": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-4.3.2.tgz", - "integrity": "sha512-K3Yhay9MgkBjJJ0WEL5MxnACModX9xuNt3UlQQkDEDZJZ0+aeWKtOkxHNndMRkMBnHdYvQjxkm6mdlneOtU1IQ==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-4.4.0.tgz", + "integrity": "sha512-e9vPgtih6fkawakmYo0Y6V4BKBmDV7Ykudn7ADWXUs5b6pmtBRwDbpSG/WiaUG63G28OkJDEnsMvgIAnZgGwYw==", "license": "MIT" }, "node_modules/@docsearch/js": { diff --git a/docs/package.json b/docs/package.json index dd66cead668..4900701f02b 100644 --- a/docs/package.json +++ b/docs/package.json @@ -16,7 +16,7 @@ "preview": "vite preview --outDir public" }, "dependencies": { - "@docsearch/css": "4.3.2", + "@docsearch/css": "4.4.0", "@docsearch/js": "4.3.2", "@tabler/icons": "3.36.0", "@thulite/doks-core": "1.8.3", From 611243d6ac2151040a0793185a26626c98659e9d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 28 Dec 2025 21:22:35 -0600 Subject: [PATCH 082/126] chore(deps): bump pdfmake from 0.2.20 to 0.2.21 in /components (#13972) Bumps [pdfmake](https://github.com/bpampuch/pdfmake) from 0.2.20 to 0.2.21. - [Release notes](https://github.com/bpampuch/pdfmake/releases) - [Changelog](https://github.com/bpampuch/pdfmake/blob/0.2.21/CHANGELOG.md) - [Commits](https://github.com/bpampuch/pdfmake/compare/0.2.20...0.2.21) --- updated-dependencies: - dependency-name: pdfmake dependency-version: 0.2.21 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> --- components/package.json | 2 +- components/yarn.lock | 38 +++++++++++++++++++------------------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/components/package.json b/components/package.json index 58d5dfaef4a..4628edb5bce 100644 --- a/components/package.json +++ b/components/package.json @@ -33,7 +33,7 @@ "metismenu": "~3.0.7", "moment": "^2.30.1", "morris.js": "morrisjs/morris.js", - "pdfmake": "^0.2.20", + "pdfmake": "^0.2.21", "startbootstrap-sb-admin-2": "1.0.7" }, "engines": { diff --git a/components/yarn.lock b/components/yarn.lock index 96796efd8ca..17f9251d27e 100644 --- a/components/yarn.lock +++ b/components/yarn.lock @@ -418,10 +418,10 @@ hasown@^2.0.2: dependencies: function-bind "^1.1.2" -iconv-lite@^0.6.3: - version "0.6.3" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" - integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== +iconv-lite@^0.7.1: + version "0.7.1" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.7.1.tgz#d4af1d2092f2bb05aab6296e5e7cd286d2f15432" + integrity sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw== dependencies: safer-buffer ">= 2.1.2 < 3.0.0" @@ -575,15 +575,15 @@ pako@~1.0.2: resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== -pdfmake@^0.2.20: - version "0.2.20" - resolved "https://registry.yarnpkg.com/pdfmake/-/pdfmake-0.2.20.tgz#a2e37114e46247c9a295df2fc1c7184942de567e" - integrity sha512-bGbxbGFP5p8PWNT3Phsu1ZcRLnRfF6jmnuKTkgmt6i5PZzSdX6JaB+NeTz9q+aocfW8SE9GUjL3o/5GroBqGcQ== +pdfmake@^0.2.21: + version "0.2.21" + resolved "https://registry.yarnpkg.com/pdfmake/-/pdfmake-0.2.21.tgz#dbaadda4567d67c5be7feac54f6e8e23af776be6" + integrity sha512-kgBj6Bbj57vY/f0zpBz/OLmO4n248RopEEA+IRkfdKZtravqQL6lEkILYsdjiPFYCXImZA+62EtT2zjUVKb8YQ== dependencies: "@foliojs-fork/linebreak" "^1.1.2" "@foliojs-fork/pdfkit" "^0.15.3" - iconv-lite "^0.6.3" - xmldoc "^2.0.1" + iconv-lite "^0.7.1" + xmldoc "^2.0.3" png-js@^1.0.0: version "1.0.0" @@ -637,10 +637,10 @@ safe-buffer@~5.1.0, safe-buffer@~5.1.1: resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== -sax@^1.2.4: - version "1.4.1" - resolved "https://registry.yarnpkg.com/sax/-/sax-1.4.1.tgz#44cc8988377f126304d3b3fc1010c733b929ef0f" - integrity sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg== +sax@^1.4.3: + version "1.4.3" + resolved "https://registry.yarnpkg.com/sax/-/sax-1.4.3.tgz#fcebae3b756cdc8428321805f4b70f16ec0ab5db" + integrity sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ== select@^1.1.2: version "1.1.2" @@ -722,9 +722,9 @@ util-deprecate@~1.0.1: resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== -xmldoc@^2.0.1: - version "2.0.2" - resolved "https://registry.yarnpkg.com/xmldoc/-/xmldoc-2.0.2.tgz#1ad89f9054cc8b1c500135e746da2a608b7bca6b" - integrity sha512-UiRwoSStEXS3R+YE8OqYv3jebza8cBBAI2y8g3B15XFkn3SbEOyyLnmPHjLBPZANrPJKEzxxB7A3XwcLikQVlQ== +xmldoc@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/xmldoc/-/xmldoc-2.0.3.tgz#65b4226b753ea6cd4601f3f56d52338941d38380" + integrity sha512-6gRk4NY/Jvg67xn7OzJuxLRsGgiXBaPUQplVJ/9l99uIugxh4FTOewYz5ic8WScj7Xx/2WvhENiQKwkK9RpE4w== dependencies: - sax "^1.2.4" + sax "^1.4.3" From 1373f5c0bd574dec475a6ce8fd9be64e8661edb3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 28 Dec 2025 21:24:09 -0600 Subject: [PATCH 083/126] chore(deps): bump datatables.net-buttons-bs in /components (#13977) Bumps [datatables.net-buttons-bs](https://github.com/DataTables/Dist-DataTables-Buttons-Bootstrap) from 3.2.5 to 3.2.6. - [Release notes](https://github.com/DataTables/Dist-DataTables-Buttons-Bootstrap/releases) - [Commits](https://github.com/DataTables/Dist-DataTables-Buttons-Bootstrap/compare/3.2.5...3.2.6) --- updated-dependencies: - dependency-name: datatables.net-buttons-bs dependency-version: 3.2.6 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> --- components/package.json | 2 +- components/yarn.lock | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/components/package.json b/components/package.json index 4628edb5bce..6ccd842eb86 100644 --- a/components/package.json +++ b/components/package.json @@ -13,7 +13,7 @@ "chosen-js": "^1.8.7", "clipboard": "^2.0.11", "datatables.net": "^2.3.6", - "datatables.net-buttons-bs": "^3.2.5", + "datatables.net-buttons-bs": "^3.2.6", "datatables.net-colreorder": "^2.1.2", "drmonty-datatables-plugins": "^1.0.0", "drmonty-datatables-responsive": "^1.0.0", diff --git a/components/yarn.lock b/components/yarn.lock index 17f9251d27e..67eca1c957e 100644 --- a/components/yarn.lock +++ b/components/yarn.lock @@ -187,19 +187,19 @@ datatables.net-bs@^2: datatables.net "2.3.2" jquery ">=1.7" -datatables.net-buttons-bs@^3.2.5: - version "3.2.5" - resolved "https://registry.yarnpkg.com/datatables.net-buttons-bs/-/datatables.net-buttons-bs-3.2.5.tgz#fe9a8085a66cabd723833834f29e68e91f28ea14" - integrity sha512-7fXOIue+2jpWPWcIrAXWH3BjEhMUD8L2pInT0tqfoEcl/3T+CH0Q6dHJRI5RiYmYKO/HLjpCQ5yqYAL5DT7iHA== +datatables.net-buttons-bs@^3.2.6: + version "3.2.6" + resolved "https://registry.yarnpkg.com/datatables.net-buttons-bs/-/datatables.net-buttons-bs-3.2.6.tgz#8bc20d4df5a539d87add4702fdda134d001b1e77" + integrity sha512-DofCIF9VHbEejmooaVCNxiR7ecrYaWhQN8Tm0sKeofGwYu8h533adUG/SlYy20OSSeF2zRYmY/TsWOJBh5TuAA== dependencies: datatables.net-bs "^2" - datatables.net-buttons "3.2.5" + datatables.net-buttons "3.2.6" jquery ">=1.7" -datatables.net-buttons@3.2.5: - version "3.2.5" - resolved "https://registry.yarnpkg.com/datatables.net-buttons/-/datatables.net-buttons-3.2.5.tgz#e37fc4f06743e057e8e3e4abfda60c988e7c16da" - integrity sha512-OSTl7evbfe0SMee11lyzu5iv/z8Yp05eh3s1QBte/FNqHcoXN8hlAVSSGpYgk5pj8zwHPYIu6fHeMEue4ARUNg== +datatables.net-buttons@3.2.6: + version "3.2.6" + resolved "https://registry.yarnpkg.com/datatables.net-buttons/-/datatables.net-buttons-3.2.6.tgz#dad80c8f28eb18741cec49fb33397073217ca63e" + integrity sha512-rLqkB3xLIAYwVLt+lUSxybo/1WqveTAxhQm6wj6yvXlJiWq+oJ8MKW6H1q90QrXbNp0fGngnfD0cmpMZnNnnNw== dependencies: datatables.net "^2" jquery ">=1.7" From 806d757795d531c3f4cdf5887bb5265c9e35dec9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 28 Dec 2025 21:24:55 -0600 Subject: [PATCH 084/126] chore(deps): bump django-fieldsignals from 0.7.0 to 0.8.0 (#13979) Bumps [django-fieldsignals](https://github.com/craigds/django-fieldsignals) from 0.7.0 to 0.8.0. - [Release notes](https://github.com/craigds/django-fieldsignals/releases) - [Commits](https://github.com/craigds/django-fieldsignals/compare/0.7.0...0.8.0) --- updated-dependencies: - dependency-name: django-fieldsignals dependency-version: 0.8.0 dependency-type: direct:production update-type: version-update:semver-minor ... 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 1ad90d4e558..6f32810833b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -54,7 +54,7 @@ django-split-settings==1.3.2 git+https://github.com/valentijnscholten/django-tagulous.git@2b514f9140acfce608238d1426d864185b3c60a2#egg=django-tagulous PyJWT==2.10.1 cvss==3.6 -django-fieldsignals==0.7.0 +django-fieldsignals==0.8.0 hyperlink==21.0.0 djangosaml2==1.11.1 drf-spectacular==0.29.0 From 9b72139cd286c9542eff4c4897d4e6be6ab51581 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 28 Dec 2025 21:25:34 -0600 Subject: [PATCH 085/126] chore(deps): bump django-polymorphic from 4.4.1 to 4.5.1 (#13980) Bumps [django-polymorphic](https://github.com/jazzband/django-polymorphic) from 4.4.1 to 4.5.1. - [Release notes](https://github.com/jazzband/django-polymorphic/releases) - [Changelog](https://github.com/jazzband/django-polymorphic/blob/master/docs/changelog.rst) - [Commits](https://github.com/jazzband/django-polymorphic/compare/v4.4.1...v4.5.1) --- updated-dependencies: - dependency-name: django-polymorphic dependency-version: 4.5.1 dependency-type: direct:production update-type: version-update:semver-minor ... 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 6f32810833b..a90a791d746 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ django-environ==0.12.0 django-filter==25.1 django-imagekit==6.0.0 django-multiselectfield==1.0.1 -django-polymorphic==4.4.1 +django-polymorphic==4.5.1 django-crispy-forms==2.5 django_extensions==4.1 django-slack==5.19.0 From c3514d833c87db2a3180e371c5a1541b17940235 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 28 Dec 2025 21:31:14 -0600 Subject: [PATCH 086/126] chore(deps): update dependency renovatebot/renovate from 42.66.4 to v42.66.11 (.github/workflows/renovate.yaml) (#13987) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/renovate.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/renovate.yaml b/.github/workflows/renovate.yaml index 9936c8aa4cb..ac275d65550 100644 --- a/.github/workflows/renovate.yaml +++ b/.github/workflows/renovate.yaml @@ -21,4 +21,4 @@ jobs: uses: suzuki-shunsuke/github-action-renovate-config-validator@c22827f47f4f4a5364bdba19e1fe36907ef1318e # v1.1.1 with: strict: "true" - validator_version: 42.66.4 # renovate: datasource=github-releases depName=renovatebot/renovate + validator_version: 42.66.11 # renovate: datasource=github-releases depName=renovatebot/renovate From 6ac5b36e77b8ad21e4ea0b61bf04cfe09c52af04 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 28 Dec 2025 21:46:23 -0600 Subject: [PATCH 087/126] fix(deps): update dependency @docsearch/js from 4.3.2 to v4.4.0 (docs/package.json) (#13957) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docs/package-lock.json | 530 ++++++++++++++++++++++++++++++++++++++++- docs/package.json | 2 +- 2 files changed, 527 insertions(+), 5 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index 1c507feed08..8ee0ad96459 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@docsearch/css": "4.4.0", - "@docsearch/js": "4.3.2", + "@docsearch/js": "4.4.0", "@tabler/icons": "3.36.0", "@thulite/doks-core": "1.8.3", "@thulite/images": "3.3.3", @@ -26,6 +26,304 @@ "node": ">=20.11.0" } }, + "node_modules/@ai-sdk/gateway": { + "version": "2.0.23", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.23.tgz", + "integrity": "sha512-qmX7afPRszUqG5hryHF3UN8ITPIRSGmDW6VYCmByzjoUkgm3MekzSx2hMV1wr0P+llDeuXb378SjqUfpvWJulg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "2.0.0", + "@ai-sdk/provider-utils": "3.0.19", + "@vercel/oidc": "3.0.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-2.0.0.tgz", + "integrity": "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "3.0.19", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.19.tgz", + "integrity": "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "2.0.0", + "@standard-schema/spec": "^1.0.0", + "eventsource-parser": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/react": { + "version": "2.0.118", + "resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-2.0.118.tgz", + "integrity": "sha512-K/5VVEGTIu9SWrdQ0s/11OldFU8IjprDzeE6TaC2fOcQWhG7dGVGl9H8Z32QBHzdfJyMhFUxEyFKSOgA2j9+VQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider-utils": "3.0.19", + "ai": "5.0.116", + "swr": "^2.2.5", + "throttleit": "2.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1", + "zod": "^3.25.76 || ^4.1.8" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@algolia/abtesting": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.12.2.tgz", + "integrity": "sha512-oWknd6wpfNrmRcH0vzed3UPX0i17o4kYLM5OMITyMVM2xLgaRbIafoxL0e8mcrNNb0iORCJA0evnNDKRYth5WQ==", + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.46.2", + "@algolia/requester-browser-xhr": "5.46.2", + "@algolia/requester-fetch": "5.46.2", + "@algolia/requester-node-http": "5.46.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/autocomplete-core": { + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.19.2.tgz", + "integrity": "sha512-mKv7RyuAzXvwmq+0XRK8HqZXt9iZ5Kkm2huLjgn5JoCPtDy+oh9yxUMfDDaVCw0oyzZ1isdJBc7l9nuCyyR7Nw==", + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-plugin-algolia-insights": "1.19.2", + "@algolia/autocomplete-shared": "1.19.2" + } + }, + "node_modules/@algolia/autocomplete-plugin-algolia-insights": { + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.19.2.tgz", + "integrity": "sha512-TjxbcC/r4vwmnZaPwrHtkXNeqvlpdyR+oR9Wi2XyfORkiGkLTVhX2j+O9SaCCINbKoDfc+c2PB8NjfOnz7+oKg==", + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-shared": "1.19.2" + }, + "peerDependencies": { + "search-insights": ">= 1 < 3" + } + }, + "node_modules/@algolia/autocomplete-shared": { + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.19.2.tgz", + "integrity": "sha512-jEazxZTVD2nLrC+wYlVHQgpBoBB5KPStrJxLzsIFl6Kqd1AlG9sIAGl39V5tECLpIQzB3Qa2T6ZPJ1ChkwMK/w==", + "license": "MIT", + "peerDependencies": { + "@algolia/client-search": ">= 4.9.1 < 6", + "algoliasearch": ">= 4.9.1 < 6" + } + }, + "node_modules/@algolia/client-abtesting": { + "version": "5.46.2", + "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.46.2.tgz", + "integrity": "sha512-oRSUHbylGIuxrlzdPA8FPJuwrLLRavOhAmFGgdAvMcX47XsyM+IOGa9tc7/K5SPvBqn4nhppOCEz7BrzOPWc4A==", + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.46.2", + "@algolia/requester-browser-xhr": "5.46.2", + "@algolia/requester-fetch": "5.46.2", + "@algolia/requester-node-http": "5.46.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-analytics": { + "version": "5.46.2", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.46.2.tgz", + "integrity": "sha512-EPBN2Oruw0maWOF4OgGPfioTvd+gmiNwx0HmD9IgmlS+l75DatcBkKOPNJN+0z3wBQWUO5oq602ATxIfmTQ8bA==", + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.46.2", + "@algolia/requester-browser-xhr": "5.46.2", + "@algolia/requester-fetch": "5.46.2", + "@algolia/requester-node-http": "5.46.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-common": { + "version": "5.46.2", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.46.2.tgz", + "integrity": "sha512-Hj8gswSJNKZ0oyd0wWissqyasm+wTz1oIsv5ZmLarzOZAp3vFEda8bpDQ8PUhO+DfkbiLyVnAxsPe4cGzWtqkg==", + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-insights": { + "version": "5.46.2", + "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.46.2.tgz", + "integrity": "sha512-6dBZko2jt8FmQcHCbmNLB0kCV079Mx/DJcySTL3wirgDBUH7xhY1pOuUTLMiGkqM5D8moVZTvTdRKZUJRkrwBA==", + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.46.2", + "@algolia/requester-browser-xhr": "5.46.2", + "@algolia/requester-fetch": "5.46.2", + "@algolia/requester-node-http": "5.46.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-personalization": { + "version": "5.46.2", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.46.2.tgz", + "integrity": "sha512-1waE2Uqh/PHNeDXGn/PM/WrmYOBiUGSVxAWqiJIj73jqPqvfzZgzdakHscIVaDl6Cp+j5dwjsZ5LCgaUr6DtmA==", + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.46.2", + "@algolia/requester-browser-xhr": "5.46.2", + "@algolia/requester-fetch": "5.46.2", + "@algolia/requester-node-http": "5.46.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-query-suggestions": { + "version": "5.46.2", + "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.46.2.tgz", + "integrity": "sha512-EgOzTZkyDcNL6DV0V/24+oBJ+hKo0wNgyrOX/mePBM9bc9huHxIY2352sXmoZ648JXXY2x//V1kropF/Spx83w==", + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.46.2", + "@algolia/requester-browser-xhr": "5.46.2", + "@algolia/requester-fetch": "5.46.2", + "@algolia/requester-node-http": "5.46.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-search": { + "version": "5.46.2", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.46.2.tgz", + "integrity": "sha512-ZsOJqu4HOG5BlvIFnMU0YKjQ9ZI6r3C31dg2jk5kMWPSdhJpYL9xa5hEe7aieE+707dXeMI4ej3diy6mXdZpgA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@algolia/client-common": "5.46.2", + "@algolia/requester-browser-xhr": "5.46.2", + "@algolia/requester-fetch": "5.46.2", + "@algolia/requester-node-http": "5.46.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/ingestion": { + "version": "1.46.2", + "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.46.2.tgz", + "integrity": "sha512-1Uw2OslTWiOFDtt83y0bGiErJYy5MizadV0nHnOoHFWMoDqWW0kQoMFI65pXqRSkVvit5zjXSLik2xMiyQJDWQ==", + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.46.2", + "@algolia/requester-browser-xhr": "5.46.2", + "@algolia/requester-fetch": "5.46.2", + "@algolia/requester-node-http": "5.46.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/monitoring": { + "version": "1.46.2", + "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.46.2.tgz", + "integrity": "sha512-xk9f+DPtNcddWN6E7n1hyNNsATBCHIqAvVGG2EAGHJc4AFYL18uM/kMTiOKXE/LKDPyy1JhIerrh9oYb7RBrgw==", + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.46.2", + "@algolia/requester-browser-xhr": "5.46.2", + "@algolia/requester-fetch": "5.46.2", + "@algolia/requester-node-http": "5.46.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/recommend": { + "version": "5.46.2", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.46.2.tgz", + "integrity": "sha512-NApbTPj9LxGzNw4dYnZmj2BoXiAc8NmbbH6qBNzQgXklGklt/xldTvu+FACN6ltFsTzoNU6j2mWNlHQTKGC5+Q==", + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.46.2", + "@algolia/requester-browser-xhr": "5.46.2", + "@algolia/requester-fetch": "5.46.2", + "@algolia/requester-node-http": "5.46.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-browser-xhr": { + "version": "5.46.2", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.46.2.tgz", + "integrity": "sha512-ekotpCwpSp033DIIrsTpYlGUCF6momkgupRV/FA3m62SreTSZUKjgK6VTNyG7TtYfq9YFm/pnh65bATP/ZWJEg==", + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.46.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-fetch": { + "version": "5.46.2", + "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.46.2.tgz", + "integrity": "sha512-gKE+ZFi/6y7saTr34wS0SqYFDcjHW4Wminv8PDZEi0/mE99+hSrbKgJWxo2ztb5eqGirQTgIh1AMVacGGWM1iw==", + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.46.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-node-http": { + "version": "5.46.2", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.46.2.tgz", + "integrity": "sha512-ciPihkletp7ttweJ8Zt+GukSVLp2ANJHU+9ttiSxsJZThXc4Y2yJ8HGVWesW5jN1zrsZsezN71KrMx/iZsOYpg==", + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.46.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, "node_modules/@babel/cli": { "version": "7.28.3", "resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.28.3.tgz", @@ -1481,6 +1779,28 @@ "node": ">=6.9.0" } }, + "node_modules/@docsearch/core": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@docsearch/core/-/core-4.4.0.tgz", + "integrity": "sha512-kiwNo5KEndOnrf5Kq/e5+D9NBMCFgNsDoRpKQJ9o/xnSlheh6b8AXppMuuUVVdAUIhIfQFk/07VLjjk/fYyKmw==", + "license": "MIT", + "peerDependencies": { + "@types/react": ">= 16.8.0 < 20.0.0", + "react": ">= 16.8.0 < 20.0.0", + "react-dom": ">= 16.8.0 < 20.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/@docsearch/css": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-4.4.0.tgz", @@ -1488,14 +1808,51 @@ "license": "MIT" }, "node_modules/@docsearch/js": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-4.3.2.tgz", - "integrity": "sha512-xdfpPXMgKRY9EW7U1vtY7gLKbLZFa9ed+t0Dacquq8zXBqAlH9HlUf0h4Mhxm0xatsVeMaIR2wr/u6g0GsZyQw==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-4.4.0.tgz", + "integrity": "sha512-vCiKzjYD54bugUIMZA6YzuLDilkD3TNH/kfbvqsnzxiLTMu8F13psD+hdMSEOn7j+dFJOaf49fZ+gwr+rXctMw==", "license": "MIT", "dependencies": { + "@docsearch/react": "4.4.0", "htm": "3.1.1" } }, + "node_modules/@docsearch/react": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-4.4.0.tgz", + "integrity": "sha512-z12zeg1mV7WD4Ag4pKSuGukETJLaucVFwszDXL/qLaEgRqxEaVacO9SR1qqnCXvZztlvz2rt7cMqryi/7sKfjA==", + "license": "MIT", + "dependencies": { + "@ai-sdk/react": "^2.0.30", + "@algolia/autocomplete-core": "1.19.2", + "@docsearch/core": "4.4.0", + "@docsearch/css": "4.4.0", + "ai": "^5.0.30", + "algoliasearch": "^5.28.0", + "marked": "^16.3.0", + "zod": "^4.1.8" + }, + "peerDependencies": { + "@types/react": ">= 16.8.0 < 20.0.0", + "react": ">= 16.8.0 < 20.0.0", + "react-dom": ">= 16.8.0 < 20.0.0", + "search-insights": ">= 1 < 3" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "search-insights": { + "optional": true + } + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.1.tgz", @@ -2119,6 +2476,15 @@ "license": "MIT", "optional": true }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -2438,6 +2804,12 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@tabler/icons": { "version": "3.36.0", "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.36.0.tgz", @@ -2531,6 +2903,59 @@ "dev": true, "license": "MIT" }, + "node_modules/@vercel/oidc": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.0.5.tgz", + "integrity": "sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, + "node_modules/ai": { + "version": "5.0.116", + "resolved": "https://registry.npmjs.org/ai/-/ai-5.0.116.tgz", + "integrity": "sha512-+2hYJ80/NcDWuv9K2/MLP3cTCFgwWHmHlS1tOpFUKKcmLbErAAlE/S2knsKboc3PNAu8pQkDr2N3K/Vle7ENgQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "2.0.23", + "@ai-sdk/provider": "2.0.0", + "@ai-sdk/provider-utils": "3.0.19", + "@opentelemetry/api": "1.9.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/algoliasearch": { + "version": "5.46.2", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.46.2.tgz", + "integrity": "sha512-qqAXW9QvKf2tTyhpDA4qXv1IfBwD2eduSW6tUEBFIfCeE9gn9HQ9I5+MaKoenRuHrzk5sQoNh1/iof8mY7uD6Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "@algolia/abtesting": "1.12.2", + "@algolia/client-abtesting": "5.46.2", + "@algolia/client-analytics": "5.46.2", + "@algolia/client-common": "5.46.2", + "@algolia/client-insights": "5.46.2", + "@algolia/client-personalization": "5.46.2", + "@algolia/client-query-suggestions": "5.46.2", + "@algolia/client-search": "5.46.2", + "@algolia/ingestion": "1.46.2", + "@algolia/monitoring": "1.46.2", + "@algolia/recommend": "5.46.2", + "@algolia/requester-browser-xhr": "5.46.2", + "@algolia/requester-fetch": "5.46.2", + "@algolia/requester-node-http": "5.46.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -2943,6 +3368,15 @@ "node": ">=4" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -3021,6 +3455,15 @@ "node": ">=0.10.0" } }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -3461,6 +3904,12 @@ "node": ">=6" } }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -3570,6 +4019,18 @@ "semver": "bin/semver" } }, + "node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -4063,6 +4524,16 @@ } } }, + "node_modules/react": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -4257,6 +4728,13 @@ "node": ">=6.0.0" } }, + "node_modules/search-insights": { + "version": "2.17.3", + "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", + "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==", + "license": "MIT", + "peer": true + }, "node_modules/select": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz", @@ -4395,12 +4873,37 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swr": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.8.tgz", + "integrity": "sha512-gaCPRVoMq8WGDcWj9p4YWzCMPHzE0WNl6W8ADIx9c3JBEIdMkJGMzW+uzXvxHMltwcYACr9jP+32H8/hgwMR7w==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/thenby": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/thenby/-/thenby-1.3.4.tgz", "integrity": "sha512-89Gi5raiWA3QZ4b2ePcEwswC3me9JIg+ToSgtE0JWeCynLnLxNr/f9G+xfo9K+Oj4AFdom8YNJjibIARTJmapQ==", "license": "Apache-2.0" }, + "node_modules/throttleit": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz", + "integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/throttles": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/throttles/-/throttles-1.0.1.tgz", @@ -4565,6 +5068,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -4793,6 +5305,16 @@ "engines": { "node": ">=12" } + }, + "node_modules/zod": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", + "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/docs/package.json b/docs/package.json index 4900701f02b..614f1de5f74 100644 --- a/docs/package.json +++ b/docs/package.json @@ -17,7 +17,7 @@ }, "dependencies": { "@docsearch/css": "4.4.0", - "@docsearch/js": "4.3.2", + "@docsearch/js": "4.4.0", "@tabler/icons": "3.36.0", "@thulite/doks-core": "1.8.3", "@thulite/images": "3.3.3", From 2741ed201edcdd2a316bdef3d09040257681c610 Mon Sep 17 00:00:00 2001 From: kiblik <5609770+kiblik@users.noreply.github.com> Date: Mon, 29 Dec 2025 09:16:57 +0100 Subject: [PATCH 088/126] feat(docker): Clean official image from (unit)tests (#13877) Signed-off-by: kiblik <5609770+kiblik@users.noreply.github.com> --- .../release-x-manual-docker-containers.yml | 1 + Dockerfile.django-alpine | 22 ++++++++++++------- Dockerfile.django-debian | 22 ++++++++++++------- Dockerfile.nginx-alpine | 2 +- docker-compose.override.integration_tests.yml | 2 ++ 5 files changed, 32 insertions(+), 17 deletions(-) diff --git a/.github/workflows/release-x-manual-docker-containers.yml b/.github/workflows/release-x-manual-docker-containers.yml index 13466030c9e..8e73c08b6e7 100644 --- a/.github/workflows/release-x-manual-docker-containers.yml +++ b/.github/workflows/release-x-manual-docker-containers.yml @@ -76,6 +76,7 @@ jobs: push: true file: ./Dockerfile.${{ matrix.docker-image }}-${{ matrix.os }} context: . + target: release outputs: type=image,"name=${{ env.DOCKER_ORG }}/defectdojo-${{ matrix.docker-image}}",push-by-digest=true,name-canonical=true cache-from: type=gha,scope=${{ matrix.docker-image}}-${{ matrix.os }}-${{ env.PLATFORM }}-${{ github.head_ref || github.ref_name }} cache-to: type=gha,mode=max,scope=${{ matrix.docker-image}}-${{ matrix.os }}-${{ env.PLATFORM }}-${{ github.head_ref || github.ref_name }} diff --git a/Dockerfile.django-alpine b/Dockerfile.django-alpine index 40365930275..828a15b786f 100644 --- a/Dockerfile.django-alpine +++ b/Dockerfile.django-alpine @@ -68,26 +68,21 @@ RUN \ COPY \ docker/entrypoint-celery-beat.sh \ docker/entrypoint-celery-worker.sh \ - docker/entrypoint-celery-worker-dev.sh \ docker/entrypoint-initializer.sh \ docker/entrypoint-first-boot.sh \ docker/entrypoint-uwsgi.sh \ - docker/entrypoint-uwsgi-dev.sh \ - docker/entrypoint-unit-tests.sh \ - docker/entrypoint-unit-tests-devDocker.sh \ docker/wait-for-it.sh \ docker/secret-file-loader.sh \ docker/reach_database.sh \ docker/reach_broker.sh \ docker/certs/* \ / -COPY wsgi.py manage.py docker/unit-tests.sh ./ +COPY wsgi.py manage.py ./ COPY dojo/ ./dojo/ # Add extra fixtures to docker image which are loaded by the initializer COPY docker/extra_fixtures/* /app/dojo/fixtures/ -COPY tests/ ./tests/ RUN \ # Remove placeholder copied from docker/certs rm -f /readme.txt && \ @@ -140,9 +135,20 @@ ENTRYPOINT ["/entrypoint-uwsgi.sh"] FROM release AS development USER root -COPY requirements-dev.txt ./ -RUN pip3 install --no-cache-dir -r requirements-dev.txt +COPY \ + requirements-dev.txt \ + docker/unit-tests.sh \ + ./ +COPY \ + docker/entrypoint-celery-worker-dev.sh \ + docker/entrypoint-uwsgi-dev.sh \ + docker/entrypoint-unit-tests.sh \ + docker/entrypoint-unit-tests-devDocker.sh \ + / +RUN pip3 install --no-cache-dir -r requirements-dev.txt && \ + chmod 775 /*.sh USER ${uid} FROM development AS django-unittests COPY unittests/ ./unittests/ +COPY tests/ ./tests/ diff --git a/Dockerfile.django-debian b/Dockerfile.django-debian index eccf9bd6dae..779dbcba13d 100644 --- a/Dockerfile.django-debian +++ b/Dockerfile.django-debian @@ -71,26 +71,21 @@ RUN \ COPY \ docker/entrypoint-celery-beat.sh \ docker/entrypoint-celery-worker.sh \ - docker/entrypoint-celery-worker-dev.sh \ docker/entrypoint-initializer.sh \ docker/entrypoint-first-boot.sh \ docker/entrypoint-uwsgi.sh \ - docker/entrypoint-uwsgi-dev.sh \ - docker/entrypoint-unit-tests.sh \ - docker/entrypoint-unit-tests-devDocker.sh \ docker/wait-for-it.sh \ docker/secret-file-loader.sh \ docker/reach_database.sh \ docker/reach_broker.sh \ docker/certs/* \ / -COPY wsgi.py manage.py docker/unit-tests.sh ./ +COPY wsgi.py manage.py ./ COPY dojo/ ./dojo/ # Add extra fixtures to docker image which are loaded by the initializer COPY docker/extra_fixtures/* /app/dojo/fixtures/ -COPY tests/ ./tests/ RUN \ # Remove placeholder copied from docker/certs rm -f /readme.txt && \ @@ -143,9 +138,20 @@ ENTRYPOINT ["/entrypoint-uwsgi.sh"] FROM release AS development USER root -COPY requirements-dev.txt ./ -RUN pip3 install --no-cache-dir -r requirements-dev.txt +COPY \ + requirements-dev.txt \ + docker/unit-tests.sh \ + ./ +COPY \ + docker/entrypoint-celery-worker-dev.sh \ + docker/entrypoint-uwsgi-dev.sh \ + docker/entrypoint-unit-tests.sh \ + docker/entrypoint-unit-tests-devDocker.sh \ + / +RUN pip3 install --no-cache-dir -r requirements-dev.txt && \ + chmod 775 /*.sh USER ${uid} FROM development AS django-unittests COPY unittests/ ./unittests/ +COPY tests/ ./tests/ diff --git a/Dockerfile.nginx-alpine b/Dockerfile.nginx-alpine index aa867828a2f..bfdf4a4114a 100644 --- a/Dockerfile.nginx-alpine +++ b/Dockerfile.nginx-alpine @@ -63,7 +63,7 @@ COPY dojo/ ./dojo/ # always collect static for debug toolbar as we can't make it dependant on env variables or build arguments without breaking docker layer caching RUN env DD_SECRET_KEY='.' DD_DJANGO_DEBUG_TOOLBAR_ENABLED=True python3 manage.py collectstatic --noinput --verbosity=2 && true -FROM nginx:1.29.3-alpine3.22@sha256:b3c656d55d7ad751196f21b7fd2e8d4da9cb430e32f646adcf92441b72f82b14 +FROM nginx:1.29.3-alpine3.22@sha256:b3c656d55d7ad751196f21b7fd2e8d4da9cb430e32f646adcf92441b72f82b14 AS release ARG uid=1001 ARG appuser=defectdojo COPY --from=collectstatic /app/static/ /usr/share/nginx/html/static/ diff --git a/docker-compose.override.integration_tests.yml b/docker-compose.override.integration_tests.yml index 215529180df..8d6efe954f7 100644 --- a/docker-compose.override.integration_tests.yml +++ b/docker-compose.override.integration_tests.yml @@ -23,6 +23,8 @@ services: volumes: - defectdojo_media_integration_tests:/usr/share/nginx/html/media uwsgi: + build: + target: development entrypoint: ['/wait-for-it.sh', '${DD_DATABASE_HOST:-postgres}:${DD_DATABASE_PORT:-5432}', '-t', '30', '--', '/entrypoint-uwsgi-dev.sh'] volumes: - '.:/app:z' From 676fc540eeb434d86780512b00b71320dfb4b93a Mon Sep 17 00:00:00 2001 From: manuelsommer <47991713+manuel-sommer@users.noreply.github.com> Date: Mon, 29 Dec 2025 09:17:06 +0100 Subject: [PATCH 089/126] :tada: Implement pingcastle vulnerability parser (#13933) * :tada: Implement pingcastle vulnerability parser * udpate * update severity calculation * fix * Update unittests/tools/test_pingcastle_parser.py Co-authored-by: valentijnscholten * Update dojo/tools/pingcastle/parser.py Co-authored-by: valentijnscholten * Update dojo/tools/pingcastle/parser.py Co-authored-by: valentijnscholten * fix * Update docs/content/supported_tools/parsers/file/pingcastle.md --------- Co-authored-by: valentijnscholten --- .../parsers/file/pingcastle.md | 7 + dojo/tools/pingcastle/__init__.py | 0 dojo/tools/pingcastle/parser.py | 285 ++++++ unittests/scans/pingcastle/many.xml | 927 ++++++++++++++++++ unittests/scans/pingcastle/one.xml | 32 + unittests/scans/pingcastle/zero.xml | 23 + unittests/tools/test_pingcastle_parser.py | 49 + 7 files changed, 1323 insertions(+) create mode 100644 docs/content/supported_tools/parsers/file/pingcastle.md create mode 100644 dojo/tools/pingcastle/__init__.py create mode 100644 dojo/tools/pingcastle/parser.py create mode 100644 unittests/scans/pingcastle/many.xml create mode 100644 unittests/scans/pingcastle/one.xml create mode 100644 unittests/scans/pingcastle/zero.xml create mode 100644 unittests/tools/test_pingcastle_parser.py diff --git a/docs/content/supported_tools/parsers/file/pingcastle.md b/docs/content/supported_tools/parsers/file/pingcastle.md new file mode 100644 index 00000000000..836877d5519 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/pingcastle.md @@ -0,0 +1,7 @@ +title: "PingCastle" +toc_hide: true +--- +Import results from the [PingCastle](https://www.pingcastle.com/documentation/). + +### Sample Scan Data +Sample PingCastle scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/pingcastle). \ No newline at end of file diff --git a/dojo/tools/pingcastle/__init__.py b/dojo/tools/pingcastle/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/pingcastle/parser.py b/dojo/tools/pingcastle/parser.py new file mode 100644 index 00000000000..e346184a875 --- /dev/null +++ b/dojo/tools/pingcastle/parser.py @@ -0,0 +1,285 @@ + +import contextlib +import datetime +import re + +from defusedxml.ElementTree import parse + +from dojo.models import Endpoint, Finding + + +class PingCastleParser: + + CVE_REGEX = re.compile(r"(CVE-\d{4}-\d{4,7})", re.IGNORECASE) + + _SEVERITY_ORDER = ["Info", "Low", "Medium", "High", "Critical"] + + def get_scan_types(self): + return ["PingCastle"] + + def get_label_for_scan_types(self, scan_type): + return scan_type + + def get_description_for_scan_types(self, scan_type): + return "PingCastle XML export" + + def get_findings(self, file, test): + try: + tree = parse(file) + root = tree.getroot() + except Exception as e: + exception = f"Invalid PingCastle XML format: {e}" + raise ValueError(exception) + dupes = {} + report_date = self._parse_datetime(root.findtext("GenerationDate")) + domain_fqdn = root.findtext("DomainFQDN") or "" + dc_infos, dc_endpoints = self._collect_domain_controllers(root) + findings = [] + for rr in root.findall("RiskRules/HealthcheckRiskRule"): + points = self._safe_int(rr.findtext("Points")) + category = rr.findtext("Category") or "" + model = rr.findtext("Model") or "" + risk_id = rr.findtext("RiskId") or "" + rationale = rr.findtext("Rationale") or "" + severity = self._map_points_to_severity(points) + severity = self._apply_contextual_bump( + severity=severity, + category=category, + model=model, + risk_id=risk_id, + rationale=rationale, + ) + if not severity or severity not in self._SEVERITY_ORDER: + severity = "Info" + title = f"[PingCastle] {risk_id} ({category}/{model})" + description = self._compose_risk_rule_description( + domain_fqdn=domain_fqdn, + risk_id=risk_id, + points=points, + category=category, + model=model, + rationale=rationale, + dc_infos=dc_infos, + root=root, + ) + finding = Finding( + title=title, + test=test, + description=description, + severity=severity, + mitigation="Review and remediate according to PingCastle recommendations.", + impact="Risk identified by PingCastle HealthCheck.", + vuln_id_from_tool=risk_id, + ) + if report_date: + finding.date = report_date + cves = list(self.CVE_REGEX.findall(rationale or "")) + if cves: + finding.unsaved_vulnerability_ids = cves + finding.unsaved_endpoints = [] + if self._is_dc_specific_risk(risk_id, model, rationale): + finding.unsaved_endpoints.extend(dc_endpoints) + elif domain_fqdn: + finding.unsaved_endpoints.append(Endpoint(host=domain_fqdn)) + if risk_id == "A-DC-Coerce": + self._enrich_coerce_with_rpc_interfaces(finding, dc_infos) + if risk_id == "A-DC-Spooler": + self._enrich_spooler_status(finding, dc_infos) + if risk_id == "A-MinPwdLen": + self._enrich_password_policy(finding, root) + dupe_key = risk_id + if dupe_key in dupes: + existing = dupes[dupe_key] + existing.description += "\n\n-----\n\n" + finding.description + existing.unsaved_endpoints.extend(finding.unsaved_endpoints) + else: + dupes[dupe_key] = finding + findings.extend(list(dupes.values())) + return findings + + def _compose_risk_rule_description( + self, + domain_fqdn, + risk_id, + points, + category, + model, + rationale, + dc_infos, + root, + ): + lines = [] + lines.append("### PingCastle Risk Rule") # noqa: FURB113 + lines.append(f"**Domain**: `{domain_fqdn}`") + lines.append(f"**RiskId**: `{risk_id}`") + lines.append(f"**Category/Model**: `{category}` / `{model}`") + lines.append(f"**Points**: `{points}`") + if rationale: + lines.append(f"**Rationale**: {rationale}") + if risk_id.startswith("A-DC-") or "DomainControllers" in root.tag: + if dc_infos: + lines.append("\n#### Domain Controllers") + for dc in dc_infos: + ips = ", ".join(dc.get("ips", [])) + lines.append( + f"- **{dc['name']}** (OS: {dc.get('os', '?')}, IPs: {ips}, " + f"SpoolerRemote: {dc.get('remote_spooler', 'false')})", + ) + return "\n".join(lines) + + def _collect_domain_controllers(self, root): + dc_infos = [] + endpoints = [] + for dc in root.findall("DomainControllers/HealthcheckDomainController"): + name = dc.findtext("DCName") or "" + os = dc.findtext("OperatingSystem") or "" + remote_spooler = dc.findtext("RemoteSpoolerDetected") or "false" + ip_elems = dc.findall("IP/string") + ips = [ip_elem.text for ip_elem in ip_elems if ip_elem is not None and ip_elem.text] + dc_info = { + "name": name, + "os": os, + "remote_spooler": remote_spooler.lower() == "true", + "ips": ips, + "rpc_interfaces": [], + } + for rpc in dc.findall("RPCInterfacesOpen/HealthcheckDCRPCInterface"): + dc_info["rpc_interfaces"].append({ + "ip": rpc.attrib.get("IP", ""), + "interface": rpc.attrib.get("Interface", ""), + "opnum": rpc.attrib.get("OpNum", ""), + "function": rpc.attrib.get("Function", ""), + }) + dc_infos.append(dc_info) + if name: + endpoints.append(Endpoint(host=name)) + endpoints.extend(Endpoint(host=ip) for ip in ips) + return dc_infos, endpoints + + def _enrich_coerce_with_rpc_interfaces(self, finding, dc_infos): + added_any = False + for dc in dc_infos: + if dc.get("rpc_interfaces"): + if not added_any: + finding.description += "\n\n#### RPC Interfaces (potential coercion surface)\n" + added_any = True + finding.description += f"\n**{dc['name']}**:\n" + for ri in dc["rpc_interfaces"]: + finding.description += ( + f"- IP: `{ri['ip']}` | Interface: `{ri['interface']}` | " + f"OpNum: `{ri['opnum']}` | Function: `{ri['function']}`\n" + ) + + def _enrich_spooler_status(self, finding, dc_infos): + any_remote_spooler = any(dc.get("remote_spooler") for dc in dc_infos) + finding.description += ( + f"\n\n**Remote spooler exposure detected**: `{any_remote_spooler}`" + ) + + def _enrich_password_policy(self, finding, root): + min_len = None + complexity = None + for prop in root.findall("GPPPasswordPolicy/GPPSecurityPolicy/Properties/GPPSecurityPolicyProperty"): + key = (prop.findtext("Property") or "").strip() + val = (prop.findtext("Value") or "").strip() + if key == "MinimumPasswordLength": + min_len = val + elif key == "PasswordComplexity": + complexity = val + if min_len is not None or complexity is not None: + finding.description += "\n\n#### Observed Password Policy from GPO\n" + if min_len is not None: + finding.description += f"- MinimumPasswordLength: `{min_len}`\n" + if complexity is not None: + friendly = {"0": "disabled", "1": "enabled"}.get(complexity, complexity) + finding.description += f"- PasswordComplexity: `{friendly}`\n" + + @staticmethod + def _parse_datetime(text): + if not text: + return None + with contextlib.suppress(ValueError): + return datetime.datetime.fromisoformat(text) + return None + + @staticmethod + def _safe_int(text): + try: + return int(text) + except (TypeError, ValueError): + return 0 + + @staticmethod + def _map_points_to_severity(points): + if points <= 0: + return "Info" + if points <= 5: + return "Low" + if points <= 10: + return "Medium" + if points <= 15: + return "High" + return "Critical" + + @staticmethod + def _is_dc_specific_risk(risk_id: str, model: str = "", rationale: str = "") -> bool: + """ + Best effort classification: return True if the risk targets Domain Controllers specifically. + Signals: + - RiskId prefixes for DC: "A-DC-" (anomalies on DC), "S-DC-" (stale/DC subnet), and known IDs. + - Model contains DC-specific notions (e.g., "Audit" with RiskId A-AuditDC). + - Rationale text mentions DC count/context ("from X DC", "on domain controllers"). + """ + rid = (risk_id or "").strip() + mod = (model or "").strip() + rat = (rationale or "").strip().lower() + dc_prefixes = ("A-DC-", "S-DC-") + if rid.startswith(dc_prefixes): + return True + dc_specific_ids = { + "A-DC-Spooler", + "A-DC-Coerce", + "A-AuditDC", + "S-DC-SubnetMissing", + } + if rid in dc_specific_ids: + return True + if mod == "Audit" and rid.endswith("DC"): + return True + dc_markers = ( + " from ", + " dc", + " dcs", + " domain controller", + " domain controllers", + ) + return bool(any(marker in rat for marker in dc_markers)) + + def _apply_contextual_bump(self, severity: str, category: str = "", model: str = "", + risk_id: str = "", rationale: str = "") -> str: + """ + Minimal additive logic on top of points-based severity: + - If a CVE is mentioned -> bump by 1 level (at least Low). + If rationale indicates missing/not enabled mitigation -> ensure at least Medium. + - If DC-specific -> bump by 1 level. + - If category is 'Exposure' -> bump by 1 level. + """ + if not severity or severity not in self._SEVERITY_ORDER: + severity = "Info" + idx = self._SEVERITY_ORDER.index(severity) + rat = (rationale or "").lower() + cat = (category or "").strip().lower() + + if self.CVE_REGEX.search(rationale or ""): + idx = min(idx + 1, len(self._SEVERITY_ORDER) - 1) + mitigation_markers = ("mitigation", "not set", "disabled", "missing", "not enabled", "enable") + if any(m in rat for m in mitigation_markers): + idx = max(idx, self._SEVERITY_ORDER.index("Medium")) + + if self._is_dc_specific_risk(risk_id, model, rationale): + idx = min(idx + 1, len(self._SEVERITY_ORDER) - 1) + + if cat == "exposure": + idx = min(idx + 1, len(self._SEVERITY_ORDER) - 1) + + return self._SEVERITY_ORDER[idx] diff --git a/unittests/scans/pingcastle/many.xml b/unittests/scans/pingcastle/many.xml new file mode 100644 index 00000000000..33080ff4ac0 --- /dev/null +++ b/unittests/scans/pingcastle/many.xml @@ -0,0 +1,927 @@ + + + 3.2.0.1 + 2024-06-06T13:01:09.2643869+02:00 + Normal + 2 + example.local + EXAMPLE + example.local + 2024-03-13T00:24:53 + S-1-5-21-1111111111-2222222222-3333333333 + 7 + 7 + 88 + 0 + false + 2024-03-13T00:25:36 + 0001-01-01T00:00:00 + 1 + 70 + 36 + 70 + 0 + 65 + 0001-01-01T00:00:00 + 0 + + + + DC-NQ7M31 + 2024-03-13T00:25:35 + 2024-06-06T12:14:42.680185+02:00 + 2024-06-06T09:05:02.4862126+02:00 + CN=DC-NQ7M31,OU=Domain Controllers,DC=example,DC=local + Server OS + S-1-5-21-1111111111-2222222222-3333333333-512 + EXAMPLE\Domain Admins + false + false + NotTested + true + SmbSigningEnabled SmbSigningRequired + true + + fe80::2a9 + 198.51.10 + + + PDC + RID pool manager + Infrastructure master + Schema master + Domain naming Master + + + false + false + 2024-06-06T09:04:51.018471+02:00 + false + false + + + + + + + + + + + + + + Default-First-Site-Name + + + + + false + false + true + false + + + 20 + PrivilegedAccounts + AdminControl + P-AdminLogin + The native administrator account has been used recently: 0 day(s) ago + + + 20 + PrivilegedAccounts + AccountTakeOver + P-Delegated + Presence of Admin accounts which do not have the flag "This account is sensitive and cannot be delegated": 7 + + + 15 + StaleObjects + OldAuthenticationProtocols + S-OldNtlm + The LAN Manager Authentication Level allows the use of NTLMv1 or LM. + + + 15 + Anomalies + PassTheCredential + A-LAPS-Not-Installed + LAPS doesn't seem to be installed + + + 10 + PrivilegedAccounts + IrreversibleChange + P-SchemaAdmin + The group Schema Admins is not empty: 1 account(s) + + + 10 + Anomalies + WeakPassword + A-MinPwdLen + Policy where the password length is less than 8 characters: 1 + + + 10 + StaleObjects + Provisioning + S-ADRegistration + Non-admin users can add up to 10 computer(s) to a domain + + + 10 + Anomalies + PassTheCredential + A-DC-Spooler + The spooler service is remotely accessible from 1 DC + + + 10 + PrivilegedAccounts + IrreversibleChange + P-RecycleBin + The Recycle Bin is not enabled + + + 10 + PrivilegedAccounts + AccountTakeOver + P-ProtectedUsers + Number of admins not in Protected Users: 7 + + + 10 + Anomalies + Audit + A-AuditDC + The audit policy on domain controllers does not collect key events. + + + 10 + Anomalies + PassTheCredential + A-DC-Coerce + RPC interfaces of DC are likely vulnerable to coercion attacks. Identified interfaces: 6 + + + 5 + StaleObjects + ObjectConfig + S-NoPreAuth + Number of accounts which do not require Kerberos pre-authentication: 1 + + + 5 + Anomalies + Backup + A-NotEnoughDC + The number of DCs is too small to provide redundancy: 1 DC + + + 5 + Anomalies + NetworkSniffing + A-HardenedPaths + Hardened Paths have been modified to lower the security level + + + 5 + StaleObjects + NetworkTopography + S-DC-SubnetMissing + The subnet declaration is incomplete [1 IP of DC not found in declared subnets] + + + 1 + StaleObjects + ObjectConfig + S-PwdNeverExpires + Number of accounts which have never expiring passwords: 1 + + + 0 + Anomalies + Reconnaissance + A-PreWin2000AuthenticatedUsers + The PreWin2000 compatible group contains "Authenticated Users" + + + 0 + Anomalies + Reconnaissance + A-DsHeuristicsLDAPSecurity + DsHeuristics has not been set to enable the mitigation for CVE-2019-12345 + + + 0 + PrivilegedAccounts + ACLCheck + P-DNSAdmin + Number of members of the Dns Admins group: 6 + + + 0 + Anomalies + NetworkSniffing + A-DnsZoneAUCreateChild + Authenticated Users can create DNS records + + + 0 + Anomalies + Audit + A-AuditPowershell + The PowerShell audit configuration is not fully enabled. + + + 0 + StaleObjects + ObjectConfig + S-KerberosArmoringDC + Verify Kerberos Armoring is enabled on DCs and the domain functional level is at least Windows Server 2012 + + + 0 + Anomalies + WeakPassword + A-NoServicePolicy + No password policy for service accounts found (MinimumPasswordLength>=20) + + + 0 + StaleObjects + ObjectConfig + S-KerberosArmoring + Verify Kerberos Armoring is enabled on clients and the domain functional level is at least Windows Server 2012 + + + 0 + PrivilegedAccounts + IrreversibleChange + P-UnprotectedOU + OU without the accidental deletion protection have been found + + + 0 + Anomalies + NetworkSniffing + A-NoGPOLLMNR + No GPO has been found which disables LLMNR or at least one GPO does enable it explicitly + + + 0 + Anomalies + Reconnaissance + A-NoNetSessionHardening + No GPO has been found which implements NetCease + + + + 105 + 104 + 1 + 104 + 0 + 0 + 1 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 1 + 0 + 0 + + + 1 + 1 + 0 + 1 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 1 + 0 + 0 + 0 + 0 + 0 + + + + Server OS + 1 + + 1 + 1 + 0 + 1 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 1 + 0 + 0 + 0 + 0 + 0 + + + + + + + 1 + 1 + 0 + 1 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 1 + 0 + 0 + 0 + 0 + 0 + + + + + + Default Domain Policy + {31B2F340-016D-11D2-945F-00C04FB984F9} + false + + DC=example,DC=local + + + 1 + + + + Default Domain Controllers Policy + {6AC1786C-016F-11D2-945F-00C04fB984F9} + false + + OU=Domain Controllers,DC=example,DC=local + + + 1 + + + + + + None + 104 + + + + 2024-06-06T10:04:32+02:00 + 9999-12-31T23:59:59.9999999 + 9999-12-31T23:59:59.9999999 + 0001-01-01T00:00:00 + 2024-03-13T01:25:35.9203718+01:00 + 2 + false + 2024-06-06T12:18:47.7308022+02:00 + false + + + + + + + + + + MinimumPasswordAge + 1 + + + MaximumPasswordAge + 42 + + + MinimumPasswordLength + 4 + + + PasswordComplexity + 0 + + + PasswordHistorySize + 24 + + + LockoutBadCount + 0 + + + ClearTextPassword + 0 + + + Default Domain Policy + {31B2F340-016D-11D2-945F-00C04FB984F9} + + + + + + + + + + + + + Account Operators + CN=Account Operators,CN=Builtin,DC=example,DC=local + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + + + Administrators + CN=Administrators,CN=Builtin,DC=example,DC=local + 1 + 0 + 0 + 1 + 0 + 0 + 1 + 1 + 1 + 0 + 0 + 0 + 0 + + + Backup Operators + CN=Backup Operators,CN=Builtin,DC=example,DC=local + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + + + Certificate Operators + CN=Cryptographic Operators,CN=Builtin,DC=example,DC=local + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + + + Certificate Publishers + CN=Cert Publishers,CN=Users,DC=example,DC=local + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + + + Dns Admins + CN=DnsAdmins,CN=Users,DC=example,DC=local + 6 + 0 + 0 + 0 + 0 + 6 + 0 + 6 + 6 + 0 + 0 + 0 + 0 + + + Domain Administrators + CN=Domain Admins,CN=Users,DC=example,DC=local + 1 + 0 + 0 + 1 + 0 + 0 + 1 + 1 + 1 + 0 + 0 + 0 + 0 + + + Enterprise Administrators + CN=Enterprise Admins,CN=Users,DC=example,DC=local + 1 + 0 + 0 + 1 + 0 + 0 + 1 + 1 + 1 + 0 + 0 + 0 + 0 + + + Enterprise Key Administrators + CN=Enterprise Key Admins,CN=Users,DC=example,DC=local + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + + + Key Administrators + CN=Key Admins,CN=Users,DC=example,DC=local + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + + + Print Operators + CN=Print Operators,CN=Builtin,DC=example,DC=local + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + + + Replicator + CN=Replicator,CN=Builtin,DC=example,DC=local + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + + + Schema Administrators + CN=Schema Admins,CN=Users,DC=example,DC=local + 1 + 0 + 0 + 1 + 0 + 0 + 1 + 1 + 1 + 0 + 0 + 0 + 0 + + + Server Operators + CN=Server Operators,CN=Builtin,DC=example,DC=local + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + + + 0 + 0 + 0 + 0 + false + 10 + + + + example.local + false + false + + + RootDNSServers + false + false + + + RootDNSServers + false + false + + + + + + + + + 0001-01-01T00:00:00 + 0 + 0 + + + + + + + + + false + + + + + 0 + 0 + 0 + 0 + + + + 0 + 0 + 0 + 0 + + + + 1 + 0 + 0 + 0 + + + + 0 + 0 + 0 + 0 + + + + 0 + 0 + 0 + 0 + + + + 0 + 0 + 0 + 0 + + + + 0 + 0 + 0 + 0 + + + + 0 + 0 + 0 + 0 + + + + 6 + 0 + 10 + 0 + + + + 1 + 0 + 0 + 0 + + + + 0 + 1 + 0 + 0 + + + + 0 + 0 + 0 + 0 + + + + 1 + 0 + 0 + 0 + + + + 0 + 0 + 0 + 0 + + + + 0 + 0 + 0 + 0 + + + + 1 + 0 + 0 + 0 + + + + 0 + 0 + 0 + 0 + + + + 0 + 0 + 0 + 0 + + + + 0 + 0 + 0 + 0 + + + + 0 + 0 + 0 + 0 + + + + 0 + 0 + 0 + 0 + + + + 1 + 0 + 0 + 0 + + + + 0 + 0 + 0 + 0 + + + + 0 + 0 + 0 + 0 + + + + + + + + + + + 0 + 0 + 0 + 0 + 0 + + \ No newline at end of file diff --git a/unittests/scans/pingcastle/one.xml b/unittests/scans/pingcastle/one.xml new file mode 100644 index 00000000000..d6b00890e38 --- /dev/null +++ b/unittests/scans/pingcastle/one.xml @@ -0,0 +1,32 @@ + + + 3.2.0.1 + 2024-06-06T13:01:09+02:00 + Normal + 2 + example.local + EXAMPLE + example.local + 2024-03-13T00:24:53 + S-1-5-21-1111111111-2222222222-3333333333 + 1 + 10 + + + DC-NQ7M31 + Server OS + + 198.51.10 + + + + + + 10 + Anomalies + WeakPassword + A-MinPwdLen + Password policy allows length less than 8 characters + + + \ No newline at end of file diff --git a/unittests/scans/pingcastle/zero.xml b/unittests/scans/pingcastle/zero.xml new file mode 100644 index 00000000000..21355251c96 --- /dev/null +++ b/unittests/scans/pingcastle/zero.xml @@ -0,0 +1,23 @@ + + + 3.2.0.1 + 2024-06-06T13:01:09+02:00 + Normal + 2 + example.local + EXAMPLE + example.local + 2024-03-13T00:24:53 + S-1-5-21-1111111111-2222222222-3333333333 + 1 + 10 + + + DC-NQ7M31 + Server OS + + 198.51.10 + + + + \ No newline at end of file diff --git a/unittests/tools/test_pingcastle_parser.py b/unittests/tools/test_pingcastle_parser.py new file mode 100644 index 00000000000..64aece31447 --- /dev/null +++ b/unittests/tools/test_pingcastle_parser.py @@ -0,0 +1,49 @@ + +from dojo.tools.pingcastle.parser import PingCastleParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestPingCastleParser(DojoTestCase): + + def test_no_findings(self): + with (get_unit_tests_scans_path("pingcastle") / "zero.xml").open(encoding="utf-8") as my_file_handle: + parser = PingCastleParser() + findings = parser.get_findings(my_file_handle, None) + self.assertEqual(0, len(findings)) + + def test_one_finding(self): + with (get_unit_tests_scans_path("pingcastle") / "one.xml").open(encoding="utf-8") as my_file_handle: + parser = PingCastleParser() + findings = parser.get_findings(my_file_handle, None) + self.assertEqual(1, len(findings)) + self.assertEqual(findings[0].title, "[PingCastle] A-MinPwdLen (Anomalies/WeakPassword)") + self.assertEqual(findings[0].severity, "Medium") + self.assertTrue(hasattr(findings[0], "unsaved_endpoints")) + + def test_many_findings(self): + with (get_unit_tests_scans_path("pingcastle") / "many.xml").open(encoding="utf-8") as my_file_handle: + parser = PingCastleParser() + findings = parser.get_findings(my_file_handle, None) + self.assertEqual(28, len(findings)) + admin_login = next((f for f in findings if f.vuln_id_from_tool == "P-AdminLogin"), None) + self.assertIsNotNone(admin_login) + self.assertEqual(admin_login.title, "[PingCastle] P-AdminLogin (PrivilegedAccounts/AdminControl)") + self.assertEqual(admin_login.severity, "Critical") + spooler = next((f for f in findings if f.vuln_id_from_tool == "A-DC-Spooler"), None) + self.assertIsNotNone(spooler) + self.assertEqual(spooler.title, "[PingCastle] A-DC-Spooler (Anomalies/PassTheCredential)") + self.assertEqual(spooler.severity, "High") + self.assertTrue(len(getattr(spooler, "unsaved_endpoints", [])) > 0) + for endpoint in spooler.unsaved_endpoints: + endpoint.clean() + ds_heuristics = next((f for f in findings if f.vuln_id_from_tool == "A-DsHeuristicsLDAPSecurity"), None) + self.assertIsNotNone(ds_heuristics) + self.assertEqual(ds_heuristics.title, "[PingCastle] A-DsHeuristicsLDAPSecurity (Anomalies/Reconnaissance)") + self.assertEqual(ds_heuristics.severity, "Medium") + self.assertTrue( + hasattr(ds_heuristics, "unsaved_vulnerability_ids") + and len(ds_heuristics.unsaved_vulnerability_ids) >= 1, + ) + coerce = next((f for f in findings if f.vuln_id_from_tool == "A-DC-Coerce"), None) + self.assertIsNotNone(coerce) + self.assertEqual(coerce.severity, "High") From cb61c593da7159b03c7fb0b86561be03ce620302 Mon Sep 17 00:00:00 2001 From: kiblik <5609770+kiblik@users.noreply.github.com> Date: Mon, 29 Dec 2025 09:17:14 +0100 Subject: [PATCH 090/126] fix(HELM): Docs after #13907 (#13942) Signed-off-by: kiblik <5609770+kiblik@users.noreply.github.com> --- helm/defectdojo/Chart.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/helm/defectdojo/Chart.yaml b/helm/defectdojo/Chart.yaml index 1d6a2637af0..5e1b2718a98 100644 --- a/helm/defectdojo/Chart.yaml +++ b/helm/defectdojo/Chart.yaml @@ -34,4 +34,6 @@ dependencies: # description: Critical bug annotations: artifacthub.io/prerelease: "true" - artifacthub.io/changes: "" + artifacthub.io/changes: | + - kind: changed + description: chore(deps)_ update valkey docker tag from 0.10.2 to v0.13.0 (helm/defectdojo/chart.yaml) From 4b5839428ff86c93e70e6df62ee487a7a532cc9f Mon Sep 17 00:00:00 2001 From: Paul Osinski <42211303+paulOsinski@users.noreply.github.com> Date: Mon, 29 Dec 2025 10:04:41 -0700 Subject: [PATCH 091/126] [docs] pro changelog: 2.53.3-4 (#13978) * update changelog * correct dates * update screenshots --------- Co-authored-by: Paul Osinski --- docs/assets/images/asset-hierarchy-2.53.4.png | Bin 0 -> 122672 bytes docs/assets/images/pro_tablepreferences.png | Bin 0 -> 50146 bytes docs/content/en/changelog/changelog.md | 14 ++++++++++++++ 3 files changed, 14 insertions(+) create mode 100644 docs/assets/images/asset-hierarchy-2.53.4.png create mode 100644 docs/assets/images/pro_tablepreferences.png diff --git a/docs/assets/images/asset-hierarchy-2.53.4.png b/docs/assets/images/asset-hierarchy-2.53.4.png new file mode 100644 index 0000000000000000000000000000000000000000..eeac88a1f5f1a552b16c423b92431181009c44ed GIT binary patch literal 122672 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%0AfROXKNNv#Zy4+1QjRDAw|;kKO}w;?7>~7j*x4 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 literal 0 HcmV?d00001 diff --git a/docs/assets/images/pro_tablepreferences.png b/docs/assets/images/pro_tablepreferences.png new file mode 100644 index 0000000000000000000000000000000000000000..65a2a736604ea3b4936ad861afce22c409f61935 GIT binary patch literal 50146 zcmeGEWmuJ6*FOp`8bLw@kzS&b0@7WAAky6+-MQ!v1(lX=l+GpHp`hfV7tNx(yJ4Sr z>2rJezuyn(8<`}=_dwE$294rzn5D0`L`BF>~1VU*Cfsh|# zq6018GLYp!pxeUcqN4JWqM~4V2U`l40WZB`>9$VBZSS6P&3~kP?6}6 zGT-z)-^S#8eit1507axV>?wV@J;-o7X~M!SP%tn+jrC-d=+ZQMJkmbGX8X6`Tnd3EfJDE+`J7QvD ztaO~TYnyEw5nHnn>nzRdjcaK3kNIUgBw?zDva^eJWe){!gLFFx-$RhVuiT=DYpCb~ zam?Q7`{Ogm&av|pN6!)PAei^xhnk2?-uCss8}vvPMfj9{VbyJtJ@?ZjUP(g)4L)-E z^Po`%J`WPSIiq)spTaQ>@&q3(Vx*KenP-QXIdGdlQWqpjpgXzcAf}O=aBa$SRb^}{ z8E6>ak{!*2`ysZ6cz9EGsT`k9)xTOU{w@Vl32!SUDu04dEA11Vy96}H5{K11Pu1Dd z(Mklu=ZTO@C|Z%e74Gys6nvtB-yHa$7@u7)qATpJI_cc^3$p9@hiQy+=KZ22eYMIg z!91;cGw+P;vq~r=S?A+L&!B^+8xOstYrq1WNCsG~*>3(PcJE>yR(5rS+#=p1){E%D zqCC1I^*QS_={XsU2>q<{u%uquUicuwN7A`x$FqcS71{X&Uobw2g7h&IZ@7E0eJ=>| zwRrDsceOsbch8%?*_xdb@2kBULjg~H?e2&k425Fba?hu-cZ^@)p3eWcC={l%yh-%GzfkObn7_>U_4cX$5nsz2=@tA(#9=Shh>sqh-Mf4z z(hB-aSRCvzOW{h(f7ZP8j?J!W*3I*nhW|S)?zr)BEF-lK^qg+1C* zys^FI$j;%;o=5Zpn%C--ZpCzu4oyN2F38djMGxI}$o9%;Dj)E@sd#PrC3w`nkfN_d zNgZYQLrTP4B>YgUvNkZ|Mb}=(8(x2(R?^0@fmchTBcsRTK9R?Zd;3}2X3dGziFEff zvz7-Ec7mI8%HC?%u{cV2NSJ~$r-@G2*JYwN>io^Qt{}=8Jbw|?cWCcl zV|5FX7eiR$kxT;6(jP?o6TW7o#YpKj&T*Rmpg3Ux6-}V|%u>^1jS4USzR)@PLj>>LO;YPfCO#Tt4BZ@#|F2X_b zdnQh%K&DcrWv0yl2HFcWQ8bCSptq0FK0W{RJmH(o+bFCNf>b(B>Vl83_N@g>)^Pr) z`sDhQ$(N1Kx2S!xa}}P_rbes3LN`V-7RZWq2;1}B6IW2^SMQf$d8Lrft!Ljb=lvb~ zL&^urn4y@$81+|C(T`tsMLW}{sMZ!71is2jRPxG|9MWE6wct2>dU)qBfK_HpHle6n zDK$s?lXi}$%xM;u#&_wrd8{w8gtMd6b}^7 z`=J}k8R{+LBtrvpg?YlTVD}QqHREGz?rSkfS=J1T6lrOuiLZtmgw2I(vgSJvRVZv% zNLZem?N>;ZADd4PU1f@n?2dE{Y2+zE_vML3DuzP`Qa;s59w*yOn}zmMWYlCxiBpn! z$>G7vhJ%NbvZ`dDDz?JY!b0+TnN1?sh2Cj?v3}tqfnP}iqR<-x*+rQ}{bos8lmdHK z6CJ4qf*Vs2(%W1EY*?%xA6Ro(u$@_1>64jio7MN8e7g1hjs8cA(jS=}nbSjk?A?x) z=Ao+sY^B64Pp7O>G=j%GAlUn;`!ALbmjv#Q-?s`z4kii4h*g%d?OKghiRCJMr+T|U zx*$|ltw_Prblq_sYn_2JH#9i3*(5{e{K2;eRV?3FR19q<=*L*C_pL0g_%_Qoo^N7p zu5IduagZmmJ1>sLmP~J?Y?f_QO-#GUyI?xgI+{7UZklbjI~Cg1vxM<7y`HSM$C=mV z8ROnf;pCaJ&*ll@VRo=_xLTWAqjJu3T6N-Zl5s9_Y=HH>7GsTNWvae2={dDjL+*0E zvNe~w?%j9EjHeYY9X>@NFwgj7zs;6%deMBNV|Q|GZ1%?S=}ix$U_ha&!7|`^z%449r-R44vERgAYS>C_IFESY_{y z5J2w=(KYg3dHL&o%YPGg%aEo!!H!3POCe%3LhRKCx$#K$XZ=2N1yfP;3;=S ze^sX3JEW2)V4iATFz7zmFxWM4j`~!jW=2I^{>M_tal)MhJ~oW9yQN3Q3`R7jl!W=% zY;R$Mx3d@JEQ!Y6eRx4WIF$oUK``Uq*=`qcpk8KQuxO?{RehheKqP6Js;2t_5k$Pp zhPzm>h`q06s@bc}y00N3{MEK>M`Kb$rYzKiIerQ_WYcwRBb(02Y&Yut4@*wHc*#wc zNX8;d{?pi-*lL80I`Ln=l)P**^HvyP_G=m=tS-rEp=xbe;Y@0(8SAQ4hE_A$P7H4DPH1@>UZ^&Rx=5^sLHXYD@j1WPwkUQk zDn=AldAQ+dKFV92Vl(fBH+XxHT3B0FZ4HjA)lzQ6ZTNVDPn(MZ9%3Znvf#ogIQg{Q z-Z*Dw;^rl3jOeB5PWMNQ_Ib0Pm?>pQ%tr$9;!MHCt84#!zjZGvI_d=th8$rs2{$9} zlneEywA$cM_PHrdazj5EeA1a|Ve;)h(sFO*Gc8%2oqD^A&*$GW;vU4UG1qAqTRPbY z?A7gWON?ueuN7D8npdwmSCn{po_AOxMtZc5>%6bf&wnf?&2mlHexIs^cnoZY@zwJQ zxRh-**2cMP?_ZwcVdA}_$QOL-k$IfEhF2hT5`B6wcSNieV{`=lKy?45vTMSo9o4uWJvCMeai~DMp*Ke-2)aKLzpmB}; zZe{yLgA>_)?S2y5rPE&CdV3;f!f_1qIIe=doA2&Y-G#q)l%)^vzK0UMo9mZLcxRyH1zHII9Kq*1w!h3 zIWc>Ly;dNN4E~5ETu3V17D{T~;=wW2sAwTpjW>FCm*WW^+68IUHjhvH`7&EXKCfR| z?=`X+yQfy+v04OhrHD0Fmo$-)0nr1`m>@KyyC4+c2?_WILLvd7{d@+2ULleG&$A*D z?XNz_AW)Dw2=!MVP2l&<*9}!*^snD29|J)cz+ZQP?>Fhlzk8#!r=$FSMt%&u1Bob! zN=gF1l?)w>jcp)iwvH<~Z_a^+TXrusARy5FCpX_nl8TRafcwyhxw5*Wx{Nfhp{+HO zzLBkgF_Wvc-3=WOzbh~BXl?AM4|cV-vVrir3Q+y*!3#X!G&56ye|B-S6rfUm@>2Q@bEA{ zV`XM#WdwRKLfmW|^<5clAdmhb@|%vBF~rcp+|JS5)&_h-SKq+a$x(oc>Sm(<`TA#_ z#;)f7naKw7Yg@nunQz`OvoJkl{%^w^%}xG)47+*r&#<5S`e!=+8)Cfj=B~z8>SE^B z0IPwe3G#4q^Z%UY|9JDCh5j>A1!C+VYHJM)bQJv0S^XOP=gYqb{+v?dKT~qB|7*%W z-uyB0W(&NE#t>U8ryCZk*qA#Cvhp+kx8}b`Y5ZrHAPYPDuR(vd{yjqdzmE8`_3sgK z4(0$K^lz3X$ntB3Kihuw=V!h-`aeYBA36Kk3J97Y7C-a<2(2I%)z|g{5J(s#DJG)q zinK9>;YtQ?INp8Kp%c5vG~vKp?Gp0!f%dcS2X|jS_l*GgMWDWX^w6(a=8^5)_P3Ml zA0t4w9ty8Kc=nyZ@b(txRzv5D*rZA8Q-P+8Zg&?BhRxzEc=3`)Y8zrIfo;*#goE#z z$GCJ2Uf|OmfiDa~LjB_hX0@<$bC=xioxj@ckPqDt*Rau$F~Po|KYsWTwRd)P<-B*A zX+j76`xRziYvT5weSZ#B^#vtBk^Z3YzpZ~TH^r zFXdm4m+LI0&;{Mo{;;u&i4She5TgKIg5M%zh1oV_nPOG7Xa5p^a2cxVD5!?J%$Y~@ zD3*Gd*dcvX=1<)a2Kgltf`yAU^oGO!E!Pnbkx|?Kd;fGi2iRGuGe9R@6V(Jp&qCQt zn-}X;a`zYIb~3-nN1BPVEm|f$xTYtmcfqD7NJhtZguxGhCHkDPq0eLf=+BP+$s^76_MyAU{uAjzkb{|3SS6Z53a(#tjvW-wIgDg8k7TO~%rX2&&4`n7Q z$)&x$u?Ne|i#s~<4wXDA;vxzESy4nDvP3uqOIDT#tXPMyd$=g6`;%x8TLrD(=(c9V z2Mc(9sQF-aGXaq;2PtP^1yQ62tUp5n1xfDVPo4ly_-IM5*u4L9*6LVU_*kWN@K~dl zRs~_>moHJPLwN-nP2NQswT_?08a#4%JmV=57APh!Fv0RPsMNCctxvVNt7M~}s6IS9 zrc})7ieFvibKZ#0Q-d}Vvn~*n+0XNy+j

    b{uHl&y4}`@bOKQ+fH{3XOgjE-|sGt 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 z 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 BB8UJ0 literal 0 HcmV?d00001 diff --git a/docs/content/en/changelog/changelog.md b/docs/content/en/changelog/changelog.md index a747c2c6f8a..8cd48ce5e3b 100644 --- a/docs/content/en/changelog/changelog.md +++ b/docs/content/en/changelog/changelog.md @@ -10,10 +10,24 @@ For Open Source release notes, please see the [Releases page on GitHub](https:// ## Dec 2025: v2.53 +### Dec 22, 2025: v2.53.4 + +* **(Pro UI)** Asset Hierarchy now uses separate tabs for Asset selection and for the rendered Asset tree: +![image](images/asset-hierarchy-2.53.4.png) + +### Dec 15, 2025: v2.53.3 + +*DefectDojo v2.53.2 does not have a corresponding Pro release.* + +* **(Connectors)** Support for private CA certificates has been added to Connectors to assist with connectivity. + ### Dec 8, 2025: v2.53.1 * **(Assets/Organizations)** Introduced overhaul to Products/Product Types, added the ability to create and diagram relationships between Assets. See [Assets/Organizations documentation](/en/working_with_findings/organizing_engagements_tests/pro_assets_organizations/) for details, and information on opting in to the Beta. * **(Findings)** Added new KEV fields for ransomware, exploits, and date handling. +* **(Pro UI)** Added Table Preferences menu, allowing you to store preset lists of columns for each table. + +![image](images/pro_tablepreferences.png) ### Dec 1, 2025: v2.53.0 From c35e8fa7a02a79d5947b4c482c3d57e26597d6d7 Mon Sep 17 00:00:00 2001 From: valentijnscholten Date: Mon, 29 Dec 2025 18:04:47 +0100 Subject: [PATCH 092/126] Handle System_Settings errors better in middleware (#13982) * Handle System_Settings.DoesNotExist in get_from_db Refactor get_from_db method to handle specific exception. * add error message to UI --- dojo/context_processors.py | 22 +++++++++++++++++++++- dojo/middleware.py | 24 +++++++++++++++++++----- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/dojo/context_processors.py b/dojo/context_processors.py index 409851e2458..6f6bef626d6 100644 --- a/dojo/context_processors.py +++ b/dojo/context_processors.py @@ -3,6 +3,7 @@ # import the settings file from django.conf import settings +from django.contrib import messages from dojo.labels import get_labels from dojo.models import Alerts, System_Settings, UserAnnouncement @@ -39,7 +40,26 @@ def globalize_vars(request): def bind_system_settings(request): - return {"system_settings": System_Settings.objects.get()} + """Load system settings and display warning if there's a database error.""" + try: + system_settings = System_Settings.objects.get() + # Check if there was an error stored on the request (from middleware) + if hasattr(request, "system_settings_error"): + error_msg = request.system_settings_error + messages.add_message( + request, + messages.WARNING, + f"Warning: Unable to load system settings from database: {error_msg}. " + "Default values are being used. Please check your database configuration and run migrations if needed.", + extra_tags="alert-warning", + ) + # Clear after adding message + delattr(request, "system_settings_error") + except Exception: + # If we can't get settings, return empty dict (will cause errors elsewhere, but that's expected) + return {} + + return {"system_settings": system_settings} def bind_alert_count(request): diff --git a/dojo/middleware.py b/dojo/middleware.py index f2c6c1f908a..b23e94027af 100644 --- a/dojo/middleware.py +++ b/dojo/middleware.py @@ -114,6 +114,12 @@ def __init__(self, get_response): def __call__(self, request): self.load() try: + # Store error in request for context processor to display + # (We can't use messages here because MessageMiddleware runs after this middleware) + if hasattr(self._thread_local, "system_settings_error"): + request.system_settings_error = self._thread_local.system_settings_error + # Clear from thread-local after copying to request + delattr(self._thread_local, "system_settings_error") return self.get_response(request) finally: # ensure cleanup happens even if an exception occurs @@ -132,6 +138,8 @@ def get_system_settings(cls): def cleanup(cls, *args, **kwargs): # noqa: ARG003 if hasattr(cls._thread_local, "system_settings"): del cls._thread_local.system_settings + if hasattr(cls._thread_local, "system_settings_error"): + delattr(cls._thread_local, "system_settings_error") @classmethod def load(cls): @@ -147,13 +155,19 @@ class System_Settings_Manager(models.Manager): def get_from_db(self, *args, **kwargs): # logger.debug('refreshing system_settings from db') + from dojo.models import System_Settings # noqa: PLC0415 circular import try: from_db = super().get(*args, **kwargs) - except: - from dojo.models import System_Settings # noqa: PLC0415 circular import - # this mimics the existing code that was in filters.py and utils.py. - # cases I have seen triggering this is for example manage.py collectstatic inside a docker build where mysql is not available - # logger.debug('unable to get system_settings from database, constructing (new) default instance. Exception was:', exc_info=True) + except Exception as e: + # Store error message in thread-local for middleware to display + error_msg = str(e) + if hasattr(DojoSytemSettingsMiddleware._thread_local, "system_settings_error"): + # Only store the first error to avoid duplicates + pass + else: + DojoSytemSettingsMiddleware._thread_local.system_settings_error = error_msg + # Return defaults so app can still start - error will be displayed as warning message + # logger.debug('unable to get system_settings from database, returning defaults. Exception was:', exc_info=True) return System_Settings() return from_db From 0ffcacc3c9b593b27213bf898d5928cb1ae12170 Mon Sep 17 00:00:00 2001 From: valentijnscholten Date: Mon, 29 Dec 2025 18:04:59 +0100 Subject: [PATCH 093/126] pghistory: add context for each process and celery tasks (#13988) * pghistory: add context for each process * pghistory: pass on context to celery tasks * support vue as source --------- Co-authored-by: Valentijn Scholten --- dojo/api_v2/views.py | 26 +++ dojo/celery.py | 28 ++- dojo/decorators.py | 7 + dojo/engagement/views.py | 9 + dojo/finding/views.py | 6 + dojo/jira_link/views.py | 164 ++++++++++-------- dojo/management/commands/dedupe.py | 16 ++ .../commands/jira_status_reconciliation.py | 14 +- dojo/pghistory_models.py | 5 + dojo/pghistory_utils.py | 99 +++++++++++ dojo/risk_acceptance/helper.py | 54 +++--- dojo/tasks.py | 19 +- dojo/test/views.py | 7 + dojo/tools/tool_issue_updater.py | 11 +- 14 files changed, 350 insertions(+), 115 deletions(-) create mode 100644 dojo/pghistory_utils.py diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index caa4cef95df..c18fd647608 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -4,6 +4,7 @@ from datetime import datetime from pathlib import Path +import pghistory import tagulous from crum import get_current_user from dateutil.relativedelta import relativedelta @@ -2530,7 +2531,17 @@ def perform_create(self, serializer): if jira_project := (jira_helper.get_jira_project(jira_driver) if jira_driver else None): push_to_jira = push_to_jira or jira_project.push_all_issues + # Add pghistory context for audit trail (adds to existing middleware context). + # /api/vue is the Pro UI + source = "import_vue" if "/api/vue/" in self.request.path else "import_api" + pghistory.context( + source=source, + scan_type=serializer.validated_data.get("scan_type"), + ) serializer.save(push_to_jira=push_to_jira) + # Add test_id to pghistory context now that test is created + if test_id := serializer.data.get("test"): + pghistory.context(test_id=test_id) def get_queryset(self): return get_authorized_tests(Permissions.Import_Scan_Result) @@ -2678,7 +2689,22 @@ def perform_create(self, serializer): if jira_project := (jira_helper.get_jira_project(jira_driver) if jira_driver else None): push_to_jira = push_to_jira or jira_project.push_all_issues logger.debug("push_to_jira: %s", push_to_jira) + # Add pghistory context for audit trail (adds to existing middleware context) + # For reimport, test may already exist or be created during save + test_id = test.id if test else serializer.validated_data.get("test", {}) + if hasattr(test_id, "id"): + test_id = test_id.id + # /api/vue is the Pro UI + source = "reimport_vue" if "/api/vue/" in self.request.path else "reimport_api" + pghistory.context( + source=source, + test_id=test_id if isinstance(test_id, int) else None, + scan_type=serializer.validated_data.get("scan_type"), + ) serializer.save(push_to_jira=push_to_jira) + # Update test_id if it wasn't available before save + if test_id_from_response := serializer.data.get("test"): + pghistory.context(test_id=test_id_from_response) # Authorization: configuration diff --git a/dojo/celery.py b/dojo/celery.py index 5f2935b4460..ead4a8813a8 100644 --- a/dojo/celery.py +++ b/dojo/celery.py @@ -2,7 +2,7 @@ import os from logging.config import dictConfig -from celery import Celery +from celery import Celery, Task from celery.signals import setup_logging from django.conf import settings @@ -11,7 +11,31 @@ # set the default Django settings module for the 'celery' program. os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dojo.settings.settings") -app = Celery("dojo") + +class PgHistoryTask(Task): + + """ + Custom Celery base task that automatically applies pghistory context. + + When a task is dispatched via dojo_async_task, the current pghistory + context is captured and passed in kwargs as "_pgh_context". This base + class extracts that context and applies it before running the task, + ensuring all database events share the same context as the original + request. + """ + + def __call__(self, *args, **kwargs): + # Import here to avoid circular imports during Celery startup + from dojo.pghistory_utils import get_pghistory_context_manager # noqa: PLC0415 + + # Extract context from kwargs (won't be passed to task function) + pgh_context = kwargs.pop("_pgh_context", None) + + with get_pghistory_context_manager(pgh_context): + return super().__call__(*args, **kwargs) + + +app = Celery("dojo", task_cls=PgHistoryTask) # Using a string here means the worker will not have to # pickle the object when using Windows. diff --git a/dojo/decorators.py b/dojo/decorators.py index bba9efe234c..91f6934b719 100644 --- a/dojo/decorators.py +++ b/dojo/decorators.py @@ -83,10 +83,17 @@ def dojo_async_task(func=None, *, signature=False): def decorator(func): @wraps(func) def __wrapper__(*args, **kwargs): + from dojo.pghistory_utils import get_serializable_pghistory_context # noqa: PLC0415 circular import from dojo.utils import get_current_user # noqa: PLC0415 circular import + user = get_current_user() kwargs["async_user"] = user + # Capture pghistory context to pass to Celery worker + # The PgHistoryTask base class will apply this context in the worker + if pgh_context := get_serializable_pghistory_context(): + kwargs["_pgh_context"] = pgh_context + dojo_async_task_counter.incr( func.__name__, args=args, diff --git a/dojo/engagement/views.py b/dojo/engagement/views.py index a726f514421..9ecdabfdd9b 100644 --- a/dojo/engagement/views.py +++ b/dojo/engagement/views.py @@ -10,6 +10,7 @@ from tempfile import NamedTemporaryFile from time import strftime +import pghistory from django.conf import settings from django.contrib import messages from django.contrib.admin.utils import NestedObjects @@ -1138,10 +1139,18 @@ def post( if form_error := self.process_form(request, context.get("form"), context): add_error_message_to_response(form_error) return self.failure_redirect(request, context) + # Add pghistory context for audit trail (adds to existing middleware context) + pghistory.context( + source="import", + scan_type=context.get("scan_type"), + ) # Kick off the import process if import_error := self.import_findings(context): add_error_message_to_response(import_error) return self.failure_redirect(request, context) + # Add test_id to pghistory context now that test is created + if test := context.get("test"): + pghistory.context(test_id=test.id) # Process the credential form if form_error := self.process_credentials_form(request, context.get("cred_form"), context): add_error_message_to_response(form_error) diff --git a/dojo/finding/views.py b/dojo/finding/views.py index c6caa802027..0d05d24fde1 100644 --- a/dojo/finding/views.py +++ b/dojo/finding/views.py @@ -9,6 +9,7 @@ from itertools import chain from pathlib import Path +import pghistory from django.conf import settings from django.contrib import messages from django.core import serializers @@ -2557,6 +2558,11 @@ def finding_bulk_update_all(request, pid=None): logger.debug("bulk 20") finding_to_update = request.POST.getlist("finding_to_update") + # Add pghistory context for audit trail (adds to existing middleware context) + pghistory.context( + source="bulk_edit", + finding_count=len(finding_to_update), + ) finds = Finding.objects.filter(id__in=finding_to_update).order_by("id") total_find_count = finds.count() prods = set(find.test.engagement.product for find in finds) # noqa: C401 diff --git a/dojo/jira_link/views.py b/dojo/jira_link/views.py index d30681bef27..ebcc9616d4a 100644 --- a/dojo/jira_link/views.py +++ b/dojo/jira_link/views.py @@ -5,6 +5,7 @@ import re # Third party imports +import pghistory from django.contrib import messages from django.contrib.admin.utils import NestedObjects from django.core.exceptions import PermissionDenied @@ -85,85 +86,98 @@ def webhook(request, secret=None): if request.content_type != "application/json": return webhook_responser_handler("debug", "only application/json supported") # Time to process the request + # Parse the JSON first to get webhook event type for context try: parsed = json.loads(request.body.decode("utf-8")) - # Check if the events supplied are supported - if parsed.get("webhookEvent") not in {"comment_created", "jira:issue_updated"}: - return webhook_responser_handler("info", f"Unrecognized JIRA webhook event received: {parsed.get('webhookEvent')}") - - if parsed.get("webhookEvent") == "jira:issue_updated": - # xml examples at the end of file - jid = parsed["issue"]["id"] - # This may raise a 404, but it will be handled in the exception response - try: - jissue = JIRA_Issue.objects.get(jira_id=jid) - except JIRA_Instance.DoesNotExist: - return webhook_responser_handler("info", f"JIRA issue {jid} is not linked to a DefectDojo Finding") - findings = None - # Determine what type of object we will be working with - if jissue.finding: - logger.debug(f"Received issue update for {jissue.jira_key} for finding {jissue.finding.id}") - findings = [jissue.finding] - elif jissue.finding_group: - logger.debug(f"Received issue update for {jissue.jira_key} for finding group {jissue.finding_group}") - findings = jissue.finding_group.findings.all() - elif jissue.engagement: - return webhook_responser_handler("debug", "Update for engagement ignored") - else: - return webhook_responser_handler("info", f"Received issue update for {jissue.jira_key} for unknown object") - # Process the assignee if present - assignee = parsed["issue"]["fields"].get("assignee") - assignee_name = "Jira User" - if assignee is not None: - # First look for the 'name' field. If not present, try 'displayName'. Else put None - assignee_name = assignee.get("name", assignee.get("displayName")) - - # "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" - - resolution = parsed["issue"]["fields"]["resolution"] - resolution = resolution if resolution and resolution != "None" else None - resolution_id = resolution["id"] if resolution else None - resolution_name = resolution["name"] if resolution else None - jira_now = parse_datetime(parsed["issue"]["fields"]["updated"]) - - if findings: - for finding in findings: - jira_helper.process_resolution_from_jira(finding, resolution_id, resolution_name, assignee_name, jira_now, jissue, finding_group=jissue.finding_group) - # Check for any comment that could have come along with the resolution - if (error_response := check_for_and_create_comment(parsed)) is not None: - return error_response - - if parsed.get("webhookEvent") == "comment_created": - if (error_response := check_for_and_create_comment(parsed)) is not None: - return error_response - except Exception as e: - # Check if the issue is originally a 404 - if isinstance(e, Http404): - return webhook_responser_handler("debug", str(e)) - # Try to get a little more information on the exact exception + return webhook_responser_handler("debug", f"Failed to parse JSON: {e}") + + # Check if the events supplied are supported + if parsed.get("webhookEvent") not in {"comment_created", "jira:issue_updated"}: + return webhook_responser_handler("info", f"Unrecognized JIRA webhook event received: {parsed.get('webhookEvent')}") + + # Wrap processing with pghistory context for audit trail + # JIRA webhooks don't have a user session, so we create a new context + with pghistory.context( + source="jira_webhook", + jira_event=parsed.get("webhookEvent"), + ): try: - message = ( - f"Original Exception: {e}\n" - f"jira webhook body parsed:\n{json.dumps(parsed, indent=4)}" - ) - except Exception: - message = ( - f"Original Exception: {e}\n" - f"jira webhook body :\n{request.body.decode('utf-8')}" - ) - return webhook_responser_handler("debug", message) + if parsed.get("webhookEvent") == "jira:issue_updated": + # xml examples at the end of file + jid = parsed["issue"]["id"] + # This may raise a 404, but it will be handled in the exception response + try: + jissue = JIRA_Issue.objects.get(jira_id=jid) + except JIRA_Instance.DoesNotExist: + return webhook_responser_handler("info", f"JIRA issue {jid} is not linked to a DefectDojo Finding") + # Add jira_key to context now that we have it + pghistory.context(jira_key=jissue.jira_key) + findings = None + # Determine what type of object we will be working with + if jissue.finding: + logger.debug(f"Received issue update for {jissue.jira_key} for finding {jissue.finding.id}") + findings = [jissue.finding] + elif jissue.finding_group: + logger.debug(f"Received issue update for {jissue.jira_key} for finding group {jissue.finding_group}") + findings = jissue.finding_group.findings.all() + elif jissue.engagement: + return webhook_responser_handler("debug", "Update for engagement ignored") + else: + return webhook_responser_handler("info", f"Received issue update for {jissue.jira_key} for unknown object") + # Process the assignee if present + assignee = parsed["issue"]["fields"].get("assignee") + assignee_name = "Jira User" + if assignee is not None: + # First look for the 'name' field. If not present, try 'displayName'. Else put None + assignee_name = assignee.get("name", assignee.get("displayName")) + + # "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" + + resolution = parsed["issue"]["fields"]["resolution"] + resolution = resolution if resolution and resolution != "None" else None + resolution_id = resolution["id"] if resolution else None + resolution_name = resolution["name"] if resolution else None + jira_now = parse_datetime(parsed["issue"]["fields"]["updated"]) + + if findings: + for finding in findings: + jira_helper.process_resolution_from_jira(finding, resolution_id, resolution_name, assignee_name, jira_now, jissue, finding_group=jissue.finding_group) + # Check for any comment that could have come along with the resolution + if (error_response := check_for_and_create_comment(parsed)) is not None: + return error_response + + if parsed.get("webhookEvent") == "comment_created": + if (error_response := check_for_and_create_comment(parsed)) is not None: + return error_response + + except Exception as e: + # Check if the issue is originally a 404 + if isinstance(e, Http404): + return webhook_responser_handler("debug", str(e)) + # Try to get a little more information on the exact exception + try: + message = ( + f"Original Exception: {e}\n" + f"jira webhook body parsed:\n{json.dumps(parsed, indent=4)}" + ) + except Exception: + message = ( + f"Original Exception: {e}\n" + f"jira webhook body :\n{request.body.decode('utf-8')}" + ) + return webhook_responser_handler("debug", message) return webhook_responser_handler("No logging here", "Success!") diff --git a/dojo/management/commands/dedupe.py b/dojo/management/commands/dedupe.py index a8e0a538cfe..3eddccdc45d 100644 --- a/dojo/management/commands/dedupe.py +++ b/dojo/management/commands/dedupe.py @@ -1,5 +1,6 @@ import logging +import pghistory from django.conf import settings from django.core.management.base import BaseCommand from django.db.models import Prefetch @@ -64,6 +65,21 @@ def handle(self, *args, **options): dedupe_sync = options["dedupe_sync"] dedupe_batch_mode = options.get("dedupe_batch_mode", True) # Default to True (batch mode enabled) + # Wrap with pghistory context for audit trail + with pghistory.context( + source="dedupe_command", + dedupe_sync=dedupe_sync, + ): + self._run_dedupe( + restrict_to_parsers=restrict_to_parsers, + hash_code_only=hash_code_only, + dedupe_only=dedupe_only, + dedupe_sync=dedupe_sync, + dedupe_batch_mode=dedupe_batch_mode, + ) + + def _run_dedupe(self, *, restrict_to_parsers, hash_code_only, dedupe_only, dedupe_sync, dedupe_batch_mode): + """Internal method to run the dedupe logic within pghistory context.""" if restrict_to_parsers is not None: findings = Finding.objects.filter(test__test_type__name__in=restrict_to_parsers).exclude(duplicate=True) logger.info("######## Will process only parsers %s and %d findings ########", *restrict_to_parsers, findings.count()) diff --git a/dojo/management/commands/jira_status_reconciliation.py b/dojo/management/commands/jira_status_reconciliation.py index c8ce694015d..a6a49e9256b 100644 --- a/dojo/management/commands/jira_status_reconciliation.py +++ b/dojo/management/commands/jira_status_reconciliation.py @@ -1,5 +1,6 @@ import logging +import pghistory from dateutil.relativedelta import relativedelta from django.conf import settings from django.core.management.base import BaseCommand @@ -216,10 +217,9 @@ def add_arguments(self, parser): parser.add_argument("--dryrun", action="store_true", help="Only print actions to be performed, but make no modifications.") def handle(self, *args, **options): - # mode = options['mode'] - # product = options['product'] - # engagement = options['engagement'] - # daysback = options['daysback'] - # dryrun = options['dryrun'] - - return jira_status_reconciliation(*args, **options) + # Wrap with pghistory context for audit trail + with pghistory.context( + source="jira_reconciliation", + mode=options.get("mode", "reconcile"), + ): + return jira_status_reconciliation(*args, **options) diff --git a/dojo/pghistory_models.py b/dojo/pghistory_models.py index 936bd939c60..0902c775ec1 100644 --- a/dojo/pghistory_models.py +++ b/dojo/pghistory_models.py @@ -22,10 +22,15 @@ class DojoEvents(pghistory.models.Events): as regular model fields instead of accessing nested JSON data. """ + # Middleware-provided fields user = pghistory.ProxyField("pgh_context__user", models.IntegerField(null=True)) url = pghistory.ProxyField("pgh_context__url", models.TextField(null=True)) remote_addr = pghistory.ProxyField("pgh_context__remote_addr", models.CharField(max_length=45, null=True)) + # Process identification fields + source = pghistory.ProxyField("pgh_context__source", models.CharField(max_length=50, null=True)) + scan_type = pghistory.ProxyField("pgh_context__scan_type", models.CharField(max_length=100, null=True)) + class Meta: proxy = True app_label = "dojo" diff --git a/dojo/pghistory_utils.py b/dojo/pghistory_utils.py new file mode 100644 index 00000000000..4477ee8c124 --- /dev/null +++ b/dojo/pghistory_utils.py @@ -0,0 +1,99 @@ +""" +Utilities for passing pghistory context to Celery tasks. + +pghistory uses thread-local storage, so context is lost when tasks run +in Celery workers. These utilities allow capturing context in the sender +process and recreating it in the worker. +""" +import uuid +from contextlib import nullcontext + +from pghistory import runtime as pghistory_runtime + + +def get_serializable_pghistory_context(): + """ + Capture the current pghistory context for passing to Celery tasks. + + Returns a JSON-serializable dict with context id and metadata, + or None if no context is active. + """ + if hasattr(pghistory_runtime._tracker, "value"): + ctx = pghistory_runtime._tracker.value + return { + "id": str(ctx.id), + "metadata": ctx.metadata.copy(), + } + return None + + +class PgHistoryContextFromTask: + + """ + Context manager to apply pghistory context received from a Celery task. + + This recreates the exact same context (with the same UUID) that was + active when the task was dispatched, ensuring all events share the + same pgh_context_id. + + Usage: + pgh_context = kwargs.pop("_pgh_context", None) + with PgHistoryContextFromTask(pgh_context): + # Task body runs here with context applied + """ + + def __init__(self, context_data): + """ + Initialize with context data from Celery kwargs. + + Args: + context_data: Dict with "id" (UUID string) and "metadata" (dict), + or None for no-op behavior. + + """ + self.context_data = context_data + self._pre_execute_hook = None + self._owns_context = False + + def __enter__(self): + if not self.context_data: + return None + + from django.db import connection # noqa: PLC0415 + + context_id = uuid.UUID(self.context_data["id"]) + metadata = self.context_data["metadata"] + + # Only create a new context if one doesn't already exist + if not hasattr(pghistory_runtime._tracker, "value"): + self._pre_execute_hook = connection.execute_wrapper( + pghistory_runtime._inject_history_context, + ) + self._pre_execute_hook.__enter__() + pghistory_runtime._tracker.value = pghistory_runtime.Context( + id=context_id, + metadata=metadata, + ) + self._owns_context = True + else: + # Context already exists, just merge metadata + pghistory_runtime._tracker.value.metadata.update(metadata) + + return pghistory_runtime._tracker.value + + def __exit__(self, *exc): + if self._owns_context and self._pre_execute_hook: + delattr(pghistory_runtime._tracker, "value") + self._pre_execute_hook.__exit__(*exc) + + +def get_pghistory_context_manager(context_data): + """ + Return appropriate context manager for the given context data. + + Returns PgHistoryContextFromTask if context_data is provided, + otherwise returns a no-op nullcontext. + """ + if context_data: + return PgHistoryContextFromTask(context_data) + return nullcontext() diff --git a/dojo/risk_acceptance/helper.py b/dojo/risk_acceptance/helper.py index 1aa09e82669..1ce170deabb 100644 --- a/dojo/risk_acceptance/helper.py +++ b/dojo/risk_acceptance/helper.py @@ -1,6 +1,7 @@ import logging from contextlib import suppress +import pghistory from dateutil.relativedelta import relativedelta from django.core.exceptions import PermissionDenied from django.urls import reverse @@ -175,39 +176,42 @@ def expiration_handler(*args, **kwargs): If configured also sends a JIRA comment in both case to each jira issue. This is per finding. """ - try: - system_settings = System_Settings.objects.get() - except System_Settings.DoesNotExist: - logger.warning("Unable to get system_settings, skipping risk acceptance expiration job") + # Wrap with pghistory context for audit trail + with pghistory.context(source="risk_acceptance_expiration"): + try: + system_settings = System_Settings.objects.get() + except System_Settings.DoesNotExist: + logger.warning("Unable to get system_settings, skipping risk acceptance expiration job") + return - risk_acceptances = get_expired_risk_acceptances_to_handle() + risk_acceptances = get_expired_risk_acceptances_to_handle() - logger.info("expiring %i risk acceptances that are past expiration date", len(risk_acceptances)) - for risk_acceptance in risk_acceptances: - expire_now(risk_acceptance) - # notification created by expire_now code + logger.info("expiring %i risk acceptances that are past expiration date", len(risk_acceptances)) + for risk_acceptance in risk_acceptances: + expire_now(risk_acceptance) + # notification created by expire_now code - heads_up_days = system_settings.risk_acceptance_notify_before_expiration - if heads_up_days > 0: - risk_acceptances = get_almost_expired_risk_acceptances_to_handle(heads_up_days) + heads_up_days = system_settings.risk_acceptance_notify_before_expiration + if heads_up_days > 0: + risk_acceptances = get_almost_expired_risk_acceptances_to_handle(heads_up_days) - logger.info("notifying for %i risk acceptances that are expiring within %i days", len(risk_acceptances), heads_up_days) - for risk_acceptance in risk_acceptances: - logger.debug("notifying for risk acceptance %i:%s with %i findings", risk_acceptance.id, risk_acceptance, len(risk_acceptance.accepted_findings.all())) + logger.info("notifying for %i risk acceptances that are expiring within %i days", len(risk_acceptances), heads_up_days) + for risk_acceptance in risk_acceptances: + logger.debug("notifying for risk acceptance %i:%s with %i findings", risk_acceptance.id, risk_acceptance, len(risk_acceptance.accepted_findings.all())) - notification_title = "Risk acceptance with " + str(len(risk_acceptance.accepted_findings.all())) + " accepted findings will expire on " + \ - timezone.localtime(risk_acceptance.expiration_date).strftime("%b %d, %Y") + " for " + \ - str(risk_acceptance.engagement.product) + ": " + str(risk_acceptance.engagement.name) + notification_title = "Risk acceptance with " + str(len(risk_acceptance.accepted_findings.all())) + " accepted findings will expire on " + \ + timezone.localtime(risk_acceptance.expiration_date).strftime("%b %d, %Y") + " for " + \ + str(risk_acceptance.engagement.product) + ": " + str(risk_acceptance.engagement.name) - create_notification(event="risk_acceptance_expiration", title=notification_title, risk_acceptance=risk_acceptance, - accepted_findings=risk_acceptance.accepted_findings.all(), engagement=risk_acceptance.engagement, - product=risk_acceptance.engagement.product, - url=reverse("view_risk_acceptance", args=(risk_acceptance.engagement.id, risk_acceptance.id))) + create_notification(event="risk_acceptance_expiration", title=notification_title, risk_acceptance=risk_acceptance, + accepted_findings=risk_acceptance.accepted_findings.all(), engagement=risk_acceptance.engagement, + product=risk_acceptance.engagement.product, + url=reverse("view_risk_acceptance", args=(risk_acceptance.engagement.id, risk_acceptance.id))) - post_jira_comments(risk_acceptance, risk_acceptance.accepted_findings.all(), expiration_warning_message_creator, heads_up_days) + post_jira_comments(risk_acceptance, risk_acceptance.accepted_findings.all(), expiration_warning_message_creator, heads_up_days) - risk_acceptance.expiration_date_warned = timezone.now() - risk_acceptance.save() + risk_acceptance.expiration_date_warned = timezone.now() + risk_acceptance.save() def get_view_risk_acceptance(risk_acceptance: Risk_Acceptance) -> str: diff --git a/dojo/tasks.py b/dojo/tasks.py index f74ffbd6389..d02040fa5b3 100644 --- a/dojo/tasks.py +++ b/dojo/tasks.py @@ -1,6 +1,7 @@ import logging from datetime import timedelta +import pghistory from celery.utils.log import get_task_logger from django.apps import apps from django.conf import settings @@ -99,6 +100,13 @@ def flush_auditlog(*args, **kwargs): @app.task(bind=True) def async_dupe_delete(*args, **kwargs): + # Wrap with pghistory context for audit trail + with pghistory.context(source="dupe_delete_task"): + _async_dupe_delete_impl() + + +def _async_dupe_delete_impl(): + """Internal implementation of async_dupe_delete within pghistory context.""" try: system_settings = System_Settings.objects.get() enabled = system_settings.delete_duplicates @@ -182,12 +190,19 @@ def async_sla_compute_and_notify_task(*args, **kwargs): @app.task def jira_status_reconciliation_task(*args, **kwargs): - return jira_status_reconciliation(*args, **kwargs) + # Wrap with pghistory context for audit trail + with pghistory.context( + source="jira_reconciliation", + mode=kwargs.get("mode", "reconcile"), + ): + return jira_status_reconciliation(*args, **kwargs) @app.task def fix_loop_duplicates_task(*args, **kwargs): - return fix_loop_duplicates() + # Wrap with pghistory context for audit trail + with pghistory.context(source="fix_loop_duplicates"): + return fix_loop_duplicates() @app.task diff --git a/dojo/test/views.py b/dojo/test/views.py index d2bf11092e7..4e6c6353be6 100644 --- a/dojo/test/views.py +++ b/dojo/test/views.py @@ -6,6 +6,7 @@ from datetime import datetime, timedelta from functools import reduce +import pghistory from django.contrib import messages from django.contrib.admin.utils import NestedObjects from django.core.exceptions import ValidationError @@ -1075,6 +1076,12 @@ def post( if form_error := self.process_form(request, context.get("form"), context): add_error_message_to_response(form_error) return self.failure_redirect(request, context) + # Add pghistory context for audit trail (adds to existing middleware context) + pghistory.context( + source="reimport", + test_id=context.get("test").id, + scan_type=context.get("scan_type"), + ) # Kick off the import process if import_error := self.reimport_findings(context): add_error_message_to_response(import_error) diff --git a/dojo/tools/tool_issue_updater.py b/dojo/tools/tool_issue_updater.py index add5beb54a5..fd203edebea 100644 --- a/dojo/tools/tool_issue_updater.py +++ b/dojo/tools/tool_issue_updater.py @@ -1,3 +1,5 @@ +import pghistory + from dojo.celery import app from dojo.decorators import dojo_async_task, dojo_model_from_id, dojo_model_to_id from dojo.tools.api_sonarqube.parser import SCAN_SONARQUBE_API @@ -30,8 +32,9 @@ def tool_issue_updater(finding, *args, **kwargs): @dojo_async_task @app.task def update_findings_from_source_issues(**kwargs): + # Wrap with pghistory context for audit trail + with pghistory.context(source="sonarqube_sync"): + findings = SonarQubeApiUpdaterFromSource().get_findings_to_update() - findings = SonarQubeApiUpdaterFromSource().get_findings_to_update() - - for finding in findings: - SonarQubeApiUpdaterFromSource().update(finding) + for finding in findings: + SonarQubeApiUpdaterFromSource().update(finding) From 7122e55d821f26490cf01099ccf43f609bae29fe Mon Sep 17 00:00:00 2001 From: valentijnscholten Date: Mon, 29 Dec 2025 18:05:09 +0100 Subject: [PATCH 094/126] finding template refactor (#13946) * finding_templates: make tags work again * finding_templates: reinstate unit tests * finding_templates: remove automated matching logic * finding tempaltes: more auhtoirzation tests to apply template * fixture cleanup * upgrade notes * finding_template: add cvss validation * increase memory hugo * finding_template: align fields with finding model * finding_templates: update api schema * finding_templates: centralize logic * squash migrations * squash migrations * revert git hub pages changes * fix user interface test * update upgrade notes * fix test * move to 2.54 * fix test * move to 2.54 * Bumping hugo version due to memory issue --------- Co-authored-by: Ross Esposito --- .github/workflows/gh-pages.yml | 3 +- docs/content/en/open_source/upgrading/2.54.md | 6 +- dojo/api_v2/serializers.py | 89 ++-- .../0252_finding_template_changes.py | 200 +++++++++ ..._migrate_vulnerability_ids_to_textfield.py | 105 +++++ ..._remove_vulnerability_id_template_model.py | 17 + dojo/finding/helper.py | 173 +++++++- dojo/finding/urls.py | 2 - dojo/finding/views.py | 233 ++++++---- dojo/fixtures/defect_dojo_sample_data.json | 7 - dojo/forms.py | 123 +++++- dojo/management/commands/migrate_cve.py | 8 +- dojo/models.py | 62 ++- dojo/templates/dojo/add_findings.html | 2 +- dojo/templates/dojo/add_template.html | 5 - dojo/templates/dojo/templates.html | 6 +- dojo/templates/dojo/view_finding.html | 25 +- dojo/test/urls.py | 2 +- dojo/test/views.py | 97 ++++- dojo/utils.py | 21 - unittests/test_apply_finding_template.py | 401 +++++++++++++++--- unittests/test_finding_helper.py | 14 +- 22 files changed, 1299 insertions(+), 302 deletions(-) create mode 100644 dojo/db_migrations/0252_finding_template_changes.py create mode 100644 dojo/db_migrations/0253_migrate_vulnerability_ids_to_textfield.py create mode 100644 dojo/db_migrations/0254_remove_vulnerability_id_template_model.py diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index 2a398aeb441..5490cb9b162 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -46,13 +46,12 @@ jobs: - name: Install dependencies run: cd docs && npm ci - + - name: Build production website env: HUGO_ENVIRONMENT: production HUGO_ENV: production run: cd docs && hugo --minify --gc --config config/production/hugo.toml - - name: Deploy uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4.0.0 if: github.repository == 'DefectDojo/django-DefectDojo' # Deploy docs only in core repo, not in forks - it would just fail in fork diff --git a/docs/content/en/open_source/upgrading/2.54.md b/docs/content/en/open_source/upgrading/2.54.md index c6843812cb7..7a8b6bad844 100644 --- a/docs/content/en/open_source/upgrading/2.54.md +++ b/docs/content/en/open_source/upgrading/2.54.md @@ -2,7 +2,7 @@ title: 'Upgrading to DefectDojo Version 2.54.x' toc_hide: true weight: -20250804 -description: Removal of django-auditlog & Dropped support for DD_PARSER_EXCLUDE & Reimport performance improvements +description: Removal of django-auditlog & Dropped support for DD_PARSER_EXCLUDE & Reimport performance improvements & Removal of Finding Template Matching --- ## Breaking Change: Removal of django-auditlog @@ -55,5 +55,9 @@ DefectDojo 2.54.x includes performance improvements for reimporting scan results No action is required after upgrading. (Optional tuning knobs exist via `DD_IMPORT_REIMPORT_MATCH_BATCH_SIZE` and `DD_IMPORT_REIMPORT_DEDUPE_BATCH_SIZE`.) +## Finding Template enhancements and removal of CWE matching + +As communicated in the [2025Q1 community update](https://github.com/DefectDojo/django-DefectDojo/discussions/12153) the automated matching of Finding Templates based on `CWE` and/or `title` has now been removed. + There are other instructions for upgrading to 2.54.x. Check the Release Notes for the contents of the release: `https://github.com/DefectDojo/django-DefectDojo/releases/tag/2.54.0` Check the [Release Notes](https://github.com/DefectDojo/django-DefectDojo/releases/tag/2.54.0) for the contents of the release. diff --git a/dojo/api_v2/serializers.py b/dojo/api_v2/serializers.py index 3835318bc5f..6bc18b5115f 100644 --- a/dojo/api_v2/serializers.py +++ b/dojo/api_v2/serializers.py @@ -29,6 +29,7 @@ from dojo.authorization.roles_permissions import Permissions from dojo.endpoint.utils import endpoint_filter, endpoint_meta_import from dojo.finding.helper import ( + save_endpoints_template, save_vulnerability_ids, save_vulnerability_ids_template, ) @@ -111,7 +112,6 @@ User, UserContactInfo, Vulnerability_Id, - Vulnerability_Id_Template, get_current_date, ) from dojo.notifications.helper import create_notification @@ -2029,57 +2029,80 @@ def validate_severity(self, value: str) -> str: return value -class VulnerabilityIdTemplateSerializer(serializers.ModelSerializer): - class Meta: - model = Vulnerability_Id_Template - fields = ["vulnerability_id"] - - class FindingTemplateSerializer(serializers.ModelSerializer): tags = TagListSerializerField(required=False) - vulnerability_ids = VulnerabilityIdTemplateSerializer( - source="vulnerability_id_template_set", many=True, required=False, - ) + vulnerability_ids = serializers.SerializerMethodField() + endpoints = serializers.SerializerMethodField() class Meta: model = Finding_Template - exclude = ("cve",) + exclude = ("cve", "vulnerability_ids_text") + + @extend_schema_field(serializers.ListField(child=serializers.CharField())) + def get_vulnerability_ids(self, obj): + """Return vulnerability IDs as a list of strings.""" + return obj.vulnerability_ids + + @extend_schema_field(serializers.ListField(child=serializers.CharField())) + def get_endpoints(self, obj): + """Return endpoints as a list of URL strings.""" + return obj.endpoints if hasattr(obj, "endpoints") else [] def create(self, validated_data): - # Save vulnerability ids and pop them - if "vulnerability_id_template_set" in validated_data: - vulnerability_id_set = validated_data.pop( - "vulnerability_id_template_set", - ) - else: - vulnerability_id_set = None + # Handle vulnerability_ids if provided as list + vulnerability_ids = None + if "vulnerability_ids" in self.initial_data: + vulnerability_ids = self.initial_data.get("vulnerability_ids", []) + if isinstance(vulnerability_ids, str): + # If it's a string, split by newlines + vulnerability_ids = [vid.strip() for vid in vulnerability_ids.split("\n") if vid.strip()] + elif not isinstance(vulnerability_ids, list): + vulnerability_ids = [] + + # Handle endpoints if provided as list + endpoint_urls = None + if "endpoints" in self.initial_data: + endpoint_urls = self.initial_data.get("endpoints", []) + if isinstance(endpoint_urls, str): + # If it's a string, split by newlines + endpoint_urls = [url.strip() for url in endpoint_urls.split("\n") if url.strip()] + elif not isinstance(endpoint_urls, list): + endpoint_urls = [] new_finding_template = super().create( validated_data, ) - if vulnerability_id_set: - vulnerability_ids = [vulnerability_id["vulnerability_id"] for vulnerability_id in vulnerability_id_set] - validated_data["cve"] = vulnerability_ids[0] - save_vulnerability_ids_template( - new_finding_template, vulnerability_ids, - ) - new_finding_template.save() + # Save vulnerability IDs using helper + if vulnerability_ids: + save_vulnerability_ids_template(new_finding_template, vulnerability_ids) + + # Save endpoints using helper + if endpoint_urls: + save_endpoints_template(new_finding_template, endpoint_urls) return new_finding_template def update(self, instance, validated_data): - # Save vulnerability ids and pop them - if "vulnerability_id_template_set" in validated_data: - vulnerability_id_set = validated_data.pop( - "vulnerability_id_template_set", - ) - vulnerability_ids = [] - if vulnerability_id_set: - vulnerability_ids.extend(vulnerability_id["vulnerability_id"] for vulnerability_id in vulnerability_id_set) + # Handle vulnerability_ids if provided + if "vulnerability_ids" in self.initial_data: + vulnerability_ids = self.initial_data.get("vulnerability_ids", []) + if isinstance(vulnerability_ids, str): + vulnerability_ids = [vid.strip() for vid in vulnerability_ids.split("\n") if vid.strip()] + elif not isinstance(vulnerability_ids, list): + vulnerability_ids = [] save_vulnerability_ids_template(instance, vulnerability_ids) + # Handle endpoints if provided + if "endpoints" in self.initial_data: + endpoint_urls = self.initial_data.get("endpoints", []) + if isinstance(endpoint_urls, str): + endpoint_urls = [url.strip() for url in endpoint_urls.split("\n") if url.strip()] + elif not isinstance(endpoint_urls, list): + endpoint_urls = [] + save_endpoints_template(instance, endpoint_urls) + return super().update(instance, validated_data) diff --git a/dojo/db_migrations/0252_finding_template_changes.py b/dojo/db_migrations/0252_finding_template_changes.py new file mode 100644 index 00000000000..4fd2ac3a304 --- /dev/null +++ b/dojo/db_migrations/0252_finding_template_changes.py @@ -0,0 +1,200 @@ +# Generated by Django 5.2.9 on 2025-12-21 07:29 + +import dojo.validators +import pgtrigger.compiler +import pgtrigger.migrations +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dojo', '0251_usercontactinfo_reset_timestamps'), + ] + + operations = [ + pgtrigger.migrations.RemoveTrigger( + model_name='finding_template', + name='insert_insert', + ), + pgtrigger.migrations.RemoveTrigger( + model_name='finding_template', + name='update_update', + ), + pgtrigger.migrations.RemoveTrigger( + model_name='finding_template', + name='delete_delete', + ), + migrations.RemoveField( + model_name='system_settings', + name='enable_template_match', + ), + migrations.RemoveField( + model_name='finding_template', + name='template_match', + ), + migrations.RemoveField( + model_name='finding_template', + name='template_match_title', + ), + migrations.RemoveField( + model_name='finding_templateevent', + name='template_match', + ), + migrations.RemoveField( + model_name='finding_templateevent', + name='template_match_title', + ), + migrations.AddField( + model_name='finding_template', + name='cvssv4', + field=models.TextField(help_text='Common Vulnerability Scoring System version 4 (CVSS4) score associated with this finding.', max_length=255, null=True, validators=[dojo.validators.cvss4_validator], verbose_name='CVSS4 vector'), + ), + migrations.AddField( + model_name='finding_template', + name='component_name', + field=models.CharField(blank=True, help_text='Affected component name (e.g., library name)', max_length=500, null=True), + ), + migrations.AddField( + model_name='finding_template', + name='component_version', + field=models.CharField(blank=True, help_text='Affected component version', max_length=100, null=True), + ), + migrations.AddField( + model_name='finding_template', + name='cvssv3_score', + field=models.FloatField(blank=True, help_text='CVSSv3 score', null=True), + ), + migrations.AddField( + model_name='finding_template', + name='cvssv4_score', + field=models.FloatField(blank=True, help_text='CVSSv4 score', null=True), + ), + migrations.AddField( + model_name='finding_template', + name='effort_for_fixing', + field=models.CharField(blank=True, help_text='Effort estimate for fixing (e.g., Low/Medium/High)', max_length=99, null=True), + ), + migrations.AddField( + model_name='finding_template', + name='fix_available', + field=models.BooleanField(blank=True, help_text='Indicates if a fix is available for this vulnerability type', null=True), + ), + migrations.AddField( + model_name='finding_template', + name='fix_version', + field=models.CharField(blank=True, help_text='Version where fix is available', max_length=100, null=True), + ), + migrations.AddField( + model_name='finding_template', + name='notes', + field=models.TextField(blank=True, help_text='Note content to add when applying this template', null=True), + ), + migrations.AddField( + model_name='finding_template', + name='planned_remediation_version', + field=models.CharField(blank=True, help_text='Target version for remediation', max_length=99, null=True), + ), + migrations.AddField( + model_name='finding_template', + name='severity_justification', + field=models.TextField(blank=True, help_text='Explanation of why this severity level is appropriate', null=True), + ), + migrations.AddField( + model_name='finding_template', + name='steps_to_reproduce', + field=models.TextField(blank=True, help_text='Standard reproduction steps for this vulnerability type', null=True), + ), + migrations.AddField( + model_name='finding_template', + name='vulnerability_ids_text', + field=models.TextField(blank=True, help_text='Vulnerability IDs (one per line)', null=True), + ), + migrations.AddField( + model_name='finding_template', + name='endpoints_text', + field=models.TextField(blank=True, help_text='Endpoint URLs (one per line)', null=True), + ), + migrations.AddField( + model_name='finding_templateevent', + name='component_name', + field=models.CharField(blank=True, help_text='Affected component name (e.g., library name)', max_length=500, null=True), + ), + migrations.AddField( + model_name='finding_templateevent', + name='component_version', + field=models.CharField(blank=True, help_text='Affected component version', max_length=100, null=True), + ), + migrations.AddField( + model_name='finding_templateevent', + name='cvssv3_score', + field=models.FloatField(blank=True, help_text='CVSSv3 score', null=True), + ), + migrations.AddField( + model_name='finding_templateevent', + name='cvssv4', + field=models.TextField(help_text='Common Vulnerability Scoring System version 4 (CVSS4) score associated with this finding.', max_length=255, null=True, validators=[dojo.validators.cvss4_validator], verbose_name='CVSS4 vector'), + ), + migrations.AddField( + model_name='finding_templateevent', + name='cvssv4_score', + field=models.FloatField(blank=True, help_text='CVSSv4 score', null=True), + ), + migrations.AddField( + model_name='finding_templateevent', + name='effort_for_fixing', + field=models.CharField(blank=True, help_text='Effort estimate for fixing (e.g., Low/Medium/High)', max_length=99, null=True), + ), + migrations.AddField( + model_name='finding_templateevent', + name='fix_available', + field=models.BooleanField(blank=True, help_text='Indicates if a fix is available for this vulnerability type', null=True), + ), + migrations.AddField( + model_name='finding_templateevent', + name='fix_version', + field=models.CharField(blank=True, help_text='Version where fix is available', max_length=100, null=True), + ), + migrations.AddField( + model_name='finding_templateevent', + name='notes', + field=models.TextField(blank=True, help_text='Note content to add when applying this template', null=True), + ), + migrations.AddField( + model_name='finding_templateevent', + name='planned_remediation_version', + field=models.CharField(blank=True, help_text='Target version for remediation', max_length=99, null=True), + ), + migrations.AddField( + model_name='finding_templateevent', + name='severity_justification', + field=models.TextField(blank=True, help_text='Explanation of why this severity level is appropriate', null=True), + ), + migrations.AddField( + model_name='finding_templateevent', + name='steps_to_reproduce', + field=models.TextField(blank=True, help_text='Standard reproduction steps for this vulnerability type', null=True), + ), + migrations.AddField( + model_name='finding_templateevent', + name='vulnerability_ids_text', + field=models.TextField(blank=True, help_text='Vulnerability IDs (one per line)', null=True), + ), + migrations.AddField( + model_name='finding_templateevent', + name='endpoints_text', + field=models.TextField(blank=True, help_text='Endpoint URLs (one per line)', null=True), + ), + pgtrigger.migrations.AddTrigger( + model_name='finding_template', + trigger=pgtrigger.compiler.Trigger(name='insert_insert', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "dojo_finding_templateevent" ("component_name", "component_version", "cve", "cvssv3", "cvssv3_score", "cvssv4", "cvssv4_score", "cwe", "description", "effort_for_fixing", "endpoints_text", "fix_available", "fix_version", "id", "impact", "last_used", "mitigation", "notes", "numerical_severity", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "planned_remediation_version", "refs", "severity", "severity_justification", "steps_to_reproduce", "title", "vulnerability_ids_text") VALUES (NEW."component_name", NEW."component_version", NEW."cve", NEW."cvssv3", NEW."cvssv3_score", NEW."cvssv4", NEW."cvssv4_score", NEW."cwe", NEW."description", NEW."effort_for_fixing", NEW."endpoints_text", NEW."fix_available", NEW."fix_version", NEW."id", NEW."impact", NEW."last_used", NEW."mitigation", NEW."notes", NEW."numerical_severity", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."planned_remediation_version", NEW."refs", NEW."severity", NEW."severity_justification", NEW."steps_to_reproduce", NEW."title", NEW."vulnerability_ids_text"); RETURN NULL;', hash='602d9e872906719a1c95d671a0c9e4ffe5b1b7ec', operation='INSERT', pgid='pgtrigger_insert_insert_59092', table='dojo_finding_template', when='AFTER')), + ), + pgtrigger.migrations.AddTrigger( + model_name='finding_template', + trigger=pgtrigger.compiler.Trigger(name='update_update', sql=pgtrigger.compiler.UpsertTriggerSql(condition='WHEN (OLD.* IS DISTINCT FROM NEW.*)', func='INSERT INTO "dojo_finding_templateevent" ("component_name", "component_version", "cve", "cvssv3", "cvssv3_score", "cvssv4", "cvssv4_score", "cwe", "description", "effort_for_fixing", "endpoints_text", "fix_available", "fix_version", "id", "impact", "last_used", "mitigation", "notes", "numerical_severity", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "planned_remediation_version", "refs", "severity", "severity_justification", "steps_to_reproduce", "title", "vulnerability_ids_text") VALUES (NEW."component_name", NEW."component_version", NEW."cve", NEW."cvssv3", NEW."cvssv3_score", NEW."cvssv4", NEW."cvssv4_score", NEW."cwe", NEW."description", NEW."effort_for_fixing", NEW."endpoints_text", NEW."fix_available", NEW."fix_version", NEW."id", NEW."impact", NEW."last_used", NEW."mitigation", NEW."notes", NEW."numerical_severity", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."planned_remediation_version", NEW."refs", NEW."severity", NEW."severity_justification", NEW."steps_to_reproduce", NEW."title", NEW."vulnerability_ids_text"); RETURN NULL;', hash='644c1afec5497d6e86c4c1c861801819b7363d61', operation='UPDATE', pgid='pgtrigger_update_update_43036', table='dojo_finding_template', when='AFTER')), + ), + pgtrigger.migrations.AddTrigger( + model_name='finding_template', + trigger=pgtrigger.compiler.Trigger(name='delete_delete', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "dojo_finding_templateevent" ("component_name", "component_version", "cve", "cvssv3", "cvssv3_score", "cvssv4", "cvssv4_score", "cwe", "description", "effort_for_fixing", "endpoints_text", "fix_available", "fix_version", "id", "impact", "last_used", "mitigation", "notes", "numerical_severity", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "planned_remediation_version", "refs", "severity", "severity_justification", "steps_to_reproduce", "title", "vulnerability_ids_text") VALUES (OLD."component_name", OLD."component_version", OLD."cve", OLD."cvssv3", OLD."cvssv3_score", OLD."cvssv4", OLD."cvssv4_score", OLD."cwe", OLD."description", OLD."effort_for_fixing", OLD."endpoints_text", OLD."fix_available", OLD."fix_version", OLD."id", OLD."impact", OLD."last_used", OLD."mitigation", OLD."notes", OLD."numerical_severity", _pgh_attach_context(), NOW(), \'delete\', OLD."id", OLD."planned_remediation_version", OLD."refs", OLD."severity", OLD."severity_justification", OLD."steps_to_reproduce", OLD."title", OLD."vulnerability_ids_text"); RETURN NULL;', hash='925844fc74c390b9d4fc446e0d3f44f556817fd4', operation='DELETE', pgid='pgtrigger_delete_delete_3f3a6', table='dojo_finding_template', when='AFTER')), + ) + ] diff --git a/dojo/db_migrations/0253_migrate_vulnerability_ids_to_textfield.py b/dojo/db_migrations/0253_migrate_vulnerability_ids_to_textfield.py new file mode 100644 index 00000000000..d69ddb8a857 --- /dev/null +++ b/dojo/db_migrations/0253_migrate_vulnerability_ids_to_textfield.py @@ -0,0 +1,105 @@ +# Generated migration for migrating Vulnerability_Id_Template to vulnerability_ids_text + +from django.db import migrations + + +def migrate_vulnerability_ids_to_textfield(apps, schema_editor): + """ + Migrate Vulnerability_Id_Template records to vulnerability_ids_text TextField. + Converts all vulnerability IDs for each Finding_Template into a newline-separated string. + """ + Finding_Template = apps.get_model('dojo', 'Finding_Template') + Vulnerability_Id_Template = apps.get_model('dojo', 'Vulnerability_Id_Template') + + # Process each Finding_Template + for template in Finding_Template.objects.all(): + # Get all vulnerability IDs for this template + vuln_ids = Vulnerability_Id_Template.objects.filter( + finding_template=template + ).values_list('vulnerability_id', flat=True).order_by('id') + + # Convert to list and remove duplicates while preserving order + vulnerability_ids = list(dict.fromkeys([vid.strip() for vid in vuln_ids if vid.strip()])) + + # Save as newline-separated string + if vulnerability_ids: + template.vulnerability_ids_text = '\n'.join(vulnerability_ids) + # Also update CVE field for backward compatibility (first ID) + if not template.cve: + template.cve = vulnerability_ids[0] + else: + # If no vulnerability IDs, check if CVE field has a value + if template.cve: + template.vulnerability_ids_text = template.cve + else: + template.vulnerability_ids_text = None + + template.save(update_fields=['vulnerability_ids_text', 'cve']) + + +def reverse_migrate_vulnerability_ids_to_textfield(apps, schema_editor): + """ + Reverse migration: populate Vulnerability_Id_Template from vulnerability_ids_text. + Note: This will recreate Vulnerability_Id_Template records if they were deleted. + """ + Finding_Template = apps.get_model('dojo', 'Finding_Template') + Vulnerability_Id_Template = apps.get_model('dojo', 'Vulnerability_Id_Template') + + # Process each Finding_Template + for template in Finding_Template.objects.all(): + # Parse vulnerability_ids_text + vulnerability_ids = [] + if template.vulnerability_ids_text: + vulnerability_ids = [ + vid.strip() + for vid in template.vulnerability_ids_text.split('\n') + if vid.strip() + ] + + # Also check CVE field for backward compatibility + if not vulnerability_ids and template.cve: + vulnerability_ids = [template.cve] + + # Remove duplicates while preserving order + vulnerability_ids = list(dict.fromkeys(vulnerability_ids)) + + # Delete existing Vulnerability_Id_Template records for this template + Vulnerability_Id_Template.objects.filter(finding_template=template).delete() + + # Create new Vulnerability_Id_Template records + for vuln_id in vulnerability_ids: + Vulnerability_Id_Template.objects.create( + finding_template=template, + vulnerability_id=vuln_id + ) + + +def delete_old_vulnerability_id_templates(apps, schema_editor): + """ + Delete all Vulnerability_Id_Template records after migration is complete. + This is a one-way operation and cannot be reversed. + """ + Vulnerability_Id_Template = apps.get_model('dojo', 'Vulnerability_Id_Template') + Vulnerability_Id_Template.objects.all().delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('dojo', '0252_finding_template_changes'), + ] + + operations = [ + # Step 1: Migrate data from Vulnerability_Id_Template to vulnerability_ids_text + migrations.RunPython( + migrate_vulnerability_ids_to_textfield, + reverse_code=reverse_migrate_vulnerability_ids_to_textfield, + ), + # Step 2: Delete old Vulnerability_Id_Template records + # Note: This is irreversible - if you need to rollback, you'll need to restore from backup + migrations.RunPython( + delete_old_vulnerability_id_templates, + reverse_code=migrations.RunPython.noop, + ), + ] + diff --git a/dojo/db_migrations/0254_remove_vulnerability_id_template_model.py b/dojo/db_migrations/0254_remove_vulnerability_id_template_model.py new file mode 100644 index 00000000000..5c671726947 --- /dev/null +++ b/dojo/db_migrations/0254_remove_vulnerability_id_template_model.py @@ -0,0 +1,17 @@ +# Generated migration to remove Vulnerability_Id_Template model + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dojo', '0253_migrate_vulnerability_ids_to_textfield'), + ] + + operations = [ + migrations.DeleteModel( + name='Vulnerability_Id_Template', + ), + ] + diff --git a/dojo/finding/helper.py b/dojo/finding/helper.py index 733e4d6f06f..a0587540ce6 100644 --- a/dojo/finding/helper.py +++ b/dojo/finding/helper.py @@ -17,7 +17,7 @@ import dojo.risk_acceptance.helper as ra_helper from dojo.celery import app from dojo.decorators import dojo_async_task, dojo_model_from_id, dojo_model_to_id -from dojo.endpoint.utils import save_endpoints_to_add +from dojo.endpoint.utils import endpoint_get_or_create, save_endpoints_to_add from dojo.file_uploads.helper import delete_related_files from dojo.finding.deduplication import ( dedupe_batch_of_findings, @@ -34,7 +34,6 @@ System_Settings, Test, Vulnerability_Id, - Vulnerability_Id_Template, ) from dojo.notes.helper import delete_related_notes from dojo.notifications.helper import create_notification @@ -767,7 +766,10 @@ def add_endpoints(new_finding, form): added_endpoints = save_endpoints_to_add(form.endpoints_to_add_list, new_finding.test.engagement.product) endpoint_ids = [endpoint.id for endpoint in added_endpoints] - new_finding.endpoints.set(form.cleaned_data["endpoints"] | Endpoint.objects.filter(id__in=endpoint_ids)) + # Merge form endpoints with existing endpoints (don't replace) + form_endpoints = form.cleaned_data.get("endpoints", Endpoint.objects.none()) + new_endpoints = Endpoint.objects.filter(id__in=endpoint_ids) + new_finding.endpoints.set(form_endpoints | new_endpoints | new_finding.endpoints.all()) for endpoint in new_finding.endpoints.all(): _eps, _created = Endpoint_Status.objects.get_or_create( @@ -798,22 +800,169 @@ def save_vulnerability_ids(finding, vulnerability_ids, *, delete_existing: bool def save_vulnerability_ids_template(finding_template, vulnerability_ids): - # Remove duplicates - vulnerability_ids = list(dict.fromkeys(vulnerability_ids)) - - # Remove old vulnerability ids - Vulnerability_Id_Template.objects.filter(finding_template=finding_template).delete() + """Save vulnerability IDs as newline-separated string in TextField.""" + # Remove duplicates and empty strings + vulnerability_ids = list(dict.fromkeys([vid.strip() for vid in vulnerability_ids if vid.strip()])) - # Save new vulnerability ids - for vulnerability_id in vulnerability_ids: - Vulnerability_Id_Template(finding_template=finding_template, vulnerability_id=vulnerability_id).save() + # Save as newline-separated string + finding_template.vulnerability_ids_text = "\n".join(vulnerability_ids) if vulnerability_ids else None - # Set CVE + # Set CVE for backward compatibility if vulnerability_ids: finding_template.cve = vulnerability_ids[0] else: finding_template.cve = None + finding_template.save() + + +def save_endpoints_template(finding_template, endpoint_urls): + """Save endpoint URLs as newline-separated string in TextField.""" + # Remove duplicates and empty strings + endpoint_urls = list(dict.fromkeys([url.strip() for url in endpoint_urls if url.strip()])) + # Save as newline-separated string + finding_template.endpoints_text = "\n".join(endpoint_urls) if endpoint_urls else None + finding_template.save() + + +def copy_template_fields_to_finding( + finding, + template, + form_data=None, + user=None, + *, + copy_vulnerability_ids=True, + copy_endpoints=True, + copy_notes=True, +): + """ + Copy fields from Finding_Template to Finding. + + Args: + finding: Finding instance to update + template: Finding_Template instance (source) + form_data: Optional dict of form cleaned_data (if provided, uses form values instead of template) + user: User instance (required for notes) + copy_vulnerability_ids: Whether to copy vulnerability IDs (default True) + copy_endpoints: Whether to copy endpoints (default True) + copy_notes: Whether to copy notes (default True) + + """ + # Helper to get value from form_data or template + def get_value(field_name, default=None): + if form_data and field_name in form_data: + value = form_data.get(field_name) + # Handle None checks for boolean/optional fields + if value is not None or field_name not in form_data: + return value + return getattr(template, field_name, default) + + # Copy CVSS fields + cvssv3 = get_value("cvssv3") + if cvssv3: + finding.cvssv3 = cvssv3 + cvssv3_score = get_value("cvssv3_score") + if cvssv3_score is not None: + finding.cvssv3_score = cvssv3_score + cvssv4 = get_value("cvssv4") + if cvssv4: + finding.cvssv4 = cvssv4 + cvssv4_score = get_value("cvssv4_score") + if cvssv4_score is not None: + finding.cvssv4_score = cvssv4_score + + # Copy remediation planning fields + fix_available = get_value("fix_available") + if fix_available is not None: + finding.fix_available = fix_available + fix_version = get_value("fix_version") + if fix_version: + finding.fix_version = fix_version + planned_remediation_version = get_value("planned_remediation_version") + if planned_remediation_version: + finding.planned_remediation_version = planned_remediation_version + effort_for_fixing = get_value("effort_for_fixing") + if effort_for_fixing: + finding.effort_for_fixing = effort_for_fixing + + # Copy technical details fields + steps_to_reproduce = get_value("steps_to_reproduce") + if steps_to_reproduce: + finding.steps_to_reproduce = steps_to_reproduce + severity_justification = get_value("severity_justification") + if severity_justification: + finding.severity_justification = severity_justification + component_name = get_value("component_name") + if component_name: + finding.component_name = component_name + component_version = get_value("component_version") + if component_version: + finding.component_version = component_version + + # Copy vulnerability IDs + if copy_vulnerability_ids: + if form_data and "vulnerability_ids" in form_data: + # Split form data (space or newline separated) + vulnerability_ids = form_data["vulnerability_ids"] + if isinstance(vulnerability_ids, str): + vulnerability_ids = vulnerability_ids.split() + save_vulnerability_ids(finding, vulnerability_ids, delete_existing=True) + elif template.vulnerability_ids: + save_vulnerability_ids(finding, template.vulnerability_ids, delete_existing=False) + + # Copy endpoints + if copy_endpoints: + endpoint_urls = None + if form_data and form_data.get("endpoints"): + # Parse from form data (newline-separated string) + endpoint_urls = [url.strip() for url in form_data["endpoints"].split("\n") if url.strip()] + elif template.endpoints: + # Parse from template (list or newline-separated string) + if isinstance(template.endpoints, list): + endpoint_urls = template.endpoints + else: + endpoint_urls = [url.strip() for url in template.endpoints.split("\n") if url.strip()] + + if endpoint_urls: + product = finding.test.engagement.product + for endpoint_url in endpoint_urls: + try: + endpoint = Endpoint.from_uri(endpoint_url) + ep, _ = endpoint_get_or_create( + protocol=endpoint.protocol, + host=endpoint.host, + port=endpoint.port, + path=endpoint.path, + query=endpoint.query, + fragment=endpoint.fragment, + product=product, + ) + Endpoint_Status.objects.get_or_create( + finding=finding, + endpoint=ep, + defaults={"date": finding.date or timezone.now()}, + ) + except Exception as e: + logger.warning(f"Failed to parse endpoint URL '{endpoint_url}': {e}") + + # Copy notes + if copy_notes and user: + notes_content = None + if form_data and form_data.get("notes"): + notes_content = form_data["notes"] + elif template.notes: + notes_content = template.notes + + if notes_content: + note = Notes( + entry=notes_content, + author=user, + date=timezone.now(), + private=False, + ) + note.save() + finding.notes.add(note) + def normalize_datetime(value): """Ensure value is timezone-aware datetime.""" diff --git a/dojo/finding/urls.py b/dojo/finding/urls.py index 3b59624029a..fa442df384c 100644 --- a/dojo/finding/urls.py +++ b/dojo/finding/urls.py @@ -134,8 +134,6 @@ views.clear_finding_review, name="clear_finding_review"), re_path(r"^finding/(?P\d+)/copy$", views.copy_finding, name="copy_finding"), - re_path(r"^finding/(?P\d+)/apply_cwe$", - views.apply_template_cwe, name="apply_template_cwe"), re_path(r"^finding/(?P\d+)/mktemplate$", views.mktemplate, name="mktemplate"), re_path(r"^finding/(?P\d+)/find_template_to_apply$", views.find_template_to_apply, diff --git a/dojo/finding/views.py b/dojo/finding/views.py index 0d05d24fde1..c3b4024ee44 100644 --- a/dojo/finding/views.py +++ b/dojo/finding/views.py @@ -15,12 +15,11 @@ from django.core import serializers from django.core.exceptions import PermissionDenied, ValidationError from django.db import models -from django.db.models import F, QuerySet, Value +from django.db.models import Case, F, QuerySet, Value, When from django.db.models.functions import Coalesce, ExtractDay, Length, TruncDate from django.db.models.query import Prefetch from django.http import Http404, HttpRequest, HttpResponse, HttpResponseRedirect, JsonResponse, StreamingHttpResponse from django.shortcuts import get_object_or_404, render -from django.template.defaultfilters import pluralize from django.urls import reverse from django.utils import formats, timezone from django.utils.safestring import mark_safe @@ -63,7 +62,6 @@ EditPlannedRemediationDateFindingForm, FindingBulkUpdateForm, FindingForm, - FindingFormID, FindingTemplateForm, GITHUBFindingForm, JIRAFindingForm, @@ -98,7 +96,6 @@ Test_Import, Test_Import_Finding_Action, User, - Vulnerability_Id_Template, ) from dojo.notifications.helper import create_notification from dojo.tag_utils import bulk_add_tags_to_instances @@ -112,7 +109,6 @@ add_external_issue, add_field_errors_to_response, add_success_message_to_response, - apply_cwe_to_template, calculate_grade, do_false_positive_history, get_page_items, @@ -490,15 +486,6 @@ def get_credential_objects(self, finding: Finding): "cred_engagement": cred_engagement, } - def get_cwe_template(self, finding: Finding): - cwe_template = None - with contextlib.suppress(Finding_Template.DoesNotExist): - cwe_template = Finding_Template.objects.filter(cwe=finding.cwe).first() - - return { - "cwe_template": cwe_template, - } - def get_request_response(self, finding: Finding): request_response = None burp_request = None @@ -709,7 +696,6 @@ def get(self, request: HttpRequest, finding_id: int): # Add in the other extras context |= self.get_previous_and_next_findings(finding) context |= self.get_credential_objects(finding) - context |= self.get_cwe_template(finding) # Add in more of the other extras context |= self.get_request_response(finding) context |= self.get_similar_findings(request, finding) @@ -1357,31 +1343,6 @@ def reopen_finding(request, fid): return HttpResponseRedirect(reverse("view_finding", args=(finding.id,))) -@user_is_authorized(Finding, Permissions.Finding_Edit, "fid") -def apply_template_cwe(request, fid): - finding = get_object_or_404(Finding, id=fid) - if request.method == "POST": - form = FindingFormID(request.POST, instance=finding) - if form.is_valid(): - finding = apply_cwe_to_template(finding) - finding.save() - messages.add_message( - request, - messages.SUCCESS, - "Finding CWE template applied successfully.", - extra_tags="alert-success", - ) - return HttpResponseRedirect(reverse("view_finding", args=(fid,))) - messages.add_message( - request, - messages.ERROR, - "Unable to apply CWE template finding, please try again.", - extra_tags="alert-danger", - ) - return None - raise PermissionDenied - - @user_is_authorized(Finding, Permissions.Finding_Edit, "fid") def copy_finding(request, fid): finding = get_object_or_404(Finding, id=fid) @@ -1708,21 +1669,38 @@ def mktemplate(request, fid): title=finding.title, cwe=finding.cwe, cvssv3=finding.cvssv3, + cvssv3_score=finding.cvssv3_score, + cvssv4=finding.cvssv4, + cvssv4_score=finding.cvssv4_score, severity=finding.severity, description=finding.description, mitigation=finding.mitigation, impact=finding.impact, references=finding.references, numerical_severity=finding.numerical_severity, + fix_available=finding.fix_available, + fix_version=finding.fix_version, + planned_remediation_version=finding.planned_remediation_version, + effort_for_fixing=finding.effort_for_fixing, + steps_to_reproduce=finding.steps_to_reproduce, + severity_justification=finding.severity_justification, + component_name=finding.component_name, + component_version=finding.component_version, tags=finding.tags.all(), ) template.save() template.tags = finding.tags.all() + # Ensure template tags exist in Finding's tag model + # (They should already exist since they come from a finding, but ensure for consistency) + ensure_template_tags_in_finding_model(template) - for vulnerability_id in finding.vulnerability_ids: - Vulnerability_Id_Template( - finding_template=template, vulnerability_id=vulnerability_id, - ).save() + # Save vulnerability IDs using helper (handles both old and new format) + finding_helper.save_vulnerability_ids_template(template, finding.vulnerability_ids) + + # Copy endpoints if they exist + if finding.endpoints.exists(): + endpoint_urls = [str(ep) for ep in finding.endpoints.all()] + finding_helper.save_endpoints_template(template, endpoint_urls) messages.add_message( request, @@ -1764,9 +1742,20 @@ def find_template_to_apply(request, fid): cve_len=Length("cve"), order=models.Value(2, models.IntegerField()), ) ) - templates = templates_by_last_used.union(templates_by_cve).order_by( + union_queryset = templates_by_last_used.union(templates_by_cve).order_by( "order", "-last_used", ) + # Convert union queryset to regular queryset to avoid issues with distinct() in filters + # Get IDs from union queryset and create a new queryset filtered by those IDs + template_ids = list(union_queryset.values_list("id", flat=True)) + templates = Finding_Template.objects.filter(id__in=template_ids).annotate( + cve_len=Length("cve"), + order=Case( + *[When(id=template_id, then=models.Value(i + 1)) for i, template_id in enumerate(template_ids)], + default=models.Value(len(template_ids) + 1), + output_field=models.IntegerField(), + ), + ).order_by("order", "-last_used") templates = TemplateFindingFilter(request.GET, queryset=templates) paged_templates = get_page_items(request, templates.qs, 25) @@ -1796,12 +1785,72 @@ def find_template_to_apply(request, fid): def choose_finding_template_options(request, tid, fid): finding = get_object_or_404(Finding, id=fid) template = get_object_or_404(Finding_Template, id=tid) - data = finding.__dict__ - # Not sure what's going on here, just leave same as with django-tagging - data["tags"] = [tag.name for tag in template.tags.all()] + data = finding.__dict__.copy() + # Remove tags and other non-serializable fields + data.pop("tags", None) + data.pop("_state", None) + data.pop("_tags_tagulous", None) + + # Populate from template for fields that exist on template + template_fields = ["cvssv3", "cvssv3_score", "cvssv4", "cvssv4_score", + "fix_available", "fix_version", "planned_remediation_version", + "effort_for_fixing", "steps_to_reproduce", "severity_justification", + "component_name", "component_version", "notes"] + for field in template_fields: + if hasattr(template, field): + value = getattr(template, field) + if value is not None: + data[field] = value + + # Handle vulnerability_ids and endpoints (convert lists to strings) data["vulnerability_ids"] = "\n".join(finding.vulnerability_ids) + if hasattr(template, "endpoints") and template.endpoints: + endpoints_value = template.endpoints + if isinstance(endpoints_value, list): + data["endpoints"] = "\n".join(endpoints_value) + else: + data["endpoints"] = endpoints_value + + template_tag_names = [tag.name for tag in template.tags.all()] + # Add tags as comma-separated string for TagField + if template_tag_names: + data["tags"] = ", ".join(template_tag_names) form = ApplyFindingTemplateForm(data=data, template=template) + # Combine tags from both Finding_Template and Finding tag models + # This ensures we don't lose tags that exist on templates but may have been removed from findings + if "tags" in form.fields: + finding_tag_model = Finding.tags.tag_model + template_tag_model = Finding_Template.tags.tag_model + + # Get all tags from Finding_Template model + template_tags = set(template_tag_model.objects.values_list("name", flat=True)) + # Get all tags from Finding model + finding_tags = set(finding_tag_model.objects.values_list("name", flat=True)) + # Combine both sets to get all unique tag names + all_tag_names = template_tags | finding_tags + + # Ensure all tags from both models exist in Finding's tag model (where they'll be applied) + # Strictly speaking, creating tags here isn't necessary since TagField can create them on save, + # but it's the safest option to avoid tags getting lost or not getting rendered properly. + # This prevents tagulous from removing tags that only exist on templates and ensures + # TagField can display them correctly during form rendering. + # Store tag objects in a dict for reuse + tag_objects = {} + for tag_name in all_tag_names: + tag, _ = finding_tag_model.objects.get_or_create( + name=tag_name, + defaults={"name": tag_name, "protected": False}, + ) + tag_objects[tag_name] = tag + + # Update autocomplete_tags to include tags from both models + form.fields["tags"].autocomplete_tags = finding_tag_model.objects.all().order_by("name") + + # Set initial value using template tags (already created above) + if template_tag_names: + template_finding_tags = [tag_objects[tag_name] for tag_name in template_tag_names] + form.fields["tags"].initial = template_finding_tags product_tab = Product_Tab( finding.test.engagement.product, title="Finding Template Options", @@ -1831,6 +1880,8 @@ def apply_template_to_finding(request, fid, tid): if form.is_valid(): template.last_used = timezone.now() template.save() + + # Apply basic fields (existing) finding.title = form.cleaned_data["title"] finding.cwe = form.cleaned_data["cwe"] finding.severity = form.cleaned_data["severity"] @@ -1838,15 +1889,24 @@ def apply_template_to_finding(request, fid, tid): finding.mitigation = form.cleaned_data["mitigation"] finding.impact = form.cleaned_data["impact"] finding.references = form.cleaned_data["references"] - finding.last_reviewed = timezone.now() - finding.last_reviewed_by = request.user finding.tags = form.cleaned_data["tags"] - finding.cve = None - finding_helper.save_vulnerability_ids( - finding, form.cleaned_data["vulnerability_ids"].split(), + # Copy template fields (using centralized helper) + finding_helper.copy_template_fields_to_finding( + finding=finding, + template=template, + form_data=form.cleaned_data, + user=request.user, + copy_vulnerability_ids=True, + copy_endpoints=True, + copy_notes=True, ) + # Update review fields + finding.last_reviewed = timezone.now() + finding.last_reviewed_by = request.user + + # Save finding (this will trigger CVSS score computation if vectors are set) finding.save() else: messages.add_message( @@ -2123,6 +2183,28 @@ def export_templates_to_json(request): return HttpResponse(leads_as_json, content_type="json") +def ensure_template_tags_in_finding_model(template): + """ + Ensure all tags on a Finding_Template also exist in Finding's tag model. + This prevents tags from being lost when tagulous cleans up unused tags and ensures + tags can be properly applied when templates are used. + """ + if not template or not template.pk: + return + + finding_tag_model = Finding.tags.tag_model + + # Get all tag names from the template + template_tag_names = [tag.name for tag in template.tags.all()] + + # Ensure each tag exists in Finding's tag model + for tag_name in template_tag_names: + finding_tag_model.objects.get_or_create( + name=tag_name, + defaults={"name": tag_name, "protected": False}, + ) + + def apply_cwe_mitigation(apply_to_findings, template, *, update=True): count = 0 if apply_to_findings and template.template_match and template.cwe is not None: @@ -2195,28 +2277,27 @@ def add_template(request): if request.method == "POST": form = FindingTemplateForm(request.POST) if form.is_valid(): - apply_message = "" template = form.save(commit=False) template.numerical_severity = Finding.get_numerical_severity( template.severity, ) - template.save() + # Save vulnerability IDs using helper finding_helper.save_vulnerability_ids_template( template, form.cleaned_data["vulnerability_ids"].split(), ) + # Save endpoints using helper + if form.cleaned_data.get("endpoints"): + endpoint_urls = [url.strip() for url in form.cleaned_data["endpoints"].split("\n") if url.strip()] + finding_helper.save_endpoints_template(template, endpoint_urls) + template.save() form.save_m2m() - count = apply_cwe_mitigation( - form.cleaned_data["apply_to_findings"], template, - ) - if count > 0: - apply_message = ( - " and " + str(count) + pluralize(count, "finding,findings") + " " - ) + # Ensure template tags exist in Finding's tag model + ensure_template_tags_in_finding_model(template) messages.add_message( request, messages.SUCCESS, - "Template created successfully. " + apply_message, + "Template created successfully.", extra_tags="alert-success", ) return HttpResponseRedirect(reverse("templates")) @@ -2235,9 +2316,17 @@ def add_template(request): @user_has_global_permission(Permissions.Finding_Edit) def edit_template(request, tid): template = get_object_or_404(Finding_Template, id=tid) + initial_data = {"vulnerability_ids": "\n".join(template.vulnerability_ids)} + # Add endpoints to initial data if they exist + if hasattr(template, "endpoints") and template.endpoints: + endpoints_value = template.endpoints + if isinstance(endpoints_value, list): + initial_data["endpoints"] = "\n".join(endpoints_value) + else: + initial_data["endpoints"] = endpoints_value form = FindingTemplateForm( instance=template, - initial={"vulnerability_ids": "\n".join(template.vulnerability_ids)}, + initial=initial_data, ) if request.method == "POST": @@ -2247,21 +2336,23 @@ def edit_template(request, tid): template.numerical_severity = Finding.get_numerical_severity( template.severity, ) + # Save vulnerability IDs using helper finding_helper.save_vulnerability_ids_template( template, form.cleaned_data["vulnerability_ids"].split(), ) + # Save endpoints using helper + if form.cleaned_data.get("endpoints"): + endpoint_urls = [url.strip() for url in form.cleaned_data["endpoints"].split("\n") if url.strip()] + finding_helper.save_endpoints_template(template, endpoint_urls) template.save() form.save_m2m() - - count = apply_cwe_mitigation( - form.cleaned_data["apply_to_findings"], template, - ) - apply_message = " and " + str(count) + " " + pluralize(count, "finding,findings") + " " if count > 0 else "" + # Ensure template tags exist in Finding's tag model + ensure_template_tags_in_finding_model(template) messages.add_message( request, messages.SUCCESS, - "Template " + apply_message + "updated successfully.", + "Template updated successfully.", extra_tags="alert-success", ) return HttpResponseRedirect(reverse("templates")) @@ -2272,14 +2363,12 @@ def edit_template(request, tid): extra_tags="alert-danger", ) - count = apply_cwe_mitigation(apply_to_findings=True, template=template, update=False) add_breadcrumb(title="Edit Template", top_level=False, request=request) return render( request, "dojo/add_template.html", { "form": form, - "count": count, "name": "Edit Template", "template": template, }, diff --git a/dojo/fixtures/defect_dojo_sample_data.json b/dojo/fixtures/defect_dojo_sample_data.json index 4dd8181df5f..a9dbc052b5d 100644 --- a/dojo/fixtures/defect_dojo_sample_data.json +++ b/dojo/fixtures/defect_dojo_sample_data.json @@ -822,7 +822,6 @@ "product_grade_f": 59, "enable_product_tag_inheritance": false, "enable_benchmark": true, - "enable_template_match": false, "enable_similar_findings": true, "engagement_auto_close": false, "engagement_auto_close_days": 3, @@ -32239,8 +32238,6 @@ "references": "", "last_used": null, "numerical_severity": null, - "template_match": false, - "template_match_title": false, "tags": [] } }, @@ -32259,8 +32256,6 @@ "references": "", "last_used": null, "numerical_severity": null, - "template_match": false, - "template_match_title": false, "tags": [] } }, @@ -32279,8 +32274,6 @@ "references": "", "last_used": null, "numerical_severity": null, - "template_match": false, - "template_match_title": false, "tags": [] } }, diff --git a/dojo/forms.py b/dojo/forms.py index 8cf90935cd1..871c3c14383 100644 --- a/dojo/forms.py +++ b/dojo/forms.py @@ -114,7 +114,7 @@ is_finding_groups_enabled, is_scan_file_too_large, ) -from dojo.validators import ImporterFileExtensionValidator, tag_validator +from dojo.validators import ImporterFileExtensionValidator, cvss3_validator, cvss4_validator, tag_validator from dojo.widgets import TableCheckboxWidget logger = logging.getLogger(__name__) @@ -1612,7 +1612,9 @@ class ApplyFindingTemplateForm(forms.Form): cwe = forms.IntegerField(label="CWE", required=False) vulnerability_ids = vulnerability_ids_field cvssv3 = forms.CharField(label="CVSSv3", max_length=117, required=False, widget=forms.TextInput(attrs={"class": "btn btn-secondary dropdown-toggle", "data-toggle": "dropdown", "aria-haspopup": "true", "aria-expanded": "false"})) - cvssv4 = forms.CharField(label="CVSSv3", max_length=117, required=False) + cvssv3_score = forms.FloatField(required=False, label="CVSSv3 Score") + cvssv4 = forms.CharField(label="CVSSv4", max_length=255, required=False) + cvssv4_score = forms.FloatField(required=False, label="CVSSv4 Score") severity = forms.ChoiceField(required=False, choices=SEVERITY_CHOICES, error_messages={"required": "Select valid choice: In Progress, On Hold, Completed", "invalid_choice": "Select valid choice: Critical,High,Medium,Low"}) @@ -1621,6 +1623,26 @@ class ApplyFindingTemplateForm(forms.Form): impact = forms.CharField(widget=forms.Textarea, required=False) references = forms.CharField(widget=forms.Textarea, required=False) + # Remediation planning fields + fix_available = forms.BooleanField(required=False) + fix_version = forms.CharField(max_length=100, required=False) + planned_remediation_version = forms.CharField(max_length=99, required=False) + effort_for_fixing = forms.CharField(max_length=99, required=False) + + # Technical details fields + steps_to_reproduce = forms.CharField(widget=forms.Textarea, required=False) + severity_justification = forms.CharField(widget=forms.Textarea, required=False) + component_name = forms.CharField(max_length=500, required=False) + component_version = forms.CharField(max_length=100, required=False) + + # Notes field + notes = forms.CharField(widget=forms.Textarea, required=False, help_text="Note content to add when applying template") + + # Endpoints field + endpoints = forms.CharField(max_length=5000, required=False, + help_text="Endpoint URLs (one per line)", + widget=forms.widgets.Textarea(attrs={"rows": "3", "cols": "400"})) + tags = TagField(required=False, help_text="Add tags that help describe this finding template. Choose from the list or add new tags. Press Enter key to add.", initial=Finding.tags.tag_model.objects.all().order_by("name")) def __init__(self, template=None, *args, **kwargs): @@ -1628,7 +1650,36 @@ def __init__(self, template=None, *args, **kwargs): self.fields["tags"].autocomplete_tags = Finding.tags.tag_model.objects.all().order_by("name") self.template = template if template: - self.template.vulnerability_ids = "\n".join(template.vulnerability_ids) + # Populate vulnerability_ids field initial value + self.fields["vulnerability_ids"].initial = "\n".join(template.vulnerability_ids) + + # Populate CVSS fields from template + if hasattr(template, "cvssv3"): + self.fields["cvssv3"].initial = template.cvssv3 + if hasattr(template, "cvssv4"): + self.fields["cvssv4"].initial = template.cvssv4 + if hasattr(template, "cvssv3_score"): + self.fields["cvssv3_score"].initial = template.cvssv3_score + if hasattr(template, "cvssv4_score"): + self.fields["cvssv4_score"].initial = template.cvssv4_score + + # Populate all other new fields from template + for field_name in ["fix_available", "fix_version", "planned_remediation_version", + "effort_for_fixing", "steps_to_reproduce", "severity_justification", + "component_name", "component_version", "notes"]: + if hasattr(template, field_name): + value = getattr(template, field_name) + if value is not None: + self.fields[field_name].initial = value + + # Populate endpoints + if hasattr(template, "endpoints"): + endpoints_value = template.endpoints + if endpoints_value: + if isinstance(endpoints_value, list): + self.fields["endpoints"].initial = "\n".join(endpoints_value) + else: + self.fields["endpoints"].initial = endpoints_value # Hide CVSS fields based on system settings hide_cvss_fields_if_disabled(self) @@ -1651,17 +1702,27 @@ def clean_tags(self): return self.cleaned_data.get("tags") class Meta: - fields = ["title", "cwe", "vulnerability_ids", "cvssv3", "cvssv4", "severity", "description", "mitigation", "impact", "references", "tags"] - order = ("title", "cwe", "vulnerability_ids", "cvssv3", "cvssv4", "severity", "description", "impact", "is_mitigated") + fields = ["title", "cwe", "vulnerability_ids", "cvssv3", "cvssv3_score", "cvssv4", "cvssv4_score", + "severity", "description", "mitigation", "impact", "references", "tags", + "fix_available", "fix_version", "planned_remediation_version", "effort_for_fixing", + "steps_to_reproduce", "severity_justification", "component_name", "component_version", + "notes", "endpoints"] + order = ("title", "cwe", "vulnerability_ids", "cvssv3", "cvssv3_score", "cvssv4", "cvssv4_score", + "severity", "description", "impact", "steps_to_reproduce", "severity_justification", + "mitigation", "fix_available", "fix_version", "planned_remediation_version", + "effort_for_fixing", "component_name", "component_version", "references", "notes", + "endpoints", "tags") class FindingTemplateForm(forms.ModelForm): - apply_to_findings = forms.BooleanField(required=False, help_text="Apply template to all findings that match this CWE. (Update will overwrite mitigation, impact and references for any active, verified findings.)") title = forms.CharField(max_length=1000, required=True) cwe = forms.IntegerField(label="CWE", required=False) vulnerability_ids = vulnerability_ids_field cvssv3 = forms.CharField(label="CVSS3 Vector", max_length=117, required=False, widget=forms.TextInput(attrs={"class": "btn btn-secondary dropdown-toggle", "data-toggle": "dropdown", "aria-haspopup": "true", "aria-expanded": "false"})) + cvssv3_score = forms.FloatField(required=False, label="CVSSv3 Score") + cvssv4 = forms.CharField(label="CVSS4 Vector", max_length=255, required=False) + cvssv4_score = forms.FloatField(required=False, label="CVSSv4 Score") severity = forms.ChoiceField( required=False, choices=SEVERITY_CHOICES, @@ -1669,7 +1730,30 @@ class FindingTemplateForm(forms.ModelForm): "required": "Select valid choice: In Progress, On Hold, Completed", "invalid_choice": "Select valid choice: Critical,High,Medium,Low"}) - field_order = ["title", "cwe", "vulnerability_ids", "severity", "cvssv3", "description", "mitigation", "impact", "references", "tags", "template_match", "template_match_cwe", "template_match_title", "apply_to_findings"] + # Remediation planning fields + fix_available = forms.BooleanField(required=False) + fix_version = forms.CharField(max_length=100, required=False) + planned_remediation_version = forms.CharField(max_length=99, required=False) + effort_for_fixing = forms.CharField(max_length=99, required=False) + + # Technical details fields + steps_to_reproduce = forms.CharField(widget=forms.Textarea, required=False) + severity_justification = forms.CharField(widget=forms.Textarea, required=False) + component_name = forms.CharField(max_length=500, required=False) + component_version = forms.CharField(max_length=100, required=False) + + # Notes field + notes = forms.CharField(widget=forms.Textarea, required=False, help_text="Note content to add when applying template") + + # Endpoints field + endpoints = forms.CharField(max_length=5000, required=False, + help_text="Endpoint URLs (one per line)", + widget=forms.widgets.Textarea(attrs={"rows": "3", "cols": "400"})) + + field_order = ["title", "cwe", "vulnerability_ids", "severity", "cvssv3", "cvssv3_score", "cvssv4", "cvssv4_score", + "description", "impact", "steps_to_reproduce", "severity_justification", "mitigation", + "fix_available", "fix_version", "planned_remediation_version", "effort_for_fixing", + "component_name", "component_version", "references", "notes", "endpoints", "tags"] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -1680,8 +1764,29 @@ def __init__(self, *args, **kwargs): class Meta: model = Finding_Template - order = ("title", "cwe", "vulnerability_ids", "cvssv3", "severity", "description", "impact") - exclude = ("numerical_severity", "is_mitigated", "last_used", "endpoint_status", "cve") + order = ("title", "cwe", "vulnerability_ids", "cvssv3", "cvssv3_score", "cvssv4", "cvssv4_score", "severity", "description", "impact", + "steps_to_reproduce", "severity_justification", "mitigation", "fix_available", "fix_version", + "planned_remediation_version", "effort_for_fixing", "component_name", "component_version", + "references", "notes", "endpoints", "tags") + exclude = ("numerical_severity", "is_mitigated", "last_used", "endpoint_status", "cve", "vulnerability_ids_text") + + def clean_cvssv3(self): + value = self.cleaned_data.get("cvssv3") + if value: + try: + cvss3_validator(value) + except ValidationError as e: + raise forms.ValidationError(e.messages) + return value + + def clean_cvssv4(self): + value = self.cleaned_data.get("cvssv4") + if value: + try: + cvss4_validator(value) + except ValidationError as e: + raise forms.ValidationError(e.messages) + return value def clean_tags(self): tag_validator(self.cleaned_data.get("tags")) diff --git a/dojo/management/commands/migrate_cve.py b/dojo/management/commands/migrate_cve.py index c5d0a1fdc62..eaa3985c19c 100644 --- a/dojo/management/commands/migrate_cve.py +++ b/dojo/management/commands/migrate_cve.py @@ -2,11 +2,11 @@ from django.core.management.base import BaseCommand +from dojo.finding.helper import save_vulnerability_ids_template from dojo.models import ( Finding, Finding_Template, Vulnerability_Id, - Vulnerability_Id_Template, ) from dojo.utils import mass_model_updater @@ -20,9 +20,9 @@ def create_vulnerability_id(finding): def create_vulnerability_id_template(finding_template): - Vulnerability_Id_Template.objects.get_or_create( - finding_template=finding_template, vulnerability_id=finding_template.cve, - ) + # Use the new TextField-based approach + if finding_template.cve: + save_vulnerability_ids_template(finding_template, [finding_template.cve]) class Command(BaseCommand): diff --git a/dojo/models.py b/dojo/models.py index 7a82a77f167..20c7a5560e2 100644 --- a/dojo/models.py +++ b/dojo/models.py @@ -475,12 +475,6 @@ class System_Settings(models.Model): help_text=_("Enables Benchmarks such as the OWASP ASVS " "(Application Security Verification Standard)")) - enable_template_match = models.BooleanField( - default=False, - blank=False, - verbose_name=_("Enable Remediation Advice"), - help_text=_("Enables global remediation advice and matching on CWE and Title. The text will be replaced for mitigation, impact and references on a finding. Useful for providing consistent impact and remediation advice regardless of the scanner.")) - enable_similar_findings = models.BooleanField( default=True, blank=False, @@ -2814,10 +2808,6 @@ def save(self, dedupe_option=True, rules_option=True, product_grading_option=Tru self.set_hash_code(dedupe_option) if is_new_finding: - # We enter here during the first call from serializers.py - from dojo.utils import apply_cwe_to_template # noqa: PLC0415 circular import - # No need to use the returned variable since `self` Is updated in memory - apply_cwe_to_template(self) if (self.file_path is not None) and (len(self.unsaved_endpoints) == 0): self.static_finding = True self.dynamic_finding = False @@ -3653,6 +3643,9 @@ class Finding_Template(models.Model): verbose_name="Vulnerability Id", help_text="An id of a vulnerability in a security advisory associated with this finding. Can be a Common Vulnerabilities and Exposures (CVE) or from other sources.") cvssv3 = models.TextField(help_text=_("Common Vulnerability Scoring System version 3 (CVSSv3) score associated with this finding."), validators=[cvss3_validator], max_length=117, null=True, verbose_name=_("CVSS v3 vector")) + cvssv3_score = models.FloatField(null=True, blank=True, help_text=_("CVSSv3 score")) + cvssv4 = models.TextField(help_text=_("Common Vulnerability Scoring System version 4 (CVSS4) score associated with this finding."), validators=[cvss4_validator], max_length=255, null=True, verbose_name=_("CVSS4 vector")) + cvssv4_score = models.FloatField(null=True, blank=True, help_text=_("CVSSv4 score")) severity = models.CharField(max_length=200, null=True, blank=True) description = models.TextField(null=True, blank=True) @@ -3661,8 +3654,25 @@ class Finding_Template(models.Model): references = models.TextField(null=True, blank=True, db_column="refs") last_used = models.DateTimeField(null=True, editable=False) numerical_severity = models.CharField(max_length=4, null=True, blank=True, editable=False) - template_match = models.BooleanField(default=False, verbose_name=_("Template Match Enabled"), help_text=_("Enables this template for matching remediation advice. Match will be applied to all active, verified findings by CWE.")) - template_match_title = models.BooleanField(default=False, verbose_name=_("Match Template by Title and CWE"), help_text=_("Matches by title text (contains search) and CWE.")) + + # Remediation planning fields + fix_available = models.BooleanField(null=True, blank=True, help_text=_("Indicates if a fix is available for this vulnerability type")) + fix_version = models.CharField(max_length=100, null=True, blank=True, help_text=_("Version where fix is available")) + planned_remediation_version = models.CharField(max_length=99, null=True, blank=True, help_text=_("Target version for remediation")) + effort_for_fixing = models.CharField(max_length=99, null=True, blank=True, help_text=_("Effort estimate for fixing (e.g., Low/Medium/High)")) + + # Technical details fields + steps_to_reproduce = models.TextField(null=True, blank=True, help_text=_("Standard reproduction steps for this vulnerability type")) + severity_justification = models.TextField(null=True, blank=True, help_text=_("Explanation of why this severity level is appropriate")) + component_name = models.CharField(max_length=500, null=True, blank=True, help_text=_("Affected component name (e.g., library name)")) + component_version = models.CharField(max_length=100, null=True, blank=True, help_text=_("Affected component version")) + + # Notes field (single note content, not a list) + notes = models.TextField(null=True, blank=True, help_text=_("Note content to add when applying this template")) + + # String-based list fields (newline-separated) + vulnerability_ids_text = models.TextField(null=True, blank=True, help_text=_("Vulnerability IDs (one per line)")) + endpoints_text = models.TextField(null=True, blank=True, help_text=_("Endpoint URLs (one per line)")) tags = TagField(blank=True, force_lowercase=True, help_text=_("Add tags that help describe this finding template. Choose from the list or add new tags. Press Enter key to add.")) @@ -3682,16 +3692,20 @@ def get_breadcrumbs(self): return [{"title": str(self), "url": reverse("view_template", args=(self.id,))}] - @cached_property + @property def vulnerability_ids(self): - # Get vulnerability ids from database and convert to list of strings - vulnerability_ids_model = self.vulnerability_id_template_set.all() - vulnerability_ids = [vulnerability_id.vulnerability_id for vulnerability_id in vulnerability_ids_model] + """Parse vulnerability IDs from TextField string (newline-separated).""" + vulnerability_ids = [] - # Synchronize the cve field with the unsaved_vulnerability_ids + # Get from the TextField + if self.vulnerability_ids_text: + # Parse newline-separated string, remove empty lines + vulnerability_ids = [line.strip() for line in self.vulnerability_ids_text.split("\n") if line.strip()] + + # Synchronize the cve field with the vulnerability_ids # We do this to be as flexible as possible to handle the fields until # the cve field is not needed anymore and can be removed. - if vulnerability_ids and self.cve: + if vulnerability_ids and self.cve and self.cve not in vulnerability_ids: # Make sure the first entry of the list is the value of the cve field vulnerability_ids.insert(0, self.cve) elif not vulnerability_ids and self.cve: @@ -3701,10 +3715,13 @@ def vulnerability_ids(self): # Remove duplicates return list(dict.fromkeys(vulnerability_ids)) - -class Vulnerability_Id_Template(models.Model): - finding_template = models.ForeignKey(Finding_Template, editable=False, on_delete=models.CASCADE) - vulnerability_id = models.TextField(max_length=50, blank=False, null=False) + @property + def endpoints(self): + """Parse endpoint URLs from TextField string (newline-separated).""" + if not self.endpoints_text: + return [] + # Parse newline-separated string, remove empty lines + return [line.strip() for line in self.endpoints_text.split("\n") if line.strip()] class Check_List(models.Model): @@ -4859,7 +4876,6 @@ def __str__(self): admin.site.register(Development_Environment) admin.site.register(Finding_Template) admin.site.register(Vulnerability_Id) -admin.site.register(Vulnerability_Id_Template) admin.site.register(BurpRawRequestResponse) admin.site.register(Announcement) admin.site.register(UserAnnouncement) diff --git a/dojo/templates/dojo/add_findings.html b/dojo/templates/dojo/add_findings.html index 465e547be26..9a552cb0fd6 100644 --- a/dojo/templates/dojo/add_findings.html +++ b/dojo/templates/dojo/add_findings.html @@ -23,7 +23,7 @@

    Add Findings to a Test

    {% if temp %} -
    + {% csrf_token %} {% include "dojo/form_fields.html" with form=form %} {% if jform %} diff --git a/dojo/templates/dojo/add_template.html b/dojo/templates/dojo/add_template.html index 541d2999f10..3953559891b 100644 --- a/dojo/templates/dojo/add_template.html +++ b/dojo/templates/dojo/add_template.html @@ -22,11 +22,6 @@

    {{ name }} {{ template }}

    {% csrf_token %} {% include "dojo/form_fields.html" with form=form %}
    - {% if count > 0 %} -
    -

    {{ count }} active, verified finding{{ count|pluralize }} match this template.

    -
    - {% endif %}
    diff --git a/dojo/templates/dojo/templates.html b/dojo/templates/dojo/templates.html index 12cec47befc..0abe328aa00 100644 --- a/dojo/templates/dojo/templates.html +++ b/dojo/templates/dojo/templates.html @@ -57,7 +57,6 @@

    {% comment %} The display field is translated in the function. No need to translate here as well{% endcomment %} {% dojo_sort request 'CWE' 'cwe' 'asc' %} - Match Enabled {% comment %} The display field is translated in the function. No need to translate here as well{% endcomment %} {% dojo_sort request 'Name' 'title' %} {% comment %} The display field is translated in the function. No need to translate here as well{% endcomment %} @@ -76,10 +75,9 @@

    {% endif %} - {{ finding.template_match }} {% if add_from_template %} - @@ -110,7 +108,7 @@

    {% else %}

    {% endif %}{{ finding.severity }}

    {% if add_from_template %} -
    Use This Template diff --git a/dojo/templates/dojo/view_finding.html b/dojo/templates/dojo/view_finding.html index 2626130ed07..b03f632c6b5 100755 --- a/dojo/templates/dojo/view_finding.html +++ b/dojo/templates/dojo/view_finding.html @@ -101,24 +101,7 @@

    {% endif %} - {% if cwe_template.cwe and finding|has_object_permission:"Finding_Edit" %} -
  • - - Apply CWE Template Remediation to Finding - -
  • - - {% csrf_token %} - - - {% endif %} - {% if not cwe_template.cwe and "Finding_Add"|has_global_permission %} -
  • - - Create a CWE Remediation Template - -
  • - {% endif %} + {% if finding|has_object_permission:"Finding_Edit" %}
  • @@ -1273,12 +1256,6 @@

    Credential }); - $('a.apply-cwe-finding').on('click', function (e) { - if (confirm('Apply CWE template to this finding? (This will overwrite mitigation, impact and references.)')) { - $("form#apply-cwe-finding-form").submit(); - } - - }); $("a#next").on('click', function (e) { e.preventDefault(); diff --git a/dojo/test/urls.py b/dojo/test/urls.py index 6bea2675615..335cf260b86 100644 --- a/dojo/test/urls.py +++ b/dojo/test/urls.py @@ -23,7 +23,7 @@ views.AddFindingView.as_view(), name="add_findings"), re_path(r"^test/(?P\d+)/add_findings/(?P\d+)$", - views.add_temp_finding, name="add_temp_finding"), + views.add_finding_from_template, name="add_finding_from_template"), re_path(r"^test/(?P\d+)/search$", views.search, name="search"), re_path( r"^test/(?P\d+)/re_import_scan_results", diff --git a/dojo/test/views.py b/dojo/test/views.py index 4e6c6353be6..a05c0b3b660 100644 --- a/dojo/test/views.py +++ b/dojo/test/views.py @@ -659,12 +659,12 @@ def post(self, request: HttpRequest, test_id: int): @user_is_authorized(Test, Permissions.Finding_Add, "tid") -def add_temp_finding(request, tid, fid): +def add_finding_from_template(request, tid, fid): jform = None test = get_object_or_404(Test, id=tid) - finding = get_object_or_404(Finding_Template, id=fid) + template = get_object_or_404(Finding_Template, id=fid) findings = Finding_Template.objects.all() - push_all_jira_issues = jira_helper.is_push_all_issues(finding) + push_all_jira_issues = jira_helper.is_push_all_issues(template) if request.method == "POST": @@ -691,8 +691,8 @@ def add_temp_finding(request, tid, fid): _("Can not set a finding as inactive or false positive without adding all mandatory notes"), extra_tags="alert-danger") if form.is_valid(): - finding.last_used = timezone.now() - finding.save() + template.last_used = timezone.now() + template.save() new_finding = form.save(commit=False) new_finding.test = test new_finding.reporter = request.user @@ -700,14 +700,23 @@ def add_temp_finding(request, tid, fid): new_finding.severity) new_finding.tags = form.cleaned_data["tags"] - new_finding.cvssv3 = finding.cvssv3 new_finding.date = form.cleaned_data["date"] or datetime.today() finding_helper.update_finding_status(new_finding, request.user) new_finding.save(dedupe_option=False) - # Save and add new endpoints + # Copy all fields from template + finding_helper.copy_template_fields_to_finding( + finding=new_finding, + template=template, + user=request.user, + copy_vulnerability_ids=True, + copy_endpoints=True, + copy_notes=True, + ) + + # Save and add new endpoints from form (user may have added more) finding_helper.add_endpoints(new_finding, form) new_finding.save() @@ -738,20 +747,64 @@ def add_temp_finding(request, tid, fid): extra_tags="alert-danger") else: - form = AddFindingForm(req_resp=None, product=test.engagement.product, initial={"active": False, - "date": timezone.now().date(), - "verified": False, - "false_p": False, - "duplicate": False, - "out_of_scope": False, - "title": finding.title, - "description": finding.description, - "cwe": finding.cwe, - "severity": finding.severity, - "mitigation": finding.mitigation, - "impact": finding.impact, - "references": finding.references, - "numerical_severity": finding.numerical_severity}) + # Build initial data with all template fields + initial_data = { + "active": False, + "date": timezone.now().date(), + "verified": False, + "false_p": False, + "duplicate": False, + "out_of_scope": False, + "title": template.title, + "description": template.description, + "cwe": template.cwe, + "severity": template.severity, + "mitigation": template.mitigation, + "impact": template.impact, + "references": template.references, + "numerical_severity": template.numerical_severity, + } + + # Add CVSS fields + if template.cvssv3: + initial_data["cvssv3"] = template.cvssv3 + if template.cvssv3_score is not None: + initial_data["cvssv3_score"] = template.cvssv3_score + if template.cvssv4: + initial_data["cvssv4"] = template.cvssv4 + if template.cvssv4_score is not None: + initial_data["cvssv4_score"] = template.cvssv4_score + + # Add remediation fields + if template.fix_available is not None: + initial_data["fix_available"] = template.fix_available + if template.fix_version: + initial_data["fix_version"] = template.fix_version + if template.planned_remediation_version: + initial_data["planned_remediation_version"] = template.planned_remediation_version + if template.effort_for_fixing: + initial_data["effort_for_fixing"] = template.effort_for_fixing + + # Add technical details fields + if template.steps_to_reproduce: + initial_data["steps_to_reproduce"] = template.steps_to_reproduce + if template.severity_justification: + initial_data["severity_justification"] = template.severity_justification + if template.component_name: + initial_data["component_name"] = template.component_name + if template.component_version: + initial_data["component_version"] = template.component_version + + # Add vulnerability IDs + if template.vulnerability_ids: + initial_data["vulnerability_ids"] = " ".join(template.vulnerability_ids) + + # Add endpoints to endpoints_to_add field + if template.endpoints: + endpoint_urls = template.endpoints if isinstance(template.endpoints, list) else template.endpoints.split("\n") + initial_data["endpoints_to_add"] = "\n".join([url.strip() for url in endpoint_urls if url.strip()]) + + form = AddFindingForm(req_resp=None, product=test.engagement.product, initial=initial_data) if jira_helper.get_jira_project(test): jform = JIRAFindingForm(push_all=jira_helper.is_push_all_issues(test), prefix="jiraform", jira_project=jira_helper.get_jira_project(test), finding_form=form) @@ -764,7 +817,7 @@ def add_temp_finding(request, tid, fid): "jform": jform, "findings": findings, "temp": True, - "fid": finding.id, + "fid": template.id, "tid": test.id, "test": test, }) diff --git a/dojo/utils.py b/dojo/utils.py index 2febe00cd5f..33e99846b81 100644 --- a/dojo/utils.py +++ b/dojo/utils.py @@ -1427,27 +1427,6 @@ def add_language(product, language, files=1, code=1): pass -# Apply finding template data by matching CWE + Title or CWE -def apply_cwe_to_template(finding, *, override=False): - if System_Settings.objects.get().enable_template_match or override: - # Attempt to match on CWE and Title First - template = Finding_Template.objects.filter( - cwe=finding.cwe, title__icontains=finding.title, template_match=True).first() - - # If none then match on CWE - template = Finding_Template.objects.filter( - cwe=finding.cwe, template_match=True).first() - - if template: - finding.mitigation = template.mitigation - finding.impact = template.impact - finding.references = template.references - template.last_used = timezone.now() - template.save() - - return finding - - def truncate_with_dots(the_string, max_length_including_dots): if not the_string: return the_string diff --git a/unittests/test_apply_finding_template.py b/unittests/test_apply_finding_template.py index e845a0752bb..685d39fcfe9 100644 --- a/unittests/test_apply_finding_template.py +++ b/unittests/test_apply_finding_template.py @@ -1,14 +1,30 @@ import datetime -from unittest import skip +from crum import impersonate from django.contrib.auth.models import User from django.contrib.messages.storage.fallback import FallbackStorage +from django.core.exceptions import PermissionDenied from django.http import Http404 from django.test.client import RequestFactory from django.utils import timezone from dojo.finding import views -from dojo.models import Engagement, Finding, Finding_Template, Product, Product_Type, System_Settings, Test, Test_Type +from dojo.finding.helper import save_endpoints_template, save_vulnerability_ids_template +from dojo.models import ( + Engagement, + Finding, + Finding_Template, + Notes, + Product, + Product_Member, + Product_Type, + Role, + System_Settings, + Test, + Test_Type, + Vulnerability_Id, +) +from dojo.test import views as test_views from .dojo_test_case import DojoTestCase @@ -21,8 +37,8 @@ def create(): settings.save() p = Product() - p.Name = "Test Product" - p.Description = "Product for Testing Apply Template functionality" + p.name = "Test Product" + p.description = "Product for Testing Apply Template functionality" p.prod_type = Product_Type.objects.get(id=1) p.save() @@ -53,7 +69,9 @@ def create(): f.reporter = user f.last_reviewed = timezone.now() f.last_reviewed_by = user + f.cve = None # Set explicitly as it's required (blank=False) f.save() + return f class FindingTemplateMother: @@ -67,6 +85,7 @@ def create(): tmp.mitigation = "Finding Template Mitigation" tmp.impact = "Finding Template Impact" tmp.save() + return tmp class FindingTemplateTestUtil: @@ -79,10 +98,24 @@ def create_user(is_staff): user_count = User.objects.count() user = User() user.is_staff = is_staff + user.is_superuser = is_staff # Superuser has all permissions user.username = "TestUser" + str(user_count) user.save() return user + @staticmethod + def create_user_with_role(product, role_name, *, is_staff=False): + """Create a user with a specific role on a product""" + user_count = User.objects.count() + user = User() + user.is_staff = is_staff + user.is_superuser = False + user.username = f"TestUser{role_name}{user_count}" + user.save() + role = Role.objects.get(name=role_name) + Product_Member(user=user, product=product, role=role).save() + return user + @staticmethod def create_get_request(user, path): rf = RequestFactory() @@ -104,15 +137,13 @@ def create_post_request(user, path, data): return post_request -@skip("outdated so doesn't work with current fixture") class TestApplyFindingTemplate(DojoTestCase): fixtures = ["dojo_testdata.json"] - apply_template_url = "finding/2/2/apply_template_to_finding" - def setUp(self): - FindingMother.create() - FindingTemplateMother.create() + self.finding = FindingMother.create() + self.template = FindingTemplateMother.create() + self.apply_template_url = f"finding/{self.finding.id}/{self.template.id}/apply_template_to_finding" def make_request(self, user_is_staff, finding_id, template_id, data=None): user = FindingTemplateTestUtil.create_user(user_is_staff) @@ -122,20 +153,22 @@ def make_request(self, user_is_staff, finding_id, template_id, data=None): else: request = FindingTemplateTestUtil.create_get_request(user, self.apply_template_url) - return views.apply_template_to_finding(request, finding_id, template_id) + with impersonate(user): + return views.apply_template_to_finding(request, fid=finding_id, tid=template_id) def test_apply_template_to_finding_with_data_does_not_display_error_success(self): - result = self.make_request(user_is_staff=True, finding_id=1, template_id=1, + result = self.make_request(user_is_staff=True, finding_id=self.finding.id, template_id=self.template.id, data={"title": "Finding for Testing Apply Template functionality", "cwe": "89", "severity": "High", "description": "Finding for Testing Apply Template Functionality", "mitigation": "template mitigation", "impact": "template impact"}) - self.assertNotContains(result, "There appears to be errors on the form", 302) + self.assertEqual(result.status_code, 302) + self.assertNotIn("There appears to be errors on the form", str(result)) def test_apply_template_to_finding_with_data_returns_to_view_success(self): - result = self.make_request(user_is_staff=True, finding_id=1, template_id=1, + result = self.make_request(user_is_staff=True, finding_id=self.finding.id, template_id=self.template.id, data={"title": "Finding for Testing Apply Template functionality", "cwe": "89", "severity": "High", @@ -144,7 +177,7 @@ def test_apply_template_to_finding_with_data_returns_to_view_success(self): "impact": "template impact"}) self.assertIsNotNone(result) self.assertEqual(302, result.status_code) - self.assertEqual("/finding/1", result.url) + self.assertEqual(f"/finding/{self.finding.id}", result.url) def test_apply_template_to_finding_with_data_saves_success(self): test_title = "Finding for Testing Apply Template functionality" @@ -154,7 +187,7 @@ def test_apply_template_to_finding_with_data_saves_success(self): test_mitigation = "template mitigation" test_impact = "template impact" - self.make_request(user_is_staff=True, finding_id=1, template_id=1, + self.make_request(user_is_staff=True, finding_id=self.finding.id, template_id=self.template.id, data={"title": test_title, "cwe": test_cwe, "severity": test_severity, @@ -162,8 +195,9 @@ def test_apply_template_to_finding_with_data_saves_success(self): "mitigation": test_mitigation, "impact": test_impact}) - f = Finding.objects.get(id=1) - self.assertEqual(test_title, f.title) + f = Finding.objects.get(id=self.finding.id) + # Title is automatically title-cased by Finding.save() + self.assertEqual("Finding for Testing Apply Template Functionality", f.title) self.assertEqual(test_cwe, f.cwe) self.assertEqual(test_severity, f.severity) self.assertEqual(test_description, f.description) @@ -171,7 +205,9 @@ def test_apply_template_to_finding_with_data_saves_success(self): self.assertEqual(test_impact, f.impact) def test_unauthorized_apply_template_to_finding_fails(self): - result = self.make_request(user_is_staff=False, finding_id=1, template_id=1, + """Test that a non-superuser without permissions cannot apply template""" + with self.assertRaises(PermissionDenied): + self.make_request(user_is_staff=False, finding_id=self.finding.id, template_id=self.template.id, data={"title": "Finding for Testing Apply Template functionality", "cwe": "89", "severity": "High", @@ -179,23 +215,59 @@ def test_unauthorized_apply_template_to_finding_fails(self): "mitigation": "template mitigation", "impact": "template impact"}, ) - self.assertEqual(302, result.status_code) - self.assertIn("login", result.url) + + def test_reader_role_cannot_apply_template(self): + """Test that a Reader role user (read-only) cannot apply template""" + reader_user = FindingTemplateTestUtil.create_user_with_role( + self.finding.test.engagement.product, "Reader", is_staff=False, + ) + request = FindingTemplateTestUtil.create_post_request( + reader_user, self.apply_template_url, + data={"title": "Finding for Testing Apply Template functionality", + "cwe": "89", + "severity": "High", + "description": "Finding for Testing Apply Template Functionality", + "mitigation": "template mitigation", + "impact": "template impact"}, + ) + with impersonate(reader_user), self.assertRaises(PermissionDenied): + views.apply_template_to_finding(request, fid=self.finding.id, tid=self.template.id) + + def test_writer_role_can_apply_template(self): + """Test that a Writer role user (non-staff) can apply template""" + writer_user = FindingTemplateTestUtil.create_user_with_role( + self.finding.test.engagement.product, "Writer", is_staff=False, + ) + request = FindingTemplateTestUtil.create_post_request( + writer_user, self.apply_template_url, + data={"title": "Finding for Testing Apply Template functionality", + "cwe": "89", + "severity": "High", + "description": "Finding for Testing Apply Template Functionality", + "mitigation": "template mitigation", + "impact": "template impact"}, + ) + with impersonate(writer_user): + result = views.apply_template_to_finding(request, fid=self.finding.id, tid=self.template.id) + self.assertEqual(302, result.status_code) + self.assertEqual(f"/finding/{self.finding.id}", result.url) def test_apply_template_to_finding_with_illegal_finding_fails(self): - self.make_request(user_is_staff=True, finding_id=None, template_id=1) + with self.assertRaises(Http404): + self.make_request(user_is_staff=True, finding_id=99999, template_id=self.template.id) def test_apply_template_to_finding_with_illegal_template_fails(self): - self.make_request(user_is_staff=True, finding_id=1, template_id=None) + with self.assertRaises(Http404): + self.make_request(user_is_staff=True, finding_id=self.finding.id, template_id=99999) def test_apply_template_to_finding_with_no_data_returns_view_success(self): - result = self.make_request(user_is_staff=True, finding_id=1, template_id=1, data=None) + result = self.make_request(user_is_staff=True, finding_id=self.finding.id, template_id=self.template.id, data=None) self.assertIsNotNone(result) self.assertEqual(302, result.status_code) - self.assertEqual("/finding/1", result.url) + self.assertEqual(f"/finding/{self.finding.id}", result.url) def test_apply_template_to_finding_without_required_field_displays_field_title_success(self): - result = self.make_request(user_is_staff=True, finding_id=1, template_id=1, + result = self.make_request(user_is_staff=True, finding_id=self.finding.id, template_id=self.template.id, data={"title": "", "cwe": "89", "severity": "High", @@ -205,7 +277,7 @@ def test_apply_template_to_finding_without_required_field_displays_field_title_s self.assertContains(result, "The title is required.") def test_apply_template_to_finding_without_required_field_displays_error_success(self): - result = self.make_request(user_is_staff=True, finding_id=1, template_id=1, + result = self.make_request(user_is_staff=True, finding_id=self.finding.id, template_id=self.template.id, data={"title": "", "cwe": "89", "severity": "High", @@ -215,14 +287,13 @@ def test_apply_template_to_finding_without_required_field_displays_error_success self.assertContains(result, "There appears to be errors on the form") -@skip("outdated so doesn't work with current fixture") class TestFindTemplateToApply(DojoTestCase): fixtures = ["dojo_testdata.json"] - choose_template_url = "finding/2/find_template_to_apply" def setUp(self): - FindingMother.create() + self.finding = FindingMother.create() FindingTemplateMother.create() + self.choose_template_url = f"finding/{self.finding.id}/find_template_to_apply" def make_request(self, user_is_staff, finding_id, data=None): user = FindingTemplateTestUtil.create_user(user_is_staff) @@ -232,34 +303,33 @@ def make_request(self, user_is_staff, finding_id, data=None): else: request = FindingTemplateTestUtil.create_get_request(user, self.choose_template_url) - return views.find_template_to_apply(request, finding_id) + with impersonate(user): + return views.find_template_to_apply(request, fid=finding_id) def test_unauthorized_find_template_to_apply_fails(self): - result = self.make_request(user_is_staff=False, finding_id=1) - self.assertEqual(302, result.status_code) - self.assertIn("login", result.url) + with self.assertRaises(PermissionDenied): + self.make_request(user_is_staff=False, finding_id=self.finding.id) def test_authorized_find_template_to_apply_success(self): - result = self.make_request(user_is_staff=True, finding_id=1) + result = self.make_request(user_is_staff=True, finding_id=self.finding.id) self.assertEqual(200, result.status_code) def test_find_template_to_apply_displays_templates_success(self): - result = self.make_request(user_is_staff=True, finding_id=1) + result = self.make_request(user_is_staff=True, finding_id=self.finding.id) self.assertContains(result, "Finding Template for Testing Apply Template functionality") def test_find_template_to_apply_displays_breadcrumb(self): - result = self.make_request(user_is_staff=True, finding_id=1) + result = self.make_request(user_is_staff=True, finding_id=self.finding.id) self.assertContains(result, "Apply Template to Finding") -@skip("outdated so doesn't work with current fixture") class TestChooseFindingTemplateOptions(DojoTestCase): fixtures = ["dojo_testdata.json"] - finding_template_options_url = "finding/2/2/choose_finding_template_options" def setUp(self): - FindingMother.create() - FindingTemplateMother.create() + self.finding = FindingMother.create() + self.template = FindingTemplateMother.create() + self.finding_template_options_url = f"finding/{self.template.id}/{self.finding.id}/choose_finding_template_options" def make_request(self, user_is_staff, finding_id, template_id, data=None): user = FindingTemplateTestUtil.create_user(user_is_staff) @@ -269,27 +339,258 @@ def make_request(self, user_is_staff, finding_id, template_id, data=None): else: request = FindingTemplateTestUtil.create_get_request(user, self.finding_template_options_url) - return views.choose_finding_template_options(request, finding_id, template_id) + with impersonate(user): + return views.choose_finding_template_options(request, tid=template_id, fid=finding_id) def test_unauthorized_choose_finding_template_options_fails(self): - result = self.make_request(user_is_staff=False, finding_id=1, template_id=1) - self.assertEqual(302, result.status_code) - self.assertIn("login", result.url) + with self.assertRaises(PermissionDenied): + self.make_request(user_is_staff=False, finding_id=self.finding.id, template_id=self.template.id) def test_authorized_choose_finding_template_options_success(self): - result = self.make_request(user_is_staff=True, finding_id=1, template_id=1) + result = self.make_request(user_is_staff=True, finding_id=self.finding.id, template_id=self.template.id) self.assertEqual(200, result.status_code) def test_choose_finding_template_options_with_invalid_finding_fails(self): with self.assertRaises(Http404): - result = self.make_request(user_is_staff=True, finding_id=0, template_id=1) - self.assertEqual(404, result.status_code) + self.make_request(user_is_staff=True, finding_id=99999, template_id=self.template.id) def test_choose_finding_template_options_with_invalid_template_fails(self): with self.assertRaises(Http404): - result = self.make_request(user_is_staff=True, finding_id=1, template_id=0) - self.assertEqual(404, result.status_code) + self.make_request(user_is_staff=True, finding_id=self.finding.id, template_id=99999) def test_choose_finding_template_options_with_valid_finding_and_template_renders_apply_finding_template_view(self): - result = self.make_request(user_is_staff=True, finding_id=1, template_id=1) + result = self.make_request(user_is_staff=True, finding_id=self.finding.id, template_id=self.template.id) self.assertContains(result, "

    Apply template to a Finding

    ") + + +class TestMkTemplate(DojoTestCase): + fixtures = ["dojo_testdata.json"] + + def setUp(self): + self.finding = FindingMother.create() + self.user = FindingTemplateTestUtil.create_user(is_staff=True) + self.user.is_superuser = True + self.user.save() + + def make_request(self, user, finding_id): + rf = RequestFactory() + request = rf.get(f"/finding/{finding_id}/mktemplate") + request.user = user + request.session = {} + messages = FallbackStorage(request) + request._messages = messages + return views.mktemplate(request, finding_id) + + def test_mktemplate_creates_template_from_finding(self): + """Test that mktemplate creates a template from an existing finding""" + # Verify no template exists with this title + initial_count = Finding_Template.objects.filter(title=self.finding.title).count() + self.assertEqual(initial_count, 0) + + # Create template from finding + result = self.make_request(self.user, self.finding.id) + + # Verify redirect to finding view + self.assertEqual(result.status_code, 302) + self.assertEqual(result.url, f"/finding/{self.finding.id}") + + # Verify template was created + templates = Finding_Template.objects.filter(title=self.finding.title) + self.assertEqual(templates.count(), 1) + + template = templates.first() + self.assertEqual(template.title, self.finding.title) + self.assertEqual(template.cwe, self.finding.cwe) + self.assertEqual(template.severity, self.finding.severity) + self.assertEqual(template.description, self.finding.description) + self.assertEqual(template.mitigation, self.finding.mitigation) + self.assertEqual(template.impact, self.finding.impact) + self.assertEqual(template.references, self.finding.references) + + def test_mktemplate_fails_when_template_exists(self): + """Test that mktemplate fails when a template with the same title already exists""" + # Create a template with the same title first + existing_template = Finding_Template() + existing_template.title = self.finding.title + existing_template.cwe = 0 + existing_template.severity = "Low" + existing_template.save() + + # Try to create template from finding + result = self.make_request(self.user, self.finding.id) + + # Verify redirect (still redirects but with error message) + self.assertEqual(result.status_code, 302) + self.assertEqual(result.url, f"/finding/{self.finding.id}") + + # Verify only one template exists (the original one) + templates = Finding_Template.objects.filter(title=self.finding.title) + self.assertEqual(templates.count(), 1) + self.assertEqual(templates.first().id, existing_template.id) + + def test_mktemplate_requires_permission(self): + """Test that mktemplate requires Finding_Add permission""" + user = FindingTemplateTestUtil.create_user(is_staff=False) + user.is_superuser = False + user.save() + + # Should raise PermissionDenied + with self.assertRaises(PermissionDenied): + self.make_request(user, self.finding.id) + + +class TestAddFindingFromTemplate(DojoTestCase): + fixtures = ["dojo_testdata.json"] + + def setUp(self): + self.test = FindingMother.create().test + self.template = FindingTemplateMother.create() + self.user = FindingTemplateTestUtil.create_user(is_staff=True) + self.user.is_superuser = True + self.user.save() + # Add user as product member with Maintainer role (has Finding_Add permission) + maintainer_role = Role.objects.get(name="Maintainer") + Product_Member(user=self.user, product=self.test.engagement.product, role=maintainer_role).save() + + def make_get_request(self, user, test_id, template_id): + rf = RequestFactory() + request = rf.get(f"/test/{test_id}/add_findings/{template_id}") + request.user = user + request.session = {} + messages = FallbackStorage(request) + request._messages = messages + with impersonate(user): + return test_views.add_finding_from_template(request, tid=test_id, fid=template_id) + + def make_post_request(self, user, test_id, template_id, data=None): + rf = RequestFactory() + if data is None: + data = { + "title": self.template.title, + "date": timezone.now().date(), + "severity": self.template.severity, + "description": self.template.description, + "mitigation": self.template.mitigation or "", + "impact": self.template.impact or "", + "references": self.template.references or "", + "active": True, + "verified": True, + "false_p": False, + "duplicate": False, + "out_of_scope": False, + } + request = rf.post(f"/test/{test_id}/add_findings/{template_id}", data) + request.user = user + request.session = {} + messages = FallbackStorage(request) + request._messages = messages + with impersonate(user): + return test_views.add_finding_from_template(request, tid=test_id, fid=template_id) + + def test_add_finding_from_template_renders_form(self): + """Test that GET request renders the form with template data""" + result = self.make_get_request(self.user, self.test.id, self.template.id) + self.assertEqual(result.status_code, 200) + self.assertContains(result, self.template.title) + + def test_add_finding_from_template_creates_finding(self): + """Test that POST request creates a new finding from template""" + initial_count = Finding.objects.filter(test=self.test).count() + + result = self.make_post_request(self.user, self.test.id, self.template.id) + + # Should redirect to test view + self.assertEqual(result.status_code, 302) + self.assertEqual(result.url, f"/test/{self.test.id}") + + # Verify finding was created + final_count = Finding.objects.filter(test=self.test).count() + self.assertEqual(final_count, initial_count + 1) + + # Verify finding has template data + finding = Finding.objects.filter(test=self.test).order_by("-id").first() + # Note: title casing may vary, so just check it contains the template title + self.assertIn(self.template.title.lower(), finding.title.lower()) + self.assertEqual(finding.cwe, self.template.cwe) + self.assertEqual(finding.severity, self.template.severity) + self.assertEqual(finding.description, self.template.description) + self.assertEqual(finding.mitigation, self.template.mitigation or "") + self.assertEqual(finding.impact, self.template.impact or "") + self.assertEqual(finding.references, self.template.references or "") + + def test_add_finding_from_template_copies_all_fields(self): + """Test that all template fields are copied to the finding""" + # Update template with all new fields + self.template.cvssv3_score = 7.5 + self.template.cvssv4_score = 8.0 + self.template.fix_available = True + self.template.fix_version = "1.2.3" + self.template.planned_remediation_version = "1.3.0" + self.template.effort_for_fixing = "Low" + self.template.steps_to_reproduce = "Step 1: Do this\nStep 2: Do that" + self.template.severity_justification = "This is critical because..." + self.template.component_name = "test-component" + self.template.component_version = "1.0.0" + self.template.notes = "Template note content" + self.template.save() + + # Set vulnerability IDs + save_vulnerability_ids_template(self.template, ["CVE-2023-1234", "CVE-2023-5678"]) + + # Set endpoints + save_endpoints_template(self.template, ["https://example.com/api", "https://example.com/admin"]) + + result = self.make_post_request(self.user, self.test.id, self.template.id) + self.assertEqual(result.status_code, 302) + + finding = Finding.objects.filter(test=self.test).order_by("-id").first() + + # Verify all fields were copied + self.assertEqual(finding.cvssv3_score, 7.5) + self.assertEqual(finding.cvssv4_score, 8.0) + self.assertEqual(finding.fix_available, True) + self.assertEqual(finding.fix_version, "1.2.3") + self.assertEqual(finding.planned_remediation_version, "1.3.0") + self.assertEqual(finding.effort_for_fixing, "Low") + self.assertEqual(finding.steps_to_reproduce, "Step 1: Do this\nStep 2: Do that") + self.assertEqual(finding.severity_justification, "This is critical because...") + self.assertEqual(finding.component_name, "test-component") + self.assertEqual(finding.component_version, "1.0.0") + + # Verify vulnerability IDs were copied + vulnerability_ids = [vid.vulnerability_id for vid in Vulnerability_Id.objects.filter(finding=finding)] + self.assertIn("CVE-2023-1234", vulnerability_ids) + self.assertIn("CVE-2023-5678", vulnerability_ids) + + # Verify endpoints were copied + self.assertTrue(any("example.com/api" in str(ep) for ep in finding.endpoints.all())) + self.assertTrue(any("example.com/admin" in str(ep) for ep in finding.endpoints.all())) + + # Verify note was created + notes = Notes.objects.filter(finding=finding) + self.assertTrue(notes.exists()) + note = notes.first() + self.assertEqual(note.entry, "Template note content") + + def test_add_finding_from_template_requires_permission(self): + """Test that add_finding_from_template requires Finding_Add permission""" + unauthorized_user = FindingTemplateTestUtil.create_user(is_staff=False) + unauthorized_user.is_superuser = False + unauthorized_user.save() + + # Should raise PermissionDenied + with self.assertRaises(PermissionDenied): + self.make_get_request(unauthorized_user, self.test.id, self.template.id) + + def test_add_finding_from_template_updates_template_last_used(self): + """Test that template.last_used is updated when creating finding""" + original_last_used = self.template.last_used + + result = self.make_post_request(self.user, self.test.id, self.template.id) + self.assertEqual(result.status_code, 302) + + # Refresh template from database + self.template.refresh_from_db() + self.assertIsNotNone(self.template.last_used) + if original_last_used: + self.assertGreaterEqual(self.template.last_used, original_last_used) diff --git a/unittests/test_finding_helper.py b/unittests/test_finding_helper.py index c6d1d65008d..7d92ab2992c 100644 --- a/unittests/test_finding_helper.py +++ b/unittests/test_finding_helper.py @@ -10,7 +10,7 @@ from rest_framework.test import APIClient from dojo.finding.helper import save_vulnerability_ids, save_vulnerability_ids_template -from dojo.models import Finding, Finding_Template, Test, Vulnerability_Id, Vulnerability_Id_Template +from dojo.models import Finding, Finding_Template, Test, Vulnerability_Id from .dojo_test_case import DojoAPITestCase, DojoTestCase @@ -233,19 +233,15 @@ def test_save_vulnerability_ids(self, save_mock, delete_mock, filter_mock): self.assertEqual(save_mock.call_count, 2) self.assertEqual("REF-1", finding.cve) - @patch("dojo.finding.helper.Vulnerability_Id_Template.objects.filter") - @patch("django.db.models.query.QuerySet.delete") - @patch("dojo.finding.helper.Vulnerability_Id_Template.save") - def test_save_vulnerability_id_templates(self, save_mock, delete_mock, filter_mock): + @patch("dojo.models.Finding_Template.save") + def test_save_vulnerability_id_templates(self, save_mock): finding_template = Finding_Template() new_vulnerability_ids = ["REF-1", "REF-2", "REF-2"] - filter_mock.return_value = Vulnerability_Id_Template.objects.none() save_vulnerability_ids_template(finding_template, new_vulnerability_ids) - filter_mock.assert_called_with(finding_template=finding_template) - delete_mock.assert_called_once() - self.assertEqual(save_mock.call_count, 2) + save_mock.assert_called_once() + self.assertEqual("REF-1\nREF-2", finding_template.vulnerability_ids_text) self.assertEqual("REF-1", finding_template.cve) From 0c4bf3ff06aa160976990b700d163fc4e9bce7f0 Mon Sep 17 00:00:00 2001 From: valentijnscholten Date: Mon, 29 Dec 2025 18:05:17 +0100 Subject: [PATCH 095/126] Fix bulk edit validation: prevent duplicate findings from being active/verified (#13965) * Fix bulk edit validation: prevent duplicate findings from being active/verified - Add validation in FindingBulkUpdateForm to prevent active findings from being risk accepted - Add view-level validation to check existing duplicate status before setting active/verified - Add view-level validation to check existing active status before risk accepting - Add comprehensive user feedback for skipped findings with reasons - Track actually_updated_count to accurately report successful updates Fixes #11336 * add bulk edit validation tests * bulk edit: reduce method complexity --- dojo/finding/views.py | 735 +++++++++++++----------- dojo/forms.py | 3 + unittests/test_bulk_edit_validation.py | 746 +++++++++++++++++++++++++ 3 files changed, 1166 insertions(+), 318 deletions(-) create mode 100644 unittests/test_bulk_edit_validation.py diff --git a/dojo/finding/views.py b/dojo/finding/views.py index c3b4024ee44..043167a6948 100644 --- a/dojo/finding/views.py +++ b/dojo/finding/views.py @@ -2635,6 +2635,393 @@ def merge_finding_product(request, pid): # bulk update and delete are combined, so we can't have the nice user_is_authorized decorator + + +def _bulk_delete_findings(request, pid, form, finding_to_update, finds, total_find_count): + """Helper function to handle bulk deletion of findings.""" + if form.is_valid() and finding_to_update: + if pid is not None: + product = get_object_or_404(Product, id=pid) + user_has_permission_or_403( + request.user, product, Permissions.Finding_Delete, + ) + + finds = get_authorized_findings( + Permissions.Finding_Delete, finds, + ).distinct() + + skipped_find_count = total_find_count - finds.count() + deleted_find_count = finds.count() + + for find in finds: + find.delete() + + if skipped_find_count > 0: + add_error_message_to_response( + f"Skipped deletion of {skipped_find_count} findings because you are not authorized.", + ) + + if deleted_find_count > 0: + messages.add_message( + request, + messages.SUCCESS, + f"Bulk delete of {deleted_find_count} findings was successful.", + extra_tags="alert-success", + ) + + +def _bulk_update_finding_status_and_severity(finds, form, request, system_settings, prods, now): + """Helper function to handle status and severity updates for findings.""" + skipped_duplicate_count = 0 + actually_updated_count = 0 + + if form.cleaned_data["severity"] or form.cleaned_data["status"]: + for find in finds: + old_find = copy.deepcopy(find) + + if form.cleaned_data["severity"]: + find.severity = form.cleaned_data["severity"] + find.numerical_severity = Finding.get_numerical_severity( + form.cleaned_data["severity"], + ) + find.last_reviewed = now + find.last_reviewed_by = request.user + + if form.cleaned_data["status"]: + # logger.debug('setting status from bulk edit form: %s', form) + # Check if finding is duplicate and user wants to set active/verified + if find.duplicate and (form.cleaned_data["active"] or form.cleaned_data["verified"]): + # Skip active/verified but allow other status changes + skipped_duplicate_count += 1 + # Set other fields but not active/verified + find.false_p = form.cleaned_data["false_p"] + find.out_of_scope = form.cleaned_data["out_of_scope"] + find.is_mitigated = form.cleaned_data["is_mitigated"] + find.under_review = form.cleaned_data["under_review"] + else: + # Apply all status changes normally + find.active = form.cleaned_data["active"] + find.verified = form.cleaned_data["verified"] + find.false_p = form.cleaned_data["false_p"] + find.out_of_scope = form.cleaned_data["out_of_scope"] + find.is_mitigated = form.cleaned_data["is_mitigated"] + find.under_review = form.cleaned_data["under_review"] + find.last_reviewed = timezone.now() + find.last_reviewed_by = request.user + + # use super to avoid all custom logic in our overriden save method + # it will trigger the pre_save signal + find.save_no_options() + actually_updated_count += 1 + + if system_settings.false_positive_history: + # If finding is being marked as false positive + if find.false_p: + do_false_positive_history(find) + + # If finding was a false positive and is being reactivated: retroactively reactivates all equal findings + elif old_find.false_p and not find.false_p: + if system_settings.retroactive_false_positive_history: + logger.debug("FALSE_POSITIVE_HISTORY: Reactivating existing findings based on: %s", find) + + existing_fp_findings = match_finding_to_existing_findings( + find, product=find.test.engagement.product, + ).filter(false_p=True) + + for fp in existing_fp_findings: + logger.debug("FALSE_POSITIVE_HISTORY: Reactivating false positive %i: %s", fp.id, fp) + fp.active = find.active + fp.verified = find.verified + fp.false_p = False + fp.out_of_scope = find.out_of_scope + fp.is_mitigated = find.is_mitigated + fp.save_no_options() + + for prod in prods: + calculate_grade(prod) + + if skipped_duplicate_count > 0: + messages.add_message( + request, + messages.WARNING, + f"Skipped status update of {skipped_duplicate_count} duplicate findings. Duplicate findings cannot be active or verified.", + extra_tags="alert-warning", + ) + + return skipped_duplicate_count, actually_updated_count + + +def _bulk_update_simple_fields(finds, form): + """Helper function to handle simple field updates (date, planned_remediation_date, etc.).""" + if form.cleaned_data["date"]: + for finding in finds: + finding.date = form.cleaned_data["date"] + finding.save_no_options() + + if form.cleaned_data["planned_remediation_date"]: + for finding in finds: + finding.planned_remediation_date = form.cleaned_data[ + "planned_remediation_date" + ] + finding.save_no_options() + + if form.cleaned_data["planned_remediation_version"]: + for finding in finds: + finding.planned_remediation_version = form.cleaned_data[ + "planned_remediation_version" + ] + finding.save_no_options() + + +def _bulk_update_risk_acceptance(finds, form, request, prods): + """Helper function to handle risk acceptance updates.""" + skipped_risk_accept_count = 0 + skipped_active_risk_accept_count = 0 + + if form.cleaned_data["risk_acceptance"]: + for finding in finds: + if finding.active: + skipped_active_risk_accept_count += 1 + # Allow risk acceptance for inactive findings (whether duplicate or not) + elif form.cleaned_data["risk_accept"]: + if ( + not finding.test.engagement.product.enable_simple_risk_acceptance + ): + skipped_risk_accept_count += 1 + else: + ra_helper.simple_risk_accept(request.user, finding) + elif form.cleaned_data["risk_unaccept"]: + ra_helper.risk_unaccept(request.user, finding) + + for prod in prods: + calculate_grade(prod) + + if skipped_risk_accept_count > 0: + messages.add_message( + request, + messages.WARNING, + (f"Skipped simple risk acceptance of {skipped_risk_accept_count} findings, " + "simple risk acceptance is disabled on the related products"), + extra_tags="alert-warning", + ) + + if skipped_active_risk_accept_count > 0: + messages.add_message( + request, + messages.WARNING, + f"Skipped risk acceptance of {skipped_active_risk_accept_count} active findings. Active findings cannot be risk accepted.", + extra_tags="alert-warning", + ) + + return skipped_risk_accept_count, skipped_active_risk_accept_count + + +def _bulk_update_finding_groups(finds, form): + """Helper function to handle finding group operations.""" + return_url = None + + if form.cleaned_data["finding_group_create"]: + logger.debug("finding_group_create checked!") + finding_group_name = form.cleaned_data["finding_group_create_name"] + logger.debug("finding_group_create_name: %s", finding_group_name) + finding_group, added, skipped = finding_helper.create_finding_group( + finds, finding_group_name, + ) + + if added: + add_success_message_to_response( + f"Created finding group with {added} findings", + ) + return_url = reverse( + "view_finding_group", args=(finding_group.id,), + ) + + if skipped: + add_success_message_to_response( + f"Skipped {skipped} findings in group creation, findings already part of another group", + ) + + # refresh findings from db + finds = finds.all() + + if form.cleaned_data["finding_group_add"]: + logger.debug("finding_group_add checked!") + fgid = form.cleaned_data["add_to_finding_group_id"] + finding_group = Finding_Group.objects.get(id=fgid) + finding_group, added, skipped = finding_helper.add_to_finding_group( + finding_group, finds, + ) + + if added: + add_success_message_to_response( + f"Added {added} findings to finding group {finding_group.name}", + ) + return_url = reverse( + "view_finding_group", args=(finding_group.id,), + ) + + if skipped: + add_success_message_to_response( + f"Skipped {skipped} findings when adding to finding group {finding_group.name}, " + "findings already part of another group", + ) + + # refresh findings from db + finds = finds.all() + + if form.cleaned_data["finding_group_remove"]: + logger.debug("finding_group_remove checked!") + ( + finding_groups, + removed, + skipped, + ) = finding_helper.remove_from_finding_group(finds) + + if removed: + add_success_message_to_response( + "Removed {} findings from finding groups {}".format( + removed, + ",".join( + [ + finding_group.name + for finding_group in finding_groups + ], + ), + ), + ) + + if skipped: + add_success_message_to_response( + f"Skipped {skipped} findings when removing from any finding group, findings not part of any group", + ) + + # refresh findings from db + finds = finds.all() + + if form.cleaned_data["finding_group_by"]: + logger.debug("finding_group_by checked!") + logger.debug(form.cleaned_data) + finding_group_by_option = form.cleaned_data[ + "finding_group_by_option" + ] + logger.debug("finding_group_by_option: %s", finding_group_by_option) + + ( + finding_groups, + grouped, + skipped, + groups_created, + ) = finding_helper.group_findings_by(finds, finding_group_by_option) + + if grouped: + add_success_message_to_response( + f"Grouped {grouped} findings into {len(finding_groups)} ({groups_created} newly created) finding groups", + ) + + if skipped: + add_success_message_to_response( + f"Skipped {skipped} findings when grouping by {finding_group_by_option} as these findings " + "were already in an existing group", + ) + + # refresh findings from db + finds = finds.all() + + return return_url, finds + + +def _bulk_push_to_jira(finds, form, note): + """Helper function to handle JIRA push operations.""" + error_counts = defaultdict(lambda: 0) + success_count = 0 + finding_groups = set( # noqa: C401 + finding.finding_group + for finding in finds + if finding.has_finding_group + and ( + jira_helper.is_push_all_issues(finding) + or jira_helper.is_keep_in_sync_with_jira(finding) + or form.cleaned_data.get("push_to_jira") + ) + ) + logger.debug("finding_groups: %s", finding_groups) + for group in finding_groups: + if form.cleaned_data.get("push_to_jira"): + ( + can_be_pushed_to_jira, + error_message, + _error_code, + ) = jira_helper.can_be_pushed_to_jira(group) + if not can_be_pushed_to_jira: + error_counts[error_message] += 1 + jira_helper.log_jira_cannot_be_pushed_reason(error_message, group) + else: + logger.debug( + "pushing to jira from finding.finding_bulk_update_all()", + ) + jira_helper.push_to_jira(group) + success_count += 1 + + for error_message, error_count in error_counts.items(): + add_error_message_to_response(f"{error_count} finding groups could not be pushed to JIRA: {error_message}") + + if success_count > 0: + add_success_message_to_response(f"{success_count} finding groups pushed to JIRA successfully") + + # refresh from db + finds = finds.all() + + error_counts = defaultdict(lambda: 0) + success_count = 0 + for finding in finds: + tool_issue_updater.async_tool_issue_update(finding) + + # not sure yet if we want to support bulk unlink, so leave as commented out for now + # if form.cleaned_data['unlink_from_jira']: + # if finding.has_jira_issue: + # jira_helper.finding_unlink_jira(request, finding) + + # Because we never call finding.save() in a bulk update, we need to actually + # push the JIRA stuff here, rather than in finding.save() + # can't use helper as when push_all_jira_issues is True, + # the checkbox gets disabled and is always false + # push_to_jira = jira_helper.is_push_to_jira(new_finding, + # form.cleaned_data.get('push_to_jira')) + if ( + form.cleaned_data.get("push_to_jira") + or jira_helper.is_push_all_issues(finding) + or jira_helper.is_keep_in_sync_with_jira(finding) + ) and not finding.has_finding_group: + ( + can_be_pushed_to_jira, + error_message, + _error_code, + ) = jira_helper.can_be_pushed_to_jira(finding) + if finding.has_jira_group_issue and not finding.has_jira_issue: + error_message = ( + "finding already pushed as part of Finding Group" + ) + error_counts[error_message] += 1 + jira_helper.log_jira_cannot_be_pushed_reason(error_message, finding) + elif not can_be_pushed_to_jira: + error_counts[error_message] += 1 + jira_helper.log_jira_cannot_be_pushed_reason(error_message, finding) + else: + logger.debug( + "pushing to jira from finding.finding_bulk_update_all()", + ) + jira_helper.push_to_jira(finding) + if note is not None and isinstance(note, Notes): + jira_helper.add_comment(finding, note) + success_count += 1 + + for error_message, error_count in error_counts.items(): + add_error_message_to_response(f"{error_count} findings could not be pushed to JIRA: {error_message}") + + if success_count > 0: + add_success_message_to_response(f"{success_count} findings pushed to JIRA successfully") + + def finding_bulk_update_all(request, pid=None): system_settings = System_Settings.objects.get() @@ -2656,35 +3043,7 @@ def finding_bulk_update_all(request, pid=None): total_find_count = finds.count() prods = set(find.test.engagement.product for find in finds) # noqa: C401 if request.POST.get("delete_bulk_findings"): - if form.is_valid() and finding_to_update: - if pid is not None: - product = get_object_or_404(Product, id=pid) - user_has_permission_or_403( - request.user, product, Permissions.Finding_Delete, - ) - - finds = get_authorized_findings( - Permissions.Finding_Delete, finds, - ).distinct() - - skipped_find_count = total_find_count - finds.count() - deleted_find_count = finds.count() - - for find in finds: - find.delete() - - if skipped_find_count > 0: - add_error_message_to_response( - f"Skipped deletion of {skipped_find_count} findings because you are not authorized.", - ) - - if deleted_find_count > 0: - messages.add_message( - request, - messages.SUCCESS, - f"Bulk delete of {deleted_find_count} findings was successful.", - extra_tags="alert-success", - ) + _bulk_delete_findings(request, pid, form, finding_to_update, finds, total_find_count) elif form.is_valid() and finding_to_update: if pid is not None: product = get_object_or_404(Product, id=pid) @@ -2707,210 +3066,21 @@ def finding_bulk_update_all(request, pid=None): finds = prefetch_for_findings(finds) note = None - if form.cleaned_data["severity"] or form.cleaned_data["status"]: - for find in finds: - old_find = copy.deepcopy(find) - - if form.cleaned_data["severity"]: - find.severity = form.cleaned_data["severity"] - find.numerical_severity = Finding.get_numerical_severity( - form.cleaned_data["severity"], - ) - find.last_reviewed = now - find.last_reviewed_by = request.user - - if form.cleaned_data["status"]: - # logger.debug('setting status from bulk edit form: %s', form) - find.active = form.cleaned_data["active"] - find.verified = form.cleaned_data["verified"] - find.false_p = form.cleaned_data["false_p"] - find.out_of_scope = form.cleaned_data["out_of_scope"] - find.is_mitigated = form.cleaned_data["is_mitigated"] - find.under_review = form.cleaned_data["under_review"] - find.last_reviewed = timezone.now() - find.last_reviewed_by = request.user - - # use super to avoid all custom logic in our overriden save method - # it will trigger the pre_save signal - find.save_no_options() - - if system_settings.false_positive_history: - # If finding is being marked as false positive - if find.false_p: - do_false_positive_history(find) - - # If finding was a false positive and is being reactivated: retroactively reactivates all equal findings - elif old_find.false_p and not find.false_p: - if system_settings.retroactive_false_positive_history: - logger.debug("FALSE_POSITIVE_HISTORY: Reactivating existing findings based on: %s", find) - - existing_fp_findings = match_finding_to_existing_findings( - find, product=find.test.engagement.product, - ).filter(false_p=True) - - for fp in existing_fp_findings: - logger.debug("FALSE_POSITIVE_HISTORY: Reactivating false positive %i: %s", fp.id, fp) - fp.active = find.active - fp.verified = find.verified - fp.false_p = False - fp.out_of_scope = find.out_of_scope - fp.is_mitigated = find.is_mitigated - fp.save_no_options() - - for prod in prods: - calculate_grade(prod) - - if form.cleaned_data["date"]: - for finding in finds: - finding.date = form.cleaned_data["date"] - finding.save_no_options() - - if form.cleaned_data["planned_remediation_date"]: - for finding in finds: - finding.planned_remediation_date = form.cleaned_data[ - "planned_remediation_date" - ] - finding.save_no_options() - - if form.cleaned_data["planned_remediation_version"]: - for finding in finds: - finding.planned_remediation_version = form.cleaned_data[ - "planned_remediation_version" - ] - finding.save_no_options() - - skipped_risk_accept_count = 0 - if form.cleaned_data["risk_acceptance"]: - for finding in finds: - if not finding.duplicate: - if form.cleaned_data["risk_accept"]: - if ( - not finding.test.engagement.product.enable_simple_risk_acceptance - ): - skipped_risk_accept_count += 1 - else: - ra_helper.simple_risk_accept(request.user, finding) - elif form.cleaned_data["risk_unaccept"]: - ra_helper.risk_unaccept(request.user, finding) - - for prod in prods: - calculate_grade(prod) - - if skipped_risk_accept_count > 0: - messages.add_message( - request, - messages.WARNING, - (f"Skipped simple risk acceptance of {skipped_risk_accept_count} findings, " - "simple risk acceptance is disabled on the related products"), - extra_tags="alert-warning", - ) - - if form.cleaned_data["finding_group_create"]: - logger.debug("finding_group_create checked!") - finding_group_name = form.cleaned_data["finding_group_create_name"] - logger.debug("finding_group_create_name: %s", finding_group_name) - finding_group, added, skipped = finding_helper.create_finding_group( - finds, finding_group_name, - ) + actually_updated_count = 0 - if added: - add_success_message_to_response( - f"Created finding group with {added} findings", - ) - return_url = reverse( - "view_finding_group", args=(finding_group.id,), - ) - - if skipped: - add_success_message_to_response( - f"Skipped {skipped} findings in group creation, findings already part of another group", - ) - - # refresh findings from db - finds = finds.all() - - if form.cleaned_data["finding_group_add"]: - logger.debug("finding_group_add checked!") - fgid = form.cleaned_data["add_to_finding_group_id"] - finding_group = Finding_Group.objects.get(id=fgid) - finding_group, added, skipped = finding_helper.add_to_finding_group( - finding_group, finds, - ) - - if added: - add_success_message_to_response( - f"Added {added} findings to finding group {finding_group.name}", - ) - return_url = reverse( - "view_finding_group", args=(finding_group.id,), - ) - - if skipped: - add_success_message_to_response( - f"Skipped {skipped} findings when adding to finding group {finding_group.name}, " - "findings already part of another group", - ) - - # refresh findings from db - finds = finds.all() - - if form.cleaned_data["finding_group_remove"]: - logger.debug("finding_group_remove checked!") - ( - finding_groups, - removed, - skipped, - ) = finding_helper.remove_from_finding_group(finds) - - if removed: - add_success_message_to_response( - "Removed {} findings from finding groups {}".format( - removed, - ",".join( - [ - finding_group.name - for finding_group in finding_groups - ], - ), - ), - ) + _skipped_duplicate_count, actually_updated_count = _bulk_update_finding_status_and_severity( + finds, form, request, system_settings, prods, now, + ) - if skipped: - add_success_message_to_response( - f"Skipped {skipped} findings when removing from any finding group, findings not part of any group", - ) + _bulk_update_simple_fields(finds, form) - # refresh findings from db - finds = finds.all() - - if form.cleaned_data["finding_group_by"]: - logger.debug("finding_group_by checked!") - logger.debug(form.cleaned_data) - finding_group_by_option = form.cleaned_data[ - "finding_group_by_option" - ] - logger.debug("finding_group_by_option: %s", finding_group_by_option) - - ( - finding_groups, - grouped, - skipped, - groups_created, - ) = finding_helper.group_findings_by(finds, finding_group_by_option) - - if grouped: - add_success_message_to_response( - f"Grouped {grouped} findings into {len(finding_groups)} ({groups_created} newly created) finding groups", - ) - - if skipped: - add_success_message_to_response( - f"Skipped {skipped} findings when grouping by {finding_group_by_option} as these findings " - "were already in an existing group", - ) + _skipped_risk_accept_count, _skipped_active_risk_accept_count = _bulk_update_risk_acceptance( + finds, form, request, prods, + ) - # refresh findings from db - finds = finds.all() + group_return_url, finds = _bulk_update_finding_groups(finds, form) + if group_return_url: + return_url = group_return_url if form.cleaned_data["push_to_github"]: logger.debug("push selected findings to github") @@ -2946,96 +3116,25 @@ def finding_bulk_update_all(request, pid=None): # Delegate parsing and handling of strings/iterables to helper bulk_add_tags_to_instances(tag_or_tags=tags, instances=finds, tag_field_name="tags") - error_counts = defaultdict(lambda: 0) - success_count = 0 - finding_groups = set( # noqa: C401 - finding.finding_group - for finding in finds - if finding.has_finding_group - and ( - jira_helper.is_push_all_issues(finding) - or jira_helper.is_keep_in_sync_with_jira(finding) - or form.cleaned_data.get("push_to_jira") - ) - ) - logger.debug("finding_groups: %s", finding_groups) - for group in finding_groups: - if form.cleaned_data.get("push_to_jira"): - ( - can_be_pushed_to_jira, - error_message, - _error_code, - ) = jira_helper.can_be_pushed_to_jira(group) - if not can_be_pushed_to_jira: - error_counts[error_message] += 1 - jira_helper.log_jira_cannot_be_pushed_reason(error_message, group) - else: - logger.debug( - "pushing to jira from finding.finding_bulk_update_all()", - ) - jira_helper.push_to_jira(group) - success_count += 1 - - for error_message, error_count in error_counts.items(): - add_error_message_to_response(f"{error_count} finding groups could not be pushed to JIRA: {error_message}") - - if success_count > 0: - add_success_message_to_response(f"{success_count} finding groups pushed to JIRA successfully") - - # refresh from db - finds = finds.all() - - error_counts = defaultdict(lambda: 0) - success_count = 0 - for finding in finds: - tool_issue_updater.async_tool_issue_update(finding) - - # not sure yet if we want to support bulk unlink, so leave as commented out for now - # if form.cleaned_data['unlink_from_jira']: - # if finding.has_jira_issue: - # jira_helper.finding_unlink_jira(request, finding) - - # Because we never call finding.save() in a bulk update, we need to actually - # push the JIRA stuff here, rather than in finding.save() - # can't use helper as when push_all_jira_issues is True, - # the checkbox gets disabled and is always false - # push_to_jira = jira_helper.is_push_to_jira(new_finding, - # form.cleaned_data.get('push_to_jira')) - if ( - form.cleaned_data.get("push_to_jira") - or jira_helper.is_push_all_issues(finding) - or jira_helper.is_keep_in_sync_with_jira(finding) - ) and not finding.has_finding_group: - ( - can_be_pushed_to_jira, - error_message, - _error_code, - ) = jira_helper.can_be_pushed_to_jira(finding) - if finding.has_jira_group_issue and not finding.has_jira_issue: - error_message = ( - "finding already pushed as part of Finding Group" - ) - error_counts[error_message] += 1 - jira_helper.log_jira_cannot_be_pushed_reason(error_message, finding) - elif not can_be_pushed_to_jira: - error_counts[error_message] += 1 - jira_helper.log_jira_cannot_be_pushed_reason(error_message, finding) - else: - logger.debug( - "pushing to jira from finding.finding_bulk_update_all()", - ) - jira_helper.push_to_jira(finding) - if note is not None and isinstance(note, Notes): - jira_helper.add_comment(finding, note) - success_count += 1 - - for error_message, error_count in error_counts.items(): - add_error_message_to_response(f"{error_count} findings could not be pushed to JIRA: {error_message}") + _bulk_push_to_jira(finds, form, note) - if success_count > 0: - add_success_message_to_response(f"{success_count} findings pushed to JIRA successfully") - - if updated_find_count > 0: + # Show success message if status/severity updates were made (using actually_updated_count) + # or if other updates were made (using updated_find_count) + if (form.cleaned_data["severity"] or form.cleaned_data["status"]) and actually_updated_count > 0: + messages.add_message( + request, + messages.SUCCESS, + f"Bulk update of {actually_updated_count} findings was successful.", + extra_tags="alert-success", + ) + elif updated_find_count > 0 and ( + form.cleaned_data["date"] or form.cleaned_data["planned_remediation_date"] + or form.cleaned_data["planned_remediation_version"] or form.cleaned_data["tags"] + or form.cleaned_data["notes"] or form.cleaned_data["risk_acceptance"] + or form.cleaned_data["finding_group_create"] or form.cleaned_data["finding_group_add"] + or form.cleaned_data["finding_group_remove"] or form.cleaned_data["finding_group_by"] + or form.cleaned_data["push_to_jira"] or form.cleaned_data["push_to_github"] + ): messages.add_message( request, messages.SUCCESS, diff --git a/dojo/forms.py b/dojo/forms.py index 871c3c14383..a73abb00ce6 100644 --- a/dojo/forms.py +++ b/dojo/forms.py @@ -1843,6 +1843,9 @@ def clean(self): if cleaned_data["false_p"] and cleaned_data["verified"]: msg = "False positive findings cannot be verified." raise forms.ValidationError(msg) + if cleaned_data["active"] and cleaned_data.get("risk_acceptance") and cleaned_data.get("risk_accept"): + msg = "Active findings cannot be risk accepted." + raise forms.ValidationError(msg) return cleaned_data def clean_tags(self): diff --git a/unittests/test_bulk_edit_validation.py b/unittests/test_bulk_edit_validation.py new file mode 100644 index 00000000000..84ce98e396f --- /dev/null +++ b/unittests/test_bulk_edit_validation.py @@ -0,0 +1,746 @@ +import datetime + +from django.contrib.auth.models import User +from django.contrib.messages import get_messages +from django.urls import reverse +from django.utils import timezone + +from dojo.authorization.roles_permissions import Roles +from dojo.models import ( + Engagement, + Finding, + Product, + Product_Type, + Product_Type_Member, + Role, + Test, + Test_Type, +) + +from .dojo_test_case import DojoTestCase + + +class TestBulkEditValidation(DojoTestCase): + + """Test bulk edit validation rules for issue #11336""" + + @classmethod + def setUpTestData(cls): + # Create user with permissions + cls.user = User(username="testuser", is_staff=True) + cls.user.set_password("testpass") + cls.user.save() + + # Create product type and product + cls.product_type = Product_Type.objects.create(name="Web App") + cls.product = Product.objects.create( + prod_type=cls.product_type, + name="Test Product", + description="Test product for bulk edit validation", + ) + # Give user owner role + Product_Type_Member.objects.create( + product_type=cls.product_type, + user=cls.user, + role=Role.objects.get(id=Roles.Owner), + ) + + # Create engagement and test + cls.engagement = Engagement.objects.create( + product=cls.product, + target_start=timezone.now(), + target_end=timezone.now() + datetime.timedelta(days=30), + ) + cls.test_type = Test_Type.objects.create( + name="Manual Test", + static_tool=True, + ) + cls.test = Test.objects.create( + engagement=cls.engagement, + test_type=cls.test_type, + target_start=timezone.now(), + target_end=timezone.now() + datetime.timedelta(days=1), + ) + + # Create findings in various states + # Normal finding (not duplicate, not active) + cls.normal_finding = Finding.objects.create( + test=cls.test, + title="Normal Finding", + severity="High", + active=False, + verified=False, + duplicate=False, + reporter=cls.user, + numerical_severity="S1", + ) + + # Duplicate finding (duplicate=True, active=False) + cls.original_finding = Finding.objects.create( + test=cls.test, + title="Original Finding", + severity="High", + active=True, + verified=True, + duplicate=False, + reporter=cls.user, + numerical_severity="S1", + ) + cls.duplicate_finding = Finding.objects.create( + test=cls.test, + title="Duplicate Finding", + severity="High", + active=False, + verified=False, + duplicate=True, + duplicate_finding=cls.original_finding, + reporter=cls.user, + numerical_severity="S1", + ) + + # Active finding (active=True, not duplicate) + cls.active_finding = Finding.objects.create( + test=cls.test, + title="Active Finding", + severity="High", + active=True, + verified=True, + duplicate=False, + reporter=cls.user, + numerical_severity="S1", + ) + + # Inactive finding (active=False, not duplicate) + cls.inactive_finding = Finding.objects.create( + test=cls.test, + title="Inactive Finding", + severity="High", + active=False, + verified=False, + duplicate=False, + reporter=cls.user, + numerical_severity="S1", + ) + + def setUp(self): + self.client.force_login(self.user) + + def _bulk_edit_post_data(self, finding_ids, **kwargs): + """Helper to build POST data for bulk edit""" + data = { + "finding_to_update": [str(fid) for fid in finding_ids], + "return_url": reverse("view_test", args=(self.test.id,)), + } + # Add status checkbox if any status fields are being set + if any( + key in kwargs + for key in [ + "active", + "verified", + "false_p", + "out_of_scope", + "is_mitigated", + "under_review", + ] + ): + data["status"] = "on" + + # Add status fields + if kwargs.get("active"): + data["active"] = "on" + if kwargs.get("verified"): + data["verified"] = "on" + if kwargs.get("false_p"): + data["false_p"] = "on" + if kwargs.get("out_of_scope"): + data["out_of_scope"] = "on" + if kwargs.get("is_mitigated"): + data["is_mitigated"] = "on" + if kwargs.get("under_review"): + data["under_review"] = "on" + if kwargs.get("duplicate"): + data["duplicate"] = "on" + + # Add risk acceptance fields + if kwargs.get("risk_acceptance"): + data["risk_acceptance"] = "on" + if kwargs.get("risk_accept"): + data["risk_accept"] = "on" + if kwargs.get("risk_unaccept"): + data["risk_unaccept"] = "on" + + # Add other fields + if "severity" in kwargs: + data["severity"] = kwargs["severity"] + if "date" in kwargs: + data["date"] = kwargs["date"] + + return data + + def _assert_finding_status(self, finding, **expected_fields): + """Helper to verify finding state""" + finding.refresh_from_db() + for field, expected_value in expected_fields.items(): + actual_value = getattr(finding, field) + self.assertEqual( + actual_value, + expected_value, + f"Finding {finding.id} field {field}: expected {expected_value}, got {actual_value}", + ) + + def _get_messages_text(self, response): + """Helper to get all message texts from response""" + # Django test client stores messages in the session + # Try multiple methods to access them + messages_list = [] + try: + # Method 1: Try via wsgi_request if available + if hasattr(response, "wsgi_request") and response.wsgi_request: + messages_list = [str(m) for m in get_messages(response.wsgi_request)] + # Method 2: Try via response.request._messages + elif hasattr(response, "request") and hasattr(response.request, "_messages"): + storage = response.request._messages + messages_list = [str(m) for m in storage] + # Method 3: Try via client session (Django test client stores messages here) + elif hasattr(response, "client") and hasattr(response.client, "session"): + # Messages are stored in the session + # Create a mock request to access messages from session + from django.test import RequestFactory # noqa: PLC0415 + + factory = RequestFactory() + request = factory.get("/") + request.session = response.client.session + messages_list = [str(m) for m in get_messages(request)] + except (AttributeError, TypeError, ImportError): + # If messages aren't accessible, that's okay + # Tests can still verify behavior by checking finding state + pass + return messages_list + + # Form Validation Tests + + def test_form_rejects_active_and_risk_accept_together(self): + """Test that form validation rejects active + risk_accept""" + from dojo.forms import FindingBulkUpdateForm # noqa: PLC0415 + form_data = { + "active": True, + "risk_acceptance": True, + "risk_accept": True, + "severity": "", + "verified": False, + "false_p": False, + "duplicate": False, + "out_of_scope": False, + "under_review": False, + "is_mitigated": False, + } + form = FindingBulkUpdateForm(data=form_data) + self.assertFalse(form.is_valid()) + self.assertIn("Active findings cannot be risk accepted", str(form.errors)) + + def test_form_rejects_duplicate_and_active_together(self): + """Test that form validation rejects duplicate + active""" + from dojo.forms import FindingBulkUpdateForm # noqa: PLC0415 + form_data = { + "duplicate": True, + "active": True, + "severity": "", + "verified": False, + "false_p": False, + "out_of_scope": False, + "under_review": False, + "is_mitigated": False, + } + form = FindingBulkUpdateForm(data=form_data) + self.assertFalse(form.is_valid()) + self.assertIn("Duplicate findings cannot be verified or active", str(form.errors)) + + def test_form_rejects_duplicate_and_verified_together(self): + """Test that form validation rejects duplicate + verified""" + from dojo.forms import FindingBulkUpdateForm # noqa: PLC0415 + form_data = { + "duplicate": True, + "verified": True, + "severity": "", + "active": False, + "false_p": False, + "out_of_scope": False, + "under_review": False, + "is_mitigated": False, + } + form = FindingBulkUpdateForm(data=form_data) + self.assertFalse(form.is_valid()) + self.assertIn("Duplicate findings cannot be verified or active", str(form.errors)) + + def test_form_allows_duplicate_with_other_fields(self): + """Test that form allows duplicate with other non-conflicting fields""" + from dojo.forms import FindingBulkUpdateForm # noqa: PLC0415 + form_data = { + "duplicate": True, + "false_p": True, + "out_of_scope": True, + "severity": "", + "active": False, + "verified": False, + "under_review": False, + "is_mitigated": False, + } + form = FindingBulkUpdateForm(data=form_data) + # Form should be valid (though it may have other validation issues) + # The key is that duplicate + false_p + out_of_scope doesn't raise our specific error + form_errors = str(form.errors) if not form.is_valid() else "" + self.assertNotIn("Duplicate findings cannot be verified or active", form_errors) + + # View-Level Validation Tests (Duplicate Findings) + + def test_bulk_edit_duplicate_finding_cannot_set_active(self): + """Test that duplicate findings cannot be set as active via bulk edit""" + post_data = self._bulk_edit_post_data( + [self.duplicate_finding.id], + active=True, + false_p=False, + out_of_scope=False, + ) + + response = self.client.post( + reverse("finding_bulk_update_all"), + post_data, + follow=True, + ) + + # Verify finding remains inactive + self._assert_finding_status( + self.duplicate_finding, + active=False, + duplicate=True, + ) + # Verify other fields can still be updated + # (We set false_p=False, so it should remain False) + + # Verify warning message + messages = self._get_messages_text(response) + warning_messages = [m for m in messages if "duplicate findings" in m.lower()] + self.assertGreater( + len(warning_messages), + 0, + f"Expected warning about duplicate findings, got messages: {messages}", + ) + self.assertIn("Skipped status update", warning_messages[0]) + + def test_bulk_edit_duplicate_finding_cannot_set_verified(self): + """Test that duplicate findings cannot be set as verified via bulk edit""" + post_data = self._bulk_edit_post_data( + [self.duplicate_finding.id], + verified=True, + ) + + response = self.client.post( + reverse("finding_bulk_update_all"), + post_data, + follow=True, + ) + + # Verify finding remains unverified + self._assert_finding_status( + self.duplicate_finding, + verified=False, + duplicate=True, + ) + + # Verify warning message + messages = self._get_messages_text(response) + warning_messages = [m for m in messages if "duplicate findings" in m.lower()] + self.assertGreater(len(warning_messages), 0) + + def test_bulk_edit_duplicate_finding_can_update_other_fields(self): + """Test that duplicate findings can update other status fields""" + post_data = self._bulk_edit_post_data( + [self.duplicate_finding.id], + false_p=True, + out_of_scope=True, + ) + + response = self.client.post( + reverse("finding_bulk_update_all"), + post_data, + follow=True, + ) + + # Verify other fields are updated + self._assert_finding_status( + self.duplicate_finding, + false_p=True, + out_of_scope=True, + duplicate=True, + active=False, # Should remain False + ) + + # Verify no warning about duplicates (no conflict) + messages = self._get_messages_text(response) + warning_messages = [m for m in messages if "duplicate findings" in m.lower()] + self.assertEqual( + len(warning_messages), + 0, + f"Unexpected duplicate warning: {warning_messages}", + ) + + def test_bulk_edit_duplicate_finding_severity_update_works(self): + """Test that severity can be updated on duplicate findings""" + post_data = self._bulk_edit_post_data( + [self.duplicate_finding.id], + severity="Critical", + ) + + response = self.client.post( + reverse("finding_bulk_update_all"), + post_data, + follow=True, + ) + + # Verify severity is updated + self._assert_finding_status( + self.duplicate_finding, + severity="Critical", + duplicate=True, + ) + + # Verify no validation errors + self.assertNotEqual(response.status_code, 500) + + # View-Level Validation Tests (Active + Risk Acceptance) + + def test_bulk_edit_active_finding_cannot_accept_risk(self): + """Test that active findings cannot accept risk via bulk edit""" + # Enable simple risk acceptance on product + self.product.enable_simple_risk_acceptance = True + self.product.save() + + post_data = self._bulk_edit_post_data( + [self.active_finding.id], + risk_acceptance=True, + risk_accept=True, + ) + + response = self.client.post( + reverse("finding_bulk_update_all"), + post_data, + follow=True, + ) + + # Verify finding is NOT risk accepted + self.active_finding.refresh_from_db() + self.assertFalse( + self.active_finding.risk_accepted, + "Active finding should not be risk accepted", + ) + + # Verify warning message + messages = self._get_messages_text(response) + warning_messages = [ + m for m in messages if "active findings" in m.lower() and "risk" in m.lower() + ] + self.assertGreater( + len(warning_messages), + 0, + f"Expected warning about active findings and risk acceptance, got: {messages}", + ) + + def test_bulk_edit_inactive_finding_can_accept_risk(self): + """Test that inactive findings can accept risk""" + # Enable simple risk acceptance on product + self.product.enable_simple_risk_acceptance = True + self.product.save() + + post_data = self._bulk_edit_post_data( + [self.inactive_finding.id], + risk_acceptance=True, + risk_accept=True, + ) + + response = self.client.post( + reverse("finding_bulk_update_all"), + post_data, + follow=True, + ) + + # Verify finding IS risk accepted + self.inactive_finding.refresh_from_db() + self.assertTrue( + self.inactive_finding.risk_accepted, + "Inactive finding should be risk accepted", + ) + + # Verify no warning about active findings + messages = self._get_messages_text(response) + warning_messages = [ + m for m in messages if "active findings" in m.lower() and "risk" in m.lower() + ] + self.assertEqual( + len(warning_messages), + 0, + f"Unexpected active findings warning: {warning_messages}", + ) + + def test_bulk_edit_duplicate_finding_can_accept_risk_if_not_active(self): + """Test that duplicate but inactive findings can accept risk""" + # Enable simple risk acceptance on product + self.product.enable_simple_risk_acceptance = True + self.product.save() + + post_data = self._bulk_edit_post_data( + [self.duplicate_finding.id], + risk_acceptance=True, + risk_accept=True, + ) + + self.client.post( + reverse("finding_bulk_update_all"), + post_data, + follow=True, + ) + + # Verify finding IS risk accepted (duplicate check happens first, then active check) + self.duplicate_finding.refresh_from_db() + self.assertTrue( + self.duplicate_finding.risk_accepted, + "Duplicate but inactive finding should be risk accepted", + ) + + # User Feedback Tests + + def test_bulk_edit_shows_success_message_with_actual_count(self): + """Test that success message shows actually_updated_count""" + # Create mix: 1 duplicate, 2 normal findings + normal1 = Finding.objects.create( + test=self.test, + title="Normal 1", + severity="High", + active=False, + reporter=self.user, + numerical_severity="S1", + ) + normal2 = Finding.objects.create( + test=self.test, + title="Normal 2", + severity="High", + active=False, + reporter=self.user, + numerical_severity="S1", + ) + + post_data = self._bulk_edit_post_data( + [self.duplicate_finding.id, normal1.id, normal2.id], + active=True, + ) + + response = self.client.post( + reverse("finding_bulk_update_all"), + post_data, + follow=True, + ) + + # Verify success message + messages = self._get_messages_text(response) + success_messages = [m for m in messages if "successful" in m.lower()] + self.assertGreater(len(success_messages), 0) + + # Verify warning about skipped duplicate + warning_messages = [m for m in messages if "duplicate findings" in m.lower()] + self.assertGreater(len(warning_messages), 0) + + # Verify normal findings were updated + self._assert_finding_status(normal1, active=True) + self._assert_finding_status(normal2, active=True) + + def test_bulk_edit_shows_multiple_warning_messages(self): + """Test that multiple warning messages appear for different conflicts""" + # Enable simple risk acceptance + self.product.enable_simple_risk_acceptance = True + self.product.save() + + # First, try to set duplicate finding as active (will be skipped) + post_data1 = self._bulk_edit_post_data( + [self.duplicate_finding.id], + active=True, # Will conflict with duplicate + ) + response1 = self.client.post( + reverse("finding_bulk_update_all"), + post_data1, + follow=True, + ) + + # Then, try to risk accept active finding (will be skipped) + post_data2 = self._bulk_edit_post_data( + [self.active_finding.id], + risk_acceptance=True, + risk_accept=True, # Will conflict with active + ) + response2 = self.client.post( + reverse("finding_bulk_update_all"), + post_data2, + follow=True, + ) + + # Combine messages from both requests + messages1 = self._get_messages_text(response1) + messages2 = self._get_messages_text(response2) + all_messages = messages1 + messages2 + + duplicate_warnings = [ + m for m in all_messages if "duplicate findings" in m.lower() + ] + active_warnings = [ + m + for m + in all_messages + if "active findings" in m.lower() and "risk" in m.lower() + ] + + self.assertGreater( + len(duplicate_warnings), + 0, + f"Expected duplicate warning, got: {all_messages}", + ) + self.assertGreater( + len(active_warnings), + 0, + f"Expected active risk acceptance warning, got: {all_messages}", + ) + + def test_bulk_edit_no_warning_when_no_conflicts(self): + """Test that no warnings appear when there are no conflicts""" + post_data = self._bulk_edit_post_data( + [self.normal_finding.id], + active=True, + verified=True, + ) + + response = self.client.post( + reverse("finding_bulk_update_all"), + post_data, + follow=True, + ) + + messages = self._get_messages_text(response) + warning_messages = [ + m + for m + in messages + if "duplicate" in m.lower() or ("active" in m.lower() and "risk" in m.lower()) + ] + + self.assertEqual( + len(warning_messages), + 0, + f"Unexpected warnings: {warning_messages}", + ) + + # Verify success message + success_messages = [m for m in messages if "successful" in m.lower()] + self.assertGreater(len(success_messages), 0) + + # Edge Cases + + def test_bulk_edit_mixed_findings_partial_success(self): + """Test bulk edit with mix of duplicate and normal findings""" + # Create 2 more normal findings + normal1 = Finding.objects.create( + test=self.test, + title="Normal 1", + severity="High", + active=False, + reporter=self.user, + numerical_severity="S1", + ) + normal2 = Finding.objects.create( + test=self.test, + title="Normal 2", + severity="High", + active=False, + reporter=self.user, + numerical_severity="S1", + ) + normal3 = Finding.objects.create( + test=self.test, + title="Normal 3", + severity="High", + active=False, + reporter=self.user, + numerical_severity="S1", + ) + + post_data = self._bulk_edit_post_data( + [ + self.duplicate_finding.id, + self.duplicate_finding.id, # Add same duplicate twice to test + normal1.id, + normal2.id, + normal3.id, + ], + active=True, + ) + + response = self.client.post( + reverse("finding_bulk_update_all"), + post_data, + follow=True, + ) + + # Verify normal findings were updated + self._assert_finding_status(normal1, active=True) + self._assert_finding_status(normal2, active=True) + self._assert_finding_status(normal3, active=True) + + # Verify duplicate findings remain inactive + self._assert_finding_status(self.duplicate_finding, active=False, duplicate=True) + + # Verify warning message shows skipped count + messages = self._get_messages_text(response) + warning_messages = [m for m in messages if "duplicate findings" in m.lower()] + self.assertGreater(len(warning_messages), 0) + + def test_bulk_edit_severity_only_no_status_conflicts(self): + """Test that severity-only updates work regardless of duplicate status""" + post_data = self._bulk_edit_post_data( + [self.duplicate_finding.id], + severity="Critical", + ) + + response = self.client.post( + reverse("finding_bulk_update_all"), + post_data, + follow=True, + ) + + # Verify severity is updated + self._assert_finding_status( + self.duplicate_finding, + severity="Critical", + duplicate=True, + ) + + # Verify no validation errors or warnings + messages = self._get_messages_text(response) + warning_messages = [m for m in messages if "duplicate" in m.lower()] + self.assertEqual(len(warning_messages), 0) + + def test_bulk_edit_date_update_works_regardless_of_duplicate_status(self): + """Test that date updates work regardless of duplicate status""" + new_date = timezone.now().date() + post_data = self._bulk_edit_post_data( + [self.duplicate_finding.id], + date=new_date, + ) + + response = self.client.post( + reverse("finding_bulk_update_all"), + post_data, + follow=True, + ) + + # Verify date is updated + self.duplicate_finding.refresh_from_db() + self.assertEqual(self.duplicate_finding.date, new_date) + + # Verify no validation errors + self.assertNotEqual(response.status_code, 500) From 56642f95061d2ceda401c95888c040682b851186 Mon Sep 17 00:00:00 2001 From: valentijnscholten Date: Mon, 29 Dec 2025 18:05:24 +0100 Subject: [PATCH 096/126] Fix: Apply tags to findings/endpoints when TRACK_IMPORT_HISTORY is disabled (#13969) Fixes #13312 When TRACK_IMPORT_HISTORY is disabled, tags were not being applied to findings and endpoints during import because the tag application logic was inside update_import_history() which returned early. Refactored to: - Extract tag application into dedicated apply_import_tags() method - Call apply_import_tags() from importers after update_import_history() - Remove tag application logic from update_import_history() This ensures tags are applied regardless of TRACK_IMPORT_HISTORY setting while maintaining separation of concerns. --- dojo/importers/base_importer.py | 98 +++++++++++++++++++--------- dojo/importers/default_importer.py | 5 ++ dojo/importers/default_reimporter.py | 7 ++ 3 files changed, 79 insertions(+), 31 deletions(-) diff --git a/dojo/importers/base_importer.py b/dojo/importers/base_importer.py index cac02755022..45250cdb291 100644 --- a/dojo/importers/base_importer.py +++ b/dojo/importers/base_importer.py @@ -1,6 +1,7 @@ import base64 import logging import time +from collections.abc import Iterable from celery import chord, group from django.conf import settings @@ -335,6 +336,71 @@ def update_test_tags(self): if self.tags is not None and len(self.tags) > 0: self.test.tags.set(self.tags) + def apply_import_tags( + self, + new_findings: Iterable[Finding] | None = None, + closed_findings: Iterable[Finding] | None = None, + reactivated_findings: Iterable[Finding] | None = None, + untouched_findings: Iterable[Finding] | None = None, + ) -> None: + """Apply tags to findings and endpoints from an import operation.""" + # Normalize None values to empty lists and convert sets/other iterables to lists + if untouched_findings is None: + untouched_findings = [] + elif not isinstance(untouched_findings, list): + untouched_findings = list(untouched_findings) + + if reactivated_findings is None: + reactivated_findings = [] + elif not isinstance(reactivated_findings, list): + reactivated_findings = list(reactivated_findings) + + if closed_findings is None: + closed_findings = [] + elif not isinstance(closed_findings, list): + closed_findings = list(closed_findings) + + if new_findings is None: + new_findings = [] + elif not isinstance(new_findings, list): + new_findings = list(new_findings) + + # Collect all affected findings + findings_to_tag = new_findings + closed_findings + reactivated_findings + untouched_findings + + if not findings_to_tag: + return + + # Add any tags to the findings imported if necessary + if self.apply_tags_to_findings and self.tags: + findings_qs = Finding.objects.filter(id__in=[f.id for f in findings_to_tag]) + try: + bulk_add_tags_to_instances( + tag_or_tags=self.tags, + instances=findings_qs, + tag_field_name="tags", + ) + except IntegrityError: + # Fallback to safe per-instance tagging if concurrent deletes occur + for finding in findings_to_tag: + for tag in self.tags: + self.add_tags_safe(finding, tag) + + # Add any tags to any endpoints of the findings imported if necessary + if self.apply_tags_to_endpoints and self.tags: + endpoints_qs = Endpoint.objects.filter(finding__in=findings_to_tag).distinct() + try: + bulk_add_tags_to_instances( + tag_or_tags=self.tags, + instances=endpoints_qs, + tag_field_name="tags", + ) + except IntegrityError: + for finding in findings_to_tag: + for endpoint in finding.endpoints.all(): + for tag in self.tags: + self.add_tags_safe(endpoint, tag) + def update_import_history( self, new_findings: list[Finding] | None = None, @@ -355,6 +421,7 @@ def update_import_history( closed_findings = [] if new_findings is None: new_findings = [] + # Log the current state of what has occurred in case there could be # deviation from what is displayed in the view logger.debug( @@ -430,37 +497,6 @@ def update_import_history( for record in import_history_records: self.create_import_history_record_safe(record) - # Add any tags to the findings imported if necessary - if self.apply_tags_to_findings and self.tags: - findings_qs = test_import.findings_affected.all() - try: - bulk_add_tags_to_instances( - tag_or_tags=self.tags, - instances=findings_qs, - tag_field_name="tags", - ) - except IntegrityError: - # Fallback to safe per-instance tagging if concurrent deletes occur - for finding in findings_qs: - for tag in self.tags: - self.add_tags_safe(finding, tag) - - # Add any tags to any endpoints of the findings imported if necessary - if self.apply_tags_to_endpoints and self.tags: - # Collect all endpoints linked to the affected findings - endpoints_qs = Endpoint.objects.filter(finding__in=test_import.findings_affected.all()).distinct() - try: - bulk_add_tags_to_instances( - tag_or_tags=self.tags, - instances=endpoints_qs, - tag_field_name="tags", - ) - except IntegrityError: - for finding in test_import.findings_affected.all(): - for endpoint in finding.endpoints.all(): - for tag in self.tags: - self.add_tags_safe(endpoint, tag) - return test_import def create_import_history_record_safe( diff --git a/dojo/importers/default_importer.py b/dojo/importers/default_importer.py index 14cd5885f00..b4f3cc70c35 100644 --- a/dojo/importers/default_importer.py +++ b/dojo/importers/default_importer.py @@ -129,6 +129,11 @@ def process_scan( new_findings=new_findings, closed_findings=closed_findings, ) + # Apply tags to findings and endpoints + self.apply_import_tags( + new_findings=new_findings, + closed_findings=closed_findings, + ) # Send out some notifications to the user logger.debug("IMPORT_SCAN: Generating notifications") create_notification( diff --git a/dojo/importers/default_reimporter.py b/dojo/importers/default_reimporter.py index 14a7da7e21d..47ce8c61acd 100644 --- a/dojo/importers/default_reimporter.py +++ b/dojo/importers/default_reimporter.py @@ -132,6 +132,13 @@ def process_scan( reactivated_findings=reactivated_findings, untouched_findings=untouched_findings, ) + # Apply tags to findings and endpoints + self.apply_import_tags( + new_findings=new_findings, + closed_findings=closed_findings, + reactivated_findings=reactivated_findings, + untouched_findings=untouched_findings, + ) # Send out som notifications to the user logger.debug("REIMPORT_SCAN: Generating notifications") updated_count = ( From 37b3e8ec3d38363dbe0442809e6bb8cf403e9b92 Mon Sep 17 00:00:00 2001 From: valentijnscholten Date: Mon, 29 Dec 2025 18:05:34 +0100 Subject: [PATCH 097/126] Add status and notes columns to CSV/Excel exports (#13970) - Add 'status' column showing finding status (Active, Verified, etc.) - Add 'notes' column aggregating all public notes for each finding - Filter out private notes from exports for privacy compliance - Add prefetching for notes to avoid N+1 queries - Follow existing patterns for multiline field handling (NEWLINE for CSV, actual newlines for Excel) Fixes #8995 --- dojo/reports/views.py | 46 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/dojo/reports/views.py b/dojo/reports/views.py index ae6a99804eb..1dc29ff5e2d 100644 --- a/dojo/reports/views.py +++ b/dojo/reports/views.py @@ -7,6 +7,7 @@ from dateutil.relativedelta import relativedelta from django.conf import settings from django.core.exceptions import PermissionDenied +from django.db.models import Prefetch from django.http import Http404, HttpRequest, HttpResponse, QueryDict from django.shortcuts import get_object_or_404, render from django.utils import timezone @@ -28,7 +29,7 @@ from dojo.finding.views import BaseListFindings from dojo.forms import ReportOptionsForm from dojo.labels import get_labels -from dojo.models import Dojo_User, Endpoint, Engagement, Finding, Product, Product_Type, Test +from dojo.models import Dojo_User, Endpoint, Engagement, Finding, Notes, Product, Product_Type, Test from dojo.reports.widgets import ( CoverPage, CustomReportJsonForm, @@ -642,7 +643,7 @@ def prefetch_related_findings_for_report(findings): "burprawrequestresponse_set", "endpoints", "tags", - "notes", + Prefetch("notes", queryset=Notes.objects.filter(private=False)), "files", "reporter", "mitigated_by", @@ -815,6 +816,7 @@ def add_extra_values(self): def get(self, request): findings, _obj = get_findings(request) + findings = prefetch_related_findings_for_report(findings) self.findings = findings findings = self.add_findings_data() response = HttpResponse(content_type="text/csv") @@ -850,6 +852,8 @@ def get(self, request): "endpoints", "vulnerability_ids", "tags", + "status", + "notes", )) self.fields = fields self.add_extra_headers() @@ -913,6 +917,20 @@ def get(self, request): tags_value = tags_value.removesuffix("; ") fields.append(tags_value) + # Status + status_value = finding.status() + fields.append(status_value) + + # Notes + notes_value = "" + for note in finding.notes.filter(private=False): + note_entry = note.entry.replace("\n", " NEWLINE ").replace("\r", "") + notes_value += f"{note_entry}; " + notes_value = notes_value.removesuffix("; ") + if len(notes_value) > EXCEL_CHAR_LIMIT: + notes_value = notes_value[:EXCEL_CHAR_LIMIT - 3] + "..." + fields.append(notes_value) + self.fields = fields self.finding = finding self.add_extra_values() @@ -935,6 +953,7 @@ def add_extra_values(self): def get(self, request): findings, _obj = get_findings(request) + findings = prefetch_related_findings_for_report(findings) self.findings = findings findings = self.add_findings_data() workbook = Workbook() @@ -990,6 +1009,12 @@ def get(self, request): cell = worksheet.cell(row=row_num, column=col_num, value="tags") cell.font = font_bold col_num += 1 + cell = worksheet.cell(row=row_num, column=col_num, value="status") + cell.font = font_bold + col_num += 1 + cell = worksheet.cell(row=row_num, column=col_num, value="notes") + cell.font = font_bold + col_num += 1 self.row_num = row_num self.col_num = col_num self.add_extra_headers() @@ -1056,6 +1081,23 @@ def get(self, request): tags_value = tags_value.removesuffix("; \n") worksheet.cell(row=row_num, column=col_num, value=tags_value) col_num += 1 + + # Status + status_value = finding.status() + worksheet.cell(row=row_num, column=col_num, value=status_value) + col_num += 1 + + # Notes + notes_value = "" + for note in finding.notes.filter(private=False): + note_entry = note.entry.replace("\r", "") + notes_value += f"{note_entry}; \n" + notes_value = notes_value.removesuffix("; \n") + if len(notes_value) > EXCEL_CHAR_LIMIT: + notes_value = notes_value[:EXCEL_CHAR_LIMIT - 3] + "..." + worksheet.cell(row=row_num, column=col_num, value=notes_value) + col_num += 1 + self.col_num = col_num self.row_num = row_num self.finding = finding From d799696d9f4754d81e76f560437d04192bb2dd4b Mon Sep 17 00:00:00 2001 From: DefectDojo release bot Date: Mon, 29 Dec 2025 17:13:00 +0000 Subject: [PATCH 098/126] 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 0057e739952..d9500b421b6 100644 --- a/components/package.json +++ b/components/package.json @@ -1,6 +1,6 @@ { "name": "defectdojo", - "version": "2.53.5", + "version": "2.54.0-dev", "license" : "BSD-3-Clause", "private": true, "dependencies": { diff --git a/dojo/__init__.py b/dojo/__init__.py index f31e0202149..7337d10b9c1 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.53.5" +__version__ = "2.54.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 68c97d8282c..ff1d2b213f8 100644 --- a/helm/defectdojo/Chart.yaml +++ b/helm/defectdojo/Chart.yaml @@ -1,8 +1,8 @@ apiVersion: v2 -appVersion: "2.53.5" +appVersion: "2.54.0-dev" description: A Helm chart for Kubernetes to install DefectDojo name: defectdojo -version: 1.9.5 +version: 1.9.6-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: Bump DefectDojo to 2.53.5\n" + artifacthub.io/prerelease: "true" + artifacthub.io/changes: "" diff --git a/helm/defectdojo/README.md b/helm/defectdojo/README.md index be21c4353a6..31bc3123ec8 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.5](https://img.shields.io/badge/Version-1.9.5-informational?style=flat-square) ![AppVersion: 2.53.5](https://img.shields.io/badge/AppVersion-2.53.5-informational?style=flat-square) +![Version: 1.9.6-dev](https://img.shields.io/badge/Version-1.9.6--dev-informational?style=flat-square) ![AppVersion: 2.54.0-dev](https://img.shields.io/badge/AppVersion-2.54.0--dev-informational?style=flat-square) A Helm chart for Kubernetes to install DefectDojo From d96eaa7ae26fb0354bb4a3ca4aafba0c5b745de1 Mon Sep 17 00:00:00 2001 From: DefectDojo release bot Date: Mon, 29 Dec 2025 17:13:01 +0000 Subject: [PATCH 099/126] 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 0057e739952..d9500b421b6 100644 --- a/components/package.json +++ b/components/package.json @@ -1,6 +1,6 @@ { "name": "defectdojo", - "version": "2.53.5", + "version": "2.54.0-dev", "license" : "BSD-3-Clause", "private": true, "dependencies": { diff --git a/helm/defectdojo/Chart.yaml b/helm/defectdojo/Chart.yaml index 68c97d8282c..ff1d2b213f8 100644 --- a/helm/defectdojo/Chart.yaml +++ b/helm/defectdojo/Chart.yaml @@ -1,8 +1,8 @@ apiVersion: v2 -appVersion: "2.53.5" +appVersion: "2.54.0-dev" description: A Helm chart for Kubernetes to install DefectDojo name: defectdojo -version: 1.9.5 +version: 1.9.6-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: Bump DefectDojo to 2.53.5\n" + artifacthub.io/prerelease: "true" + artifacthub.io/changes: "" diff --git a/helm/defectdojo/README.md b/helm/defectdojo/README.md index be21c4353a6..31bc3123ec8 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.5](https://img.shields.io/badge/Version-1.9.5-informational?style=flat-square) ![AppVersion: 2.53.5](https://img.shields.io/badge/AppVersion-2.53.5-informational?style=flat-square) +![Version: 1.9.6-dev](https://img.shields.io/badge/Version-1.9.6--dev-informational?style=flat-square) ![AppVersion: 2.54.0-dev](https://img.shields.io/badge/AppVersion-2.54.0--dev-informational?style=flat-square) A Helm chart for Kubernetes to install DefectDojo From c7c3c560fc10bbadf272e9e588b43d62be112c04 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 29 Dec 2025 11:46:17 -0600 Subject: [PATCH 100/126] chore(deps): update dependency gohugoio/hugo from v0.153.2 to v0.153.4 (.github/workflows/validate_docs_build.yml) (#13985) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .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 5490cb9b162..e033e2da335 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -18,7 +18,7 @@ jobs: - name: Setup Hugo uses: peaceiris/actions-hugo@75d2e84710de30f6ff7268e08f310b60ef14033f # v3.0.0 with: - hugo-version: '0.153.2' # renovate: datasource=github-releases depName=gohugoio/hugo + hugo-version: '0.153.4' # renovate: datasource=github-releases depName=gohugoio/hugo extended: true - name: Setup Node diff --git a/.github/workflows/validate_docs_build.yml b/.github/workflows/validate_docs_build.yml index 9e44661be77..3c17b1147ae 100644 --- a/.github/workflows/validate_docs_build.yml +++ b/.github/workflows/validate_docs_build.yml @@ -12,7 +12,7 @@ jobs: - name: Setup Hugo uses: peaceiris/actions-hugo@75d2e84710de30f6ff7268e08f310b60ef14033f # v3.0.0 with: - hugo-version: '0.153.2' # renovate: datasource=github-releases depName=gohugoio/hugo + hugo-version: '0.153.4' # renovate: datasource=github-releases depName=gohugoio/hugo extended: true - name: Setup Node From 034e197ade9473537b14ac1cf9121f015480eb68 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Dec 2025 14:34:37 -0600 Subject: [PATCH 101/126] :arrow_up: Bump django-filter from 25.1 to 25.2 (#13346) Bumps [django-filter](https://github.com/carltongibson/django-filter) from 25.1 to 25.2. - [Release notes](https://github.com/carltongibson/django-filter/releases) - [Changelog](https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst) - [Commits](https://github.com/carltongibson/django-filter/compare/25.1...25.2) --- updated-dependencies: - dependency-name: django-filter dependency-version: '25.2' dependency-type: direct:production update-type: version-update:semver-minor ... 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 a90a791d746..4b57e450d82 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ django-auditlog==3.2.1 django-pghistory==3.9.1 django-dbbackup==5.1.0 django-environ==0.12.0 -django-filter==25.1 +django-filter==25.2 django-imagekit==6.0.0 django-multiselectfield==1.0.1 django-polymorphic==4.5.1 From 01ea5486363195e18b47a7f54b2b5d1ffebd44bb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Dec 2025 22:06:15 -0600 Subject: [PATCH 102/126] chore(deps): bump python-gitlab from 7.0.0 to 7.1.0 (#13990) Bumps [python-gitlab](https://github.com/python-gitlab/python-gitlab) from 7.0.0 to 7.1.0. - [Release notes](https://github.com/python-gitlab/python-gitlab/releases) - [Changelog](https://github.com/python-gitlab/python-gitlab/blob/main/CHANGELOG.md) - [Commits](https://github.com/python-gitlab/python-gitlab/compare/v7.0.0...v7.1.0) --- updated-dependencies: - dependency-name: python-gitlab dependency-version: 7.1.0 dependency-type: direct:production update-type: version-update:semver-minor ... 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 4b57e450d82..348936c7761 100644 --- a/requirements.txt +++ b/requirements.txt @@ -43,7 +43,7 @@ titlecase==2.4.1 social-auth-app-django==5.6.0 social-auth-core==4.8.3 gitpython==3.1.45 -python-gitlab==7.0.0 +python-gitlab==7.1.0 cpe==1.3.1 packageurl-python==0.17.6 django-crum==0.7.9 From 615d0dd73b9f488276861d81504987f376744bbb Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 29 Dec 2025 22:18:12 -0600 Subject: [PATCH 103/126] chore(deps): update python docker tag from 3.13.7 to v3.13.11 (dockerfile.nginx-alpine) (#13995) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Dockerfile.django-alpine | 2 +- Dockerfile.django-debian | 2 +- Dockerfile.integration-tests-debian | 2 +- Dockerfile.nginx-alpine | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Dockerfile.django-alpine b/Dockerfile.django-alpine index 828a15b786f..bbe35d41af9 100644 --- a/Dockerfile.django-alpine +++ b/Dockerfile.django-alpine @@ -5,7 +5,7 @@ # Dockerfile.nginx to use the caching mechanism of Docker. # Ref: https://devguide.python.org/#branchstatus -FROM python:3.13.7-alpine3.22@sha256:9ba6d8cbebf0fb6546ae71f2a1c14f6ffd2fdab83af7fa5669734ef30ad48844 AS base +FROM python:3.13.11-alpine3.22@sha256:ab45bd32143151fe060d48218b91df43a289166e72ec7877823b1c972580bed3 AS base FROM base AS build WORKDIR /app RUN \ diff --git a/Dockerfile.django-debian b/Dockerfile.django-debian index 779dbcba13d..fa80d461c79 100644 --- a/Dockerfile.django-debian +++ b/Dockerfile.django-debian @@ -5,7 +5,7 @@ # Dockerfile.nginx to use the caching mechanism of Docker. # Ref: https://devguide.python.org/#branchstatus -FROM python:3.13.7-slim-trixie@sha256:5f55cdf0c5d9dc1a415637a5ccc4a9e18663ad203673173b8cda8f8dcacef689 AS base +FROM python:3.13.11-slim-trixie@sha256:baf66684c5fcafbda38a54b227ee30ec41e40af1e4073edee3a7110a417756ba AS base FROM base AS build WORKDIR /app RUN \ diff --git a/Dockerfile.integration-tests-debian b/Dockerfile.integration-tests-debian index 53da53ea736..97b3c926ae5 100644 --- a/Dockerfile.integration-tests-debian +++ b/Dockerfile.integration-tests-debian @@ -3,7 +3,7 @@ FROM openapitools/openapi-generator-cli:v7.18.0@sha256:be5c0a17c978ed4c39985312af3129882407581e07f2e3167cf777c908ffd52b AS openapitools # currently only supports x64, no arm yet due to chrome and selenium dependencies -FROM python:3.13.7-slim-trixie@sha256:5f55cdf0c5d9dc1a415637a5ccc4a9e18663ad203673173b8cda8f8dcacef689 AS build +FROM python:3.13.11-slim-trixie@sha256:baf66684c5fcafbda38a54b227ee30ec41e40af1e4073edee3a7110a417756ba AS build WORKDIR /app RUN \ apt-get -y update && \ diff --git a/Dockerfile.nginx-alpine b/Dockerfile.nginx-alpine index bfdf4a4114a..03a6e9c5a31 100644 --- a/Dockerfile.nginx-alpine +++ b/Dockerfile.nginx-alpine @@ -5,7 +5,7 @@ # Dockerfile.django-alpine to use the caching mechanism of Docker. # Ref: https://devguide.python.org/#branchstatus -FROM python:3.13.7-alpine3.22@sha256:9ba6d8cbebf0fb6546ae71f2a1c14f6ffd2fdab83af7fa5669734ef30ad48844 AS base +FROM python:3.13.11-alpine3.22@sha256:ab45bd32143151fe060d48218b91df43a289166e72ec7877823b1c972580bed3 AS base FROM base AS build WORKDIR /app RUN \ From b44cafa81f439dee454127f3ee6c95f7a5ea3079 Mon Sep 17 00:00:00 2001 From: Paul Osinski Date: Tue, 30 Dec 2025 11:42:44 -0500 Subject: [PATCH 104/126] update changelog --- docs/content/en/changelog/changelog.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/content/en/changelog/changelog.md b/docs/content/en/changelog/changelog.md index 8cd48ce5e3b..8227102fd0b 100644 --- a/docs/content/en/changelog/changelog.md +++ b/docs/content/en/changelog/changelog.md @@ -10,6 +10,11 @@ For Open Source release notes, please see the [Releases page on GitHub](https:// ## Dec 2025: v2.53 +### Dec 29, 2025: v2.53.5 + +* **(Pro UI)** Added Finding count columns to Engagement table. +* **(Pro UI)** Enter/Return no longer automatically submits forms. + ### Dec 22, 2025: v2.53.4 * **(Pro UI)** Asset Hierarchy now uses separate tabs for Asset selection and for the rendered Asset tree: From 1e8a0a4a6db1ca04f42cb31279c726b824b0a64c Mon Sep 17 00:00:00 2001 From: valentijnscholten Date: Tue, 30 Dec 2025 17:46:18 +0100 Subject: [PATCH 105/126] Update weight of 2.54.0 upgrade notes (#13991) Updated weight for version 2.54.x and modified description. --- docs/content/en/open_source/upgrading/2.54.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/content/en/open_source/upgrading/2.54.md b/docs/content/en/open_source/upgrading/2.54.md index 7a8b6bad844..0a5c97c8df4 100644 --- a/docs/content/en/open_source/upgrading/2.54.md +++ b/docs/content/en/open_source/upgrading/2.54.md @@ -1,7 +1,7 @@ --- title: 'Upgrading to DefectDojo Version 2.54.x' toc_hide: true -weight: -20250804 +weight: -20251226 description: Removal of django-auditlog & Dropped support for DD_PARSER_EXCLUDE & Reimport performance improvements & Removal of Finding Template Matching --- From b29026cedb443d9717710e1c5b0832147ae965a8 Mon Sep 17 00:00:00 2001 From: Cody Maffucci <46459665+Maffooch@users.noreply.github.com> Date: Tue, 30 Dec 2025 15:53:15 -0600 Subject: [PATCH 106/126] Add workflow path for GitHub Actions validation (#14000) --- .github/workflows/validate_docs_build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/validate_docs_build.yml b/.github/workflows/validate_docs_build.yml index f83d6d189b8..e0286c9f1a6 100644 --- a/.github/workflows/validate_docs_build.yml +++ b/.github/workflows/validate_docs_build.yml @@ -4,6 +4,7 @@ on: pull_request: paths: - 'docs/**' + - '.github/workflows/*' jobs: deploy: From 2cc2af6f861189e5d5012ed5ca56a93cb865896a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 30 Dec 2025 15:55:36 -0600 Subject: [PATCH 107/126] chore(deps): update python:3.13.11-slim-trixie docker digest from 3.13.11 to v (dockerfile.integration-tests-debian) (#14003) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Dockerfile.django-debian | 2 +- Dockerfile.integration-tests-debian | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile.django-debian b/Dockerfile.django-debian index fa80d461c79..e68b9fce672 100644 --- a/Dockerfile.django-debian +++ b/Dockerfile.django-debian @@ -5,7 +5,7 @@ # Dockerfile.nginx to use the caching mechanism of Docker. # Ref: https://devguide.python.org/#branchstatus -FROM python:3.13.11-slim-trixie@sha256:baf66684c5fcafbda38a54b227ee30ec41e40af1e4073edee3a7110a417756ba AS base +FROM python:3.13.11-slim-trixie@sha256:b19309443a59aa604fd694e04a1698b24df57ab8b7f06a6cd3dcb1769391767e AS base FROM base AS build WORKDIR /app RUN \ diff --git a/Dockerfile.integration-tests-debian b/Dockerfile.integration-tests-debian index 97b3c926ae5..1ac67ebfdb1 100644 --- a/Dockerfile.integration-tests-debian +++ b/Dockerfile.integration-tests-debian @@ -3,7 +3,7 @@ FROM openapitools/openapi-generator-cli:v7.18.0@sha256:be5c0a17c978ed4c39985312af3129882407581e07f2e3167cf777c908ffd52b AS openapitools # currently only supports x64, no arm yet due to chrome and selenium dependencies -FROM python:3.13.11-slim-trixie@sha256:baf66684c5fcafbda38a54b227ee30ec41e40af1e4073edee3a7110a417756ba AS build +FROM python:3.13.11-slim-trixie@sha256:b19309443a59aa604fd694e04a1698b24df57ab8b7f06a6cd3dcb1769391767e AS build WORKDIR /app RUN \ apt-get -y update && \ From 2a36c78f4e5a58a836581a82933831ccf5a7c3f3 Mon Sep 17 00:00:00 2001 From: sNiXx Date: Tue, 30 Dec 2025 23:17:34 +0100 Subject: [PATCH 108/126] docs: add custom trust section --- .../supported_tools/parsers/api/_index.md | 31 +++++++++++++++++++ .../supported_tools/parsers/api/sonarqube.md | 11 +------ 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/docs/content/supported_tools/parsers/api/_index.md b/docs/content/supported_tools/parsers/api/_index.md index 2cc476beda1..deddaf88584 100644 --- a/docs/content/supported_tools/parsers/api/_index.md +++ b/docs/content/supported_tools/parsers/api/_index.md @@ -29,3 +29,34 @@ Follow these steps to set up API importing: 4. After this is done, you can import the findings on the `Product` page through `Findings -> Import Scan Results`. As the `Scan type`, select the related type (the `API Scan Configuration` created above) and click `Import`. + +## Custom Trust + +In some cases, you may want to connect to a tool that uses a certificate from a certification authority (CA) that is not +in the default trust store (e.g. a company-internal CA), which requires that you add custom trust to an existing trust +store or replace the existing trust store with your own. + +### Using a Custom-Built DefectDojo Image + +When you are building your own container image for `django-DefectDojo`, you can simply add the certificates you would +like to include as custom trust to the `docker/certs` path (see +[Dockerfile.django](https://github.com/DefectDojo/django-DefectDojo/blob/861b617bfcb17cb5e858f46e31509134d0e98171/Dockerfile.django#L70)) + +### Using the Prebuilt DefectDojo Image + +1. Create a new mounted volume where the new trust store will be added (ensures persistence). +2. Create a new trust store + 1. Prepare a new PEM-encoded trust store file (`custom-cacerts.pem`). + 2. Optional, if you want to keep existing trust: Add the custom trust to the existing trust store + 1. Find the location of the existing trust store by running `python -m certifi` in the container + 2. Append your custom trust to the existing trust store by running + `cat cacert.pem custom-cacerts.pem > extended-cacerts.pem`. + ***Important: The consequence of copying the existing trust store is that you will not receive any updates + (added or removed CA certificates).*** +3. Copy the new trust store (`custom-cacerts.pem` or `extended-cacerts.pem`) to the mounted volume. +4. Point the environment variable `REQUESTS_CA_BUNDLE` to the new trust store file. + +> `REQUESTS_CA_BUNDLE` is an environment variable from the Python `requests` package. By default, it uses the trust +> store provided by the `certifi` package. For more details, check the respective documentation +> ([requests](https://requests.readthedocs.io/en/latest/user/advanced/#ssl-cert-verification) or +> [certifi](https://certifiio.readthedocs.io/en/latest/)) \ No newline at end of file diff --git a/docs/content/supported_tools/parsers/api/sonarqube.md b/docs/content/supported_tools/parsers/api/sonarqube.md index 3f38e022ebe..c6299ad9f18 100644 --- a/docs/content/supported_tools/parsers/api/sonarqube.md +++ b/docs/content/supported_tools/parsers/api/sonarqube.md @@ -44,13 +44,4 @@ If using a version of SonarQube with multi-branch scanning, the branch to be sca be supplied in the `branch_tag` field at import/re-import time. If the branch does not exist, a notification will be generated in the alerts table, indicating that branch to be imported does not exist. If a branch name is not supplied during import/re-import, the default branch -of the SonarQube project will be used. - -## Custom Trust - -If you are connecting to SonarQube via HTTPS, the issuer of the certificate that is presented by -SonarQube must be trusted. - -One way of achieving this is by defining the `REQUESTS_CA_BUNDLE` environment variable to point -to a PEM-encoded certificate file in the container (e.g. `REQUESTS_CA_BUNDLE=/app/media/cacerts.pem`). -To ensure the certificate is persisted, the file should be in a mounted volume. \ No newline at end of file +of the SonarQube project will be used. \ No newline at end of file From 742f96fed651b17d57bd5a6edd76936dd88692fa Mon Sep 17 00:00:00 2001 From: Cody Maffucci <46459665+Maffooch@users.noreply.github.com> Date: Wed, 31 Dec 2025 08:59:22 -0600 Subject: [PATCH 109/126] Add permission classes and refine queryset in BurpRawRequestResponseViewSet --- dojo/api_v2/views.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index b321c35d558..e18a2ceeb26 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -2695,15 +2695,24 @@ class BurpRawRequestResponseViewSet( queryset = BurpRawRequestResponse.objects.none() filter_backends = (DjangoFilterBackend,) filterset_fields = ["finding"] + permission_classes = ( + IsAuthenticated, + permissions.UserHasFindingPermission, + ) def get_queryset(self): - results = BurpRawRequestResponse.objects.all() - empty_value = b"" - results = results.exclude( - burpRequestBase64__exact=empty_value, - burpResponseBase64__exact=empty_value, + return ( + BurpRawRequestResponse.objects.filter( + finding__in=get_authorized_findings( + Permissions.Finding_View, + ), + ) + .exclude( + burpRequestBase64__exact=b"", + burpResponseBase64__exact=b"", + ) + .order_by("id") ) - return results.order_by("id") # Authorization: superuser From d0234c19701245b94b6be04887e282e147fb8ecf Mon Sep 17 00:00:00 2001 From: Paul Osinski Date: Fri, 2 Jan 2026 16:53:05 -0500 Subject: [PATCH 110/126] create sitemap at root --- docs/config/_default/hugo.toml | 4 ++-- docs/content/_index.md | 5 +++++ docs/layouts/_default/sitemap.xml | 13 +++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 docs/content/_index.md create mode 100644 docs/layouts/_default/sitemap.xml diff --git a/docs/config/_default/hugo.toml b/docs/config/_default/hugo.toml index 9e3da87423f..13367504ca6 100644 --- a/docs/config/_default/hugo.toml +++ b/docs/config/_default/hugo.toml @@ -22,7 +22,7 @@ copyRight = "Copyright (c) 2020-2024 Thulite" enable = true [outputs] - home = ["HTML", "RSS", "searchIndex"] + home = ["HTML", "RSS", "searchIndex", "SITEMAP"] section = ["HTML", "RSS", "SITEMAP"] [outputFormats.searchIndex] @@ -41,7 +41,7 @@ copyRight = "Copyright (c) 2020-2024 Thulite" rel = "sitemap" [sitemap] - changefreq = "monthly" + changefreq = "weekly" filename = "sitemap.xml" priority = 0.5 diff --git a/docs/content/_index.md b/docs/content/_index.md new file mode 100644 index 00000000000..867b45ac0c0 --- /dev/null +++ b/docs/content/_index.md @@ -0,0 +1,5 @@ +--- +title: "DefectDojo Documentation" +date: 2021-02-02T20:46:29+01:00 +draft: false +--- diff --git a/docs/layouts/_default/sitemap.xml b/docs/layouts/_default/sitemap.xml new file mode 100644 index 00000000000..1b49b9ec7c1 --- /dev/null +++ b/docs/layouts/_default/sitemap.xml @@ -0,0 +1,13 @@ +{{- /* sitemap for all pages */ -}} +{{ printf "" | safeHTML }} + + {{ range .Site.RegularPages }} + + {{ .Permalink }} + weekly + {{ with .Lastmod }} + {{ .Format "2006-01-02T15:04:05-07:00" | safeHTML }} + {{ end }} + + {{ end }} + From 0cb0fb6c3b6c8919d3ad2de384d4a499d392774a Mon Sep 17 00:00:00 2001 From: Cody Maffucci <46459665+Maffooch@users.noreply.github.com> Date: Fri, 2 Jan 2026 15:46:23 -0700 Subject: [PATCH 111/126] Remove entrypoint-first-boot.sh references and implement complete initialization command (#14002) --- .dryrunsecurity.yaml | 1 - Dockerfile.django-alpine | 1 - Dockerfile.django-debian | 1 - docker/entrypoint-initializer.sh | 155 +----------- .../commands/complete_initialization.py | 233 ++++++++++++++++++ 5 files changed, 235 insertions(+), 156 deletions(-) create mode 100644 dojo/management/commands/complete_initialization.py diff --git a/.dryrunsecurity.yaml b/.dryrunsecurity.yaml index 740ae9ba1bc..1863e9a2027 100644 --- a/.dryrunsecurity.yaml +++ b/.dryrunsecurity.yaml @@ -52,7 +52,6 @@ sensitiveCodepaths: - 'docker/entrypoint-celery-beat.sh' - 'docker/entrypoint-celery-worker.sh' - 'docker/entrypoint-initializer.sh' - - 'docker/entrypoint-first-boot.sh' - 'docker/entrypoint-nginx.sh' - 'docker/entrypoint-uwsgi.sh' - 'docker/wait-for-it.sh' diff --git a/Dockerfile.django-alpine b/Dockerfile.django-alpine index 40365930275..4787e76c1b3 100644 --- a/Dockerfile.django-alpine +++ b/Dockerfile.django-alpine @@ -70,7 +70,6 @@ COPY \ docker/entrypoint-celery-worker.sh \ docker/entrypoint-celery-worker-dev.sh \ docker/entrypoint-initializer.sh \ - docker/entrypoint-first-boot.sh \ docker/entrypoint-uwsgi.sh \ docker/entrypoint-uwsgi-dev.sh \ docker/entrypoint-unit-tests.sh \ diff --git a/Dockerfile.django-debian b/Dockerfile.django-debian index eccf9bd6dae..668fb1f1361 100644 --- a/Dockerfile.django-debian +++ b/Dockerfile.django-debian @@ -73,7 +73,6 @@ COPY \ docker/entrypoint-celery-worker.sh \ docker/entrypoint-celery-worker-dev.sh \ docker/entrypoint-initializer.sh \ - docker/entrypoint-first-boot.sh \ docker/entrypoint-uwsgi.sh \ docker/entrypoint-uwsgi-dev.sh \ docker/entrypoint-unit-tests.sh \ diff --git a/docker/entrypoint-initializer.sh b/docker/entrypoint-initializer.sh index ec193ef6f06..4b01b9c7f37 100755 --- a/docker/entrypoint-initializer.sh +++ b/docker/entrypoint-initializer.sh @@ -5,42 +5,6 @@ set -e # needed to handle "exit" correctly . /secret-file-loader.sh . /reach_database.sh -initialize_data() -{ - # Test types shall be initialized every time by the initializer, to make sure test types are complete - # when new parsers have been implemented - echo "Initialization of test_types" - python3 manage.py initialize_test_types - - # Non-standard permissions cannot be created with a database migration, because the content type will only - # be available after the dojo migrations - echo "Creation of non-standard permissions" - python3 manage.py initialize_permissions -} - -create_announcement_banner() -{ -# Load the announcement banner -if [ -z "$DD_CREATE_CLOUD_BANNER" ]; then -echo "Creating Announcement Banner" -cat </dev/null || true) NUM_FILES=$(echo "$FILES" | wc -w) @@ -56,122 +20,7 @@ fi umask 0002 -if [ "${DD_INITIALIZE}" = false ] -then - echo "Echo initialization skipped. Exiting." - exit -fi -echo "Initializing." - wait_for_database_to_be_reachable -echo - -echo "Checking ENABLE_AUDITLOG" -cat < None: + if os.getenv("DD_INITIALIZE") == "false": + self.stdout.write("Initialization skipped (DD_INITIALIZE=false)") + return + + self.stdout.write("Initializing DefectDojo") + + self.check_enable_auditlog_consistency() + self.warn_on_missing_migrations() + + self.stdout.write("Applying migrations") + call_command("migrate", interactive=False) + + self.stdout.write("Configuring pghistory triggers") + configure_pghistory_triggers() + + if self.admin_user_exists(): + self.stdout.write("Admin user already exists; skipping first-boot setup") + self.create_announcement_banner() + self.initialize_data() + return + + self.ensure_admin_secrets() + self.first_boot_setup() + self.create_announcement_banner() + self.initialize_data() + + # ------------------------------------------------------------------ + # Initialization steps + # ------------------------------------------------------------------ + + def initialize_data(self) -> None: + self.stdout.write("Initializing test types") + call_command("initialize_test_types") + + self.stdout.write("Initializing non-standard permissions") + call_command("initialize_permissions") + + def create_announcement_banner(self) -> None: + if os.getenv("DD_CREATE_CLOUD_BANNER"): + return + + self.stdout.write("Creating announcement banner") + + announcement, _ = Announcement.objects.get_or_create(id=1) + announcement.message = ( + '
    ' + "DefectDojo Pro Cloud and On-Premise Subscriptions Now Available! " + "Create an account to try Pro for free!" + "" + ) + announcement.dismissable = True + announcement.save() + + for user in Dojo_User.objects.all(): + UserAnnouncement.objects.get_or_create( + user=user, + announcement=announcement, + ) + + # ------------------------------------------------------------------ + # Auditlog consistency + # ------------------------------------------------------------------ + + def check_enable_auditlog_consistency(self) -> None: + self.stdout.write("Checking ENABLE_AUDITLOG consistency") + + try: + with connections["default"].cursor() as cursor: + try: + cursor.execute("SELECT * FROM dojo_system_settings LIMIT 1") + except ProgrammingError as exc: + msg = str(exc) + if "does not exist" in msg or "doesn't exist" in msg: + self.stdout.write("Database not initialized yet; skipping auditlog check") + return + raise + row = dict(zip([col[0] for col in cursor.description], cursor.fetchone(), strict=False)) + + except Exception as exc: + msg = f"Failed to read system settings from database: {exc}" + raise CommandError(msg) from exc + + if not row.get("enable_auditlog", True) and settings.ENABLE_AUDITLOG: + msg = "Auditlog disabled in DB but ENABLE_AUDITLOG=True. Set DD_ENABLE_AUDITLOG=False for all Django containers." + raise CommandError(msg) + + # ------------------------------------------------------------------ + # Migration checks (warning only) + # ------------------------------------------------------------------ + + def warn_on_missing_migrations(self) -> None: + self.stdout.write("Checking for missing migrations") + + try: + call_command( + "makemigrations", + "--check", + "--dry-run", + verbosity=3, + ) + except SystemExit: + self.stderr.write( + "\n" + "********************************************************************************\n" + "WARNING: Missing Database Migrations Detected\n" + "********************************************************************************\n" + "You made changes to models without creating migrations.\n\n" + "Startup will continue, but you should fix this.\n" + "********************************************************************************\n", + ) + + # ------------------------------------------------------------------ + # Admin / first boot + # ------------------------------------------------------------------ + + def admin_user_exists(self) -> bool: + username = os.getenv("DD_ADMIN_USER") + if not username: + msg = "DD_ADMIN_USER is not set" + raise CommandError(msg) + + User = get_user_model() + return User.objects.filter(username=username).exists() + + def ensure_admin_secrets(self) -> None: + if not os.getenv("DD_ADMIN_PASSWORD"): + password = self.generate_password(22) + os.environ["DD_ADMIN_PASSWORD"] = password + self.stdout.write(f"Admin password: {password}") + + if not os.getenv("DD_JIRA_WEBHOOK_SECRET"): + secret = str(uuid.uuid4()) + os.environ["DD_JIRA_WEBHOOK_SECRET"] = secret + self.stdout.write(f"JIRA Webhook Secret: {secret}") + + def first_boot_setup(self) -> None: + self.stdout.write("Running first boot setup") + + self.create_admin_user() + self.load_initial_fixtures() + self.persist_jira_webhook_secret() + self.load_extra_fixtures() + self.install_watson() + call_command("migrate_textquestions") + + def create_admin_user(self) -> None: + + User = get_user_model() + username = os.getenv("DD_ADMIN_USER") + + if User.objects.filter(username=username).exists(): + self.stdout.write(f"Admin user '{username}' already exists; skipping creation") + return + + User.objects.create_superuser( + username, + os.getenv("DD_ADMIN_MAIL"), + os.getenv("DD_ADMIN_PASSWORD"), + first_name=os.getenv("DD_ADMIN_FIRST_NAME"), + last_name=os.getenv("DD_ADMIN_LAST_NAME"), + ) + + # ------------------------------------------------------------------ + # Fixtures & setup + # ------------------------------------------------------------------ + + def load_initial_fixtures(self) -> None: + self.stdout.write("Loading initial fixtures") + call_command( + "loaddata", + "system_settings", + "initial_banner_conf", + "product_type", + "test_type", + "development_environment", + "benchmark_type", + "benchmark_category", + "benchmark_requirement", + "language_type", + "objects_review", + "regulation", + "initial_surveys", + "role", + "sla_configurations", + ) + + def persist_jira_webhook_secret(self) -> None: + with connection.cursor() as cursor: + cursor.execute( + "UPDATE dojo_system_settings SET jira_webhook_secret = %s", + [os.getenv("DD_JIRA_WEBHOOK_SECRET")], + ) + + def load_extra_fixtures(self) -> None: + for fixture in sorted(Path("dojo/fixtures").glob("extra_*.json")): + self.stdout.write(f"Loading {fixture}") + call_command("loaddata", fixture.stem) + + def install_watson(self) -> None: + self.stdout.write("Installing watson search index") + call_command("installwatson") + + # ------------------------------------------------------------------ + # Utilities + # ------------------------------------------------------------------ + + def generate_password(self, length: int) -> str: + alphabet = string.ascii_letters + string.digits + return "".join(secrets.choice(alphabet) for _ in range(length)) From 1b235b5298d5d7a9275e9775ea9a141d824e4225 Mon Sep 17 00:00:00 2001 From: valentijnscholten Date: Fri, 2 Jan 2026 23:46:43 +0100 Subject: [PATCH 112/126] allow alpine in docker composer dev override (#14001) --- docker-compose.override.dev.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.override.dev.yml b/docker-compose.override.dev.yml index bc31139a352..d3d3a3cd4c1 100644 --- a/docker-compose.override.dev.yml +++ b/docker-compose.override.dev.yml @@ -3,7 +3,7 @@ services: uwsgi: build: context: . - dockerfile: Dockerfile.django-debian + dockerfile: Dockerfile.django-${DEFECT_DOJO_OS:-debian} target: development entrypoint: ['/wait-for-it.sh', '${DD_DATABASE_HOST:-postgres}:${DD_DATABASE_PORT:-5432}', '-t', '30', '--', '/entrypoint-uwsgi-dev.sh'] volumes: From 3627081bc14f595f9c93a2f7fcfa35f994b04c0c Mon Sep 17 00:00:00 2001 From: valentijnscholten Date: Sun, 4 Jan 2026 23:26:17 +0100 Subject: [PATCH 113/126] dedupe reopen: continue to try all match candidates (#14011) * dedupe reopen: add test cases that prove the bug * remove obsolete method * dedupe reopen: proceed with next candidate if candidate is mitigated * rename methods --- dojo/finding/deduplication.py | 64 +++--- unittests/test_deduplication_logic.py | 131 ++++++++++++ unittests/test_import_reimport.py | 283 +++++++++++++++++++++++++- 3 files changed, 439 insertions(+), 39 deletions(-) diff --git a/dojo/finding/deduplication.py b/dojo/finding/deduplication.py index 14e4d33477c..1c804b04067 100644 --- a/dojo/finding/deduplication.py +++ b/dojo/finding/deduplication.py @@ -1,4 +1,5 @@ import logging +from collections.abc import Iterator from operator import attrgetter import hyperlink @@ -127,7 +128,7 @@ def set_duplicate(new_finding, existing_finding): msg = "Can not add duplicate to itself" raise Exception(msg) if is_duplicate_reopen(new_finding, existing_finding): - msg = "Found a regression. Ignore this so that a new duplicate chain can be made" + msg = "Found a regression where a duplicate of a mitigated finding is found. We do not reopen this, but create a new duplicate chain as per @PR 9558: Deduplication: Do not reopen original finding" raise Exception(msg) if new_finding.duplicate and finding_mitigated(existing_finding): msg = "Skip this finding as we do not want to attach a new duplicate to a mitigated finding" @@ -174,17 +175,6 @@ def finding_not_human_set_status(finding: Finding) -> bool: return finding.out_of_scope is False and finding.false_p is False -def set_duplicate_reopen(new_finding, existing_finding): - logger.debug("duplicate reopen existing finding") - existing_finding.mitigated = new_finding.mitigated - existing_finding.is_mitigated = new_finding.is_mitigated - existing_finding.active = new_finding.active - existing_finding.verified = new_finding.verified - existing_finding.notes.create(author=existing_finding.reporter, - entry="This finding has been automatically re-opened as it was found in recent scans.") - existing_finding.save() - - def is_deduplication_on_engagement_mismatch(new_finding, to_duplicate_finding): if new_finding.test.engagement != to_duplicate_finding.test.engagement: deduplication_mismatch = new_finding.test.engagement.deduplication_on_engagement \ @@ -355,9 +345,9 @@ def _is_candidate_older(new_finding, candidate): return is_older -def match_hash_candidate(new_finding, candidates_by_hash): +def get_matches_from_hash_candidates(new_finding, candidates_by_hash) -> Iterator[Finding]: if new_finding.hash_code is None: - return None + return possible_matches = candidates_by_hash.get(new_finding.hash_code, []) deduplicationLogger.debug(f"Finding {new_finding.id}: Found {len(possible_matches)} findings with same hash_code, ids={[(c.id, c.hash_code) for c in possible_matches]}") @@ -368,13 +358,12 @@ def match_hash_candidate(new_finding, candidates_by_hash): deduplicationLogger.debug("deduplication_on_engagement_mismatch, skipping dedupe.") continue if are_endpoints_duplicates(new_finding, candidate): - return candidate - return None + yield candidate -def match_unique_id_candidate(new_finding, candidates_by_uid): +def get_matches_from_unique_id_candidates(new_finding, candidates_by_uid) -> Iterator[Finding]: if new_finding.unique_id_from_tool is None: - return None + return possible_matches = candidates_by_uid.get(new_finding.unique_id_from_tool, []) deduplicationLogger.debug(f"Finding {new_finding.id}: Found {len(possible_matches)} findings with same unique_id_from_tool, ids={[(c.id, c.unique_id_from_tool) for c in possible_matches]}") @@ -385,11 +374,10 @@ def match_unique_id_candidate(new_finding, candidates_by_uid): if is_deduplication_on_engagement_mismatch(new_finding, candidate): deduplicationLogger.debug("deduplication_on_engagement_mismatch, skipping dedupe.") continue - return candidate - return None + yield candidate -def match_uid_or_hash_candidate(new_finding, candidates_by_uid, candidates_by_hash): +def get_matches_from_uid_or_hash_candidates(new_finding, candidates_by_uid, candidates_by_hash) -> Iterator[Finding]: # Combine UID and hash candidates and walk oldest-first uid_list = candidates_by_uid.get(new_finding.unique_id_from_tool, []) if new_finding.unique_id_from_tool is not None else [] hash_list = candidates_by_hash.get(new_finding.hash_code, []) if new_finding.hash_code is not None else [] @@ -404,15 +392,15 @@ def match_uid_or_hash_candidate(new_finding, candidates_by_uid, candidates_by_ha continue if is_deduplication_on_engagement_mismatch(new_finding, candidate): deduplicationLogger.debug("deduplication_on_engagement_mismatch, skipping dedupe.") - return None + continue if are_endpoints_duplicates(new_finding, candidate): deduplicationLogger.debug("UID_OR_HASH: endpoints match, returning candidate %s with test_type %s unique_id_from_tool %s hash_code %s", candidate.id, candidate.test.test_type, candidate.unique_id_from_tool, candidate.hash_code) - return candidate - deduplicationLogger.debug("UID_OR_HASH: endpoints mismatch, skipping candidate %s", candidate.id) - return None + yield candidate + else: + deduplicationLogger.debug("UID_OR_HASH: endpoints mismatch, skipping candidate %s", candidate.id) -def match_legacy_candidate(new_finding, candidates_by_title, candidates_by_cwe): +def get_matches_from_legacy_candidates(new_finding, candidates_by_title, candidates_by_cwe) -> Iterator[Finding]: # --------------------------------------------------------- # 1) Collects all the findings that have the same: # (title and static_finding and dynamic_finding) @@ -469,8 +457,7 @@ def match_legacy_candidate(new_finding, candidates_by_title, candidates_by_cwe): + " flag_endpoints: " + str(flag_endpoints) + " flag_line_path:" + str(flag_line_path) + " flag_hash:" + str(flag_hash)) if (flag_endpoints or flag_line_path) and flag_hash: - return candidate - return None + yield candidate def _dedupe_batch_hash_code(findings): @@ -482,10 +469,10 @@ def _dedupe_batch_hash_code(findings): return for new_finding in findings: deduplicationLogger.debug(f"deduplication start for finding {new_finding.id} with DEDUPE_ALGO_HASH_CODE") - match = match_hash_candidate(new_finding, candidates_by_hash) - if match: + for match in get_matches_from_hash_candidates(new_finding, candidates_by_hash): try: set_duplicate(new_finding, match) + break except Exception as e: deduplicationLogger.debug(str(e)) @@ -499,12 +486,14 @@ def _dedupe_batch_unique_id(findings): return for new_finding in findings: deduplicationLogger.debug(f"deduplication start for finding {new_finding.id} with DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL") - match = match_unique_id_candidate(new_finding, candidates_by_uid) - if match: + for match in get_matches_from_unique_id_candidates(new_finding, candidates_by_uid): + deduplicationLogger.debug(f"Trying to deduplicate finding {new_finding.id} against candidate {match.id}") try: set_duplicate(new_finding, match) + deduplicationLogger.debug(f"Successfully deduplicated finding {new_finding.id} against candidate {match.id}") + break except Exception as e: - deduplicationLogger.debug(str(e)) + deduplicationLogger.debug(f"Exception when deduplicating finding {new_finding.id} against candidate {match.id}: {e!s}") def _dedupe_batch_uid_or_hash(findings): @@ -520,13 +509,12 @@ def _dedupe_batch_uid_or_hash(findings): if new_finding.duplicate: continue - match = match_uid_or_hash_candidate(new_finding, candidates_by_uid, existing_by_hash) - if match: + for match in get_matches_from_uid_or_hash_candidates(new_finding, candidates_by_uid, existing_by_hash): try: set_duplicate(new_finding, match) + break except Exception as e: deduplicationLogger.debug(str(e)) - continue def _dedupe_batch_legacy(findings): @@ -538,10 +526,10 @@ def _dedupe_batch_legacy(findings): return for new_finding in findings: deduplicationLogger.debug(f"deduplication start for finding {new_finding.id} with DEDUPE_ALGO_LEGACY") - match = match_legacy_candidate(new_finding, candidates_by_title, candidates_by_cwe) - if match: + for match in get_matches_from_legacy_candidates(new_finding, candidates_by_title, candidates_by_cwe): try: set_duplicate(new_finding, match) + break except Exception as e: deduplicationLogger.debug(str(e)) diff --git a/unittests/test_deduplication_logic.py b/unittests/test_deduplication_logic.py index f5390eaef60..118ca267f91 100644 --- a/unittests/test_deduplication_logic.py +++ b/unittests/test_deduplication_logic.py @@ -9,6 +9,7 @@ from django.core import serializers from django.utils import timezone +from dojo.finding.deduplication import set_duplicate from dojo.importers.default_importer import DefaultImporter from dojo.models import ( Development_Environment, @@ -879,6 +880,136 @@ def test_extra_endpoints_unique_id(self): # expect duplicate: unique_id match regardless of extra endpoints self.assert_finding(finding_new, not_pk=124, duplicate=True, duplicate_finding_id=124, hash_code=finding_124.hash_code) + def test_regression_duplicate_reopen_unique_id(self): + """ + Test the is_duplicate_reopen exception in set_duplicate. + When trying to attach a new active finding to a mitigated finding, + an exception should be raised. + + Related to: https://github.com/DefectDojo/django-DefectDojo/issues/14010 + """ + # Get an existing finding with unique_id (finding 124) + finding_124 = Finding.objects.get(id=124) + original_unique_id = finding_124.unique_id_from_tool + + # Mitigate the existing finding + finding_124.active = False + finding_124.is_mitigated = True + finding_124.mitigated = timezone.now() + finding_124.out_of_scope = False + finding_124.false_p = False + finding_124.save(dedupe_option=False) + + # Create a new finding with the same unique_id that is active (not mitigated) + finding_new, _ = self.copy_and_reset_finding(find_id=124) + finding_new.active = True + finding_new.is_mitigated = False + finding_new.mitigated = None + finding_new.unique_id_from_tool = original_unique_id + finding_new.save(dedupe_option=False) + + # Try to deduplicate - this should raise an exception + with self.assertRaises(Exception) as context: + set_duplicate(finding_new, finding_124) + + self.assertIn("Found a regression", str(context.exception)) + + def test_duplicate_attached_to_mitigated_finding_unique_id(self): + """ + Test the exception when attaching a finding that is already marked as duplicate + to a mitigated finding. + + Related to: https://github.com/DefectDojo/django-DefectDojo/issues/14010 + """ + # Get an existing finding with unique_id (finding 124) + finding_124 = Finding.objects.get(id=124) + original_unique_id = finding_124.unique_id_from_tool + + # Mitigate the existing finding + finding_124.active = False + finding_124.is_mitigated = True + finding_124.mitigated = timezone.now() + finding_124.save(dedupe_option=False) + + # Create a new finding that is already marked as duplicate AND also mitigated + # This ensures that is_duplicate_reopen doesn't trigger first + finding_new, _ = self.copy_and_reset_finding(find_id=124) + finding_new.duplicate = True + finding_new.active = False + finding_new.is_mitigated = True + finding_new.mitigated = timezone.now() + finding_new.unique_id_from_tool = original_unique_id + finding_new.save(dedupe_option=False) + + # Try to deduplicate - this should raise an exception + with self.assertRaises(Exception) as context: + set_duplicate(finding_new, finding_124) + + self.assertIn("Skip this finding as we do not want to attach a new duplicate to a mitigated finding", str(context.exception)) + + def test_multiple_findings_same_unique_id_mixed_states_unique_id(self): + """ + Test deduplication with multiple findings having the same unique_id, + where some are mitigated and some are active. The deduplication should + skip mitigated ones and use the first active one. + + Related to: https://github.com/DefectDojo/django-DefectDojo/issues/14010 + """ + # Get an existing finding with unique_id (finding 124) + finding_124 = Finding.objects.get(id=124) + original_unique_id = finding_124.unique_id_from_tool + + # Mitigate the original finding so it's not a candidate + finding_124.active = False + finding_124.is_mitigated = True + finding_124.mitigated = timezone.now() + finding_124.save() + + # Create multiple findings with the same unique_id + # First: active finding (this should become the original) + finding_active, _ = self.copy_and_reset_finding(find_id=124) + finding_active.active = True + finding_active.is_mitigated = False + finding_active.mitigated = None + finding_active.unique_id_from_tool = original_unique_id + finding_active.save(dedupe_option=False) # Don't deduplicate so it remains active + + # Second: mitigated finding + finding_mitigated, _ = self.copy_and_reset_finding(find_id=124) + finding_mitigated.active = False + finding_mitigated.is_mitigated = True + finding_mitigated.mitigated = timezone.now() + finding_mitigated.out_of_scope = False + finding_mitigated.false_p = False + finding_mitigated.unique_id_from_tool = original_unique_id + finding_mitigated.save(dedupe_option=False) + + # Ensure mitigated finding has lower ID (will be checked first) + if finding_mitigated.id > finding_active.id: + # Swap by deleting and recreating in correct order + finding_mitigated.delete() + finding_mitigated, _ = self.copy_and_reset_finding(find_id=124) + finding_mitigated.active = False + finding_mitigated.is_mitigated = True + finding_mitigated.mitigated = timezone.now() + finding_mitigated.out_of_scope = False + finding_mitigated.false_p = False + finding_mitigated.unique_id_from_tool = original_unique_id + finding_mitigated.save(dedupe_option=False) + + # Create a new finding with the same unique_id + # It should skip the mitigated finding and deduplicate against the active one + finding_new, _ = self.copy_and_reset_finding(find_id=124) + finding_new.active = True + finding_new.is_mitigated = False + finding_new.unique_id_from_tool = original_unique_id + finding_new.save() + + # The new finding should be marked as duplicate of the active finding, + # not the mitigated one (even though mitigated has lower ID) + self.assert_finding(finding_new, duplicate=True, duplicate_finding_id=finding_active.id) + self.assertNotEqual(finding_new.duplicate_finding.id, finding_mitigated.id) + # algo unique_id_or_hash_code Veracode scan def test_identical_unique_id_or_hash_code(self): diff --git a/unittests/test_import_reimport.py b/unittests/test_import_reimport.py index f51a462045a..08eb0709677 100644 --- a/unittests/test_import_reimport.py +++ b/unittests/test_import_reimport.py @@ -1,7 +1,7 @@ # from unittest import skip import logging import zoneinfo -from datetime import datetime +from datetime import date, datetime from pathlib import Path from unittest.mock import patch @@ -106,6 +106,9 @@ def __init__(self, *args, **kwargs): self.checkmarx_one_open_and_false_positive = get_unit_tests_scans_path("checkmarx_one") / "one-open-one-false-positive.json" self.checkmarx_one_two_false_positive = get_unit_tests_scans_path("checkmarx_one") / "two-false-positive.json" + self.checkmarx_one_same_unique_id_3 = get_unit_tests_scans_path("checkmarx_one") / "many_findings_same_unique_id_3.json" + self.checkmarx_one_same_unique_id_4 = get_unit_tests_scans_path("checkmarx_one") / "many_findings_same_unique_id_4.json" + self.checkmarx_one_format_two = get_unit_tests_scans_path("checkmarx_one") / "checkmarx_one_format_two.json" self.scan_type_checkmarx_one = "Checkmarx One Scan" # import zap scan, testing: @@ -721,6 +724,284 @@ def test_import_veracode_reimport_veracode_different_hash_code_different_unique_ # 1 added note for the migitated finding self.assertEqual(notes_count_before + 1, self.db_notes_count()) + def test_deduplication_continues_to_next_candidate_when_regression_detected_unique_id(self): + """ + Test that deduplication continues to try the next candidate match when + the first candidate triggers a regression exception (mitigated finding). + + Real-world scenario: + 1. Import scan → finding A (active) + 2. Mitigate finding A + 3. Reimport/import again → tries to deduplicate against A (mitigated), + exception is raised, finding B is created as new active (not duplicate) + 4. Import again → should find both A (mitigated) and B (active) as candidates, + and deduplicate against B (active), not A (mitigated) + + Related to: https://github.com/DefectDojo/django-DefectDojo/issues/14010 + """ + logger.debug("testing deduplication with mitigated finding - should continue to next candidate") + + product = Product.objects.get(id=1) + + # Step 1: Import scan into engagement 1 → finding A (active) + eng1 = Engagement.objects.create( + name="Engagement 1", + product=product, + target_start=date.today(), + target_end=date.today(), + deduplication_on_engagement=False, # Product-level deduplication + ) + + with assertTestImportModelsCreated(self, imports=1, affected_findings=1, created=1): + import1 = self.import_scan_with_params( + self.checkmarx_one_format_two, + scan_type=self.scan_type_checkmarx_one, + engagement=eng1.id, + verified=True, + force_active=True, + force_verified=True, + ) + + # Get finding A + test1_id = import1["test"] + findings1 = self.get_test_findings_api(test1_id) + self.assert_finding_count_json(1, findings1) + finding1_id = findings1["results"][0]["id"] + finding1 = Finding.objects.get(id=finding1_id) + self.assertTrue(finding1.active, "Finding 1 should be active") + + # Step 2: Mitigate finding A + finding1.active = False + finding1.is_mitigated = True + finding1.mitigated = timezone.now() + finding1.out_of_scope = False + finding1.false_p = False + finding1.save(dedupe_option=False) + + # Step 3: Import again into engagement 2 → tries to deduplicate against A (mitigated), + # exception is raised, finding B is created as new active (not duplicate) + eng2 = Engagement.objects.create( + name="Engagement 2", + product=product, + target_start=date.today(), + target_end=date.today(), + deduplication_on_engagement=False, # Product-level deduplication + ) + + # The exception will be caught and logged, but finding B will be created as new + with assertTestImportModelsCreated(self, imports=1, affected_findings=1, created=1): + import2 = self.import_scan_with_params( + self.checkmarx_one_format_two, + scan_type=self.scan_type_checkmarx_one, + engagement=eng2.id, + verified=True, + force_active=True, + force_verified=True, + ) + + # Get finding B (should be active, not a duplicate because exception was raised) + test2_id = import2["test"] + findings2 = self.get_test_findings_api(test2_id) + self.assert_finding_count_json(1, findings2) + finding2_id = findings2["results"][0]["id"] + finding2 = Finding.objects.get(id=finding2_id) + self.assertTrue(finding2.active, "Finding 2 should be active") + self.assertFalse(finding2.duplicate, "Finding 2 should NOT be a duplicate (exception was raised)") + + # Step 4: Import again into engagement 3 → should find both A (mitigated) and B (active) + # as candidates, and deduplicate against B (active), not A (mitigated) + eng3 = Engagement.objects.create( + name="Engagement 3", + product=product, + target_start=date.today(), + target_end=date.today(), + deduplication_on_engagement=False, # Product-level deduplication + ) + + # The finding is created first, then deduplicated, so created=1 is expected + with assertTestImportModelsCreated(self, imports=1, affected_findings=1, created=1): + import3 = self.import_scan_with_params( + self.checkmarx_one_format_two, + scan_type=self.scan_type_checkmarx_one, + engagement=eng3.id, + verified=True, + force_active=True, + force_verified=True, + ) + + # Get finding from engagement 3 + test3_id = import3["test"] + findings3 = self.get_test_findings_api(test3_id) + self.assert_finding_count_json(1, findings3) + finding3_id = findings3["results"][0]["id"] + finding3 = Finding.objects.get(id=finding3_id) + + # Verify that finding3 is a duplicate of finding2 (active), not finding1 (mitigated) + self.assertTrue(finding3.duplicate, "Finding 3 should be a duplicate") + self.assertIsNotNone(finding3.duplicate_finding, "Finding 3 should have a duplicate_finding") + self.assertEqual( + finding3.duplicate_finding.id, + finding2.id, + "Finding 3 should be duplicate of finding 2 (active), not finding 1 (mitigated)", + ) + self.assertNotEqual( + finding3.duplicate_finding.id, + finding1.id, + "Finding 3 should NOT be duplicate of finding 1 (mitigated)", + ) + + def test_deduplication_multiple_candidates_mixed_states_unique_id(self): + """ + Test deduplication with multiple findings having the same unique_id, + where some are mitigated and some are active. The deduplication should + skip mitigated ones and use the first active one. + + Real-world scenario: + 1. Import scan → finding A (active) + 2. Mitigate finding A + 3. Import again → exception, finding B created as new active + 4. Import again → exception, finding C created as new active + 5. Mitigate finding B + 6. Import again → should find A (mitigated), B (mitigated), C (active) as candidates, + and deduplicate against C (active), not A or B (mitigated) + + Related to: https://github.com/DefectDojo/django-DefectDojo/issues/14010 + """ + logger.debug("testing deduplication with multiple candidates in mixed states") + + product = Product.objects.get(id=1) + + # Step 1: Import scan into engagement 1 → finding A (active) + eng1 = Engagement.objects.create( + name="Engagement 1", + product=product, + target_start=date.today(), + target_end=date.today(), + deduplication_on_engagement=False, + ) + + with assertTestImportModelsCreated(self, imports=1, affected_findings=1, created=1): + import1 = self.import_scan_with_params( + self.checkmarx_one_format_two, + scan_type=self.scan_type_checkmarx_one, + engagement=eng1.id, + verified=True, + force_active=True, + force_verified=True, + ) + + test1_id = import1["test"] + findings1 = self.get_test_findings_api(test1_id) + finding1_id = findings1["results"][0]["id"] + finding1 = Finding.objects.get(id=finding1_id) + + # Step 2: Mitigate finding A + finding1.active = False + finding1.is_mitigated = True + finding1.mitigated = timezone.now() + finding1.out_of_scope = False + finding1.false_p = False + finding1.save(dedupe_option=False) + + # Step 3: Import again into engagement 2 → exception, finding B created as new active + eng2 = Engagement.objects.create( + name="Engagement 2", + product=product, + target_start=date.today(), + target_end=date.today(), + deduplication_on_engagement=False, + ) + + with assertTestImportModelsCreated(self, imports=1, affected_findings=1, created=1): + import2 = self.import_scan_with_params( + self.checkmarx_one_format_two, + scan_type=self.scan_type_checkmarx_one, + engagement=eng2.id, + verified=True, + force_active=True, + force_verified=True, + ) + + test2_id = import2["test"] + findings2 = self.get_test_findings_api(test2_id) + finding2_id = findings2["results"][0]["id"] + finding2 = Finding.objects.get(id=finding2_id) + self.assertTrue(finding2.active, "Finding 2 should be active") + self.assertFalse(finding2.duplicate, "Finding 2 should NOT be a duplicate") + + # Step 4: Import again into engagement 3 → should skip finding1 (mitigated, exception), + # then deduplicate against finding2 (active), so finding C becomes a duplicate + eng3 = Engagement.objects.create( + name="Engagement 3", + product=product, + target_start=date.today(), + target_end=date.today(), + deduplication_on_engagement=False, + ) + + # The finding is created first, then deduplicated, so created=1 is expected + with assertTestImportModelsCreated(self, imports=1, affected_findings=1, created=1): + import3 = self.import_scan_with_params( + self.checkmarx_one_format_two, + scan_type=self.scan_type_checkmarx_one, + engagement=eng3.id, + verified=True, + force_active=True, + force_verified=True, + ) + + test3_id = import3["test"] + findings3 = self.get_test_findings_api(test3_id) + finding3_id = findings3["results"][0]["id"] + finding3 = Finding.objects.get(id=finding3_id) + # Finding 3 should be a duplicate of finding 2 (active), not finding 1 (mitigated) + self.assertTrue(finding3.duplicate, "Finding 3 should be a duplicate") + self.assertEqual(finding3.duplicate_finding.id, finding2.id, "Finding 3 should be duplicate of finding 2 (active)") + + # Step 5: Mitigate finding B + finding2.active = False + finding2.is_mitigated = True + finding2.mitigated = timezone.now() + finding2.out_of_scope = False + finding2.false_p = False + finding2.save(dedupe_option=False) + + # Step 6: Import again into engagement 4 → should find A (mitigated, exception), B (mitigated, exception), + # C (duplicate, skipped), so finding D is created as new active + eng4 = Engagement.objects.create( + name="Engagement 4", + product=product, + target_start=date.today(), + target_end=date.today(), + deduplication_on_engagement=False, + ) + + # The finding is created first, then deduplicated, so created=1 is expected + with assertTestImportModelsCreated(self, imports=1, affected_findings=1, created=1): + import4 = self.import_scan_with_params( + self.checkmarx_one_format_two, + scan_type=self.scan_type_checkmarx_one, + engagement=eng4.id, + verified=True, + force_active=True, + force_verified=True, + ) + + test4_id = import4["test"] + findings4 = self.get_test_findings_api(test4_id) + finding4_id = findings4["results"][0]["id"] + finding4 = Finding.objects.get(id=finding4_id) + + # Since finding3 is a duplicate (excluded from candidates), and finding1 and finding2 are mitigated (exceptions), + # finding4 should be created as new active (not a duplicate) + self.assertTrue(finding4.active, "Finding 4 should be active") + self.assertFalse(finding4.duplicate, "Finding 4 should NOT be a duplicate (all candidates raised exceptions or are duplicates)") + + # Finding4 is not a duplicate because: + # - finding1 and finding2 are mitigated (exceptions raised) + # - finding3 is a duplicate (excluded from candidates) + # So finding4 is created as new active finding + # import 0 and then reimport 1 with zap4 as extra finding, zap1 closed. # - active findings count should be 4 # - total findings count should be 5 From 9a3167ada0dc526c3f2ba1d9be48da67d546b42a Mon Sep 17 00:00:00 2001 From: Jino Tesauro <53376807+Jino-T@users.noreply.github.com> Date: Sun, 4 Jan 2026 16:26:32 -0600 Subject: [PATCH 114/126] Vulnerability IDs: Do not allow users to import empty strings (#14017) * added code to remove unwanted vulnerability ids * Update dojo/finding/helper.py --------- Co-authored-by: Cody Maffucci <46459665+Maffooch@users.noreply.github.com> --- dojo/finding/helper.py | 7 +++++++ dojo/importers/base_importer.py | 9 +++++++++ 2 files changed, 16 insertions(+) diff --git a/dojo/finding/helper.py b/dojo/finding/helper.py index a0cf29f2120..b628bf95b9e 100644 --- a/dojo/finding/helper.py +++ b/dojo/finding/helper.py @@ -775,6 +775,11 @@ def add_endpoints(new_finding, form): endpoint=endpoint, defaults={"date": form.cleaned_data["date"] or timezone.now()}) +def sanitize_vulnerability_ids(vulnerability_ids) -> None: + """Remove undisired vulnerability id values""" + vulnerability_ids = [x for x in vulnerability_ids if x.strip()] + + def save_vulnerability_ids(finding, vulnerability_ids): # Remove duplicates vulnerability_ids = list(dict.fromkeys(vulnerability_ids)) @@ -782,6 +787,8 @@ def save_vulnerability_ids(finding, vulnerability_ids): # Remove old vulnerability ids Vulnerability_Id.objects.filter(finding=finding).delete() + # Remove undisired vulnerability ids + sanitize_vulnerability_ids(vulnerability_ids) # Save new vulnerability ids # Using bulk create throws Django 50 warnings about unsaved models... for vulnerability_id in vulnerability_ids: diff --git a/dojo/importers/base_importer.py b/dojo/importers/base_importer.py index 380fa24e4e0..4bdefbf0077 100644 --- a/dojo/importers/base_importer.py +++ b/dojo/importers/base_importer.py @@ -797,6 +797,11 @@ def process_endpoints( logger.debug("endpoints_to_add: %s", endpoints_to_add) self.endpoint_manager.chunk_endpoints_and_disperse(finding, endpoints_to_add) + def sanitize_vulnerability_ids(self, finding) -> None: + """Remove undisired vulnerability id values""" + if finding.unsaved_vulnerability_ids: + finding.unsaved_vulnerability_ids = [x for x in finding.unsaved_vulnerability_ids if x.strip()] + def process_cve( self, finding: Finding, @@ -805,6 +810,8 @@ def process_cve( # Synchronize the cve field with the unsaved_vulnerability_ids # We do this to be as flexible as possible to handle the fields until # the cve field is not needed anymore and can be removed. + # Remove undisired vulnerability ids + self.sanitize_vulnerability_ids(finding) if finding.unsaved_vulnerability_ids and finding.cve: # Make sure the first entry of the list is the value of the cve field finding.unsaved_vulnerability_ids.insert(0, finding.cve) @@ -825,6 +832,8 @@ def process_vulnerability_ids( Parse the `unsaved_vulnerability_ids` field from findings after they are parsed to create `Vulnerability_Id` objects with the finding associated correctly """ + # Remove undisired vulnerability ids + self.sanitize_vulnerability_ids(finding) if finding.unsaved_vulnerability_ids: # Remove old vulnerability ids - keeping this call only because of flake8 Vulnerability_Id.objects.filter(finding=finding).delete() From 82421194072370a0b6907ec97b97e8190939c7df Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 4 Jan 2026 21:16:46 -0600 Subject: [PATCH 115/126] chore(deps): bump drf-spectacular-sidecar from 2025.12.1 to 2026.1.1 (#14018) Bumps [drf-spectacular-sidecar](https://github.com/tfranzel/drf-spectacular-sidecar) from 2025.12.1 to 2026.1.1. - [Commits](https://github.com/tfranzel/drf-spectacular-sidecar/compare/2025.12.1...2026.1.1) --- updated-dependencies: - dependency-name: drf-spectacular-sidecar dependency-version: 2026.1.1 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 348936c7761..e6e2458b5f0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -58,7 +58,7 @@ django-fieldsignals==0.8.0 hyperlink==21.0.0 djangosaml2==1.11.1 drf-spectacular==0.29.0 -drf-spectacular-sidecar==2025.12.1 +drf-spectacular-sidecar==2026.1.1 django-ratelimit==4.1.0 argon2-cffi==25.1.0 blackduck==1.1.3 From 1a0eaa050452f3efae845f5bf614056b3d78ab7b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 4 Jan 2026 21:17:55 -0600 Subject: [PATCH 116/126] chore(deps): bump django-polymorphic from 4.5.1 to 4.5.2 (#14019) Bumps [django-polymorphic](https://github.com/jazzband/django-polymorphic) from 4.5.1 to 4.5.2. - [Release notes](https://github.com/jazzband/django-polymorphic/releases) - [Changelog](https://github.com/jazzband/django-polymorphic/blob/master/docs/changelog.rst) - [Commits](https://github.com/jazzband/django-polymorphic/compare/v4.5.1...v4.5.2) --- updated-dependencies: - dependency-name: django-polymorphic dependency-version: 4.5.2 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 e6e2458b5f0..5f9663933a2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ django-environ==0.12.0 django-filter==25.2 django-imagekit==6.0.0 django-multiselectfield==1.0.1 -django-polymorphic==4.5.1 +django-polymorphic==4.5.2 django-crispy-forms==2.5 django_extensions==4.1 django-slack==5.19.0 From bf304ac66d7c55628e4ae56a7c7be91469e3862e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 4 Jan 2026 21:19:51 -0600 Subject: [PATCH 117/126] chore(deps): bump pillow from 12.0.0 to 12.1.0 (#14020) Bumps [pillow](https://github.com/python-pillow/Pillow) from 12.0.0 to 12.1.0. - [Release notes](https://github.com/python-pillow/Pillow/releases) - [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst) - [Commits](https://github.com/python-pillow/Pillow/compare/12.0.0...12.1.0) --- updated-dependencies: - dependency-name: pillow dependency-version: 12.1.0 dependency-type: direct:production update-type: version-update:semver-minor ... 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 5f9663933a2..f74709c47f6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,7 +28,7 @@ PyGithub==2.8.1 lxml==6.0.2 Markdown==3.10 openpyxl==3.1.5 -Pillow==12.0.0 # required by django-imagekit +Pillow==12.1.0 # required by django-imagekit psycopg[c]==3.3.2 cryptography==46.0.3 python-dateutil==2.9.0.post0 From b58a19579a1f2e1f33c1b1568b43a5e472532762 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 4 Jan 2026 21:21:06 -0600 Subject: [PATCH 118/126] Update dependency renovatebot/renovate from 42.66.11 to v42.71.0 (.github/workflows/renovate.yaml) (#14025) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/renovate.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/renovate.yaml b/.github/workflows/renovate.yaml index ac275d65550..08206d44b17 100644 --- a/.github/workflows/renovate.yaml +++ b/.github/workflows/renovate.yaml @@ -21,4 +21,4 @@ jobs: uses: suzuki-shunsuke/github-action-renovate-config-validator@c22827f47f4f4a5364bdba19e1fe36907ef1318e # v1.1.1 with: strict: "true" - validator_version: 42.66.11 # renovate: datasource=github-releases depName=renovatebot/renovate + validator_version: 42.71.0 # renovate: datasource=github-releases depName=renovatebot/renovate From 72f5981b7937644e830f014d947fac79545a8640 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 4 Jan 2026 21:22:12 -0600 Subject: [PATCH 119/126] Update dependency vcrpy from 8.1.0 to v8.1.1 (requirements-dev.txt) (#14026) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 7e22e1ed1af..d2a367fca58 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -6,7 +6,7 @@ django-debug-toolbar==6.1.0 django-debug-toolbar-request-history==0.1.4 # Testing dependencies -vcrpy==8.1.0 +vcrpy==8.1.1 vcrpy-unittest==0.1.7 django-test-migrations==1.5.0 parameterized==0.9.0 From 8dee6bf830c786cf48b4f15654a4520333873a5a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 4 Jan 2026 21:23:25 -0600 Subject: [PATCH 120/126] chore(deps): bump pdfmake from 0.2.21 to 0.3.0 in /components (#14021) Bumps [pdfmake](https://github.com/bpampuch/pdfmake) from 0.2.21 to 0.3.0. - [Release notes](https://github.com/bpampuch/pdfmake/releases) - [Changelog](https://github.com/bpampuch/pdfmake/blob/master/CHANGELOG.md) - [Commits](https://github.com/bpampuch/pdfmake/compare/0.2.21...0.3.0) --- updated-dependencies: - dependency-name: pdfmake dependency-version: 0.3.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- components/package.json | 2 +- components/yarn.lock | 360 ++++++++-------------------------------- 2 files changed, 71 insertions(+), 291 deletions(-) diff --git a/components/package.json b/components/package.json index 6ccd842eb86..4b9b4c85cda 100644 --- a/components/package.json +++ b/components/package.json @@ -33,7 +33,7 @@ "metismenu": "~3.0.7", "moment": "^2.30.1", "morris.js": "morrisjs/morris.js", - "pdfmake": "^0.2.21", + "pdfmake": "^0.3.0", "startbootstrap-sb-admin-2": "1.0.7" }, "engines": { diff --git a/components/yarn.lock b/components/yarn.lock index 67eca1c957e..8f2f00bbfa1 100644 --- a/components/yarn.lock +++ b/components/yarn.lock @@ -2,43 +2,12 @@ # yarn lockfile v1 -"@foliojs-fork/fontkit@^1.9.2": - version "1.9.2" - resolved "https://registry.yarnpkg.com/@foliojs-fork/fontkit/-/fontkit-1.9.2.tgz#94241c195bc6204157bc84c33f34bdc967eca9c3" - integrity sha512-IfB5EiIb+GZk+77TRB86AHroVaqfq8JRFlUbz0WEwsInyCG0epX2tCPOy+UfaWPju30DeVoUAXfzWXmhn753KA== - dependencies: - "@foliojs-fork/restructure" "^2.0.2" - brotli "^1.2.0" - clone "^1.0.4" - deep-equal "^1.0.0" - dfa "^1.2.0" - tiny-inflate "^1.0.2" - unicode-properties "^1.2.2" - unicode-trie "^2.0.0" - -"@foliojs-fork/linebreak@^1.1.1", "@foliojs-fork/linebreak@^1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@foliojs-fork/linebreak/-/linebreak-1.1.2.tgz#32fee03d5431fa73284373439e172e451ae1e2da" - integrity sha512-ZPohpxxbuKNE0l/5iBJnOAfUaMACwvUIKCvqtWGKIMv1lPYoNjYXRfhi9FeeV9McBkBLxsMFWTVVhHJA8cyzvg== +"@swc/helpers@^0.5.12": + version "0.5.18" + resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.18.tgz#feeeabea0d10106ee25aaf900165df911ab6d3b1" + integrity sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ== dependencies: - base64-js "1.3.1" - unicode-trie "^2.0.0" - -"@foliojs-fork/pdfkit@^0.15.3": - version "0.15.3" - resolved "https://registry.yarnpkg.com/@foliojs-fork/pdfkit/-/pdfkit-0.15.3.tgz#590b31e770a98e2af62ce44f268a0d06b41ff32f" - integrity sha512-Obc0Wmy3bm7BINFVvPhcl2rnSSK61DQrlHU8aXnAqDk9LCjWdUOPwhgD8Ywz5VtuFjRxmVOM/kQ/XLIBjDvltw== - dependencies: - "@foliojs-fork/fontkit" "^1.9.2" - "@foliojs-fork/linebreak" "^1.1.1" - crypto-js "^4.2.0" - jpeg-exif "^1.1.4" - png-js "^1.0.0" - -"@foliojs-fork/restructure@^2.0.2": - version "2.0.2" - resolved "https://registry.yarnpkg.com/@foliojs-fork/restructure/-/restructure-2.0.2.tgz#73759aba2aff1da87b7c4554e6839c70d43c92b4" - integrity sha512-59SgoZ3EXbkfSX7b63tsou/SDGzwUEK6MuB5sKqgVK1/XE0fxmpsOb9DQI8LXW3KfGnAjImCGhhEb7uPPAUVNA== + tslib "^2.8.0" "@types/codemirror@^5.60.10": version "5.60.16" @@ -68,10 +37,10 @@ JUMFlot@jumjum123/JUMFlot#*: version "0.0.0" resolved "https://codeload.github.com/jumjum123/JUMFlot/tar.gz/203147fa2ace27db89e2defcde0800654015ae23" -base64-js@1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1" - integrity sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g== +base64-js@0.0.8: + version "0.0.8" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-0.0.8.tgz#1101e9544f4a76b1bc3b26d452ca96d7a35e7978" + integrity sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw== base64-js@^1.1.2, base64-js@^1.3.0: version "1.5.1" @@ -101,39 +70,13 @@ bootstrap@^3.4.1, bootstrap@~3: resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-3.4.1.tgz#c3a347d419e289ad11f4033e3c4132b87c081d72" integrity sha512-yN5oZVmRCwe5aKwzRj6736nSmKDX7pLYwsXiCj/EYmo16hODaBiT4En5btW/jhBF/seV+XMx3aYwukYC3A49DA== -brotli@^1.2.0: +brotli@^1.3.2: version "1.3.3" resolved "https://registry.yarnpkg.com/brotli/-/brotli-1.3.3.tgz#7365d8cc00f12cf765d2b2c898716bcf4b604d48" integrity sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg== dependencies: base64-js "^1.1.2" -call-bind-apply-helpers@^1.0.0, call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" - integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== - dependencies: - es-errors "^1.3.0" - function-bind "^1.1.2" - -call-bind@^1.0.7, call-bind@^1.0.8: - version "1.0.8" - resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.8.tgz#0736a9660f537e3388826f440d5ec45f744eaa4c" - integrity sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww== - dependencies: - call-bind-apply-helpers "^1.0.0" - es-define-property "^1.0.0" - get-intrinsic "^1.2.4" - set-function-length "^1.2.2" - -call-bound@^1.0.2: - version "1.0.4" - resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" - integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== - dependencies: - call-bind-apply-helpers "^1.0.2" - get-intrinsic "^1.3.0" - "chosen-bootstrap@https://github.com/dbtek/chosen-bootstrap": version "0.0.0" resolved "https://github.com/dbtek/chosen-bootstrap#12dcd363d1482c54c740ed9fe0e92549d81e9176" @@ -152,10 +95,10 @@ clipboard@^2.0.11: select "^1.1.2" tiny-emitter "^2.0.0" -clone@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" - integrity sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg== +clone@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f" + integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w== codemirror-spell-checker@1.1.2: version "1.1.2" @@ -226,36 +169,6 @@ datatables.net@^2, datatables.net@^2.3.6: dependencies: jquery ">=1.7" -deep-equal@^1.0.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.1.2.tgz#78a561b7830eef3134c7f6f3a3d6af272a678761" - integrity sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg== - dependencies: - is-arguments "^1.1.1" - is-date-object "^1.0.5" - is-regex "^1.1.4" - object-is "^1.1.5" - object-keys "^1.1.1" - regexp.prototype.flags "^1.5.1" - -define-data-property@^1.0.1, define-data-property@^1.1.4: - version "1.1.4" - resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" - integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== - dependencies: - es-define-property "^1.0.0" - es-errors "^1.3.0" - gopd "^1.0.1" - -define-properties@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.2.1.tgz#10781cc616eb951a80a034bafcaa7377f6af2b6c" - integrity sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg== - dependencies: - define-data-property "^1.0.1" - has-property-descriptors "^1.0.0" - object-keys "^1.1.1" - delegate@^3.1.2: version "3.2.0" resolved "https://registry.yarnpkg.com/delegate/-/delegate-3.2.0.tgz#b66b71c3158522e8ab5744f720d8ca0c2af59166" @@ -280,15 +193,6 @@ drmonty-datatables-responsive@^1.0.0: dependencies: jquery ">=1.7.0" -dunder-proto@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" - integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== - dependencies: - call-bind-apply-helpers "^1.0.1" - es-errors "^1.3.0" - gopd "^1.2.0" - easymde@^2.20.0: version "2.20.0" resolved "https://registry.yarnpkg.com/easymde/-/easymde-2.20.0.tgz#88b3161feab6e1900afa9c4dab3f1da352b0a26e" @@ -300,28 +204,16 @@ easymde@^2.20.0: codemirror-spell-checker "1.1.2" marked "^4.1.0" -es-define-property@^1.0.0, es-define-property@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" - integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== - -es-errors@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" - integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== - -es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1" - integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA== - dependencies: - es-errors "^1.3.0" - eve-raphael@0.5.0: version "0.5.0" resolved "https://registry.yarnpkg.com/eve-raphael/-/eve-raphael-0.5.0.tgz#17c754b792beef3fa6684d79cf5a47c63c4cda30" integrity sha512-jrxnPsCGqng1UZuEp9DecX/AuSyAszATSjf4oEcRxvfxa1Oux4KkIPKBAAWWnpdwfARtr+Q0o9aPYWjsROD7ug== +fast-deep-equal@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + flot@flot/flot#~0.8.3: version "0.8.3" resolved "https://codeload.github.com/flot/flot/tar.gz/453b017cc5acfd75e252b93e8635f57f4196d45d" @@ -336,45 +228,26 @@ font-awesome@~4.4: resolved "https://registry.yarnpkg.com/font-awesome/-/font-awesome-4.4.0.tgz#9fe43f82cf72726badcbdb2704407aadaca17da9" integrity sha512-h45v/TF2b9d6JiBnyluFrjZRyJXXkRjWbTKxb0ygTScxdP8gWdgMBaghbDuSLQFHNkj3M0eNrLxfTEiQo93ARQ== +fontkit@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/fontkit/-/fontkit-2.0.4.tgz#4765d664c68b49b5d6feb6bd1051ee49d8ec5ab0" + integrity sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g== + dependencies: + "@swc/helpers" "^0.5.12" + brotli "^1.3.2" + clone "^2.1.2" + dfa "^1.2.0" + fast-deep-equal "^3.1.3" + restructure "^3.0.0" + tiny-inflate "^1.0.3" + unicode-properties "^1.4.0" + unicode-trie "^2.0.0" + fullcalendar@^3.10.2: version "3.10.5" resolved "https://registry.yarnpkg.com/fullcalendar/-/fullcalendar-3.10.5.tgz#57a3a64d7d744181582bb9e1be32d1846e1db53a" integrity sha512-JGWpECKgza/344bbF5QT0hBJpx04DZ/7QGPlR1ZbAwrG6Yz6mWEkQd+NnZUh1sK6HCBIPnPRW2x53aJxeLGvvQ== -function-bind@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" - integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== - -functions-have-names@^1.2.3: - version "1.2.3" - resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" - integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== - -get-intrinsic@^1.2.4, get-intrinsic@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" - integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== - dependencies: - call-bind-apply-helpers "^1.0.2" - es-define-property "^1.0.1" - es-errors "^1.3.0" - es-object-atoms "^1.1.1" - function-bind "^1.1.2" - get-proto "^1.0.1" - gopd "^1.2.0" - has-symbols "^1.1.0" - hasown "^2.0.2" - math-intrinsics "^1.1.0" - -get-proto@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" - integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== - dependencies: - dunder-proto "^1.0.1" - es-object-atoms "^1.0.0" - good-listener@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/good-listener/-/good-listener-1.2.2.tgz#d53b30cdf9313dffb7dc9a0d477096aa6d145c50" @@ -387,44 +260,6 @@ google-code-prettify@^1.0.0: resolved "https://registry.yarnpkg.com/google-code-prettify/-/google-code-prettify-1.0.5.tgz#9f477f224dbfa62372e5ef803a7e157410400084" integrity sha512-Y47Bw63zJKCuqTuhTZC1ct4e/0ADuMssxXhnrP8QHq71tE2aYBKG6wQwXr8zya0zIUd0mKN3XTlI5AME4qm6NQ== -gopd@^1.0.1, gopd@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" - integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== - -has-property-descriptors@^1.0.0, has-property-descriptors@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" - integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== - dependencies: - es-define-property "^1.0.0" - -has-symbols@^1.0.3, has-symbols@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" - integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== - -has-tostringtag@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" - integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== - dependencies: - has-symbols "^1.0.3" - -hasown@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" - integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== - dependencies: - function-bind "^1.1.2" - -iconv-lite@^0.7.1: - version "0.7.1" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.7.1.tgz#d4af1d2092f2bb05aab6296e5e7cd286d2f15432" - integrity sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw== - dependencies: - safer-buffer ">= 2.1.2 < 3.0.0" - immediate@~3.0.5: version "3.0.6" resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" @@ -435,32 +270,6 @@ inherits@~2.0.3: resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== -is-arguments@^1.1.1: - version "1.2.0" - resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.2.0.tgz#ad58c6aecf563b78ef2bf04df540da8f5d7d8e1b" - integrity sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA== - dependencies: - call-bound "^1.0.2" - has-tostringtag "^1.0.2" - -is-date-object@^1.0.5: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.1.0.tgz#ad85541996fc7aa8b2729701d27b7319f95d82f7" - integrity sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg== - dependencies: - call-bound "^1.0.2" - has-tostringtag "^1.0.2" - -is-regex@^1.1.4: - version "1.2.1" - resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.2.1.tgz#76d70a3ed10ef9be48eb577887d74205bf0cad22" - integrity sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g== - dependencies: - call-bound "^1.0.2" - gopd "^1.2.0" - has-tostringtag "^1.0.2" - hasown "^2.0.2" - isarray@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" @@ -528,16 +337,19 @@ lie@~3.3.0: dependencies: immediate "~3.0.5" +linebreak@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/linebreak/-/linebreak-1.1.0.tgz#831cf378d98bced381d8ab118f852bd50d81e46b" + integrity sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ== + dependencies: + base64-js "0.0.8" + unicode-trie "^2.0.0" + marked@^4.1.0: version "4.3.0" resolved "https://registry.yarnpkg.com/marked/-/marked-4.3.0.tgz#796362821b019f734054582038b116481b456cf3" integrity sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A== -math-intrinsics@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" - integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== - metismenu@~3.0.7: version "3.0.7" resolved "https://registry.yarnpkg.com/metismenu/-/metismenu-3.0.7.tgz#613dd01d14d053474b926a1ecac24d137c934aaa" @@ -552,19 +364,6 @@ morris.js@morrisjs/morris.js: version "0.5.1" resolved "https://codeload.github.com/morrisjs/morris.js/tar.gz/14530d0733801d5bef1264cf3d062ecace7e326b" -object-is@^1.1.5: - version "1.1.6" - resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.6.tgz#1a6a53aed2dd8f7e6775ff870bea58545956ab07" - integrity sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q== - dependencies: - call-bind "^1.0.7" - define-properties "^1.2.1" - -object-keys@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" - integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== - pako@^0.2.5: version "0.2.9" resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75" @@ -575,14 +374,24 @@ pako@~1.0.2: resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== -pdfmake@^0.2.21: - version "0.2.21" - resolved "https://registry.yarnpkg.com/pdfmake/-/pdfmake-0.2.21.tgz#dbaadda4567d67c5be7feac54f6e8e23af776be6" - integrity sha512-kgBj6Bbj57vY/f0zpBz/OLmO4n248RopEEA+IRkfdKZtravqQL6lEkILYsdjiPFYCXImZA+62EtT2zjUVKb8YQ== +pdfkit@^0.17.2: + version "0.17.2" + resolved "https://registry.yarnpkg.com/pdfkit/-/pdfkit-0.17.2.tgz#f77d7129ca49bc5015246b890d1aca6cb6b71f00" + integrity sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw== dependencies: - "@foliojs-fork/linebreak" "^1.1.2" - "@foliojs-fork/pdfkit" "^0.15.3" - iconv-lite "^0.7.1" + crypto-js "^4.2.0" + fontkit "^2.0.4" + jpeg-exif "^1.1.4" + linebreak "^1.1.0" + png-js "^1.0.0" + +pdfmake@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/pdfmake/-/pdfmake-0.3.0.tgz#b42c5af9aef4095f1310564d4a2f2d49f950cae9" + integrity sha512-sS7ow3ZrdFjlC7s4J5k3UA5IHQQbXRs6+NtdzfWDR0SvPa7+M8d69rITObFAsJ4t6iwkKRsc87Q+I/gFlTUVQg== + dependencies: + linebreak "^1.1.0" + pdfkit "^0.17.2" xmldoc "^2.0.3" png-js@^1.0.0: @@ -615,28 +424,16 @@ readable-stream@~2.3.6: string_decoder "~1.1.1" util-deprecate "~1.0.1" -regexp.prototype.flags@^1.5.1: - version "1.5.4" - resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz#1ad6c62d44a259007e55b3970e00f746efbcaa19" - integrity sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA== - dependencies: - call-bind "^1.0.8" - define-properties "^1.2.1" - es-errors "^1.3.0" - get-proto "^1.0.1" - gopd "^1.2.0" - set-function-name "^2.0.2" +restructure@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/restructure/-/restructure-3.0.2.tgz#e6b2fad214f78edee21797fa8160fef50eb9b49a" + integrity sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw== safe-buffer@~5.1.0, safe-buffer@~5.1.1: version "5.1.2" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== -"safer-buffer@>= 2.1.2 < 3.0.0": - version "2.1.2" - resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" - integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== - sax@^1.4.3: version "1.4.3" resolved "https://registry.yarnpkg.com/sax/-/sax-1.4.3.tgz#fcebae3b756cdc8428321805f4b70f16ec0ab5db" @@ -647,28 +444,6 @@ select@^1.1.2: resolved "https://registry.yarnpkg.com/select/-/select-1.1.2.tgz#0e7350acdec80b1108528786ec1d4418d11b396d" integrity sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA== -set-function-length@^1.2.2: - version "1.2.2" - resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" - integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== - dependencies: - define-data-property "^1.1.4" - es-errors "^1.3.0" - function-bind "^1.1.2" - get-intrinsic "^1.2.4" - gopd "^1.0.1" - has-property-descriptors "^1.0.2" - -set-function-name@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.2.tgz#16a705c5a0dc2f5e638ca96d8a8cd4e1c2b90985" - integrity sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ== - dependencies: - define-data-property "^1.1.4" - es-errors "^1.3.0" - functions-have-names "^1.2.3" - has-property-descriptors "^1.0.2" - setimmediate@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" @@ -691,17 +466,22 @@ tiny-emitter@^2.0.0: resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423" integrity sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q== -tiny-inflate@^1.0.0, tiny-inflate@^1.0.2: +tiny-inflate@^1.0.0, tiny-inflate@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/tiny-inflate/-/tiny-inflate-1.0.3.tgz#122715494913a1805166aaf7c93467933eea26c4" integrity sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw== +tslib@^2.8.0: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + typo-js@*: version "1.2.5" resolved "https://registry.yarnpkg.com/typo-js/-/typo-js-1.2.5.tgz#0aa65e0be9b69036463a3827de8185b4144e3086" integrity sha512-F45vFWdGX8xahIk/sOp79z2NJs8ETMYsmMChm9D5Hlx3+9j7VnCyQyvij5MOCrNY3NNe8noSyokRjQRfq+Bc7A== -unicode-properties@^1.2.2: +unicode-properties@^1.4.0: version "1.4.1" resolved "https://registry.yarnpkg.com/unicode-properties/-/unicode-properties-1.4.1.tgz#96a9cffb7e619a0dc7368c28da27e05fc8f9be5f" integrity sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg== From 6b8cb7697cd2587082ca23c6efd7ac783ec1e7a9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 4 Jan 2026 21:25:31 -0600 Subject: [PATCH 121/126] chore(deps): bump gitpython from 3.1.45 to 3.1.46 (#14022) Bumps [gitpython](https://github.com/gitpython-developers/GitPython) from 3.1.45 to 3.1.46. - [Release notes](https://github.com/gitpython-developers/GitPython/releases) - [Changelog](https://github.com/gitpython-developers/GitPython/blob/main/CHANGES) - [Commits](https://github.com/gitpython-developers/GitPython/compare/3.1.45...3.1.46) --- updated-dependencies: - dependency-name: gitpython dependency-version: 3.1.46 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 f74709c47f6..19e6698df0b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -42,7 +42,7 @@ whitenoise==5.2.0 titlecase==2.4.1 social-auth-app-django==5.6.0 social-auth-core==4.8.3 -gitpython==3.1.45 +gitpython==3.1.46 python-gitlab==7.1.0 cpe==1.3.1 packageurl-python==0.17.6 From b1f97865fcfabe32ab47200b9ca92a3490adc63f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 4 Jan 2026 21:26:25 -0600 Subject: [PATCH 122/126] Update dependency @tabler/icons from 3.36.0 to v3.36.1 (docs/package.json) (#14023) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docs/package-lock.json | 8 ++++---- docs/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index 8ee0ad96459..6fc5bf14538 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@docsearch/css": "4.4.0", "@docsearch/js": "4.4.0", - "@tabler/icons": "3.36.0", + "@tabler/icons": "3.36.1", "@thulite/doks-core": "1.8.3", "@thulite/images": "3.3.3", "@thulite/inline-svg": "1.2.1", @@ -2811,9 +2811,9 @@ "license": "MIT" }, "node_modules/@tabler/icons": { - "version": "3.36.0", - "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.36.0.tgz", - "integrity": "sha512-z9OfTEG6QbaQWM9KBOxxUdpgvMUn0atageXyiaSc2gmYm51ORO8Ua7eUcjlks+Dc0YMK4rrodAFdK9SfjJ4ZcA==", + "version": "3.36.1", + "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.36.1.tgz", + "integrity": "sha512-f4Jg3Fof/Vru5ioix/UO4GX+sdDsF9wQo47FbtvG+utIYYVQ/QVAC0QYgcBbAjQGfbdOh2CCf0BgiFOF9Ixtjw==", "license": "MIT", "funding": { "type": "github", diff --git a/docs/package.json b/docs/package.json index 614f1de5f74..30d36a2f746 100644 --- a/docs/package.json +++ b/docs/package.json @@ -18,7 +18,7 @@ "dependencies": { "@docsearch/css": "4.4.0", "@docsearch/js": "4.4.0", - "@tabler/icons": "3.36.0", + "@tabler/icons": "3.36.1", "@thulite/doks-core": "1.8.3", "@thulite/images": "3.3.3", "@thulite/inline-svg": "1.2.1", From e8d833baf55167d3c3e0de48fcff39ad3c027518 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 4 Jan 2026 21:31:11 -0600 Subject: [PATCH 123/126] chore(deps): bump celery from 5.6.0 to 5.6.1 (#14005) Bumps [celery](https://github.com/celery/celery) from 5.6.0 to 5.6.1. - [Release notes](https://github.com/celery/celery/releases) - [Changelog](https://github.com/celery/celery/blob/main/Changelog.rst) - [Commits](https://github.com/celery/celery/compare/v5.6.0...v5.6.1) --- updated-dependencies: - dependency-name: celery dependency-version: 5.6.1 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 19e6698df0b..9691129a8b6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ asteval==1.0.8 bleach==6.3.0 bleach[css] -celery==5.6.0 +celery==5.6.1 defusedxml==0.7.1 django_celery_results==2.6.0 django-auditlog==3.2.1 From 507334457b0e0b755b9b8997d5d7d7e2b5d70e68 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 4 Jan 2026 21:51:21 -0600 Subject: [PATCH 124/126] Update python:3.13.11-slim-trixie Docker digest from 3.13.11 to v (Dockerfile.integration-tests-debian) (#14008) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Dockerfile.django-debian | 2 +- Dockerfile.integration-tests-debian | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile.django-debian b/Dockerfile.django-debian index e68b9fce672..1be51148a37 100644 --- a/Dockerfile.django-debian +++ b/Dockerfile.django-debian @@ -5,7 +5,7 @@ # Dockerfile.nginx to use the caching mechanism of Docker. # Ref: https://devguide.python.org/#branchstatus -FROM python:3.13.11-slim-trixie@sha256:b19309443a59aa604fd694e04a1698b24df57ab8b7f06a6cd3dcb1769391767e AS base +FROM python:3.13.11-slim-trixie@sha256:1f3781f578e17958f55ada96c0a827bf279a11e10d6a458ecb8bde667afbb669 AS base FROM base AS build WORKDIR /app RUN \ diff --git a/Dockerfile.integration-tests-debian b/Dockerfile.integration-tests-debian index 1ac67ebfdb1..4bd2caea2aa 100644 --- a/Dockerfile.integration-tests-debian +++ b/Dockerfile.integration-tests-debian @@ -3,7 +3,7 @@ FROM openapitools/openapi-generator-cli:v7.18.0@sha256:be5c0a17c978ed4c39985312af3129882407581e07f2e3167cf777c908ffd52b AS openapitools # currently only supports x64, no arm yet due to chrome and selenium dependencies -FROM python:3.13.11-slim-trixie@sha256:b19309443a59aa604fd694e04a1698b24df57ab8b7f06a6cd3dcb1769391767e AS build +FROM python:3.13.11-slim-trixie@sha256:1f3781f578e17958f55ada96c0a827bf279a11e10d6a458ecb8bde667afbb669 AS build WORKDIR /app RUN \ apt-get -y update && \ From 94c93fd9a5f3495d5746b71dfe26f22d2a33f805 Mon Sep 17 00:00:00 2001 From: Cody Maffucci <46459665+Maffooch@users.noreply.github.com> Date: Mon, 5 Jan 2026 09:03:32 -0700 Subject: [PATCH 125/126] fix: remove unnecessary blank line in sanitize_vulnerability_ids function --- dojo/finding/helper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dojo/finding/helper.py b/dojo/finding/helper.py index 10afba77e87..8b829455d21 100644 --- a/dojo/finding/helper.py +++ b/dojo/finding/helper.py @@ -781,7 +781,7 @@ def sanitize_vulnerability_ids(vulnerability_ids) -> None: """Remove undisired vulnerability id values""" vulnerability_ids = [x for x in vulnerability_ids if x.strip()] - + def save_vulnerability_ids(finding, vulnerability_ids, *, delete_existing: bool = True): # Remove duplicates vulnerability_ids = list(dict.fromkeys(vulnerability_ids)) From f1de10eae7516179acca02db132782be1d975be8 Mon Sep 17 00:00:00 2001 From: DefectDojo release bot Date: Mon, 5 Jan 2026 16:50:06 +0000 Subject: [PATCH 126/126] 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 4b9b4c85cda..348ad4248bc 100644 --- a/components/package.json +++ b/components/package.json @@ -1,6 +1,6 @@ { "name": "defectdojo", - "version": "2.54.0-dev", + "version": "2.54.0", "license" : "BSD-3-Clause", "private": true, "dependencies": { diff --git a/dojo/__init__.py b/dojo/__init__.py index 7337d10b9c1..af8f028cb1c 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.54.0-dev" +__version__ = "2.54.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 f30349e46ae..a39152718f0 100644 --- a/helm/defectdojo/Chart.yaml +++ b/helm/defectdojo/Chart.yaml @@ -1,8 +1,8 @@ apiVersion: v2 -appVersion: "2.54.0-dev" +appVersion: "2.54.0" description: A Helm chart for Kubernetes to install DefectDojo name: defectdojo -version: 1.9.6-dev +version: 1.9.6 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: "" + artifacthub.io/prerelease: "false" + artifacthub.io/changes: "- kind: changed\n description: Bump DefectDojo to 2.54.0\n" diff --git a/helm/defectdojo/README.md b/helm/defectdojo/README.md index 61c66dfeb51..ca10a9462d8 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.6-dev](https://img.shields.io/badge/Version-1.9.6--dev-informational?style=flat-square) ![AppVersion: 2.54.0-dev](https://img.shields.io/badge/AppVersion-2.54.0--dev-informational?style=flat-square) +![Version: 1.9.6](https://img.shields.io/badge/Version-1.9.6-informational?style=flat-square) ![AppVersion: 2.54.0](https://img.shields.io/badge/AppVersion-2.54.0-informational?style=flat-square) A Helm chart for Kubernetes to install DefectDojo