diff --git a/.github/workflows/build-docker-images-for-testing.yml b/.github/workflows/build-docker-images-for-testing.yml index 9f81a64f830..532d7de381f 100644 --- a/.github/workflows/build-docker-images-for-testing.yml +++ b/.github/workflows/build-docker-images-for-testing.yml @@ -40,7 +40,7 @@ jobs: echo $GITHUB_ENV - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false @@ -53,7 +53,7 @@ jobs: - name: Build id: docker_build - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 timeout-minutes: 15 env: DOCKER_BUILD_CHECKS_ANNOTATIONS: false diff --git a/.github/workflows/close-stale.yml b/.github/workflows/close-stale.yml index 5c770d0a9eb..23e5a705f2a 100644 --- a/.github/workflows/close-stale.yml +++ b/.github/workflows/close-stale.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Close issues and PRs that are pending closure - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 + uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 with: # Disable automatic stale marking - only close manually labeled items days-before-stale: -1 @@ -27,7 +27,7 @@ jobs: close-pr-message: 'This PR has been automatically closed because it was manually labeled as stale. If you believe this was closed in error, please reopen it and remove the stale label.' - name: Close stale issues and PRs - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 + uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 with: # Disable automatic stale marking - only close manually labeled items days-before-stale: -1 diff --git a/.github/workflows/fetch-oas.yml b/.github/workflows/fetch-oas.yml index 7873716642e..17d04fcca61 100644 --- a/.github/workflows/fetch-oas.yml +++ b/.github/workflows/fetch-oas.yml @@ -22,7 +22,7 @@ jobs: file-type: [yaml, json] steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: release/${{ env.release_version }} diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index 8766bc8572d..e82b274d701 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -24,7 +24,7 @@ jobs: - name: Setup Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '24.15.0' # TODO: Renovate helper might not be needed here - needs to be fully tested + node-version: '24.16.0' # TODO: Renovate helper might not be needed here - needs to be fully tested - name: Cache dependencies uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 @@ -35,7 +35,7 @@ jobs: ${{ runner.os }}-node- - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: submodules: recursive fetch-depth: 0 diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index c07a22a86a8..a2d6b9d051f 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -82,7 +82,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 # load docker images from build jobs - name: Load images from artifacts diff --git a/.github/workflows/k8s-tests.yml b/.github/workflows/k8s-tests.yml index 311ddd24f3d..d21dca99f15 100644 --- a/.github/workflows/k8s-tests.yml +++ b/.github/workflows/k8s-tests.yml @@ -22,7 +22,7 @@ jobs: os: debian steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Setup Minikube uses: manusa/actions-setup-minikube@b65276017fdec6f1e6498129fb740e34e260dc55 # v2.18.0 diff --git a/.github/workflows/performance-tests.yml b/.github/workflows/performance-tests.yml index 7d443525146..386806a0367 100644 --- a/.github/workflows/performance-tests.yml +++ b/.github/workflows/performance-tests.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set-platform run: | diff --git a/.github/workflows/release-1-create-pr.yml b/.github/workflows/release-1-create-pr.yml index 14747b46cdf..6090d078593 100644 --- a/.github/workflows/release-1-create-pr.yml +++ b/.github/workflows/release-1-create-pr.yml @@ -40,7 +40,7 @@ jobs: run: echo "GITHUB_ORG=${GITHUB_REPOSITORY%%/*}" >> $GITHUB_ENV - name: Checkout from_branch branch - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ inputs.from_branch }} @@ -58,7 +58,7 @@ jobs: run: git push origin HEAD:${NEW_BRANCH} - name: Checkout release branch - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ env.NEW_BRANCH }} diff --git a/.github/workflows/release-2-tag-docker-push.yml b/.github/workflows/release-2-tag-docker-push.yml index 6061238ad2d..00245bd3cd6 100644 --- a/.github/workflows/release-2-tag-docker-push.yml +++ b/.github/workflows/release-2-tag-docker-push.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: master diff --git a/.github/workflows/release-3-master-into-dev.yml b/.github/workflows/release-3-master-into-dev.yml index 8ebdd193247..c338e7092e0 100644 --- a/.github/workflows/release-3-master-into-dev.yml +++ b/.github/workflows/release-3-master-into-dev.yml @@ -23,7 +23,7 @@ jobs: run: echo "GITHUB_ORG=${GITHUB_REPOSITORY%%/*}" >> $GITHUB_ENV - name: Checkout master - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: master @@ -40,7 +40,7 @@ jobs: run: git push origin HEAD:${NEW_BRANCH} - name: Checkout new branch - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ env.NEW_BRANCH }} @@ -121,7 +121,7 @@ jobs: run: echo "GITHUB_ORG=${GITHUB_REPOSITORY%%/*}" >> $GITHUB_ENV - name: Checkout master - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: master @@ -138,7 +138,7 @@ jobs: run: git push origin HEAD:${NEW_BRANCH} - name: Checkout new branch - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ env.NEW_BRANCH }} diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 4b868f2cd79..31bcc7d6e8a 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -27,7 +27,7 @@ jobs: steps: - name: Create Release id: create_release - uses: release-drafter/release-drafter@c2e2804cc59f45f57076a99af580d0fedb697927 # v7.3.0 + uses: release-drafter/release-drafter@693d20e7c1ce1a81d3a41962f85914253b518449 # v7.3.1 with: version: ${{ inputs.version }} env: diff --git a/.github/workflows/release-x-manual-docker-containers.yml b/.github/workflows/release-x-manual-docker-containers.yml index 14f0b259584..ab2b0a9d04b 100644 --- a/.github/workflows/release-x-manual-docker-containers.yml +++ b/.github/workflows/release-x-manual-docker-containers.yml @@ -58,7 +58,7 @@ jobs: password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Checkout tag - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ inputs.release_number }} @@ -69,7 +69,7 @@ jobs: # we cannot set any tags here, those are set on the merged digest in release-x-manual-merge-container-digests.yml - name: Build and push images id: build - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 env: DOCKER_BUILD_CHECKS_ANNOTATIONS: false with: diff --git a/.github/workflows/release-x-manual-helm-chart.yml b/.github/workflows/release-x-manual-helm-chart.yml index 85052bd8a58..cbf2e75f841 100644 --- a/.github/workflows/release-x-manual-helm-chart.yml +++ b/.github/workflows/release-x-manual-helm-chart.yml @@ -43,7 +43,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ inputs.release_number }} fetch-depth: 0 diff --git a/.github/workflows/release-x-nightly.yml b/.github/workflows/release-x-nightly.yml index 5acd3953485..e9c84f698c2 100644 --- a/.github/workflows/release-x-nightly.yml +++ b/.github/workflows/release-x-nightly.yml @@ -39,7 +39,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ inputs.branch-to-build }} diff --git a/.github/workflows/renovate.yaml b/.github/workflows/renovate.yaml index 12334d34d2c..27ce5517328 100644 --- a/.github/workflows/renovate.yaml +++ b/.github/workflows/renovate.yaml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false diff --git a/.github/workflows/rest-framework-tests.yml b/.github/workflows/rest-framework-tests.yml index a9b45675dd3..e15a90bf2d9 100644 --- a/.github/workflows/rest-framework-tests.yml +++ b/.github/workflows/rest-framework-tests.yml @@ -27,7 +27,7 @@ jobs: echo $GITHUB_ENV - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index 2f71cdaa4be..7bcc509de5b 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Install Ruff Linter run: pip install -r requirements-lint.txt diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml index e136364e038..1eaf7e38911 100644 --- a/.github/workflows/shellcheck.yml +++ b/.github/workflows/shellcheck.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Run ShellCheck uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # 2.0.0 diff --git a/.github/workflows/test-helm-chart.yml b/.github/workflows/test-helm-chart.yml index c50866c303b..65bb07334f1 100644 --- a/.github/workflows/test-helm-chart.yml +++ b/.github/workflows/test-helm-chart.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false fetch-depth: 0 @@ -111,7 +111,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: ${{ github.event.pull_request.head.repo.full_name }} ref: ${{ github.event.pull_request.head.ref }} @@ -152,7 +152,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Generate values schema json uses: losisin/helm-values-schema-json-action@39cdf80504f6c95ad3c4f317e2135e2509ea56bb # v3 @@ -172,7 +172,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false fetch-depth: 0 @@ -194,7 +194,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Run ah lint working-directory: ./helm/defectdojo run: |- diff --git a/.github/workflows/update-sample-data.yml b/.github/workflows/update-sample-data.yml index c6f2027a26d..c970573c7fe 100644 --- a/.github/workflows/update-sample-data.yml +++ b/.github/workflows/update-sample-data.yml @@ -16,7 +16,7 @@ jobs: steps: # Checkout the repository - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ github.ref_name || 'dev'}} diff --git a/.github/workflows/validate_docs_build.yml b/.github/workflows/validate_docs_build.yml index 616cbc5910e..ee3b82e0688 100644 --- a/.github/workflows/validate_docs_build.yml +++ b/.github/workflows/validate_docs_build.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '24.15.0' # TODO: Renovate helper might not be needed here - needs to be fully tested + node-version: '24.16.0' # TODO: Renovate helper might not be needed here - needs to be fully tested - name: Cache dependencies uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 @@ -30,7 +30,7 @@ jobs: ${{ runner.os }}-node- - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: submodules: recursive fetch-depth: 0 diff --git a/components/package.json b/components/package.json index 2651883b8b7..bd883a010f6 100644 --- a/components/package.json +++ b/components/package.json @@ -1,6 +1,6 @@ { "name": "defectdojo", - "version": "2.59.0", + "version": "3.0.0", "license": "BSD-3-Clause", "private": true, "dependencies": { diff --git a/docs/content/admin/sso/_index.md b/docs/content/admin/sso/_index.md index 65c46706ad7..858967cc969 100644 --- a/docs/content/admin/sso/_index.md +++ b/docs/content/admin/sso/_index.md @@ -29,9 +29,9 @@ aliases: - /admin/sso/os__remote_user/ --- -Single Sign-On is a **DefectDojo Pro** feature. As of DefectDojo 2.59, the SSO surface — SAML, OIDC, and the bundled OAuth providers — is available only in DefectDojo Pro. Open-source DefectDojo uses local username/password login and the password-reset flow. +Single Sign-On is a **DefectDojo Pro** feature. As of DefectDojo 3.0, the SSO surface — SAML, OIDC, and the bundled OAuth providers — is available only in DefectDojo Pro. Open-source DefectDojo uses local username/password login and the password-reset flow. -If you're running open-source DefectDojo and want SSO, you'll need to switch to [DefectDojo Pro](https://defectdojo.com); the migration is covered in the [2.59 upgrade notes](/releases/os_upgrading/2.59/#sso-providers-are-available-in-defectdojo-pro-only). Existing user accounts and group memberships are preserved on upgrade. For access control on open-source DefectDojo, see the [Authorized Users](/admin/user_management/os__authorized_users/) page. +If you're running open-source DefectDojo and want SSO, you'll need to switch to [DefectDojo Pro](https://defectdojo.com); the migration is covered in the [3.0 upgrade notes](/releases/os_upgrading/3.0/#sso-providers-are-available-in-defectdojo-pro-only). Existing user accounts and group memberships are preserved on upgrade. For access control on open-source DefectDojo, see the [Authorized Users](/admin/user_management/os__authorized_users/) page. ## Supported SSO providers (DefectDojo Pro) diff --git a/docs/content/admin/user_management/OS__authorized_users.md b/docs/content/admin/user_management/OS__authorized_users.md index 1baf228c015..c758382f4ee 100644 --- a/docs/content/admin/user_management/OS__authorized_users.md +++ b/docs/content/admin/user_management/OS__authorized_users.md @@ -51,10 +51,10 @@ A few rules of thumb: ## Coming from a previous version of DefectDojo -DefectDojo open-source moved back to the Authorized Users model in version 2.59. If you're upgrading from a release that had the Members / Groups / Global Roles system, your existing access is carried forward into Authorized Users automatically by the upgrade — no manual mapping is needed. +DefectDojo open-source moved back to the Authorized Users model in version 3.0. If you're upgrading from a release that had the Members / Groups / Global Roles system, your existing access is carried forward into Authorized Users automatically by the upgrade — no manual mapping is needed. -The upgrade ships with a read-only management command, `preview_legacy_authorization_migration`, that summarizes what an upgrade would change against a copy of your database. The recommended workflow is to install 2.59 in a staging environment with a snapshot of production, run the command, review the summary, and then upgrade production. +The upgrade ships with a read-only management command, `preview_legacy_authorization_migration`, that summarizes what an upgrade would change against a copy of your database. The recommended workflow is to install 3.0 in a staging environment with a snapshot of production, run the command, review the summary, and then upgrade production. If you're moving the other direction — from open-source to DefectDojo Pro — Pro ships a `reconcile_authorized_users_to_rbac` command that brings Authorized Users access forward into Pro's RBAC. It supports `--dry-run` and is idempotent. -For more detail on both paths, see the [2.59 upgrade notes](/releases/os_upgrading/2.59/#authorized-users-panel-replaces-membersgroups-under-legacy-authorization). +For more detail on both paths, see the [3.0 upgrade notes](/releases/os_upgrading/3.0/#authorized-users-panel-replaces-membersgroups-under-legacy-authorization). diff --git a/docs/content/admin/user_management/_index.md b/docs/content/admin/user_management/_index.md index 33a52cc5583..ae5322fdda9 100644 --- a/docs/content/admin/user_management/_index.md +++ b/docs/content/admin/user_management/_index.md @@ -38,4 +38,4 @@ DefectDojo Pro uses a role-based system with Members, Groups, and Global Roles. ## Migrating between editions -If you're moving from open-source's Authorized Users to Pro's RBAC, or upgrading from a pre-2.59 open-source release that used RBAC into the current Authorized Users model, see the [2.59 upgrade notes](/releases/os_upgrading/2.59/#authorized-users-panel-replaces-membersgroups-under-legacy-authorization). Existing access is preserved automatically. +If you're moving from open-source's Authorized Users to Pro's RBAC, or upgrading from a pre-3.0 open-source release that used RBAC into the current Authorized Users model, see the [3.0 upgrade notes](/releases/os_upgrading/3.0/#authorized-users-panel-replaces-membersgroups-under-legacy-authorization). Existing access is preserved automatically. diff --git a/docs/content/admin/user_management/about_perms_and_roles.md b/docs/content/admin/user_management/about_perms_and_roles.md index 9a662bfd231..85ff79b50f9 100644 --- a/docs/content/admin/user_management/about_perms_and_roles.md +++ b/docs/content/admin/user_management/about_perms_and_roles.md @@ -7,7 +7,7 @@ aliases: - /en/customize_dojo/user_management/about_perms_and_roles --- -> **DefectDojo Pro feature.** The Members / Groups / Global Roles RBAC system described on this page is part of DefectDojo Pro. Open-source DefectDojo uses the [Authorized Users](../os__authorized_users/) model — see that page for open-source access control, and the [2.59 upgrade notes](/releases/os_upgrading/2.59/#authorized-users-panel-replaces-membersgroups-under-legacy-authorization) if you're moving between editions. +> **DefectDojo Pro feature.** The Members / Groups / Global Roles RBAC system described on this page is part of DefectDojo Pro. Open-source DefectDojo uses the [Authorized Users](../os__authorized_users/) model — see that page for open-source access control, and the [3.0 upgrade notes](/releases/os_upgrading/3.0/#authorized-users-panel-replaces-membersgroups-under-legacy-authorization) if you're moving between editions. If you have a team of users working in DefectDojo, it's important to set up Role\-Based Access Control (RBAC) appropriately so that users can only access specific data. Security data is highly sensitive, and DefectDojo's options for access control allow you to be specific about each team member’s access to information. diff --git a/docs/content/admin/user_management/create_user_group.md b/docs/content/admin/user_management/create_user_group.md index d432470212a..8107cd9dae4 100644 --- a/docs/content/admin/user_management/create_user_group.md +++ b/docs/content/admin/user_management/create_user_group.md @@ -7,7 +7,7 @@ aliases: - /en/customize_dojo/user_management/create_user_group --- -> **DefectDojo Pro feature.** User Groups and the underlying RBAC system are part of DefectDojo Pro. Open-source DefectDojo uses the [Authorized Users](../os__authorized_users/) model — see that page for open-source access control, and the [2.59 upgrade notes](/releases/os_upgrading/2.59/#authorized-users-panel-replaces-membersgroups-under-legacy-authorization) if you're moving between editions. +> **DefectDojo Pro feature.** User Groups and the underlying RBAC system are part of DefectDojo Pro. Open-source DefectDojo uses the [Authorized Users](../os__authorized_users/) model — see that page for open-source access control, and the [3.0 upgrade notes](/releases/os_upgrading/3.0/#authorized-users-panel-replaces-membersgroups-under-legacy-authorization) if you're moving between editions. If you have a significant number of DefectDojo users, you may want to create one or more **Groups**, in order to set the same Role\-Based Access Control (RBAC) rules for many users simultaneously. Only Superusers can create User Groups. diff --git a/docs/content/admin/user_management/set_user_permissions.md b/docs/content/admin/user_management/set_user_permissions.md index ed2018423fc..9134f2f61e8 100644 --- a/docs/content/admin/user_management/set_user_permissions.md +++ b/docs/content/admin/user_management/set_user_permissions.md @@ -7,7 +7,7 @@ aliases: - /en/customize_dojo/user_management/set_user_permissions --- -> **DefectDojo Pro feature.** The Members / Groups / Global Roles RBAC system described on this page is part of DefectDojo Pro. Open-source DefectDojo uses the [Authorized Users](../os__authorized_users/) model — see that page for open-source access control, and the [2.59 upgrade notes](/releases/os_upgrading/2.59/#authorized-users-panel-replaces-membersgroups-under-legacy-authorization) if you're moving between editions. +> **DefectDojo Pro feature.** The Members / Groups / Global Roles RBAC system described on this page is part of DefectDojo Pro. Open-source DefectDojo uses the [Authorized Users](../os__authorized_users/) model — see that page for open-source access control, and the [3.0 upgrade notes](/releases/os_upgrading/3.0/#authorized-users-panel-replaces-membersgroups-under-legacy-authorization) if you're moving between editions. ## Introduction to Permission Types diff --git a/docs/content/admin/user_management/user_permission_chart.md b/docs/content/admin/user_management/user_permission_chart.md index e29c65b0189..b119d48d0a3 100644 --- a/docs/content/admin/user_management/user_permission_chart.md +++ b/docs/content/admin/user_management/user_permission_chart.md @@ -7,7 +7,7 @@ aliases: - /en/customize_dojo/user_management/user_permission_chart --- -> **DefectDojo Pro feature.** The Members / Groups / Global Roles RBAC system described on this page is part of DefectDojo Pro. Open-source DefectDojo uses the [Authorized Users](../os__authorized_users/) model — see that page for open-source access control, and the [2.59 upgrade notes](/releases/os_upgrading/2.59/#authorized-users-panel-replaces-membersgroups-under-legacy-authorization) if you're moving between editions. +> **DefectDojo Pro feature.** The Members / Groups / Global Roles RBAC system described on this page is part of DefectDojo Pro. Open-source DefectDojo uses the [Authorized Users](../os__authorized_users/) model — see that page for open-source access control, and the [3.0 upgrade notes](/releases/os_upgrading/3.0/#authorized-users-panel-replaces-membersgroups-under-legacy-authorization) if you're moving between editions. ## Role Permission Chart diff --git a/docs/content/en/open_source/upgrading/2.60.md b/docs/content/en/open_source/upgrading/2.60.md new file mode 100644 index 00000000000..e7811aa0b99 --- /dev/null +++ b/docs/content/en/open_source/upgrading/2.60.md @@ -0,0 +1,7 @@ +--- +title: 'Upgrading to DefectDojo Version 2.60.x' +toc_hide: true +weight: -20260601 +description: No special instructions. +--- +There are no special instructions for upgrading to 2.60.x. Check the [Release Notes](https://github.com/DefectDojo/django-DefectDojo/releases/tag/2.60.0) for the contents of the release. diff --git a/docs/content/releases/os_upgrading/2.59.md b/docs/content/releases/os_upgrading/2.59.md deleted file mode 100644 index a36e9e88b65..00000000000 --- a/docs/content/releases/os_upgrading/2.59.md +++ /dev/null @@ -1,96 +0,0 @@ ---- -title: 'Upgrading to DefectDojo Version 2.59.x' -toc_hide: true -weight: -20260602 -description: Authorized Users panel replaces Members/Groups under legacy authorization; SSO providers move to DefectDojo Pro; removal of Questionnaire API Endpoints, Credential Manager, and Stub Findings ---- - -## Authorized Users panel replaces Members/Groups under legacy authorization - -Open Source DefectDojo uses the legacy authorization model: access to a Product is granted by `Product.authorized_users` (with cascade via `Product_Type.authorized_users`), and `is_staff` / `is_superuser` bypass everything. - -In 2.59 the classic UI restores the **"Authorized Users"** panel on the Product and Product Type detail pages. The panel reads from and writes to `Product.authorized_users` / `Product_Type.authorized_users` directly, so adding a user actually grants them the access the UI suggests it does. - -### New endpoints - -- `GET/POST /product//authorized_users/add` — list / add users to `Product.authorized_users` -- `POST /product//authorized_users//delete` — remove a user -- `GET/POST /product/type//authorized_users/add` — same for `Product_Type.authorized_users` -- `POST /product/type//authorized_users//delete` - -Both endpoints are gated so only `is_staff` / `is_superuser` users can add or remove. Non-staff users see the panel but no management actions. - -### How RBAC rows are converted - -The data migration `0267_backfill_authorized_users` translates RBAC tables into the legacy model with the following rules: - -| RBAC row | Legacy effect | -|---|---| -| `Product_Member` (any role, direct or via `Product_Group` + `Dojo_Group_Member`) | Adds the user to `Product.authorized_users` | -| `Product_Type_Member` (any role, direct or via `Product_Type_Group` + `Dojo_Group_Member`) | Adds the user to `Product_Type.authorized_users` | -| `Global_Role(Owner)` (direct or via group) | Sets `User.is_superuser = True` | -| `Global_Role(Writer | Maintainer | API_Importer)` (direct or via group) | Sets `User.is_staff = True` | -| `Global_Role(Reader)` | No global elevation — relies on per-product membership | - -Per-product role granularity (Reader vs Writer vs Maintainer vs Owner) collapses to membership-only because the legacy model has no per-product role concept. `Dojo_Group` structure as a permission-bearing entity is also lost; only the flattened individual user memberships remain. - -### Required actions - -- **Database migrations run automatically on upgrade.** Existing access is carried forward into the legacy `authorized_users` model. Existing data is preserved. -- **Audit the upgrade in staging first.** A new `python manage.py preview_legacy_authorization_migration` management command is shipped in 2.59 to summarize what an upgrade would change against a given database. It is read-only. Recommended workflow: install 2.59 in a staging environment with a snapshot of your production database, run the command, review the summary, then upgrade production. -- **Migrating from OS to Pro?** A new `python manage.py reconcile_authorized_users_to_rbac` management command is available on Pro to bring any access changes you made under OS forward into Pro RBAC. It supports `--dry-run` and is idempotent. - -### Pro customers are not impacted - -DefectDojo Pro deployments retain full RBAC. The Pro UX is unchanged — same Members/Groups management surface as before. - -## SSO providers are available in DefectDojo Pro only - -Single sign-on (SAML, OIDC, Google, Okta, Azure AD, GitLab, Auth0, Keycloak, GitHub Enterprise, and remote-user header authentication) has been consolidated into DefectDojo Pro. Open source DefectDojo now exposes only local username/password login and the password-reset flow. - -### Required actions - -- **No customizations or local-only login:** No action required. -- **Currently logging in via SSO on open source:** Existing user accounts and group memberships are preserved on upgrade, but SSO sign-in will no longer work after 2.59. To keep an SSO-driven login experience, switch to [DefectDojo Pro](https://defectdojo.com), which carries forward and extends the SSO surface (provider configuration moves to a UI-managed tuner). - -## Removal: Questionnaire API Endpoints - -As announced in DefectDojo 2.56.0, the following Questionnaire API endpoints have been removed: - -- `/api/v2/questionnaire_answered_questionnaires/` -- `/api/v2/questionnaire_answers/` -- `/api/v2/questionnaire_engagement_questionnaires/` -- `/api/v2/questionnaire_general_questionnaires/` -- `/api/v2/questionnaire_questions` - -### Required Actions - -Any requests to these endpoints will now return a 404 Not Found error. - -## Removal: Credential Manager - -As announced in DefectDojo 2.57.0, the Credential Manager feature has been removed. The following API endpoints are no longer available: - -- `/api/v2/credentials/` -- `/api/v2/credential_mappings/` - -### Required Actions - -Any requests to these endpoints will now return a 404 Not Found error. The Credential Manager UI is no longer available. - -## Removal: Stub Findings - -As announced in DefectDojo 2.57.0, the Stub Findings feature has been removed. The following API endpoint is no longer available: - -- `/api/v2/stub_findings/` - -### Required Actions - -Any requests to this endpoint will now return a 404 Not Found error. The Stub Findings UI is no longer available. - -## Configuration change in Watson Search Indexing - -In [PR 14881](https://github.com/DefectDojo/django-DefectDojo/pull/14881)We optimized the way the Django Watson search index is updated during imports and reimports. There is not a single configuration setting to manage the threshold: `DD_WATSON_ASYNC_INDEX_UPDATE_BATCH_SIZE`. The default value should work fine for most instances. - - -For more information, check the [Release Notes](https://github.com/DefectDojo/django-DefectDojo/releases/tag/2.59.0). diff --git a/docs/content/releases/os_upgrading/3.0.md b/docs/content/releases/os_upgrading/3.0.md new file mode 100644 index 00000000000..cf0df405a99 --- /dev/null +++ b/docs/content/releases/os_upgrading/3.0.md @@ -0,0 +1,173 @@ +--- +title: 'Upgrading to DefectDojo Version 3.0.x' +toc_hide: true +weight: -20260615 +description: Locations and Asset/Organization labels are now enabled by default; Authorized Users panel replaces Members/Groups under legacy authorization; SSO providers move to DefectDojo Pro; removal of Questionnaire API Endpoints, Credential Manager, and Stub Findings; Dependency Check parser no longer emits separate findings for related dependencies; related file paths are now listed in the main finding's description. +--- + +## Locations enabled by default + +`DD_V3_FEATURE_LOCATIONS` now defaults to `True`. Locations is a polymorphic location/asset model that replaces the legacy `Endpoint` model. **URL Locations** are the direct equivalent of Endpoints (same `protocol`, `host`, `port`, `path`, `query`, and `fragment` fields), and the model additionally supports **Dependency Locations** for SBOM/library data that Endpoints could never represent. + +### How to migrate + +After the feature is enabled on an existing instance, run the **`migrate_endpoints_to_locations`** management command to carry your Endpoint data forward into Locations. Enabling the flag alone does **not** move data — the command performs the one-time conversion. For every Endpoint, it: + +1. Creates (or re-uses) a **URL Location** from the Endpoint's `protocol`, `userinfo`, `host`, `port`, `path`, `query`, and `fragment`. +2. Carries over all **tags** and re-points all **metadata** (`DojoMeta`) onto the new Location. +3. Creates a **`LocationProductReference`** so the URL appears under the correct Asset (Product). +4. Creates a **`LocationFindingReference`** for every `Endpoint_Status`, collapsing the old multi-flag combinations into a single canonical status (first match wins): + + | Endpoint_Status flag | Resulting Location status | + | --- | --- | + | `risk_accepted=True` | **Risk Accepted** | + | `false_positive=True` | **False Positive** | + | `out_of_scope=True` | **Out of Scope** | + | `mitigated=True` | **Mitigated** | + | (none of the above) | **Active** | + +#### Running the migration + +``` +python manage.py migrate_endpoints_to_locations +``` + +- **Idempotent — safe to re-run.** Each phase uses `bulk_create(..., ignore_conflicts=True)`, so re-running the command will not create duplicates and will pick up any Endpoints not yet converted. After converting, it runs a tag-inheritance pass so migrated Locations pick up inherited product tags. +- **Resilient to per-row failures.** A single bad Endpoint is logged (with its ID) and skipped rather than aborting the whole run; re-run after addressing the cause to convert the remainder. The command prints live progress with an ETA and a final migrated/total summary. +- **Tuning flags (optional):** `--batch-size` (DB iterator chunk size, default `1000`) and `--progress-every` (progress-line cadence, default `50`). `--benchmark` and `--query-count` exist for profiling only and add overhead. + +For full details (including the read-compatibility behavior of the legacy API), see [Migrating from Endpoints](/asset_modelling/locations/pro__migrating_from_endpoints/). + +### What happens to existing endpoint data + +- **Nothing is deleted.** The original `Endpoint` and `Endpoint_Status` rows remain in the database to back the read-only legacy API. They are simply no longer used by the new UI or by imports. +- **Reads keep working; writes return 403.** `GET /api/v2/endpoints/` and `GET /api/v2/endpoint_status/` return rows projected from Locations, preserving the original Endpoint IDs and familiar fields. `POST`/`PUT`/`PATCH`/`DELETE` on those routes return `HTTP 403` — write clients should move to `POST /api/v2/urls/`, `POST /api/v2/location_findings/`, and `POST /api/v2/location_products/`. + +### How to roll back + +Set `DD_V3_FEATURE_LOCATIONS=False` to return to the legacy Endpoint model, UI, and read/write API. Because the original Endpoint rows are never deleted, your pre-upgrade endpoint data is still there. + +> **Caveat — migration is one-way.** There is no automated path that re-creates Endpoints from Locations. Any endpoint changes you made through the new Location endpoints while the feature was enabled are **not** back-ported into the legacy `Endpoint` tables, so they will not be visible after rolling back. + +## Asset / Organization labels enabled by default + +`DD_ENABLE_V3_ORGANIZATION_ASSET_RELABEL` now defaults to `True`. This renames **"Product Type" → "Organization"** and **"Product" → "Asset"** throughout the UI, and routes `/product/type` → `/organization` and `/product` → `/asset` (with backward-compatibility redirects from the old paths). + +This change is **cosmetic only**: the database model names, field names, and API endpoints are unchanged, so existing automation and integrations continue to work without modification. + +### How to roll back + +Set `DD_ENABLE_V3_ORGANIZATION_ASSET_RELABEL=False` to restore the "Product" / "Product Type" labels and the original URLs. No data is changed by this feature, so the rollback is fully reversible. + +## Authorized Users panel replaces Members/Groups under legacy authorization + +Open Source DefectDojo uses the legacy authorization model: access to a Product is granted by `Product.authorized_users` (with cascade via `Product_Type.authorized_users`), and `is_staff` / `is_superuser` bypass everything. + +In 2.59 the classic UI restores the **"Authorized Users"** panel on the Product and Product Type detail pages. The panel reads from and writes to `Product.authorized_users` / `Product_Type.authorized_users` directly, so adding a user actually grants them the access the UI suggests it does. + +### New endpoints + +- `GET/POST /product//authorized_users/add` — list / add users to `Product.authorized_users` +- `POST /product//authorized_users//delete` — remove a user +- `GET/POST /product/type//authorized_users/add` — same for `Product_Type.authorized_users` +- `POST /product/type//authorized_users//delete` + +Both endpoints are gated so only `is_staff` / `is_superuser` users can add or remove. Non-staff users see the panel but no management actions. + +### How RBAC rows are converted + +The data migration `0267_backfill_authorized_users` translates RBAC tables into the legacy model with the following rules: + +| RBAC row | Legacy effect | +|---|---| +| `Product_Member` (any role, direct or via `Product_Group` + `Dojo_Group_Member`) | Adds the user to `Product.authorized_users` | +| `Product_Type_Member` (any role, direct or via `Product_Type_Group` + `Dojo_Group_Member`) | Adds the user to `Product_Type.authorized_users` | +| `Global_Role(Owner)` (direct or via group) | Sets `User.is_superuser = True` | +| `Global_Role(Writer | Maintainer | API_Importer)` (direct or via group) | Sets `User.is_staff = True` | +| `Global_Role(Reader)` | No global elevation — relies on per-product membership | + +Per-product role granularity (Reader vs Writer vs Maintainer vs Owner) collapses to membership-only because the legacy model has no per-product role concept. `Dojo_Group` structure as a permission-bearing entity is also lost; only the flattened individual user memberships remain. + +### Required actions + +- **Database migrations run automatically on upgrade.** Existing access is carried forward into the legacy `authorized_users` model. Existing data is preserved. +- **Audit the upgrade in staging first.** A new `python manage.py preview_legacy_authorization_migration` management command is shipped in 2.59 to summarize what an upgrade would change against a given database. It is read-only. Recommended workflow: install 2.59 in a staging environment with a snapshot of your production database, run the command, review the summary, then upgrade production. +- **Migrating from OS to Pro?** A new `python manage.py reconcile_authorized_users_to_rbac` management command is available on Pro to bring any access changes you made under OS forward into Pro RBAC. It supports `--dry-run` and is idempotent. + +### Pro customers are not impacted + +DefectDojo Pro deployments retain full RBAC. The Pro UX is unchanged — same Members/Groups management surface as before. + +## SSO providers are available in DefectDojo Pro only + +Single sign-on (SAML, OIDC, Google, Okta, Azure AD, GitLab, Auth0, Keycloak, GitHub Enterprise, and remote-user header authentication) has been consolidated into DefectDojo Pro. Open source DefectDojo now exposes only local username/password login and the password-reset flow. + +### Required actions + +- **No customizations or local-only login:** No action required. +- **Currently logging in via SSO on open source:** Existing user accounts and group memberships are preserved on upgrade, but SSO sign-in will no longer work after 2.59. To keep an SSO-driven login experience, switch to [DefectDojo Pro](https://defectdojo.com), which carries forward and extends the SSO surface (provider configuration moves to a UI-managed tuner). + +## Removal: Questionnaire API Endpoints + +As announced in DefectDojo 2.56.0, the following Questionnaire API endpoints have been removed: + +- `/api/v2/questionnaire_answered_questionnaires/` +- `/api/v2/questionnaire_answers/` +- `/api/v2/questionnaire_engagement_questionnaires/` +- `/api/v2/questionnaire_general_questionnaires/` +- `/api/v2/questionnaire_questions` + +### Required Actions + +Any requests to these endpoints will now return a 404 Not Found error. + +## Removal: Credential Manager + +As announced in DefectDojo 2.57.0, the Credential Manager feature has been removed. The following API endpoints are no longer available: + +- `/api/v2/credentials/` +- `/api/v2/credential_mappings/` + +### Required Actions + +Any requests to these endpoints will now return a 404 Not Found error. The Credential Manager UI is no longer available. + +## Removal: Stub Findings + +As announced in DefectDojo 2.57.0, the Stub Findings feature has been removed. The following API endpoint is no longer available: + +- `/api/v2/stub_findings/` + +### Required Actions + +Any requests to this endpoint will now return a 404 Not Found error. The Stub Findings UI is no longer available. + +## Configuration change in Watson Search Indexing + +In [PR 14881](https://github.com/DefectDojo/django-DefectDojo/pull/14881)We optimized the way the Django Watson search index is updated during imports and reimports. There is not a single configuration setting to manage the threshold: `DD_WATSON_ASYNC_INDEX_UPDATE_BATCH_SIZE`. The default value should work fine for most instances. + +## Dependency Check parser: related dependencies folded into the main finding + +The Dependency Check parser previously created one finding per `` in the report, in addition to the finding for the main vulnerable dependency. Because OWASP Dependency-Check attaches the vulnerability only to the main dependency in the XML and the related entries are metadata pointing to other files in the same logical component, this produced N findings sharing the same title, CVE, component name and component version — only the file path differed. For projects with Spring Boot, ActiveMQ, or other libraries whose CPE matches many sibling artifacts this produced significant noise. + +Starting in 2.59.1, the parser emits exactly one finding per vulnerability per main dependency. The file paths of any related dependencies are surfaced in the finding description under a `**Related Filepaths:**` block. + +### Background: what `` actually contains + +OWASP Dependency-Check's `DependencyBundlingAnalyzer` merges co-grouped artifacts into a single main dependency and lists the others under ``. It does this under five scenarios: + +1. **Identical content (`hashesMatch`)** — the same jar (matching sha1) found at multiple paths, e.g. the same library packaged into multiple ear/war archives. +2. **Shaded jar (`isShadedJar`)** — a `.jar` and a `pom.xml` extracted from inside it share the same CPE; the pom.xml is recorded as related. +3. **WebJar (`isWebJar`)** — a `.js` file extracted from a WebJar matches the jar's CPE (mapped via `pkg:maven/org.webjars/@`); the js file is recorded as related to the jar. +4. **Same CPE + base path + vulnerabilities + filename match** — sibling artifacts in the same project that share a CPE. Example: `spring-boot`, `spring-boot-actuator`, `spring-boot-starter-jdbc`, etc. all map to the `spring_boot` CPE and are grouped under the main `spring-boot` jar. +5. **NPM same name + version** — the same npm package discovered via different resolution paths (for example `package-lock.json` plus `node_modules`). + +Only scenario 1 represents the same vulnerable artifact at multiple deploy locations. Scenarios 2-5 are different files representing one logical component. Both cases were previously inflated into separate findings; both now collapse to one finding with the related paths listed in the description. + +### Required actions + +- **Users filtering or grouping by the `related` tag**: that tag is no longer applied because related findings are no longer created. Update any saved filters, dashboards, or rules that depend on it. Equivalent information is now available in the finding description (look for `**Related Filepaths:**`). +- **Reimport behavior**: on the next reimport of an existing Dependency Check report, the previously-created `related` findings will be closed as no longer present in the report. This is expected and matches the new parsing behavior. + + +For more information, check the [Release Notes](https://github.com/DefectDojo/django-DefectDojo/releases/tag/3.0.0). \ No newline at end of file diff --git a/docs/content/releases/pro/changelog.md b/docs/content/releases/pro/changelog.md index 762cd12c74a..1216f187b62 100644 --- a/docs/content/releases/pro/changelog.md +++ b/docs/content/releases/pro/changelog.md @@ -10,6 +10,19 @@ Here are the release notes for **DefectDojo Pro (Cloud Version)**. These release For Open Source release notes, please see the [Releases page on GitHub](https://github.com/DefectDojo/django-DefectDojo/releases), or alternatively consult the Open Source [upgrade notes](/releases/os_upgrading/upgrading_guide/). +## June 2026: v3.0 + +### June 15, 2026: v3.0.0 + +* **(Locations)** Locations are now enabled by default, superseding the legacy Endpoint model. The legacy Endpoint API stays read-compatible and your data is preserved. See [Locations enabled by default](/releases/os_upgrading/3.0/#locations-enabled-by-default). +* **(Assets & Organizations)** "Product Type" → "Organization" and "Product" → "Asset" relabeling (UI labels + URL routing) is now on by default. The change is cosmetic — API endpoints and field names are unchanged. See [Asset / Organization labels enabled by default](/releases/os_upgrading/3.0/#asset--organization-labels-enabled-by-default). +* **(Authorization)** Open Source restores the **Authorized Users** panel on Product/Product Type detail under the legacy authorization model; Pro deployments retain full RBAC and are not impacted. See [Authorized Users panel replaces Members/Groups under legacy authorization](/releases/os_upgrading/3.0/#authorized-users-panel-replaces-membersgroups-under-legacy-authorization). +* **(SSO)** SSO providers (SAML, OIDC, Google, Okta, Azure AD, GitLab, Auth0, Keycloak, GitHub Enterprise, remote-user header auth) are now DefectDojo Pro-only. See [SSO providers are available in DefectDojo Pro only](/releases/os_upgrading/3.0/#sso-providers-are-available-in-defectdojo-pro-only). +* **(API)** Removed the Questionnaire API endpoints. See [Removal: Questionnaire API Endpoints](/releases/os_upgrading/3.0/#removal-questionnaire-api-endpoints). +* **(API)** Removed the Credential Manager feature and its API endpoints. See [Removal: Credential Manager](/releases/os_upgrading/3.0/#removal-credential-manager). +* **(API)** Removed the Stub Findings feature and its API endpoint. See [Removal: Stub Findings](/releases/os_upgrading/3.0/#removal-stub-findings). +* **(Search)** Watson search index updates during import/reimport are now batched, tunable via `DD_WATSON_ASYNC_INDEX_UPDATE_BATCH_SIZE`. See [Configuration change in Watson Search Indexing](/releases/os_upgrading/3.0/#configuration-change-in-watson-search-indexing). + ## June 2026: v2.59 ### June 1, 2026: v2.59.0 diff --git a/docs/content/supported_tools/parsers/file/alertlogic.md b/docs/content/supported_tools/parsers/file/alertlogic.md new file mode 100644 index 00000000000..572a56adbe6 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/alertlogic.md @@ -0,0 +1,135 @@ +--- +title: "Alert Logic" +toc_hide: true +--- + +The [Alert Logic](https://www.alertlogic.com/) parser for DefectDojo supports imports from CSV format. This document details the parsing of Alert Logic vulnerability scan exports into DefectDojo field mappings, unmapped fields, and transformation notes for easier troubleshooting and analysis. + +## Supported File Types + +The Alert Logic parser accepts CSV file format. To generate this file from Alert Logic: + +1. Log into the Alert Logic console +2. Navigate to **Validate → Vulnerabilities** (or the equivalent vulnerability listing view) +3. Apply the filters you want included in the export +4. Export the filtered vulnerability list as CSV +5. Save the file with a `.csv` extension +6. Upload to DefectDojo using the "Alert Logic Scan" scan type + +The parser handles UTF-8 with byte-order mark (BOM) and multi-line quoted fields commonly present in Description, Evidence, and Resolution columns. + +## Default Deduplication Hashcode Fields + +Alert Logic provides a stable native vulnerability identifier in the `Vulnerability ID` column. DefectDojo uses it as `unique_id_from_tool` with hashcode fields as a fallback: + +- title +- component_name +- vuln_id_from_tool + +### Sample Scan Data + +Sample Alert Logic scans can be found in the [sample scan data folder](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/alertlogic). + +## Link To Tool + +- [Alert Logic](https://www.alertlogic.com/) +- [Alert Logic Documentation](https://docs.alertlogic.com/) + +## CSV Format + +### Total Fields in CSV + +- Total data fields: 26 +- Total data fields parsed: 26 +- Total data fields NOT parsed: 0 + +### CSV Format Field Mapping Details + +
+Click to expand Field Mapping Table + +| Source Field | DefectDojo Field | Notes | +| ----------------------- | --------------------------- | ------------------------------------------------------------------------------ | +| Vulnerability | title | Truncated to 500 characters with "..." suffix if longer | +| Severity | severity | Direct one-to-one mapping (Info / Low / Medium / High / Critical) | +| CVSS Score | cvssv3_score | Parsed as float; empty values produce no score | +| Asset Name | component_name | The affected host or service from the scan | +| IP Address | unsaved_endpoints | Comma-separated IPv4 / IPv6 list; each value becomes a separate endpoint | +| Protocol/Port | unsaved_endpoints | Parsed as `PROTOCOL/PORT`; a port of 0 is omitted | +| CVE | unsaved_vulnerability_ids | Single CVE identifier when present | +| Resolution | mitigation | Direct copy, including multi-line content | +| Vulnerability ID | unique_id_from_tool | Alert Logic's stable native vulnerability identifier (used for deduplication) | +| Description | description | Included in structured description block | +| Evidence | description | Included in structured description block | +| Operating System | description | Included in structured description block (CPE strings preserved) | +| Vulnerability Span ID | description | Included in structured description block | +| Vulnerability Key | description | Included in structured description block | +| Asset Key | description | Included in structured description block | +| Asset Type | description | Included in structured description block | +| Service | description | Included in structured description block | +| Category | description | Included in structured description block | +| VPC/Network | description | Included in structured description block | +| Deployment Name | description | Included in structured description block | +| Customer Account | description | Included in structured description block | +| First Seen | description | Included in structured description block | +| Last Scanned | description | Included in structured description block | +| Published Date | description | Included in structured description block | +| Age (days) | description | Included in structured description block | +| CISA Known Exploited | description, unsaved_tags | Added as `cisa-known-exploited` tag when value is "Yes" | + +
+ +### Additional Finding Field Settings (CSV Format) + +
+Click to expand Additional Settings Table + +| Finding Field | Default Value | Notes | +| ---------------- | ------------- | ----------------------------------------------------------- | +| static_finding | True | Alert Logic is an infrastructure vulnerability scanner | +| dynamic_finding | False | Alert Logic is an infrastructure vulnerability scanner | +| active | True | Alert Logic exports do not carry a mitigation status column | + +
+ +## Special Processing Notes + +### Severity Conversion + +Alert Logic uses a five-level severity scale that aligns one-to-one with DefectDojo severity levels: + +- `Critical` → Critical +- `High` → High +- `Medium` → Medium +- `Low` → Low +- `Info` → Info + +Any unrecognized severity value defaults to Info. + +### Title Format + +Finding titles are derived from the "Vulnerability" column. Titles longer than 500 characters are truncated to 497 characters with a "..." suffix appended. Shorter titles are used as-is without modification. + +### Description Construction + +The parser constructs a structured markdown description containing all relevant CSV fields not already mapped to dedicated Finding columns. Each field is rendered as `**Label:** value` with blank lines between entries. Fields are included only when they contain a non-empty value, so the description stays tight for sparsely populated rows. + +### Endpoint Construction + +The "IP Address" column may contain one or more comma-separated IP addresses, mixing IPv4 and IPv6 (for example: `198.51.100.30, fe80::250:56ff:fe96:b97`). Each address becomes a separate endpoint. The "Protocol/Port" column is parsed as `PROTOCOL/PORT` (e.g., `TCP/443`); when the port is `0` the value is treated as "no specific port" and omitted from the endpoint. All endpoints are validated via `endpoint.clean()` before being attached to the finding. + +### Deduplication + +Alert Logic exports include a stable per-vulnerability identifier in the "Vulnerability ID" column. DefectDojo uses this as `unique_id_from_tool` and the deduplication algorithm `DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE`. When the ID is missing (some scan exports omit it for non-vulnerability findings), DefectDojo falls back to the hashcode algorithm using `title`, `component_name`, and `vuln_id_from_tool` (the CVE) as the stable fields. + +### CVE Handling + +The "CVE" column carries a single CVE identifier or is empty. When present it is attached to the finding via `unsaved_vulnerability_ids`; when absent no CVE is set. + +### CISA Known Exploited Tagging + +When the "CISA Known Exploited" column equals "Yes", the finding receives a `cisa-known-exploited` tag. This makes it straightforward to filter, route, or escalate findings already known to be exploited in the wild. + +### BOM and Multi-Line Field Handling + +Alert Logic exports start with a UTF-8 byte-order mark (`\xef\xbb\xbf`). The parser uses `utf-8-sig` decoding to strip the BOM transparently. Description, Evidence, and Resolution columns frequently contain multi-line content (separated by `\r\n` inside the quoted field); these newlines are preserved in the resulting `description` and `mitigation` Finding fields. diff --git a/docs/content/supported_tools/parsers/file/dependency_check.md b/docs/content/supported_tools/parsers/file/dependency_check.md index f4f56ce8325..76d51277801 100644 --- a/docs/content/supported_tools/parsers/file/dependency_check.md +++ b/docs/content/supported_tools/parsers/file/dependency_check.md @@ -7,7 +7,21 @@ OWASP Dependency Check output can be imported in Xml format. This parser ingests * Suppressed vulnerabilities are tagged with the tag: `suppressed`. * Suppressed vulnerabilities are marked as mitigated. * If the suppression is missing any `` tag, it tags them as `no_suppression_document`. -* Related vulnerable dependencies are tagged with `related` tag. + +### Related dependencies + +OWASP Dependency-Check's `DependencyBundlingAnalyzer` merges co-grouped artifacts into a single main dependency and lists the others under `` in the report. The vulnerability is attached only to the main dependency; the related entries are metadata pointing to other files in the same logical component. + +The parser emits one finding per vulnerability per main dependency and surfaces the related file paths in the finding's description under a `**Related Filepaths:**` block (rather than creating a separate finding per related entry, which produced N findings sharing the same title and CVE differing only in file path). + +DC bundles dependencies under five scenarios: + +1. **Identical content (`hashesMatch`)** — the same jar (matching sha1) found at multiple paths (e.g. packaged into multiple ear/war archives). +2. **Shaded jar (`isShadedJar`)** — a `.jar` and a `pom.xml` extracted from inside it share the same CPE. +3. **WebJar (`isWebJar`)** — a `.js` file extracted from a WebJar maps to the jar's CPE via `pkg:maven/org.webjars/@`. +4. **Same CPE + base path + vulnerabilities + filename match** — sibling artifacts sharing a CPE (e.g. `spring-boot`, `spring-boot-actuator`, `spring-boot-starter-jdbc` all map to the `spring_boot` CPE). +5. **NPM same name + version** — the same npm package discovered via different resolution paths (e.g. `package-lock.json` + `node_modules`). + ### Sample Scan Data Sample Dependency Check scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/dependency_check). diff --git a/docs/content/supported_tools/parsers/file/github_vulnerability.md b/docs/content/supported_tools/parsers/file/github_vulnerability.md index 5705165913a..db2a1ceb4c6 100644 --- a/docs/content/supported_tools/parsers/file/github_vulnerability.md +++ b/docs/content/supported_tools/parsers/file/github_vulnerability.md @@ -21,6 +21,8 @@ vulnerabilityAlerts (RepositoryVulnerabilityAlert object) + severity (CRITICAL/HIGH/LOW/MODERATE) + package (optional) + name (optional) + + firstPatchedVersion (optional, sets fix_available) + + identifier (optional) + advisory (SecurityAdvisory object) + description + summary diff --git a/dojo/__init__.py b/dojo/__init__.py index cbea93d34cd..5432d8118f7 100644 --- a/dojo/__init__.py +++ b/dojo/__init__.py @@ -4,6 +4,6 @@ # Django starts so that shared_task will use this app. from .celery import app as celery_app # noqa: F401 -__version__ = "2.59.0" +__version__ = "3.0.0" __url__ = "https://github.com/DefectDojo/django-DefectDojo" __docs__ = "https://documentation.defectdojo.com" diff --git a/dojo/admin.py b/dojo/admin.py index c7a21b91019..94d149af035 100644 --- a/dojo/admin.py +++ b/dojo/admin.py @@ -14,6 +14,20 @@ TextQuestion, ) + +# Django's default admin gate is `is_active and is_staff`. Under the legacy +# OS auth model is_staff is already a near-superuser bypass for queryset +# filters and most permission checks, so the standard UserAdmin change form +# would let any is_staff user with auth.change_user tick is_superuser on +# themselves. Rather than narrowing each ModelAdmin individually, restrict +# the entire admin site to superusers — there is no DefectDojo OS role that +# legitimately needs /admin/ access without being a superuser. +def _admin_site_has_permission(request): + return request.user.is_active and request.user.is_superuser + + +admin.site.has_permission = _admin_site_has_permission + # Conditionally unregister LogEntry from auditlog if it's registered try: from auditlog.models import LogEntry diff --git a/dojo/api_v2/serializers.py b/dojo/api_v2/serializers.py index dc15ac6c2dc..b3b83a239b5 100644 --- a/dojo/api_v2/serializers.py +++ b/dojo/api_v2/serializers.py @@ -613,6 +613,12 @@ def validate(self, data): msg = "Only superusers are allowed to add or edit superusers." raise ValidationError(msg) + instance_is_staff = self.instance.is_staff if self.instance is not None else False + data_is_staff = data.get("is_staff", instance_is_staff) + if not self.context["request"].user.is_superuser and data_is_staff != instance_is_staff: + msg = "Only superusers are allowed to add or edit staff users." + raise ValidationError(msg) + if self.context["request"].method in {"PATCH", "PUT"} and "password" in data: msg = "Update of password though API is not allowed" raise ValidationError(msg) diff --git a/dojo/asset/urls.py b/dojo/asset/urls.py index 3f4c5019fcc..1b71a03dddf 100644 --- a/dojo/asset/urls.py +++ b/dojo/asset/urls.py @@ -123,6 +123,16 @@ views.delete_engagement_presets, name="delete_engagement_presets", ), + re_path( + r"^asset/(?P\d+)/authorized_users/add$", + views.add_product_authorized_users, + name="add_product_authorized_users", + ), + re_path( + r"^asset/(?P\d+)/authorized_users/(?P\d+)/delete$", + views.delete_product_authorized_user, + name="delete_product_authorized_user", + ), re_path( r"^asset/(?P\d+)/add_api_scan_configuration$", views.add_api_scan_configuration, @@ -165,6 +175,8 @@ re_path(r"^product/(?P\d+)/engagement_presets/(?P\d+)/edit$", redirect_view("edit_engagement_presets")), re_path(r"^product/(?P\d+)/engagement_presets/add$", redirect_view("add_engagement_presets")), re_path(r"^product/(?P\d+)/engagement_presets/(?P\d+)/delete$", redirect_view("delete_engagement_presets")), + re_path(r"^product/(?P\d+)/authorized_users/add$", redirect_view("add_product_authorized_users")), + re_path(r"^product/(?P\d+)/authorized_users/(?P\d+)/delete$", redirect_view("delete_product_authorized_user")), re_path(r"^product/(?P\d+)/add_api_scan_configuration$", redirect_view("add_api_scan_configuration")), re_path(r"^product/(?P\d+)/view_api_scan_configurations$", redirect_view("view_api_scan_configurations")), re_path(r"^product/(?P\d+)/edit_api_scan_configuration/(?P\d+)$", redirect_view("edit_api_scan_configuration")), @@ -260,6 +272,9 @@ re_path(r"^asset/(?P\d+)/engagement_presets/add$", redirect_view("add_engagement_presets")), re_path(r"^asset/(?P\d+)/engagement_presets/(?P\d+)/delete$", redirect_view("delete_engagement_presets")), + re_path(r"^asset/(?P\d+)/authorized_users/add$", redirect_view("add_product_authorized_users")), + re_path(r"^asset/(?P\d+)/authorized_users/(?P\d+)/delete$", + redirect_view("delete_product_authorized_user")), re_path(r"^asset/(?P\d+)/add_api_scan_configuration$", redirect_view("add_api_scan_configuration")), re_path(r"^asset/(?P\d+)/view_api_scan_configurations$", redirect_view("view_api_scan_configurations")), re_path(r"^asset/(?P\d+)/edit_api_scan_configuration/(?P\d+)$", diff --git a/dojo/authorization/middleware.py b/dojo/authorization/middleware.py index f5543753a0c..b27479044cb 100644 --- a/dojo/authorization/middleware.py +++ b/dojo/authorization/middleware.py @@ -43,7 +43,10 @@ def process_view(self, request, view_func, view_args, view_kwargs): _, model, permission, arg_name = check lookup_value = view_kwargs.get(arg_name) if lookup_value is None: - continue # kwarg not present, skip this check + # The URL pattern and the URL_PERMISSIONS entry have drifted + # apart on the kwarg name. Treat as a configuration error + # and deny rather than silently allowing the request. + raise PermissionDenied obj = get_object_or_404(model, pk=lookup_value) user_has_permission_or_403(request.user, obj, permission) diff --git a/dojo/authorization/url_permissions.py b/dojo/authorization/url_permissions.py index 65807ee52c2..70ac4ab20bb 100644 --- a/dojo/authorization/url_permissions.py +++ b/dojo/authorization/url_permissions.py @@ -1,3 +1,6 @@ +from django.conf import settings + +from dojo.location.models import Location from dojo.models import ( App_Analysis, Endpoint, @@ -149,17 +152,11 @@ # ----------------------------------------------------------------------- # URL / Location UI (dojo/url/ui/views.py -> dojo/url/ui/urls.py) # - # These URL names overlap with the endpoint module above. Since Django - # uses the last-registered pattern for reverse() and the middleware reads - # view_kwargs from the matched pattern, the kwarg names from the actually - # matched URL are used. The endpoint entries above use "eid"; if the - # url/ui pattern matched instead, "location_id" will be present and the - # middleware will fall back (skip checks where the kwarg is missing). - # - # Unique URL names from url/ui: + # When V3_FEATURE_LOCATIONS is enabled, the endpoint URL names above + # are remapped below to the active routes' model + kwarg names. # ----------------------------------------------------------------------- "add_endpoint_to_product": [("object", Product, "add", "product_id")], - "add_endpoint_to_finding": [("object", Product, "add", "finding_id")], + "add_endpoint_to_finding": [("object", Finding, "add", "finding_id")], # ----------------------------------------------------------------------- # Reports (dojo/reports/views.py -> dojo/reports/urls.py) @@ -293,3 +290,28 @@ "delete_empty_questionnaire": [("config", "dojo.delete_engagement_survey")], "delete_general_questionnaire": [("config", "dojo.delete_engagement_survey")], } + + +# When the V3 location routes are active they replace the legacy endpoint +# routes (dojo/urls.py). The new routes operate on Location rows and carry +# a "location_id" kwarg, so the URL-name -> check mapping needs to point +# at the active route's model + kwarg for the middleware to apply the +# right per-object check. +if settings.V3_FEATURE_LOCATIONS: + URL_PERMISSIONS.update({ + "view_endpoint": [("object", Location, "view", "location_id")], + "view_endpoint_host": [("object", Location, "view", "location_id")], + "edit_endpoint": [("object", Location, "edit", "location_id")], + "delete_endpoint": [("object", Location, "delete", "location_id")], + "endpoint_report": [("object", Location, "view", "location_id")], + "endpoint_host_report": [("object", Location, "view", "location_id")], + "add_endpoint_meta_data": [("object", Location, "edit", "location_id")], + "edit_endpoint_meta_data": [("object", Location, "edit", "location_id")], + # The V3 "add_endpoint" route is an alias for add_endpoint_to_product; + # it carries product_id rather than the legacy pid. + "add_endpoint": [("object", Product, "add", "product_id")], + # Remaining V3 routes that share a URL name with the legacy module + # but carry a different kwarg. + "import_endpoint_meta": [("object", Product, "edit", "product_id")], + "endpoints_status_bulk": [("object", Finding, "edit", "finding_id")], + }) diff --git a/dojo/db_migrations/0265_remove_stub_finding.py b/dojo/db_migrations/0265_remove_stub_finding.py index a9432846d8f..65d813cb211 100644 --- a/dojo/db_migrations/0265_remove_stub_finding.py +++ b/dojo/db_migrations/0265_remove_stub_finding.py @@ -1,8 +1,10 @@ -"""Remove the Stub Findings feature. +"""Remove the Stub Findings feature (state only). -Drops the ``Stub_Finding`` model. Stub Findings was deprecated in 2.57.0 and -is end-of-life in 2.59. The model has no inbound foreign keys, so the -deletion is self-contained. +Drops the ``Stub_Finding`` model from Django's state but leaves the +``dojo_stub_finding`` table in place so a downgrade to a release that still +defines the model keeps its data. Stub Findings was deprecated in 2.57.0 and +is end-of-life in 2.59. The model has no inbound foreign keys, so the removal +is self-contained. Note: rebase the filename and the ``dependencies`` tuple to point at whatever the latest migration is at merge time if another migration has @@ -19,7 +21,14 @@ class Migration(migrations.Migration): ] operations = [ - migrations.DeleteModel( - name="Stub_Finding", + migrations.SeparateDatabaseAndState( + # State only: forget the model so it no longer has to be defined + # in dojo/models.py. database_operations is intentionally empty so + # the dojo_stub_finding table is preserved for downgrades. + state_operations=[ + migrations.DeleteModel( + name="Stub_Finding", + ), + ], ), ] diff --git a/dojo/db_migrations/0266_remove_credential_manager.py b/dojo/db_migrations/0266_remove_credential_manager.py index ba04cf317db..ddcd088fcd6 100644 --- a/dojo/db_migrations/0266_remove_credential_manager.py +++ b/dojo/db_migrations/0266_remove_credential_manager.py @@ -1,9 +1,11 @@ -"""Remove the Credential Manager feature. +"""Remove the Credential Manager feature (state only). -Drops the `Cred_User`, `Cred_Mapping`, and `Cred_UserEvent` models, removes -the pghistory triggers that wrote into the latter, and removes the -`enable_credentials` switch from System_Settings. The Credential Manager -feature was deprecated in 2.57.0 and is end-of-life in 2.59. +Removes the `Cred_User`, `Cred_Mapping`, and `Cred_UserEvent` models, their +pghistory triggers, and the `enable_credentials` switch from System_Settings +from Django's state, but leaves the underlying tables, columns, and triggers +in the database so a downgrade to a release that still defines them keeps its +data. The Credential Manager feature was deprecated in 2.57.0 and is +end-of-life in 2.59. """ import pgtrigger.migrations @@ -17,36 +19,56 @@ class Migration(migrations.Migration): ] operations = [ - # Remove pghistory triggers that mirror Cred_User changes into - # Cred_UserEvent. Triggers must be dropped before the source / event - # tables can be removed. - pgtrigger.migrations.RemoveTrigger( - model_name="cred_user", - name="insert_insert", + # State only: forget the models and their triggers so they no longer + # have to be defined in dojo/models.py. There are no database_operations + # so the dojo_cred_user, dojo_cred_mapping, and dojo_cred_userevent + # tables and the cred_user pghistory triggers are preserved for + # downgrades. + migrations.SeparateDatabaseAndState( + state_operations=[ + # Drop the pghistory triggers from state before the model they + # hang off of is removed. + pgtrigger.migrations.RemoveTrigger( + model_name="cred_user", + name="insert_insert", + ), + pgtrigger.migrations.RemoveTrigger( + model_name="cred_user", + name="update_update", + ), + pgtrigger.migrations.RemoveTrigger( + model_name="cred_user", + name="delete_delete", + ), + # Cred_UserEvent FKs Cred_User; Cred_Mapping FKs Cred_User too, + # so both come out of state before Cred_User itself. + migrations.DeleteModel( + name="Cred_UserEvent", + ), + migrations.DeleteModel( + name="Cred_Mapping", + ), + migrations.DeleteModel( + name="Cred_User", + ), + ], ), - pgtrigger.migrations.RemoveTrigger( - model_name="cred_user", - name="update_update", - ), - pgtrigger.migrations.RemoveTrigger( - model_name="cred_user", - name="delete_delete", - ), - # Drop the audit/event table (FKs from Cred_UserEvent → Cred_User get - # cleaned up automatically as part of DeleteModel). - migrations.DeleteModel( - name="Cred_UserEvent", - ), - # Cred_Mapping holds an FK to Cred_User and must be dropped first. - migrations.DeleteModel( - name="Cred_Mapping", - ), - migrations.DeleteModel( - name="Cred_User", - ), - # The UI toggle no longer has anything to gate. - migrations.RemoveField( - model_name="system_settings", - name="enable_credentials", + # Drop the enable_credentials field from state but keep the column for + # downgrades. The model no longer supplies a value on INSERT, so give + # the column a server-side default (the field defaulted to True) to + # keep new System_Settings rows satisfying its NOT NULL constraint. + migrations.SeparateDatabaseAndState( + state_operations=[ + migrations.RemoveField( + model_name="system_settings", + name="enable_credentials", + ), + ], + database_operations=[ + migrations.RunSQL( + sql="ALTER TABLE dojo_system_settings ALTER COLUMN enable_credentials SET DEFAULT true;", + reverse_sql="ALTER TABLE dojo_system_settings ALTER COLUMN enable_credentials DROP DEFAULT;", + ), + ], ), ] diff --git a/dojo/finding/helper.py b/dojo/finding/helper.py index 7808fdd7cf5..140a6bd426d 100644 --- a/dojo/finding/helper.py +++ b/dojo/finding/helper.py @@ -603,6 +603,7 @@ def reconfigure_duplicate_cluster(original, cluster_outside): duplicate=False, duplicate_finding=None, active=original.active, + verified=original.verified, is_mitigated=original.is_mitigated, ) new_original.found_by.set(original.found_by.all()) diff --git a/dojo/finding/urls.py b/dojo/finding/urls.py index fda259ee895..96bceeec4e8 100644 --- a/dojo/finding/urls.py +++ b/dojo/finding/urls.py @@ -152,7 +152,7 @@ name="reopen_finding"), re_path(r"^finding/image/(?P[^/]+)$", views.download_finding_pic, name="download_finding_pic"), - re_path(r"^finding/(?P\d+)/merge$", + re_path(r"^finding/(?P\d+)/merge$", views.merge_finding_product, name="merge_finding"), re_path(r"^product/(?P\d+)/merge$", views.merge_finding_product, name="merge_finding_product"), diff --git a/dojo/forms.py b/dojo/forms.py index e33d7ef51c4..cb90d0f57de 100644 --- a/dojo/forms.py +++ b/dojo/forms.py @@ -2176,16 +2176,13 @@ class ReviewFindingForm(forms.Form): def __init__(self, *args, **kwargs): finding = kwargs.pop("finding", None) - user = kwargs.pop("user", None) + kwargs.pop("user", None) super().__init__(*args, **kwargs) # Get the list of users if finding is not None: users = get_authorized_users_for_product_and_product_type(None, finding.test.engagement.product, "edit") else: users = get_authorized_users("edit").filter(is_active=True) - # Remove the current user - if user is not None: - users = users.exclude(id=user.id) # Save a copy of the original query to be used in the validator self.reviewer_queryset = users # Set the users in the form diff --git a/dojo/importers/default_reimporter.py b/dojo/importers/default_reimporter.py index aa33c6153b0..e9c6567107a 100644 --- a/dojo/importers/default_reimporter.py +++ b/dojo/importers/default_reimporter.py @@ -801,9 +801,16 @@ def process_matched_mitigated_finding( existing_finding.mitigated = None existing_finding.is_mitigated = False existing_finding.mitigated_by = None - existing_finding.active = True - if self.verified is not None: - existing_finding.verified = self.verified + # A duplicate finding must stay inactive/unverified (see set_duplicate and the + # "Duplicate findings cannot be verified or active" form validation). Un-mitigate it + # but do not reactivate it, otherwise we create an invalid active/verified duplicate state. + if existing_finding.duplicate: + existing_finding.active = False + existing_finding.verified = False + else: + existing_finding.active = True + if self.verified is not None: + existing_finding.verified = self.verified component_name = getattr(unsaved_finding, "component_name", None) component_version = getattr(unsaved_finding, "component_version", None) @@ -819,7 +826,11 @@ def process_matched_mitigated_finding( # don't dedupe before endpoints/locations are added, postprocessing will be done on next save (in calling method) existing_finding.save_no_options() - note = Notes(entry=f"Re-activated by {self.scan_type} re-upload.", author=self.user) + if existing_finding.duplicate: + note_entry = f"Un-mitigated by {self.scan_type} re-upload but kept inactive because the finding is a duplicate." + else: + note_entry = f"Re-activated by {self.scan_type} re-upload." + note = Notes(entry=note_entry, author=self.user) note.save() self.location_handler.record_reactivations_for_finding(existing_finding) existing_finding.notes.add(note) diff --git a/dojo/jira/helper.py b/dojo/jira/helper.py index 4ae822b8002..5b83a0596ab 100644 --- a/dojo/jira/helper.py +++ b/dojo/jira/helper.py @@ -1223,29 +1223,16 @@ def get_jira_issue_from_jira(find): return None -def issue_from_jira_is_active(issue_from_jira): - # "resolution":{ - # "self":"http://www.testjira.com/rest/api/2/resolution/11", - # "id":"11", - # "description":"Cancelled by the customer.", - # "name":"Cancelled" - # }, - - # or - # "resolution": null - - # or - # "resolution": "None" - - if not hasattr(issue_from_jira.fields, "resolution"): - logger.debug(vars(issue_from_jira)) - return True +def issue_status_category_is_done(status_category_key: str | None) -> bool: + return status_category_key == "done" - if not issue_from_jira.fields.resolution: - return True - # some kind of resolution is present that is not null or None - return issue_from_jira.fields.resolution == "None" +def issue_from_jira_is_active(issue_from_jira): + try: + statusCategoryKey = issue_from_jira.fields.status.statusCategory.key + except AttributeError: + statusCategoryKey = None + return not issue_status_category_is_done(statusCategoryKey) def push_status_to_jira(obj, jira_instance, jira, issue, *, save=False): @@ -1930,7 +1917,7 @@ def process_resolution_from_jira( # classify "done" issues as risk-accepted, false-positive, or the # default mitigated category (see jira_instance.accepted_resolutions # and .false_positive_resolutions below). - resolved = status_category_key == "done" + resolved = issue_status_category_is_done(status_category_key) jira_instance = get_jira_instance(finding) if resolved: diff --git a/dojo/organization/urls.py b/dojo/organization/urls.py index ea9e8d00cc3..0555a654b20 100644 --- a/dojo/organization/urls.py +++ b/dojo/organization/urls.py @@ -38,6 +38,16 @@ product_views.new_product, name="add_product_to_product_type", ), + re_path( + r"^organization/(?P\d+)/authorized_users/add$", + views.add_product_type_authorized_users, + name="add_product_type_authorized_users", + ), + re_path( + r"^organization/(?P\d+)/authorized_users/(?P\d+)/delete$", + views.delete_product_type_authorized_user, + name="delete_product_type_authorized_user", + ), # TODO: Backwards compatibility; remove after v3 migration is complete re_path(r"^product/type$", redirect_view("product_type")), re_path(r"^product/type/(?P\d+)$", redirect_view("view_product_type")), @@ -45,6 +55,8 @@ re_path(r"^product/type/(?P\d+)/delete$", redirect_view("delete_product_type")), re_path(r"^product/type/add$", redirect_view("add_product_type")), re_path(r"^product/type/(?P\d+)/add_product", redirect_view("add_product_to_product_type")), + re_path(r"^product/type/(?P\d+)/authorized_users/add$", redirect_view("add_product_type_authorized_users")), + re_path(r"^product/type/(?P\d+)/authorized_users/(?P\d+)/delete$", redirect_view("delete_product_type_authorized_user")), ] else: urlpatterns = [ @@ -74,4 +86,6 @@ re_path(r"^organization/(?P\d+)/delete$", redirect_view("delete_product_type")), re_path(r"^organization/add$", redirect_view("add_product_type")), re_path(r"^organization/(?P\d+)/add_product", redirect_view("add_product_to_product_type")), + re_path(r"^organization/(?P\d+)/authorized_users/add$", redirect_view("add_product_type_authorized_users")), + re_path(r"^organization/(?P\d+)/authorized_users/(?P\d+)/delete$", redirect_view("delete_product_type_authorized_user")), ] diff --git a/dojo/reports/views.py b/dojo/reports/views.py index 6b372ad8df2..f346dd89900 100644 --- a/dojo/reports/views.py +++ b/dojo/reports/views.py @@ -557,16 +557,26 @@ def generate_report(request, obj, *, host_view=False): endpoint = obj if host_view: report_name = "Endpoint Host Report: " + endpoint.url.host - endpoints = Location.objects.prefetch_related("url").filter(url__host=endpoint.url.host).distinct() + endpoints = get_authorized_locations( + Permissions.Location_View, + queryset=Location.objects.prefetch_related("url").filter(url__host=endpoint.url.host), + user=request.user, + ).distinct() report_title = "Endpoint Host Report" else: report_name = "Endpoint Report: " + str(endpoint) endpoints = Location.objects.filter(id=endpoint.id).distinct() report_title = "Endpoint Report" template = "dojo/endpoint_pdf_report.html" + # Reduce the finding queryset to the requesting user's product scope -- + # a shared Location's auth check passes for any associated product the + # user can see, but rendered findings must still be limited to that scope. + findings_for_locations = get_authorized_findings( + Permissions.Finding_View, user=request.user, + ).filter(locations__location__in=endpoints) findings = report_finding_filter_class( request.GET, - queryset=prefetch_related_findings_for_report(Finding.objects.filter(locations__location__in=endpoints)), + queryset=prefetch_related_findings_for_report(findings_for_locations), ) context = { diff --git a/dojo/settings/settings.dist.py b/dojo/settings/settings.dist.py index f99e62eca2e..e0edbf0d192 100644 --- a/dojo/settings/settings.dist.py +++ b/dojo/settings/settings.dist.py @@ -51,7 +51,7 @@ DD_DJANGO_METRICS_ENABLED=(bool, False), DD_LOGIN_REDIRECT_URL=(str, "/"), DD_LOGIN_URL=(str, "/login"), - DD_DJANGO_ADMIN_ENABLED=(bool, True), + DD_DJANGO_ADMIN_ENABLED=(bool, False), DD_SESSION_COOKIE_HTTPONLY=(bool, True), DD_CSRF_COOKIE_HTTPONLY=(bool, True), DD_SECURE_SSL_REDIRECT=(bool, False), @@ -261,10 +261,10 @@ # For HTTP requests, how long connection is open before timeout # This settings apply only on requests performed by "requests" lib used in Dojo code (if some included lib is using "requests" as well, this does not apply there) DD_REQUESTS_TIMEOUT=(int, 30), - # Dictates if v3 functionality will be enabled - DD_V3_FEATURE_LOCATIONS=(bool, False), - # Dictates if v3 org/asset relabeling (+url routing) will be enabled - DD_ENABLE_V3_ORGANIZATION_ASSET_RELABEL=(bool, False), + # Dictates if v3 functionality will be enabled (on by default as of 3.0.0; set to False to revert to the legacy Endpoint model) + DD_V3_FEATURE_LOCATIONS=(bool, True), + # Dictates if v3 org/asset relabeling (+url routing) will be enabled (on by default as of 3.0.0; set to False to restore Product/Product Type labels and URLs) + DD_ENABLE_V3_ORGANIZATION_ASSET_RELABEL=(bool, True), # Notification env-vars (SLA notify, alert refresh/counter/cap, system-level trump). Defined in dojo.notifications.settings. **NOTIFICATIONS_ENV_DEFAULTS, ) @@ -1107,6 +1107,7 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param "Orca Security Alerts": ["title", "component_name"], "Xygeni SCA Scan": ["vulnerability_ids", "component_name", "component_version"], "Qualys VMDR": ["title", "component_name", "vuln_id_from_tool"], + "Alert Logic Scan": ["title", "component_name", "vuln_id_from_tool"], } # Override the hardcoded settings here via the env var @@ -1381,6 +1382,7 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param "Xygeni SCA Scan": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, "Xygeni Secrets Scan": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL, "Qualys VMDR": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "Alert Logic Scan": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, } # Override the hardcoded settings here via the env var diff --git a/dojo/settings/template-env b/dojo/settings/template-env index bbfc9e90ad9..0263dc941a7 100644 --- a/dojo/settings/template-env +++ b/dojo/settings/template-env @@ -1,8 +1,9 @@ # Django Debug, don't enable on production! DD_DEBUG=False -# Enables Django Admin -DD_DJANGO_ADMIN_ENABLED=True +# Enables the Django admin interface at /admin/. Off by default; uncomment +# this line to expose it. Access is restricted to superusers regardless. +# DD_DJANGO_ADMIN_ENABLED=True # A secret key for a particular Django installation. DD_SECRET_KEY=#DD_SECRET_KEY# diff --git a/dojo/templates/dojo/metrics.html b/dojo/templates/dojo/metrics.html index 6e56099463f..2c7ab40fbf6 100644 --- a/dojo/templates/dojo/metrics.html +++ b/dojo/templates/dojo/metrics.html @@ -147,9 +147,11 @@

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

+ {% if form %}
{% include "dojo/filter_snippet.html" with form=form clear_link="/metrics/product/type" %}
+ {% endif %} diff --git a/dojo/tools/alertlogic/__init__.py b/dojo/tools/alertlogic/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/alertlogic/parser.py b/dojo/tools/alertlogic/parser.py new file mode 100644 index 00000000000..f56f7bd3fcd --- /dev/null +++ b/dojo/tools/alertlogic/parser.py @@ -0,0 +1,172 @@ +import csv +import io + +from django.conf import settings + +from dojo.models import Endpoint, Finding +from dojo.tools.locations import LocationData + +SEVERITY_MAPPING = { + "Info": "Info", + "Low": "Low", + "Medium": "Medium", + "High": "High", + "Critical": "Critical", +} + + +class AlertlogicParser: + + def get_scan_types(self): + return ["Alert Logic Scan"] + + def get_label_for_scan_types(self, scan_type): + return scan_type + + def get_description_for_scan_types(self, scan_type): + return "Import Alert Logic vulnerability scan findings (CSV)." + + def get_findings(self, filename, test): + content = filename.read() + if isinstance(content, bytes): + content = content.decode("utf-8-sig") + elif content.startswith(""): + content = content.lstrip("") + + reader = csv.DictReader(io.StringIO(content), delimiter=",", quotechar='"') + findings = [] + for row in reader: + vuln = (row.get("Vulnerability") or "").strip() + if not vuln: + continue + + severity_raw = (row.get("Severity") or "").strip() + severity = SEVERITY_MAPPING.get(severity_raw, "Info") + + title = vuln[:497] + "..." if len(vuln) > 500 else vuln + + description = _build_description(row) + mitigation = (row.get("Resolution") or "").strip() + component_name = (row.get("Asset Name") or "").strip() or None + unique_id = (row.get("Vulnerability ID") or "").strip() or None + cve = (row.get("CVE") or "").strip() + + finding = Finding( + test=test, + title=title, + severity=severity, + description=description, + mitigation=mitigation, + component_name=component_name, + unique_id_from_tool=unique_id, + static_finding=True, + dynamic_finding=False, + ) + + cvssv3_score = _parse_cvss(row.get("CVSS Score")) + if cvssv3_score is not None: + finding.cvssv3_score = cvssv3_score + + if cve: + finding.unsaved_vulnerability_ids = [cve] + + _add_locations( + finding, + row.get("IP Address"), + row.get("Protocol/Port"), + ) + + tags = _build_tags(row) + if tags: + finding.unsaved_tags = tags + + findings.append(finding) + + return findings + + +def _build_description(row): + field_order = [ + ("Description", "Description"), + ("Evidence", "Evidence"), + ("Operating System", "Operating System"), + ("Vulnerability ID", "Vulnerability ID"), + ("Vulnerability Span ID", "Vulnerability Span ID"), + ("Vulnerability Key", "Vulnerability Key"), + ("Asset Key", "Asset Key"), + ("Asset Type", "Asset Type"), + ("Service", "Service"), + ("Category", "Category"), + ("VPC/Network", "VPC/Network"), + ("Deployment Name", "Deployment Name"), + ("Customer Account", "Customer Account"), + ("First Seen", "First Seen"), + ("Last Scanned", "Last Scanned"), + ("Published Date", "Published Date"), + ("Age (days)", "Age (days)"), + ("CISA Known Exploited", "CISA Known Exploited"), + ] + parts = [] + for source_field, label in field_order: + value = (row.get(source_field) or "").strip() + if value: + parts.append(f"**{label}:** {value}") + return "\n\n".join(parts) + + +def _parse_cvss(value): + if value is None: + return None + value = value.strip() + if not value: + return None + try: + return float(value) + except ValueError: + return None + + +def _add_locations(finding, ip_field, protoport_field): + if not ip_field: + return + protocol, port = _parse_proto_port(protoport_field) + for raw_host in ip_field.split(","): + host = raw_host.strip() + if not host: + continue + if settings.V3_FEATURE_LOCATIONS: + finding.unsaved_locations.append( + LocationData.url(host=host, protocol=protocol or "", port=port), + ) + else: + # TODO: Delete this after the move to Locations + kwargs = {"host": host} + if protocol: + kwargs["protocol"] = protocol + if port: + kwargs["port"] = port + finding.unsaved_endpoints.append(Endpoint(**kwargs)) + + +def _parse_proto_port(value): + if not value: + return None, None + value = value.strip() + if "/" not in value: + return None, None + proto, _, port_str = value.partition("/") + proto = proto.strip().lower() or None + try: + port = int(port_str.strip()) + except (ValueError, TypeError): + port = None + if port == 0: + port = None + return proto, port + + +def _build_tags(row): + tags = [] + if (row.get("CISA Known Exploited") or "").strip().lower() == "yes": + tags.append("cisa-known-exploited") + return tags diff --git a/dojo/tools/dependency_check/parser.py b/dojo/tools/dependency_check/parser.py index 4c472e2f4c4..03590bdd02d 100644 --- a/dojo/tools/dependency_check/parser.py +++ b/dojo/tools/dependency_check/parser.py @@ -86,6 +86,57 @@ def add_finding(self, finding, dupes): if key not in dupes: dupes[key] = finding + def build_related_dependencies_block(self, dependency, namespace): + """ + Return a markdown block listing related dependencies, or ''. + + Dependency-Check's DependencyBundlingAnalyzer merges co-grouped artifacts + into one main dependency and lists the others under . + The vulnerability is attached only to the main dependency in the XML; the + related entries are metadata pointing to other files in the same logical + component. Previously we emitted one finding per related entry, which + multiplied a single CVE into N findings sharing the same title and CVE + with only the file_path differing — pure noise for the user. Instead we + keep one finding for the main dependency and surface the related file + paths in its description. + + DC bundles dependencies under five scenarios (see + DependencyBundlingAnalyzer.evaluateDependencies): + + 1. hashesMatch — identical content (same sha1) found at multiple paths, + e.g. the same jar packaged into multiple ear/war archives. + 2. isShadedJar — a .jar and a pom.xml extracted from inside it share the + same CPE; the pom.xml is recorded as related to the jar. + 3. isWebJar — a .js file extracted from a WebJar matches the jar's CPE + (mapped via pkg:maven/org.webjars/@); the js is + related to the jar. + 4. CPE + base path + vulnerabilities + filename match — sibling artifacts + in the same project that share a CPE (e.g. spring-boot, + spring-boot-actuator, spring-boot-starter all map to the + spring_boot CPE). + 5. NPM same name + version — the same npm package discovered via + different resolution paths (e.g. package-lock.json + node_modules). + + Scenario 1 is the only case where the related entries are genuinely + separate vulnerable locations; scenarios 2-5 are different files + representing one logical component. Listing all related paths in the + description preserves the per-location information for scenario 1 while + removing the noise from scenarios 2-5. + """ + related = dependency.find(namespace + "relatedDependencies") + if related is None: + return "" + entries = [] + for rd in related.findall(namespace + "relatedDependency"): + file_name = rd.findtext(f"{namespace}fileName") + if not file_name: + continue + file_path = (rd.findtext(f"{namespace}filePath") or "").strip() + entries.append(f"- {file_name} ({file_path})" if file_path else f"- {file_name}") + if not entries: + return "" + return "\n**Related Filepaths:**\n" + "\n".join(entries) + def get_filename_and_path_from_dependency( self, dependency, @@ -448,6 +499,7 @@ def get_findings(self, filename, test): namespace + "vulnerabilities", ) if vulnerabilities is not None: + related_block = self.build_related_dependencies_block(dependency, namespace) for vulnerability in vulnerabilities.findall( namespace + "vulnerability", ): @@ -459,29 +511,12 @@ def get_findings(self, filename, test): test, namespace, ) + if related_block: + finding.description += related_block if scan_date: finding.date = scan_date self.add_finding(finding, dupes) - relatedDependencies = dependency.find( - namespace + "relatedDependencies", - ) - if relatedDependencies is not None: - for relatedDependency in relatedDependencies.findall( - namespace + "relatedDependency", - ): - finding = self.get_finding_from_vulnerability( - dependency, - relatedDependency, - vulnerability, - test, - namespace, - ) - if finding: # could be None - if scan_date: - finding.date = scan_date - self.add_finding(finding, dupes) - for suppressedVulnerability in vulnerabilities.findall( namespace + "suppressedVulnerability", ): @@ -493,6 +528,8 @@ def get_findings(self, filename, test): test, namespace, ) + if related_block: + finding.description += related_block if scan_date: finding.date = scan_date self.add_finding(finding, dupes) diff --git a/dojo/tools/github_vulnerability/parser.py b/dojo/tools/github_vulnerability/parser.py index 5c646086aeb..4b9a53afed7 100644 --- a/dojo/tools/github_vulnerability/parser.py +++ b/dojo/tools/github_vulnerability/parser.py @@ -83,6 +83,12 @@ def get_findings(self, filename, test): pkg = vuln.get("package", {}) finding.component_name = pkg.get("name") + first_patched = vuln.get("firstPatchedVersion") + finding.fix_available = ( + first_patched is not None + and first_patched.get("identifier") is not None + ) + if alert.get("createdAt"): finding.date = dateutil.parser.parse(alert.get("createdAt")) if alert.get("state") in {"FIXED", "DISMISSED"}: diff --git a/helm/defectdojo/Chart.yaml b/helm/defectdojo/Chart.yaml index eb061f77bb6..842c37aedd3 100644 --- a/helm/defectdojo/Chart.yaml +++ b/helm/defectdojo/Chart.yaml @@ -1,8 +1,8 @@ apiVersion: v2 -appVersion: "2.59.0" +appVersion: "3.0.0" description: A Helm chart for Kubernetes to install DefectDojo name: defectdojo -version: 1.9.30 +version: 1.9.31 icon: https://defectdojo.com/hubfs/DefectDojo_favicon.png maintainers: - name: madchap @@ -34,4 +34,4 @@ dependencies: # description: Critical bug annotations: artifacthub.io/prerelease: "false" - artifacthub.io/changes: "- kind: changed\n description: Update valkey Docker tag from 0.20.0 to v0.20.1 (_/defect_/Chart.yaml)\n- kind: changed\n description: Update valkey Docker tag from 0.20.1 to v0.20.2 (_/defect_/Chart.yaml)\n- kind: changed\n description: Bump DefectDojo to 2.59.0\n" + artifacthub.io/changes: "- kind: changed\n description: chore(deps)_ update gcr.io/cloudsql__/gce_proxy _ tag from 1.37.12 to v1.38.0 (_/defect_/values.yaml)\n- kind: changed\n description: Bump DefectDojo to 3.0.0\n" diff --git a/helm/defectdojo/README.md b/helm/defectdojo/README.md index f8b2e30f27c..924fac6ef7c 100644 --- a/helm/defectdojo/README.md +++ b/helm/defectdojo/README.md @@ -511,7 +511,7 @@ The HELM schema will be generated for you. # General information about chart values -![Version: 1.9.30](https://img.shields.io/badge/Version-1.9.30-informational?style=flat-square) ![AppVersion: 2.59.0](https://img.shields.io/badge/AppVersion-2.59.0-informational?style=flat-square) +![Version: 1.9.31](https://img.shields.io/badge/Version-1.9.31-informational?style=flat-square) ![AppVersion: 3.0.0](https://img.shields.io/badge/AppVersion-3.0.0-informational?style=flat-square) A Helm chart for Kubernetes to install DefectDojo @@ -589,13 +589,13 @@ A Helm chart for Kubernetes to install DefectDojo | celery.worker.startupProbe | object | `{}` | Enable startup probe for Celery worker container. | | celery.worker.terminationGracePeriodSeconds | int | `300` | | | celery.worker.tolerations | list | `[]` | | -| cloudsql | object | `{"containerSecurityContext":{},"enable_iam_login":false,"enabled":false,"extraEnv":[],"extraVolumeMounts":[],"image":{"pullPolicy":"IfNotPresent","repository":"gcr.io/cloudsql-docker/gce-proxy","tag":"1.37.12"},"instance":"","resources":{},"use_private_ip":false,"verbose":true}` | Google CloudSQL support in GKE via gce-proxy | +| cloudsql | object | `{"containerSecurityContext":{},"enable_iam_login":false,"enabled":false,"extraEnv":[],"extraVolumeMounts":[],"image":{"pullPolicy":"IfNotPresent","repository":"gcr.io/cloudsql-docker/gce-proxy","tag":"1.38.0"},"instance":"","resources":{},"use_private_ip":false,"verbose":true}` | Google CloudSQL support in GKE via gce-proxy | | cloudsql.containerSecurityContext | object | `{}` | Optional: security context for the CloudSQL proxy container. | | cloudsql.enable_iam_login | bool | `false` | use IAM database authentication | | cloudsql.enabled | bool | `false` | To use CloudSQL in GKE set 'enable: true' | | cloudsql.extraEnv | list | `[]` | Additional environment variables for the CloudSQL proxy container. | | cloudsql.extraVolumeMounts | list | `[]` | Array of additional volume mount points for the CloudSQL proxy container | -| cloudsql.image | object | `{"pullPolicy":"IfNotPresent","repository":"gcr.io/cloudsql-docker/gce-proxy","tag":"1.37.12"}` | set repo and image tag of gce-proxy | +| cloudsql.image | object | `{"pullPolicy":"IfNotPresent","repository":"gcr.io/cloudsql-docker/gce-proxy","tag":"1.38.0"}` | set repo and image tag of gce-proxy | | cloudsql.instance | string | `""` | set CloudSQL instance: 'project:zone:instancename' | | cloudsql.resources | object | `{}` | Optional: add resource requests/limits for the CloudSQL proxy container. | | cloudsql.use_private_ip | bool | `false` | whether to use a private IP to connect to the database | diff --git a/helm/defectdojo/values.yaml b/helm/defectdojo/values.yaml index 792930707e4..659c7a2271c 100644 --- a/helm/defectdojo/values.yaml +++ b/helm/defectdojo/values.yaml @@ -601,7 +601,7 @@ cloudsql: # -- set repo and image tag of gce-proxy image: repository: gcr.io/cloudsql-docker/gce-proxy - tag: 1.37.12 + tag: 1.38.0 pullPolicy: IfNotPresent # -- set CloudSQL instance: 'project:zone:instancename' instance: "" diff --git a/requirements-lint.txt b/requirements-lint.txt index e4647b59e59..6fe09022800 100644 --- a/requirements-lint.txt +++ b/requirements-lint.txt @@ -1 +1 @@ -ruff==0.15.13 +ruff==0.15.15 diff --git a/requirements.txt b/requirements.txt index 9f7fd82770e..1389f552d35 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ django-filter==25.2 django-htmx==1.27.0 django-imagekit==6.1.0 django-multiselectfield==1.0.1 -django-polymorphic==4.11.3 +django-polymorphic==4.11.5 django-crispy-forms==2.6 django_extensions==4.1 django-slack==5.19.0 @@ -34,9 +34,9 @@ Pillow==12.2.0 # required by django-imagekit psycopg[c]==3.3.4 cryptography==46.0.7 python-dateutil==2.9.0.post0 -redis==7.4.0 +redis==8.0.0 requests==2.34.2 -sqlalchemy==2.0.49 # Required by Celery broker transport +sqlalchemy==2.0.50 # Required by Celery broker transport urllib3==2.7.0 uWSGI==2.0.31 vobject==0.9.9 @@ -57,7 +57,7 @@ cvss==3.6 django-fieldsignals==0.8.0 hyperlink==21.0.0 drf-spectacular==0.29.0 -drf-spectacular-sidecar==2026.5.1 +drf-spectacular-sidecar==2026.6.1 django-ratelimit==4.1.0 argon2-cffi==25.1.0 blackduck==1.1.3 diff --git a/ruff.toml b/ruff.toml index 942bd47a00a..dec50306cdf 100644 --- a/ruff.toml +++ b/ruff.toml @@ -92,6 +92,7 @@ select = [ "TRY003", "TRY004", "TRY2", "TRY300", "TRY401", ] ignore = [ + "PLW0717", "E501", "E722", "SIM102", diff --git a/tests/metrics_extended_test.py b/tests/metrics_extended_test.py index 4bb0972ab7e..7fa4c44eb9e 100644 --- a/tests/metrics_extended_test.py +++ b/tests/metrics_extended_test.py @@ -16,7 +16,7 @@ def test_metrics_all_page(self): time.sleep(1) self.assertTrue( self.is_text_present_on_page(text="Metrics") - or self.is_text_present_on_page(text="Product Type"), + or self.is_text_present_on_page(text="Organization"), ) @on_exception_html_source_logger @@ -27,7 +27,7 @@ def test_metrics_organization_page(self): time.sleep(1) self.assertTrue( self.is_text_present_on_page(text="Metric") - or self.is_text_present_on_page(text="Product Type"), + or self.is_text_present_on_page(text="Organization"), ) @on_exception_html_source_logger @@ -59,7 +59,7 @@ def test_product_type_counts_page(self): driver.get(self.base_url + "metrics/product/type/counts") time.sleep(1) self.assertTrue( - self.is_text_present_on_page(text="Product Type") + self.is_text_present_on_page(text="Organization") or self.is_text_present_on_page(text="Metric") or self.is_text_present_on_page(text="Count"), ) @@ -72,7 +72,7 @@ def test_critical_product_metrics_page(self): time.sleep(1) self.assertTrue( self.is_text_present_on_page(text="Metric") - or self.is_text_present_on_page(text="Product"), + or self.is_text_present_on_page(text="Asset"), ) diff --git a/tests/product_test.py b/tests/product_test.py index 8cc7d3cc379..29a979ed4bd 100644 --- a/tests/product_test.py +++ b/tests/product_test.py @@ -43,7 +43,7 @@ def test_create_product(self): # "Click" the dropdown button to see options driver.find_element(By.ID, "dropdownMenu1").click() # "Click" the add prodcut button - driver.find_element(By.LINK_TEXT, "Add Product").click() + driver.find_element(By.LINK_TEXT, "Add Asset").click() # Fill in th product name driver.find_element(By.ID, "id_name").clear() driver.find_element(By.ID, "id_name").send_keys("QA Test") @@ -59,7 +59,7 @@ def test_create_product(self): # Assert ot the query to dtermine status of failure # Also confirm success even if Product is returned as already exists for test sake - self.assertTrue(self.is_success_message_present(text="Product added successfully") + self.assertTrue(self.is_success_message_present(text="Asset added successfully") or self.is_success_message_present(text="Product with this Name already exists.")) self.assertFalse(self.is_error_message_present()) @@ -102,7 +102,7 @@ def test_edit_product_description(self): # Query the site to determine if the product has been added # Assert ot the query to dtermine status of failure - self.assertTrue(self.is_success_message_present(text="Product updated successfully") + self.assertTrue(self.is_success_message_present(text="Asset updated successfully") or self.is_success_message_present(text="Product with this Name already exists.")) self.assertFalse(self.is_error_message_present()) @@ -130,7 +130,7 @@ def test_enable_simple_risk_acceptance(self): # Query the site to determine if the product has been added # Assert ot the query to dtermine status of failure - self.assertTrue(self.is_success_message_present(text="Product updated successfully") + self.assertTrue(self.is_success_message_present(text="Asset updated successfully") or self.is_success_message_present(text="Product with this Name already exists.")) self.assertFalse(self.is_error_message_present()) @@ -426,7 +426,7 @@ def test_add_product_tracking_files(self): # Query the site to determine if the finding has been added # Assert ot the query to dtermine status of failure - self.assertTrue(self.is_success_message_present(text="Added Tracked File to a Product")) + self.assertTrue(self.is_success_message_present(text="Added Tracked File to an Asset")) @on_exception_html_source_logger def test_edit_product_tracking_files(self): @@ -483,7 +483,7 @@ def test_delete_product(self, name="QA Test"): # Query the site to determine if the product has been added # Assert ot the query to determine status of failure - self.assertTrue(self.is_success_message_present(text="Product and relationships removed.")) + self.assertTrue(self.is_success_message_present(text="Asset and relationships removed.")) @on_exception_html_source_logger def test_product_notifications_change(self): diff --git a/tests/product_type_test.py b/tests/product_type_test.py index 2037abeded8..ef25b5a9a6c 100644 --- a/tests/product_type_test.py +++ b/tests/product_type_test.py @@ -16,13 +16,13 @@ def test_create_product_type(self): driver = self.driver driver.get(self.base_url + "product/type") driver.find_element(By.ID, "dropdownMenu1").click() - driver.find_element(By.LINK_TEXT, "Add Product Type").click() + driver.find_element(By.LINK_TEXT, "Add Organization").click() driver.find_element(By.ID, "id_name").clear() driver.find_element(By.ID, "id_name").send_keys("Product test type") driver.find_element(By.ID, "id_critical_product").click() driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() - self.assertTrue(self.is_success_message_present(text="Product Type added successfully.")) + self.assertTrue(self.is_success_message_present(text="Organization added successfully.")) self.assertFalse(self.is_error_message_present()) @on_exception_html_source_logger @@ -35,7 +35,7 @@ def test_create_product_for_product_type(self): self.goto_product_type_overview(driver) driver.find_element(By.ID, "dropdownMenuProductType").click() - driver.find_element(By.PARTIAL_LINK_TEXT, "Add Product").click() + driver.find_element(By.PARTIAL_LINK_TEXT, "Add Asset").click() # Fill in th product name driver.find_element(By.ID, "id_name").clear() driver.find_element(By.ID, "id_name").send_keys("QA Test PT") @@ -46,7 +46,7 @@ def test_create_product_for_product_type(self): # Assert ot the query to dtermine status of failure # Also confirm success even if Product is returned as already exists for test sake - self.assertTrue(self.is_success_message_present(text="Product added successfully")) + self.assertTrue(self.is_success_message_present(text="Asset added successfully")) self.assertFalse(self.is_error_message_present()) def test_view_product_type(self): @@ -57,7 +57,7 @@ def test_view_product_type(self): driver.find_element(By.PARTIAL_LINK_TEXT, "View").click() product_type_text = driver.find_element(By.ID, "id_heading").text - self.assertEqual("Product Type Product test type", product_type_text) + self.assertEqual("Organization Product test type", product_type_text) def test_edit_product_type(self): logger.debug("\n\nDebug Print Log: testing 'edit product type' \n") @@ -69,7 +69,7 @@ def test_edit_product_type(self): driver.find_element(By.ID, "id_name").send_keys("Edited product test type") driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() - self.assertTrue(self.is_success_message_present(text="Product Type updated successfully.")) + self.assertTrue(self.is_success_message_present(text="Organization updated successfully.")) def test_delete_product_type(self): logger.debug("\n\nDebug Print Log: testing 'delete product type' \n") @@ -80,7 +80,7 @@ def test_delete_product_type(self): driver.find_element(By.PARTIAL_LINK_TEXT, "Delete").click() driver.find_element(By.CSS_SELECTOR, "button.btn.btn-danger").click() - self.assertTrue(self.is_success_message_present(text="Product Type and relationships removed.")) + self.assertTrue(self.is_success_message_present(text="Organization and relationships removed.")) def suite(): diff --git a/tests/report_builder_test.py b/tests/report_builder_test.py index 556a7dfc16b..7f11eb93fec 100644 --- a/tests/report_builder_test.py +++ b/tests/report_builder_test.py @@ -64,7 +64,7 @@ def test_product_report(self): self.goto_product_overview(driver) driver.find_element(By.LINK_TEXT, "QA Test").click() driver.find_element(By.ID, "dropdownMenu1").click() - driver.find_element(By.PARTIAL_LINK_TEXT, "Product Report").click() + driver.find_element(By.PARTIAL_LINK_TEXT, "Asset Report").click() my_select = Select(driver.find_element(By.ID, "id_include_finding_notes")) my_select.select_by_index(1) diff --git a/tests/risk_acceptance_test.py b/tests/risk_acceptance_test.py index 5f6278c6d4f..5ed3a52bdf0 100644 --- a/tests/risk_acceptance_test.py +++ b/tests/risk_acceptance_test.py @@ -26,7 +26,7 @@ def test_enable_full_risk_acceptance(self): driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() self.assertTrue( - self.is_success_message_present(text="Product updated successfully") + self.is_success_message_present(text="Asset updated successfully") or self.is_text_present_on_page(text="QA Test"), ) diff --git a/unittests/dojo_test_case.py b/unittests/dojo_test_case.py index 13cc0a20341..9f8cf55f89f 100644 --- a/unittests/dojo_test_case.py +++ b/unittests/dojo_test_case.py @@ -346,7 +346,7 @@ def get_product_with_empty_jira_project_data(self, product): } def get_expected_redirect_product(self, product): - return f"/product/{product.id}" + return f"/asset/{product.id}" def add_product_jira(self, data, expect_redirect_to=None, *, expect_200=False): response = self.client.get(reverse("new_product")) @@ -355,7 +355,7 @@ def add_product_jira(self, data, expect_redirect_to=None, *, expect_200=False): # self.log_model_instance(JIRA_Project.objects.last()) if not expect_redirect_to and not expect_200: - expect_redirect_to = "/product/%i" + expect_redirect_to = "/asset/%i" response = self.client.post(reverse("new_product"), urlencode(data), content_type="application/x-www-form-urlencoded") diff --git a/unittests/scans/alertlogic/many_vulns.csv b/unittests/scans/alertlogic/many_vulns.csv new file mode 100644 index 00000000000..6c96548f070 --- /dev/null +++ b/unittests/scans/alertlogic/many_vulns.csv @@ -0,0 +1,45 @@ +Vulnerability,CVSS Score,Severity,Deployment Name,Asset Name,IP Address,Protocol/Port,First Seen,Last Scanned,Asset Type,Customer Account,CVE,Service,VPC/Network,Category,Asset Key,Description,Evidence,Operating System,Resolution,Vulnerability ID,Vulnerability Span ID,Vulnerability Key,Age (days),CISA Known Exploited,Published Date +CVE-2021-44228 - Apache Log4j Remote Code Execution,10.0,Critical,Production,app-server-01.example.com,192.0.2.20,TCP/8080,12/10/21 14:00,5/27/26 6:37,host,AcmeCorp,CVE-2021-44228,http,prod-vpc-east,agent,/dc/host/BBBB2222-3333-4444-5555-666666666666/vulnerability/bbbbbbbbbbbbbbbbbbbb,"Apache Log4j2 2.0-beta9 through 2.15.0 (excluding security releases 2.12.2, 2.12.3, and 2.3.1) JNDI features used in configuration, log messages, and parameters do not protect against attacker controlled LDAP and other JNDI related endpoints. An attacker who can control log messages or log message parameters can execute arbitrary code loaded from LDAP servers when message lookup substitution is enabled. + +Known as Log4Shell.","Patch Scanning - oval:org.secpod.oval:def:99999002 +Refs: KB-LOG4J-RCE. Definition: log4j-core JAR detected with version 2.14.1 on classpath.",cpe:2.3:o:ubuntu:ubuntu_linux:22.04:*:*:*:*:*:*:*,"Upgrade Apache Log4j2 to version 2.17.1 or later. + +For Java 8: Log4j 2.17.1 +For Java 7: Log4j 2.12.4 +For Java 6: Log4j 2.3.2",22222222222222222222222222222222,BBBBBBBB-CCCC-DDDD-EEEE-FFFFFFFFFFFF,/dc/host/BBBB2222-3333-4444-5555-666666666666/vulnerability/2b3c4d5e6f7a8b9c0d1e,1630,Yes,12/10/21 +CVE-2014-0160 - OpenSSL Heartbleed Information Disclosure,7.5,High,Staging,tls-gateway.example.com,"198.51.100.30, fe80::250:56ff:fe96:b97",TCP/443,4/8/14 0:00,5/27/26 19:57,host,AcmeCorp,CVE-2014-0160,https,stage-vpc-west,agent,/dc/host/CCCC3333-4444-5555-6666-777777777777/vulnerability/cccccccccccccccccccc,"The (1) TLS and (2) DTLS implementations in OpenSSL 1.0.1 before 1.0.1g do not properly handle Heartbeat Extension packets, which allows remote attackers to obtain sensitive information from process memory via crafted packets that trigger a buffer over-read, as demonstrated by reading private keys, related to d1_both.c and t1_lib.c, aka the Heartbleed bug.","Patch Scanning - oval:org.secpod.oval:def:99999003 +Refs: KB-OPENSSL-HEART. Definition: OpenSSL 1.0.1 through 1.0.1f detected. Specifics: openssl version 1.0.1c is installed.",cpe:2.3:o:ubuntu:ubuntu_linux:14.04:*:*:*:*:*:*:*,"Upgrade OpenSSL to version 1.0.1g or later. + +Ubuntu: apt-get install --only-upgrade openssl libssl1.0.0 +RHEL: yum update openssl",33333333333333333333333333333333,CCCCCCCC-DDDD-EEEE-FFFF-000000000000,/dc/host/CCCC3333-4444-5555-6666-777777777777/vulnerability/3c4d5e6f7a8b9c0d1e2f,4432,Yes,4/7/14 +TCP Timestamp Response,2.6,Low,Production,edge-router.example.com,203.0.113.40,TCP/0,1/2/26 9:15,5/27/26 1:18,host,AcmeCorp,,Non-Attributable,prod-vpc-east,network,/dc/host/DDDD4444-5555-6666-7777-888888888888/vulnerability/dddddddddddddddddddd,The remote host implements TCP timestamps and therefore allows to compute the uptime.,Network Scan probe. Packet: tcp timestamp option present in SYN-ACK.,,"Disable TCP timestamps if not required: + +Linux: sysctl -w net.ipv4.tcp_timestamps=0 +Windows: netsh int tcp set global timestamps=disabled",44444444444444444444444444444444,DDDDDDDD-EEEE-FFFF-0000-111111111111,/dc/host/DDDD4444-5555-6666-7777-888888888888/vulnerability/4d5e6f7a8b9c0d1e2f30,146,No,1/1/97 +Web Server - Version Disclosure in HTTP Server Header,0,Info,Production,static-cdn.example.com,192.0.2.50,TCP/80,9/9/24 0:50,5/27/26 6:37,host,AcmeCorp,,http,prod-vpc-east,agent,/dc/host/EEEE5555-6666-7777-8888-999999999999/vulnerability/eeeeeeeeeeeeeeeeeeee,The remote web server discloses its version number in the HTTP Server response header. This may aid an attacker in fingerprinting the system.,"Server: nginx/1.18.0 + +Observed on GET /",,"Configure the web server to suppress the Server header: + +nginx: server_tokens off; +Apache: ServerTokens Prod / ServerSignature Off",55555555555555555555555555555555,EEEEEEEE-FFFF-0000-1111-222222222222,/dc/host/EEEE5555-6666-7777-8888-999999999999/vulnerability/5e6f7a8b9c0d1e2f3041,260,No,6/9/21 +"CVE-2023-23397 - Microsoft Outlook Elevation of Privilege Vulnerability - Critical zero-click NTLM credential disclosure exploited in the wild by state-sponsored actors targeting government, transportation, energy, and military organizations across Europe and reportedly used in attacks attributed to APT28 / Fancy Bear, where a crafted email with a PidLidReminderFileParameter extended MAPI property triggers an outbound SMB authentication attempt to an attacker-controlled UNC path even when the recipient never opens the message, allowing the attacker to capture the victim's Net-NTLMv2 hash for offline cracking or pass-the-hash relay attacks against domain resources, included in CISA's Known Exploited Vulnerabilities catalog with patch deadline March 2023 and remediated by KB5002375 / KB5002376 plus mitigations blocking outbound SMB",9.8,Critical,Production,mail-relay.example.com,192.0.2.60,TCP/0,3/14/23 8:00,5/27/26 19:57,host,AcmeCorp,CVE-2023-23397,Non-Attributable,prod-vpc-east,agent,/dc/host/FFFF6666-7777-8888-9999-AAAAAAAAAAAA/vulnerability/ffffffffffffffffffff,"Microsoft Outlook Elevation of Privilege Vulnerability. An attacker who successfully exploited this vulnerability could access a user's Net-NTLMv2 hash which could be used as a basis of an NTLM Relay attack against another service to authenticate as the user. + +No user interaction is required.","Patch Scanning - oval:org.secpod.oval:def:99999004 +Refs: KB5002375, KB5002376. Definition: Microsoft Outlook 2013/2016/2019/365 detected with vulnerable build.",cpe:2.3:o:microsoft:windows_server_2019:-:*:standard:*:*:*:*:*,"Apply Microsoft Security Updates: + +Outlook 2013 SP1: KB5002375 +Outlook 2016: KB5002376 +Outlook 2019/365: install latest updates via Click-to-Run + +Mitigation: block outbound SMB (TCP 445) at perimeter; add users to Protected Users group.",66666666666666666666666666666666,FFFFFFFF-0000-1111-2222-333333333333,/dc/host/FFFF6666-7777-8888-9999-AAAAAAAAAAAA/vulnerability/6f7a8b9c0d1e2f304152,805,Yes,3/14/23 +CVE-2024-3094 - XZ Utils Malicious Code Insertion,7.8,High,Staging,build-host.example.com,"198.51.100.70, 2001:db8::1:70",TCP/22,3/29/24 12:00,5/27/26 6:37,host,AcmeCorp,CVE-2024-3094,ssh,stage-vpc-west,agent,/dc/host/AAAA7777-8888-9999-AAAA-BBBBBBBBBBBB/vulnerability/aaaa1111aaaa1111aaaa,"Malicious code was discovered in the upstream tarballs of xz, starting with version 5.6.0. Through a series of complex obfuscations, the liblzma build process extracts a prebuilt object file from a disguised test file existing in the source code, which is then used to modify specific functions in the liblzma code. This results in a modified liblzma library that can be used by any software linked against this library, intercepting and modifying the data interaction with this library.",Package Scanning. Detected: xz-utils 5.6.0-0.2 / xz-utils 5.6.1-1 installed via apt repository before March 28 2024 mitigations.,cpe:2.3:o:ubuntu:ubuntu_linux:24.04:*:*:*:*:*:*:*,"Downgrade or upgrade xz-utils: + +- Downgrade to 5.4.x: apt-get install xz-utils=5.4.5-0.3 +- Upgrade to 5.6.2+ once distribution patches are available +- Reboot is recommended after replacement",77777777777777777777777777777777,AAAA1111-BBBB-2222-CCCC-DDDDDDDDDDDD,/dc/host/AAAA7777-8888-9999-AAAA-BBBBBBBBBBBB/vulnerability/7a8b9c0d1e2f30415263,60,No,3/29/24 +SSH Weak Key Exchange Algorithms Enabled,,Medium,Staging,jumpbox.example.com,2001:db8::1:80,TCP/22,11/4/25 3:30,5/27/26 1:18,host,AcmeCorp,,ssh,stage-vpc-west,agent,/dc/host/BBBB8888-9999-AAAA-BBBB-CCCCCCCCCCCC/vulnerability/bbbb2222bbbb2222bbbb,"The remote SSH server supports key exchange algorithms considered weak (diffie-hellman-group1-sha1, diffie-hellman-group14-sha1).","SSH banner probe: SSH-2.0-OpenSSH_7.4 +Negotiated kex algorithms include diffie-hellman-group1-sha1.",cpe:2.3:o:redhat:enterprise_linux:7.9:*:*:*:*:*:*:*,"Disable weak KEX algorithms in /etc/ssh/sshd_config: + +KexAlgorithms curve25519-sha256@libssh.org,diffie-hellman-group14-sha256,diffie-hellman-group16-sha512 + +Then restart: systemctl restart sshd",88888888888888888888888888888888,BBBB2222-CCCC-3333-DDDD-EEEEEEEEEEEE,/dc/host/BBBB8888-9999-AAAA-BBBB-CCCCCCCCCCCC/vulnerability/8b9c0d1e2f3041526374,204,No,1/15/15 diff --git a/unittests/scans/alertlogic/no_vuln.csv b/unittests/scans/alertlogic/no_vuln.csv new file mode 100644 index 00000000000..5fc29d28432 --- /dev/null +++ b/unittests/scans/alertlogic/no_vuln.csv @@ -0,0 +1 @@ +Vulnerability,CVSS Score,Severity,Deployment Name,Asset Name,IP Address,Protocol/Port,First Seen,Last Scanned,Asset Type,Customer Account,CVE,Service,VPC/Network,Category,Asset Key,Description,Evidence,Operating System,Resolution,Vulnerability ID,Vulnerability Span ID,Vulnerability Key,Age (days),CISA Known Exploited,Published Date diff --git a/unittests/scans/alertlogic/one_vuln.csv b/unittests/scans/alertlogic/one_vuln.csv new file mode 100644 index 00000000000..c73a7927448 --- /dev/null +++ b/unittests/scans/alertlogic/one_vuln.csv @@ -0,0 +1,10 @@ +Vulnerability,CVSS Score,Severity,Deployment Name,Asset Name,IP Address,Protocol/Port,First Seen,Last Scanned,Asset Type,Customer Account,CVE,Service,VPC/Network,Category,Asset Key,Description,Evidence,Operating System,Resolution,Vulnerability ID,Vulnerability Span ID,Vulnerability Key,Age (days),CISA Known Exploited,Published Date +CVE-2023-44487 - HTTP/2 Rapid Reset Attack,5.3,Medium,Production,web-01.example.com,192.0.2.10,TCP/443,5/15/26 0:50,5/27/26 6:37,host,AcmeCorp,CVE-2023-44487,https,prod-vpc-east,agent,/dc/host/AAAA1111-2222-3333-4444-555555555555/vulnerability/aaaaaaaaaaaaaaaaaaaa,"The HTTP/2 protocol allows a denial of service (server resource consumption) because request cancellation can reset many streams quickly, as exploited in the wild in August through October 2023. + +CVE-2023-44487 affects multiple HTTP/2 server implementations including nginx, Apache, and load balancers.","Patch Scanning - oval:org.secpod.oval:def:99999001 + +Refs: KB-HTTP2-RST. Definition: HTTP/2 server detected with vulnerable version.",cpe:2.3:o:ubuntu:ubuntu_linux:22.04:*:*:*:*:*:*:*,"Upgrade affected HTTP/2 implementation to a patched version: + +- nginx >= 1.25.3 +- Apache httpd >= 2.4.58 +- HAProxy >= 2.4.24",11111111111111111111111111111111,AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE,/dc/host/AAAA1111-2222-3333-4444-555555555555/vulnerability/1a2b3c4d5e6f7a8b9c0d,12,No,10/10/23 diff --git a/unittests/scans/github_vulnerability/github-1-vuln-no-fix.json b/unittests/scans/github_vulnerability/github-1-vuln-no-fix.json new file mode 100644 index 00000000000..748bcad7336 --- /dev/null +++ b/unittests/scans/github_vulnerability/github-1-vuln-no-fix.json @@ -0,0 +1,58 @@ +{ + "data": { + "repository": { + "vulnerabilityAlerts": { + "nodes": [ + { + "id": "RVA_kwDOLJyUo88AAAABNoFixAv", + "createdAt": "2024-03-15T10:00:00Z", + "vulnerableManifestPath": "app/package.json", + "dependabotUpdate": null, + "securityVulnerability": { + "severity": "HIGH", + "updatedAt": "2024-03-10T12:00:00Z", + "package": { + "name": "example-package", + "ecosystem": "NPM" + }, + "firstPatchedVersion": null, + "vulnerableVersionRange": "<= 2.0.0", + "advisory": { + "description": "Example vulnerability with no fix available yet.", + "summary": "Prototype pollution in example-package", + "identifiers": [ + { + "value": "GHSA-test-no-fix", + "type": "GHSA" + }, + { + "value": "CVE-2024-99999", + "type": "CVE" + } + ], + "references": [ + { + "url": "https://nvd.nist.gov/vuln/detail/CVE-2024-99999" + } + ], + "cvss": { + "vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N" + } + } + }, + "state": "OPEN", + "vulnerableManifestFilename": "package.json", + "vulnerableRequirements": "= 1.5.0", + "number": 42, + "dependencyScope": "RUNTIME", + "dismissComment": null, + "dismissReason": null, + "dismissedAt": null, + "fixedAt": null + } + ] + }, + "url": "https://github.com/example-org/example-repo" + } + } +} diff --git a/unittests/test_adminsite.py b/unittests/test_adminsite.py index 015c65ee467..70f08da8df2 100644 --- a/unittests/test_adminsite.py +++ b/unittests/test_adminsite.py @@ -1,5 +1,8 @@ import django.apps from django.contrib import admin +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AnonymousUser +from django.http import HttpRequest from .dojo_test_case import DojoTestCase @@ -20,3 +23,56 @@ def test_is_model_defined(self): else: with self.subTest(type="tag", subclass=subclass): self.assertIn(subclass, admin.site._registry.keys(), f"{subclass} is not registered in 'tagulous.admin' in models.py") + + +class AdminAccessGate(DojoTestCase): + + """ + is_staff is a near-superuser bypass under the legacy OS auth model, so + /admin/ must require is_superuser. Django's default UserAdmin change + form would otherwise let any is_staff user with auth.change_user tick + is_superuser on themselves. Tested at the gate-function level so the + assertions hold regardless of whether DD_DJANGO_ADMIN_ENABLED mounts + the admin URLConf in the current environment. + """ + + @staticmethod + def _request_for(user): + req = HttpRequest() + req.user = user + return req + + def test_staff_non_superuser_denied(self): + User = get_user_model() + password = "testTEST1234!@#$" + staff = User.objects.create_user( + username="staff-no-root", password=password, is_staff=True, + ) + self.assertFalse(admin.site.has_permission(self._request_for(staff))) + + def test_non_staff_non_superuser_denied(self): + User = get_user_model() + password = "testTEST1234!@#$" + plain = User.objects.create_user(username="plain-user", password=password) + self.assertFalse(admin.site.has_permission(self._request_for(plain))) + + def test_anonymous_denied(self): + self.assertFalse(admin.site.has_permission(self._request_for(AnonymousUser()))) + + def test_inactive_superuser_denied(self): + User = get_user_model() + password = "testTEST1234!@#$" + root = User.objects.create_superuser( + username="inactive-root", email="i@example.com", password=password, + ) + root.is_active = False + root.save(update_fields=["is_active"]) + self.assertFalse(admin.site.has_permission(self._request_for(root))) + + def test_active_superuser_allowed(self): + User = get_user_model() + password = "testTEST1234!@#$" + root = User.objects.create_superuser( + username="root-test", email="r@example.com", password=password, + ) + self.assertTrue(admin.site.has_permission(self._request_for(root))) diff --git a/unittests/test_apiv2_user.py b/unittests/test_apiv2_user.py index afce21948f2..f6273fe9b47 100644 --- a/unittests/test_apiv2_user.py +++ b/unittests/test_apiv2_user.py @@ -1,3 +1,4 @@ +from django.contrib.auth.models import Permission from django.urls import reverse from django.utils import timezone from rest_framework.authtoken.models import Token @@ -201,6 +202,118 @@ def test_user_reset_api_token_denies_non_privileged(self): r = nonpriv_client.post(url) self.assertEqual(r.status_code, 403, r.content[:1000]) + def test_non_superuser_cannot_set_is_staff_via_api(self): + """ + A delegated user-manager (auth.change_user) must not be able to + flip is_staff on themselves or anyone else — is_staff is a + superuser-only flag under the legacy OS auth model, and granting + it via API would let a non-superuser pivot into Django admin / + full RBAC bypass. + """ + password = "testTEST1234!@#$" + r = self.client.post(reverse("user-list"), { + "username": "api-user-mgr", + "email": "admin@dojo.com", + "password": password, + }, format="json") + self.assertEqual(r.status_code, 201, r.content[:1000]) + mgr = User.objects.get(username="api-user-mgr") + mgr.user_permissions.add( + Permission.objects.get(codename="change_user"), + Permission.objects.get(codename="add_user"), + ) + + token_resp = self.client.post(reverse("api-token-auth"), { + "username": "api-user-mgr", + "password": password, + }, format="json") + self.assertEqual(token_resp.status_code, 200, token_resp.content[:1000]) + mgr_client = APIClient() + mgr_client.credentials(HTTP_AUTHORIZATION="Token " + token_resp.json()["token"]) + + # Self-escalation: setting is_staff on own account must be rejected. + r = mgr_client.patch("{}{}/".format(reverse("user-list"), mgr.id), { + "is_staff": True, + }, format="json") + self.assertEqual(r.status_code, 400, r.content[:1000]) + self.assertIn( + "Only superusers are allowed to add or edit staff users.", + r.content.decode("utf-8"), + ) + mgr.refresh_from_db() + self.assertFalse(mgr.is_staff) + + # Target-escalation: setting is_staff on another user must be rejected. + r = self.client.post(reverse("user-list"), { + "username": "api-user-target", + "email": "admin@dojo.com", + "password": password, + }, format="json") + self.assertEqual(r.status_code, 201, r.content[:1000]) + target_id = r.json()["id"] + + r = mgr_client.patch("{}{}/".format(reverse("user-list"), target_id), { + "is_staff": True, + }, format="json") + self.assertEqual(r.status_code, 400, r.content[:1000]) + target = User.objects.get(id=target_id) + self.assertFalse(target.is_staff) + + # Create-time escalation must also be rejected. + r = mgr_client.post(reverse("user-list"), { + "username": "api-user-staff-on-create", + "email": "admin@dojo.com", + "password": password, + "is_staff": True, + }, format="json") + self.assertEqual(r.status_code, 400, r.content[:1000]) + self.assertFalse(User.objects.filter(username="api-user-staff-on-create").exists()) + + def test_non_superuser_can_patch_self_without_touching_is_staff(self): + """ + Negative control for the is_staff guard: a delegated user-manager + can still PATCH non-privileged fields on their own account; the + new check only fires when is_staff actually changes. + """ + password = "testTEST1234!@#$" + r = self.client.post(reverse("user-list"), { + "username": "api-user-mgr2", + "email": "admin@dojo.com", + "password": password, + }, format="json") + self.assertEqual(r.status_code, 201, r.content[:1000]) + mgr = User.objects.get(username="api-user-mgr2") + mgr.user_permissions.add(Permission.objects.get(codename="change_user")) + + token_resp = self.client.post(reverse("api-token-auth"), { + "username": "api-user-mgr2", + "password": password, + }, format="json") + self.assertEqual(token_resp.status_code, 200, token_resp.content[:1000]) + mgr_client = APIClient() + mgr_client.credentials(HTTP_AUTHORIZATION="Token " + token_resp.json()["token"]) + + r = mgr_client.patch("{}{}/".format(reverse("user-list"), mgr.id), { + "first_name": "Renamed", + }, format="json") + self.assertEqual(r.status_code, 200, r.content[:1000]) + + def test_superuser_can_set_is_staff_via_api(self): + """Positive control: a superuser is still allowed to toggle is_staff.""" + r = self.client.post(reverse("user-list"), { + "username": "api-user-promotable", + "email": "admin@dojo.com", + "password": "testTEST1234!@#$", + }, format="json") + self.assertEqual(r.status_code, 201, r.content[:1000]) + user_id = r.json()["id"] + + r = self.client.patch("{}{}/".format(reverse("user-list"), user_id), { + "is_staff": True, + }, format="json") + self.assertEqual(r.status_code, 200, r.content[:1000]) + self.assertTrue(User.objects.get(id=user_id).is_staff) + def test_user_reset_api_token_denies_global_owner_legacy(self): """ Legacy: Global_Role(role=Owner) is inert. Resetting another diff --git a/unittests/test_importers_importer.py b/unittests/test_importers_importer.py index aa14ace8beb..ad9dd8f66c3 100644 --- a/unittests/test_importers_importer.py +++ b/unittests/test_importers_importer.py @@ -932,4 +932,115 @@ def test_change_vulnerability_ids_on_reimport(self): # Verify only new Vulnerability_Id objects exist vuln_ids = list(Vulnerability_Id.objects.filter(finding=finding).values_list("vulnerability_id", flat=True)) self.assertEqual(set(new_vulnerability_ids), set(vuln_ids)) - finding.delete() + + +class ReimportDuplicateReactivationTest(DojoTestCase): + + """ + Regression test for https://github.com/DefectDojo/django-DefectDojo/issues/14910 + + Reimport reactivation of a mitigated finding must not produce an invalid + active/verified duplicate finding state. + """ + + def setUp(self): + self.user, _ = User.objects.get_or_create(username="admin", is_superuser=True) + Development_Environment.objects.get_or_create(name="Development") + self.product_type, _ = Product_Type.objects.get_or_create(name="dup_reactivation_pt") + self.product, _ = Product.objects.get_or_create( + name="DupReactivationProduct", + description="test product", + prod_type=self.product_type, + ) + self.engagement = Engagement.objects.create( + name="Dup Reactivation Engagement", + product=self.product, + target_start=timezone.now(), + target_end=timezone.now(), + ) + self.test = self.create_test(engagement=self.engagement, scan_type=NPM_AUDIT_SCAN_TYPE, title="dup reactivation test") + + def _make_finding(self, title, **kwargs): + return Finding.objects.create( + title=title, + test=self.test, + severity="High", + reporter=self.user, + **kwargs, + ) + + def test_reactivation_keeps_duplicate_inactive_and_unverified(self): + # Original active finding + original = self._make_finding("original finding", active=True, verified=True) + # Mitigated finding that is marked as a duplicate of the original + existing_duplicate = self._make_finding( + "duplicate finding", + active=False, + verified=False, + duplicate=True, + duplicate_finding=original, + is_mitigated=True, + mitigated=timezone.now(), + mitigated_by=self.user, + ) + # The reimported (unsaved) finding that re-matches the duplicate, and is active/not mitigated + unsaved_finding = self._make_finding("duplicate finding incoming", active=True, verified=True) + + reimporter = DefaultReImporter( + test=self.test, + user=self.user, + scan_type=NPM_AUDIT_SCAN_TYPE, + active=True, + verified=True, + do_not_reactivate=False, + ) + # These accumulators are normally initialised inside process_findings(); set them + # here because the test drives process_matched_mitigated_finding() directly. + reimporter.new_items = [] + reimporter.reactivated_items = [] + reimporter.unchanged_items = [] + + result_finding, _ = reimporter.process_matched_mitigated_finding(unsaved_finding, existing_duplicate) + + result_finding.refresh_from_db() + # The mitigation is cleared (the finding reappeared in the scan)... + self.assertFalse(result_finding.is_mitigated) + self.assertIsNone(result_finding.mitigated) + # ...but a duplicate must never become active or verified (issue #14910) + self.assertTrue(result_finding.duplicate) + self.assertFalse(result_finding.active) + self.assertFalse(result_finding.verified) + + def test_reactivation_of_non_duplicate_still_activates(self): + # A regular mitigated finding (not a duplicate) must still reactivate as before + existing = self._make_finding( + "regular finding", + active=False, + verified=False, + is_mitigated=True, + mitigated=timezone.now(), + mitigated_by=self.user, + ) + unsaved_finding = self._make_finding("regular finding incoming", active=True, verified=True) + + reimporter = DefaultReImporter( + test=self.test, + user=self.user, + scan_type=NPM_AUDIT_SCAN_TYPE, + active=True, + verified=True, + do_not_reactivate=False, + ) + # These accumulators are normally initialised inside process_findings(); set them + # here because the test drives process_matched_mitigated_finding() directly. + reimporter.new_items = [] + reimporter.reactivated_items = [] + reimporter.unchanged_items = [] + + result_finding, _ = reimporter.process_matched_mitigated_finding(unsaved_finding, existing) + + result_finding.refresh_from_db() + self.assertFalse(result_finding.is_mitigated) + self.assertIsNone(result_finding.mitigated) + self.assertTrue(result_finding.active) + self.assertTrue(result_finding.verified) diff --git a/unittests/test_importers_performance.py b/unittests/test_importers_performance.py index dc82f28114d..ce9133e4a6b 100644 --- a/unittests/test_importers_performance.py +++ b/unittests/test_importers_performance.py @@ -20,7 +20,6 @@ import logging from contextlib import contextmanager -from unittest import skip from unittest.mock import patch from crum import impersonate @@ -275,11 +274,6 @@ def _import_reimport_performance( self.assertGreater(len_closed_findings4, 0, "Step 4 (empty reimport with close_old_findings=True) should close findings") -@skip("Re-baseline pending: Track B legacy authorization reduces auth-layer query " - "overhead (no per-action role-permission lookups, simpler permission_to_action " - "dispatch). Expected query counts here were calibrated under RBAC and are " - "consistently 1-7 queries higher than legacy actual. Re-baseline with a fresh " - "calibration run after the upstream merge.") @tag("performance") @skip_unless_v2 class TestDojoImporterPerformanceSmall(TestDojoImporterPerformanceBase): @@ -349,13 +343,13 @@ def test_import_reimport_reimport_performance_pghistory_async(self): configure_pghistory_triggers() self._import_reimport_performance( - expected_num_queries1=171, + expected_num_queries1=170, expected_num_async_tasks1=2, - expected_num_queries2=124, + expected_num_queries2=123, expected_num_async_tasks2=1, - expected_num_queries3=29, + expected_num_queries3=28, expected_num_async_tasks3=1, - expected_num_queries4=100, + expected_num_queries4=99, expected_num_async_tasks4=0, ) @@ -373,13 +367,13 @@ def test_import_reimport_reimport_performance_pghistory_no_async(self): testuser.usercontactinfo.save() self._import_reimport_performance( - expected_num_queries1=187, + expected_num_queries1=184, expected_num_async_tasks1=2, - expected_num_queries2=132, + expected_num_queries2=131, expected_num_async_tasks2=1, - expected_num_queries3=37, + expected_num_queries3=36, expected_num_async_tasks3=1, - expected_num_queries4=100, + expected_num_queries4=99, expected_num_async_tasks4=0, ) @@ -398,13 +392,13 @@ def test_import_reimport_reimport_performance_pghistory_no_async_with_product_gr self.system_settings(enable_product_grade=True) self._import_reimport_performance( - expected_num_queries1=197, + expected_num_queries1=194, expected_num_async_tasks1=4, - expected_num_queries2=142, + expected_num_queries2=141, expected_num_async_tasks2=3, - expected_num_queries3=44, + expected_num_queries3=43, expected_num_async_tasks3=3, - expected_num_queries4=109, + expected_num_queries4=108, expected_num_async_tasks4=2, ) @@ -530,9 +524,9 @@ def test_deduplication_performance_pghistory_async(self): self.system_settings(enable_deduplication=True) self._deduplication_performance( - expected_num_queries1=110, + expected_num_queries1=109, expected_num_async_tasks1=2, - expected_num_queries2=90, + expected_num_queries2=89, expected_num_async_tasks2=2, check_duplicates=False, # Async mode - deduplication happens later ) @@ -551,18 +545,15 @@ def test_deduplication_performance_pghistory_no_async(self): testuser.usercontactinfo.save() self._deduplication_performance( - expected_num_queries1=126, + expected_num_queries1=123, expected_num_async_tasks1=2, - expected_num_queries2=107, + expected_num_queries2=104, expected_num_async_tasks2=2, ) @tag("performance") @override_settings(V3_FEATURE_LOCATIONS=True) -@skip("Re-baseline pending: same RBAC→legacy query-count drift as " - "TestDojoImporterPerformanceSmall. See that class's skip note for the " - "rationale.") class TestDojoImporterPerformanceSmallLocations(TestDojoImporterPerformanceBase): r""" @@ -642,13 +633,13 @@ def test_import_reimport_reimport_performance_pghistory_async(self): configure_pghistory_triggers() self._import_reimport_performance( - expected_num_queries1=178, + expected_num_queries1=177, expected_num_async_tasks1=2, - expected_num_queries2=133, + expected_num_queries2=132, expected_num_async_tasks2=1, - expected_num_queries3=37, + expected_num_queries3=36, expected_num_async_tasks3=1, - expected_num_queries4=101, + expected_num_queries4=100, expected_num_async_tasks4=0, ) @@ -666,13 +657,13 @@ def test_import_reimport_reimport_performance_pghistory_no_async(self): testuser.usercontactinfo.save() self._import_reimport_performance( - expected_num_queries1=196, + expected_num_queries1=193, expected_num_async_tasks1=2, - expected_num_queries2=143, + expected_num_queries2=142, expected_num_async_tasks2=1, - expected_num_queries3=47, + expected_num_queries3=46, expected_num_async_tasks3=1, - expected_num_queries4=101, + expected_num_queries4=100, expected_num_async_tasks4=0, ) @@ -691,13 +682,13 @@ def test_import_reimport_reimport_performance_pghistory_no_async_with_product_gr self.system_settings(enable_product_grade=True) self._import_reimport_performance( - expected_num_queries1=209, + expected_num_queries1=206, expected_num_async_tasks1=4, - expected_num_queries2=156, + expected_num_queries2=155, expected_num_async_tasks2=3, - expected_num_queries3=54, + expected_num_queries3=53, expected_num_async_tasks3=3, - expected_num_queries4=113, + expected_num_queries4=112, expected_num_async_tasks4=2, ) @@ -798,9 +789,9 @@ def test_deduplication_performance_pghistory_async(self): self.system_settings(enable_deduplication=True) self._deduplication_performance( - expected_num_queries1=117, + expected_num_queries1=116, expected_num_async_tasks1=2, - expected_num_queries2=93, + expected_num_queries2=92, expected_num_async_tasks2=2, check_duplicates=False, # Async mode - deduplication happens later ) @@ -818,8 +809,8 @@ def test_deduplication_performance_pghistory_no_async(self): testuser.usercontactinfo.save() self._deduplication_performance( - expected_num_queries1=135, + expected_num_queries1=132, expected_num_async_tasks1=2, - expected_num_queries2=218, + expected_num_queries2=215, expected_num_async_tasks2=2, ) diff --git a/unittests/test_jira_config_product.py b/unittests/test_jira_config_product.py index c4992caf049..52387c29d23 100644 --- a/unittests/test_jira_config_product.py +++ b/unittests/test_jira_config_product.py @@ -169,7 +169,7 @@ def test_add_product_with_jira_project(self, jira_mock): @patch("dojo.jira.views.jira_helper.is_jira_project_valid") def test_add_product_with_jira_project_invalid_jira_project(self, jira_mock): jira_mock.return_value = False # cannot set return_value in decorated AND have the mock into the method - product = self.add_product_with_jira_project(expected_delta_jira_project_db=0, expect_redirect_to="/product/%i/edit") + product = self.add_product_with_jira_project(expected_delta_jira_project_db=0, expect_redirect_to="/asset/%i/edit") # product is still saved, even with invalid jira project key self.assertIsNotNone(product) self.assertEqual(jira_mock.call_count, 1) diff --git a/unittests/test_jira_helper.py b/unittests/test_jira_helper.py new file mode 100644 index 00000000000..b90a304f7a4 --- /dev/null +++ b/unittests/test_jira_helper.py @@ -0,0 +1,31 @@ +import logging +from unittest import TestCase +from unittest.mock import Mock + +import dojo.jira.helper as jira_helper + +logger = logging.getLogger(__name__) + + +class JIRAHelperTest(TestCase): + def _make_issue(self, status_category_key): + issue = Mock() + issue.fields.status.statusCategory.key = status_category_key + return issue + + def test_issue_from_jira_is_active_with_new_status(self): + self.assertTrue(jira_helper.issue_from_jira_is_active(self._make_issue("new"))) + + def test_issue_from_jira_is_active_with_indeterminate_status(self): + self.assertTrue(jira_helper.issue_from_jira_is_active(self._make_issue("indeterminate"))) + + def test_issue_from_jira_is_active_with_done_status(self): + self.assertFalse(jira_helper.issue_from_jira_is_active(self._make_issue("done"))) + + def test_issue_from_jira_is_active_with_unknown_status(self): + """Any key that is not 'done' is treated as active.""" + self.assertTrue(jira_helper.issue_from_jira_is_active(self._make_issue("custom_status"))) + + def test_issue_from_jira_is_active_defaults_to_active_on_missing_attribute(self): + """AttributeError anywhere in the fields.status.statusCategory.key chain defaults to active.""" + self.assertTrue(jira_helper.issue_from_jira_is_active(Mock(spec=[]))) diff --git a/unittests/test_notifications.py b/unittests/test_notifications.py index 28921fdf961..1980f73504d 100644 --- a/unittests/test_notifications.py +++ b/unittests/test_notifications.py @@ -476,7 +476,7 @@ def _minimal_create_payload(self, title: str): def test_auditlog_on(self, mock): prod_type = Product_Type.objects.create(name="notif prod type API") self.client.delete(reverse("product_type-detail", args=(prod_type.pk,)), format="json") - self.assertEqual(mock.call_args_list[-1].kwargs["description"], 'The product type "notif prod type API" was deleted by admin') + self.assertEqual(mock.call_args_list[-1].kwargs["description"], 'The Organization "notif prod type API" was deleted by admin') @patch("dojo.api_v2.serializers.dojo_dispatch_task") def test_create_calls_notification_with_auto_assigned_reporter(self, mock_dispatch): diff --git a/unittests/test_prepare_duplicates_for_delete.py b/unittests/test_prepare_duplicates_for_delete.py index 78d612dfdec..89ed84069db 100644 --- a/unittests/test_prepare_duplicates_for_delete.py +++ b/unittests/test_prepare_duplicates_for_delete.py @@ -294,15 +294,56 @@ def test_multiple_originals(self): self.assertFalse(dupe_of_b.duplicate) self.assertIsNone(dupe_of_b.duplicate_finding) - def test_original_status_copied_to_new_original(self): - """New original inherits active/is_mitigated status from deleted original.""" + def test_original_status_copied_to_new_original_active_verified(self): + """ + New original inherits active/verified/is_mitigated from deleted original. + + Positive case: original is an open, verified, not-mitigated finding. + Duplicate starts with the opposite of each field so every copy is observable. + + Regression test for issue #14911: promoted duplicates kept verified=False + even when the original was verified, blocking Jira "Push All Issues". + """ + original = self._create_finding(self.test1, "Original") + original.active = True + original.verified = True + original.is_mitigated = False + super(Finding, original).save(skip_validation=True) + + outside_dupe = self._create_finding(self.test2, "Outside Dupe") + outside_dupe.is_mitigated = True + super(Finding, outside_dupe).save(skip_validation=True) + self._make_duplicate(outside_dupe, original) # forces active=False, verified default False + + with impersonate(self.testuser): + prepare_duplicates_for_delete(self.test1) + + outside_dupe.refresh_from_db() + self.assertFalse(outside_dupe.duplicate) + self.assertTrue(outside_dupe.active) + self.assertTrue(outside_dupe.verified) + self.assertFalse(outside_dupe.is_mitigated) + + def test_original_status_copied_to_new_original_inactive_mitigated(self): + """ + New original inherits active/verified/is_mitigated from deleted original. + + Negative case: original is closed, unverified, mitigated. + Duplicate starts with the opposite of each field so every copy is observable. + """ original = self._create_finding(self.test1, "Original") original.active = False + original.verified = False original.is_mitigated = True super(Finding, original).save(skip_validation=True) outside_dupe = self._create_finding(self.test2, "Outside Dupe") + outside_dupe.verified = True + super(Finding, outside_dupe).save(skip_validation=True) self._make_duplicate(outside_dupe, original) + # _make_duplicate forces active=False; flip to True so the copy is observable + outside_dupe.active = True + super(Finding, outside_dupe).save(skip_validation=True) with impersonate(self.testuser): prepare_duplicates_for_delete(self.test1) @@ -310,6 +351,7 @@ def test_original_status_copied_to_new_original(self): outside_dupe.refresh_from_db() self.assertFalse(outside_dupe.duplicate) self.assertFalse(outside_dupe.active) + self.assertFalse(outside_dupe.verified) self.assertTrue(outside_dupe.is_mitigated) def test_found_by_copied_to_new_original(self): diff --git a/unittests/test_product_endpoint_report_scoping.py b/unittests/test_product_endpoint_report_scoping.py index ccfe17a01e9..ae79b7edb90 100644 --- a/unittests/test_product_endpoint_report_scoping.py +++ b/unittests/test_product_endpoint_report_scoping.py @@ -124,7 +124,7 @@ def setUp(self): self.client.force_login(self.user) def test_product_endpoint_report_only_includes_target_product_findings(self): - url = f"/product/{self.product_a.id}/endpoint/report?_generate=1&report_type=HTML" + url = f"/asset/{self.product_a.id}/endpoint/report?_generate=1&report_type=HTML" response = self.client.get(url) self.assertEqual(response.status_code, 200, response.content[:500]) body = response.content.decode() @@ -137,7 +137,7 @@ def test_product_endpoint_report_only_includes_target_product_findings(self): ) def test_product_b_report_only_includes_product_b_findings(self): - url = f"/product/{self.product_b.id}/endpoint/report?_generate=1&report_type=HTML" + url = f"/asset/{self.product_b.id}/endpoint/report?_generate=1&report_type=HTML" response = self.client.get(url) self.assertEqual(response.status_code, 200, response.content[:500]) body = response.content.decode() diff --git a/unittests/test_v3_endpoint_route_authz.py b/unittests/test_v3_endpoint_route_authz.py new file mode 100644 index 00000000000..3aa34b07a6a --- /dev/null +++ b/unittests/test_v3_endpoint_route_authz.py @@ -0,0 +1,316 @@ +""" +Regression tests for object-permission enforcement on the V3 endpoint +(``dojo.url.ui``) routes. + +The V3 routes carry a ``location_id`` kwarg and operate on +``dojo.location.models.Location`` rows. They share URL names with the +legacy endpoint UI, so the existing ``URL_PERMISSIONS`` mapping and the +``AuthorizationMiddleware`` need to line up with the active route's +model and kwarg for the per-object check to actually run. + +These tests fix the contract: an authenticated user who has no +membership on the product backing a Location must not be able to view, +edit, delete, attach metadata to, or reach Location-scoped routes for +that Location. +""" +from unittest.mock import MagicMock + +from django.core.exceptions import PermissionDenied +from django.test import RequestFactory +from django.urls import reverse +from django.utils.timezone import now + +from dojo.authorization.middleware import AuthorizationMiddleware +from dojo.authorization.url_permissions import URL_PERMISSIONS +from dojo.location.models import Location, LocationFindingReference +from dojo.location.status import FindingLocationStatus +from dojo.models import ( + Dojo_User, + Engagement, + Finding, + Product, + Product_Type, + Test, + Test_Type, +) +from dojo.url.models import URL +from unittests.dojo_test_case import DojoTestCase, skip_unless_v3 + + +@skip_unless_v3 +class V3EndpointRouteAuthorizationTests(DojoTestCase): + + """ + Two products, two users -- each user is authorized only for their own + product. Locations are created and associated to one product apiece. + Each test crosses the membership boundary and asserts the request is + rejected. + """ + + @classmethod + def setUpTestData(cls): + cls.prod_type = Product_Type.objects.create(name="v3_authz_pt") + cls.product_a = Product.objects.create( + name="v3_authz_product_a", + description="a", + prod_type=cls.prod_type, + ) + cls.product_b = Product.objects.create( + name="v3_authz_product_b", + description="b", + prod_type=cls.prod_type, + ) + + cls.user_a = Dojo_User.objects.create(username="v3_authz_user_a", is_active=True) + cls.user_b = Dojo_User.objects.create(username="v3_authz_user_b", is_active=True) + # Legacy authorization uses ``authorized_users`` directly. + cls.product_a.authorized_users.add(cls.user_a) + cls.product_b.authorized_users.add(cls.user_b) + + # Each Location is tied to exactly one product. + cls.url_a = URL.get_or_create_from_object( + URL.from_value("https://product-a.example.test/secret"), + ) + cls.url_a.location.associate_with_product(cls.product_a) + cls.location_a = cls.url_a.location + + cls.url_b = URL.get_or_create_from_object( + URL.from_value("https://product-b.example.test/secret"), + ) + cls.url_b.location.associate_with_product(cls.product_b) + cls.location_b = cls.url_b.location + + # A Finding whose product is product_b -- used for add_endpoint_to_finding. + test_type, _ = Test_Type.objects.get_or_create(name="v3_authz_scan") + engagement_b = Engagement.objects.create( + name="v3_authz_eng", + product=cls.product_b, + target_start=now(), + target_end=now(), + ) + test_b = Test.objects.create( + engagement=engagement_b, + test_type=test_type, + target_start=now(), + target_end=now(), + ) + cls.finding_in_b = Finding.objects.create( + test=test_b, + title="v3_authz_finding", + description="x", + severity="High", + numerical_severity="S0", + active=True, + verified=True, + reporter=cls.user_b, + ) + + # A Location associated with both products, with a Finding from each + # product linked through it. Used for the shared-location scoping + # tests. + cls.shared_host = "shared.example.test" + cls.url_shared = URL.get_or_create_from_object( + URL.from_value(f"https://{cls.shared_host}/shared"), + ) + cls.location_shared = cls.url_shared.location + cls.location_shared.associate_with_product(cls.product_a) + cls.location_shared.associate_with_product(cls.product_b) + + engagement_a = Engagement.objects.create( + name="v3_authz_eng_a", + product=cls.product_a, + target_start=now(), + target_end=now(), + ) + test_a = Test.objects.create( + engagement=engagement_a, + test_type=test_type, + target_start=now(), + target_end=now(), + ) + cls.finding_in_a_on_shared = Finding.objects.create( + test=test_a, + title="v3_authz_finding_on_shared_in_a", + description="x", + severity="High", + numerical_severity="S0", + active=True, + verified=True, + reporter=cls.user_a, + ) + cls.finding_in_b_on_shared = Finding.objects.create( + test=test_b, + title="v3_authz_finding_on_shared_in_b", + description="x", + severity="High", + numerical_severity="S0", + active=True, + verified=True, + reporter=cls.user_b, + ) + LocationFindingReference.objects.create( + location=cls.location_shared, + finding=cls.finding_in_a_on_shared, + status=FindingLocationStatus.Active, + ) + LocationFindingReference.objects.create( + location=cls.location_shared, + finding=cls.finding_in_b_on_shared, + status=FindingLocationStatus.Active, + ) + + # A second Location on the same host, associated only with product_b. + # Used to verify host_view aggregation respects per-product membership. + cls.url_unauthorized_on_shared_host = URL.get_or_create_from_object( + URL.from_value(f"https://{cls.shared_host}/private-to-b"), + ) + cls.location_unauthorized_on_shared_host = cls.url_unauthorized_on_shared_host.location + cls.location_unauthorized_on_shared_host.associate_with_product(cls.product_b) + + # ------------------------------------------------------------------ + # Positive control: the authorized user can reach their own Location. + # ------------------------------------------------------------------ + def test_authorized_user_can_view_own_location(self): + self.client.force_login(self.user_a) + response = self.client.get(reverse("view_endpoint", args=(self.location_a.id,))) + self.assertEqual(response.status_code, 200) + + # ------------------------------------------------------------------ + # View / view-host / report routes must reject cross-product access. + # ------------------------------------------------------------------ + def test_view_endpoint_rejects_cross_product(self): + self.client.force_login(self.user_a) + response = self.client.get(reverse("view_endpoint", args=(self.location_b.id,))) + self.assertEqual(response.status_code, 400) + + def test_view_endpoint_host_rejects_cross_product(self): + self.client.force_login(self.user_a) + response = self.client.get(reverse("view_endpoint_host", args=(self.location_b.id,))) + self.assertEqual(response.status_code, 400) + + def test_endpoint_report_rejects_cross_product(self): + self.client.force_login(self.user_a) + response = self.client.get(reverse("endpoint_report", args=(self.location_b.id,))) + self.assertEqual(response.status_code, 400) + + def test_endpoint_host_report_rejects_cross_product(self): + self.client.force_login(self.user_a) + response = self.client.get(reverse("endpoint_host_report", args=(self.location_b.id,))) + self.assertEqual(response.status_code, 400) + + # ------------------------------------------------------------------ + # Reports on shared Locations must not surface data from products + # the requesting user is not a member of. Location-level authorization + # grants access when *any* associated product is authorized, but the + # rendered report must still be reduced to the user's product scope. + # ------------------------------------------------------------------ + def test_endpoint_report_excludes_findings_from_unauthorized_products(self): + self.client.force_login(self.user_a) + response = self.client.get( + reverse("endpoint_report", args=(self.location_shared.id,)) + "?_generate=1", + HTTP_HOST="testserver", + ) + self.assertEqual(response.status_code, 200) + finding_ids = {f.id for f in response.context["findings"]} + self.assertIn(self.finding_in_a_on_shared.id, finding_ids) + self.assertNotIn(self.finding_in_b_on_shared.id, finding_ids) + + def test_endpoint_host_report_excludes_locations_from_unauthorized_products(self): + self.client.force_login(self.user_a) + response = self.client.get( + reverse("endpoint_host_report", args=(self.location_shared.id,)) + "?_generate=1", + HTTP_HOST="testserver", + ) + self.assertEqual(response.status_code, 200) + location_ids = {loc.id for loc in response.context["endpoints"]} + self.assertIn(self.location_shared.id, location_ids) + self.assertNotIn(self.location_unauthorized_on_shared_host.id, location_ids) + finding_ids = {f.id for f in response.context["findings"]} + self.assertIn(self.finding_in_a_on_shared.id, finding_ids) + self.assertNotIn(self.finding_in_b_on_shared.id, finding_ids) + + # ------------------------------------------------------------------ + # Edit / delete routes must reject cross-product mutation. + # ------------------------------------------------------------------ + def test_edit_endpoint_rejects_cross_product(self): + self.client.force_login(self.user_a) + original_host = self.url_b.host + response = self.client.post( + reverse("edit_endpoint", args=(self.location_b.id,)), + data={ + "protocol": "https", + "host": "changed.example.test", + "path": "changed", + }, + ) + self.assertEqual(response.status_code, 400) + self.url_b.refresh_from_db() + self.assertEqual(self.url_b.host, original_host) + + def test_delete_endpoint_rejects_cross_product(self): + self.client.force_login(self.user_a) + response = self.client.post( + reverse("delete_endpoint", args=(self.location_b.id,)), + data={"id": self.location_b.id}, + ) + self.assertEqual(response.status_code, 400) + self.assertTrue(Location.objects.filter(pk=self.location_b.id).exists()) + + # ------------------------------------------------------------------ + # Metadata routes go through the same view + URL name overlap. + # ------------------------------------------------------------------ + def test_add_endpoint_meta_data_rejects_cross_product(self): + self.client.force_login(self.user_a) + response = self.client.get(reverse("add_endpoint_meta_data", args=(self.location_b.id,))) + self.assertEqual(response.status_code, 400) + + def test_edit_endpoint_meta_data_rejects_cross_product(self): + self.client.force_login(self.user_a) + response = self.client.get(reverse("edit_endpoint_meta_data", args=(self.location_b.id,))) + self.assertEqual(response.status_code, 400) + + # ------------------------------------------------------------------ + # The /endpoints/finding//add route ties URL creation to a Finding, + # so authorization has to be checked against the Finding's product -- + # not against an unrelated Product whose pk coincides with finding_id. + # ------------------------------------------------------------------ + def test_add_endpoint_to_finding_rejects_cross_product(self): + self.client.force_login(self.user_a) + response = self.client.post( + reverse("add_endpoint_to_finding", args=(self.finding_in_b.id,)), + data={ + "protocol": "https", + "host": "attacker.example.test", + "path": "", + }, + ) + self.assertEqual(response.status_code, 400) + + +class AuthorizationMiddlewareKwargContractTests(DojoTestCase): + + """ + The middleware must not silently skip an object-permission check when + the configured kwarg is absent from ``view_kwargs``. A missing kwarg + is a sign that the URL pattern and the URL_PERMISSIONS entry have + drifted apart; treating that as "allowed" is unsafe. + """ + + def test_missing_configured_kwarg_is_treated_as_denied(self): + middleware = AuthorizationMiddleware(get_response=lambda _request: None) + request = RequestFactory().get("/somepath") + request.user = Dojo_User.objects.create(username="middleware_kwarg_test_user", is_active=True) + + resolver_match = MagicMock() + # Pick any URL name that has an object check configured. + resolver_match.url_name = next( + name for name, checks in URL_PERMISSIONS.items() + if checks and checks[0][0] == "object" + ) + request.resolver_match = resolver_match + + def _view(_request, **_kwargs): + return None + + with self.assertRaises(PermissionDenied): + middleware.process_view(request, view_func=_view, view_args=(), view_kwargs={}) diff --git a/unittests/tools/test_alertlogic_parser.py b/unittests/tools/test_alertlogic_parser.py new file mode 100644 index 00000000000..92ce1338cc1 --- /dev/null +++ b/unittests/tools/test_alertlogic_parser.py @@ -0,0 +1,182 @@ +from dojo.models import Test +from dojo.tools.alertlogic.parser import AlertlogicParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestAlertlogicParser(DojoTestCase): + + @staticmethod + def _findings(filename): + with (get_unit_tests_scans_path("alertlogic") / filename).open(encoding="utf-8") as testfile: + return AlertlogicParser().get_findings(testfile, Test()) + + def test_get_scan_types(self): + self.assertEqual(["Alert Logic Scan"], AlertlogicParser().get_scan_types()) + + def test_get_label_for_scan_types(self): + self.assertEqual("Alert Logic Scan", AlertlogicParser().get_label_for_scan_types("Alert Logic Scan")) + + def test_get_description_for_scan_types(self): + description = AlertlogicParser().get_description_for_scan_types("Alert Logic Scan") + self.assertIn("Alert Logic", description) + + def test_parse_no_findings(self): + self.assertEqual(0, len(self._findings("no_vuln.csv"))) + + def test_parse_one_finding(self): + self.assertEqual(1, len(self._findings("one_vuln.csv"))) + + def test_parse_many_findings(self): + self.assertEqual(7, len(self._findings("many_vulns.csv"))) + + def test_one_finding_basic_fields(self): + finding = self._findings("one_vuln.csv")[0] + self.assertEqual("CVE-2023-44487 - HTTP/2 Rapid Reset Attack", finding.title) + self.assertEqual("Medium", finding.severity) + self.assertEqual("web-01.example.com", finding.component_name) + self.assertEqual("11111111111111111111111111111111", finding.unique_id_from_tool) + self.assertEqual(5.3, finding.cvssv3_score) + self.assertEqual(True, finding.static_finding) + self.assertEqual(False, finding.dynamic_finding) + + def test_one_finding_cve(self): + finding = self._findings("one_vuln.csv")[0] + self.assertEqual(["CVE-2023-44487"], finding.unsaved_vulnerability_ids) + + def test_one_finding_mitigation(self): + finding = self._findings("one_vuln.csv")[0] + self.assertIn("nginx", finding.mitigation) + self.assertIn("Apache httpd", finding.mitigation) + + def test_one_finding_description_includes_evidence_and_os(self): + finding = self._findings("one_vuln.csv")[0] + self.assertIn("**Description:**", finding.description) + self.assertIn("**Evidence:**", finding.description) + self.assertIn("**Operating System:**", finding.description) + self.assertIn("**Vulnerability ID:**", finding.description) + + def test_severity_critical(self): + finding = self._findings("many_vulns.csv")[0] + self.assertEqual("Critical", finding.severity) + + def test_severity_high(self): + finding = self._findings("many_vulns.csv")[1] + self.assertEqual("High", finding.severity) + + def test_severity_low(self): + finding = self._findings("many_vulns.csv")[2] + self.assertEqual("Low", finding.severity) + + def test_severity_info(self): + finding = self._findings("many_vulns.csv")[3] + self.assertEqual("Info", finding.severity) + + def test_severity_medium(self): + finding = self._findings("many_vulns.csv")[6] + self.assertEqual("Medium", finding.severity) + + def test_title_truncation_long(self): + # Row 4 has an 841-char Vulnerability value, should be truncated to 500 + finding = self._findings("many_vulns.csv")[4] + self.assertEqual(500, len(finding.title)) + self.assertTrue(finding.title.endswith("...")) + + def test_title_no_truncation_when_short(self): + # Row 0 has a 51-char title — should not be truncated + finding = self._findings("many_vulns.csv")[0] + self.assertFalse(finding.title.endswith("...")) + self.assertEqual(51, len(finding.title)) + + def test_unique_id_from_tool(self): + # Distinct unique_id per finding — these are the canonical dedup anchors + findings = self._findings("many_vulns.csv") + ids = [f.unique_id_from_tool for f in findings] + self.assertEqual(len(ids), len(set(ids))) # all unique + self.assertEqual("22222222222222222222222222222222", findings[0].unique_id_from_tool) + + def test_endpoint_single_ipv4(self): + # Row 0 has a single IPv4 address + finding = self._findings("many_vulns.csv")[0] + endpoints = self.get_unsaved_locations(finding) + self.assertEqual(1, len(endpoints)) + endpoint = endpoints[0] + self.assertEqual("192.0.2.20", endpoint.host) + self.assertEqual("tcp", endpoint.protocol) + self.assertEqual(8080, endpoint.port) + + def test_endpoint_multi_ipv4_and_ipv6(self): + # Row 1: "198.51.100.30, fe80::250:56ff:fe96:b97" + finding = self._findings("many_vulns.csv")[1] + endpoints = self.get_unsaved_locations(finding) + self.assertEqual(2, len(endpoints)) + hosts = {ep.host for ep in endpoints} + self.assertEqual({"198.51.100.30", "fe80::250:56ff:fe96:b97"}, hosts) + + def test_endpoint_ipv6_only(self): + # Row 6 has IPv6-only address + finding = self._findings("many_vulns.csv")[6] + endpoints = self.get_unsaved_locations(finding) + self.assertEqual(1, len(endpoints)) + self.assertEqual("2001:db8::1:80", endpoints[0].host) + + def test_endpoint_port_zero_is_omitted(self): + # Row 2 has Protocol/Port "TCP/0" — port should not be set + finding = self._findings("many_vulns.csv")[2] + endpoints = self.get_unsaved_locations(finding) + self.assertEqual(1, len(endpoints)) + self.assertIsNone(endpoints[0].port) + + def test_endpoint_clean_succeeds(self): + # Hard guardrail: every endpoint/location must pass clean() + # (get_unsaved_locations cleans each entry internally) + self.validate_locations(self._findings("many_vulns.csv")) + + def test_cve_present(self): + # Row 0 has CVE-2021-44228 (Log4Shell) + finding = self._findings("many_vulns.csv")[0] + self.assertEqual(["CVE-2021-44228"], finding.unsaved_vulnerability_ids) + + def test_cve_absent(self): + # Row 2 (TCP Timestamp) has no CVE — attribute should be unset or empty + finding = self._findings("many_vulns.csv")[2] + self.assertFalse(getattr(finding, "unsaved_vulnerability_ids", None)) + + def test_cisa_known_exploited_tag_added(self): + # Rows 0, 1, 4 have CISA KEV = "Yes" + findings = self._findings("many_vulns.csv") + self.assertIn("cisa-known-exploited", findings[0].unsaved_tags) + self.assertIn("cisa-known-exploited", findings[1].unsaved_tags) + self.assertIn("cisa-known-exploited", findings[4].unsaved_tags) + + def test_cisa_known_exploited_tag_not_added(self): + # Row 2 has CISA KEV = "No" — no tag should be added + finding = self._findings("many_vulns.csv")[2] + self.assertFalse(getattr(finding, "unsaved_tags", None)) + + def test_cvssv3_score_parsed(self): + finding = self._findings("many_vulns.csv")[0] + self.assertEqual(10.0, finding.cvssv3_score) + + def test_cvssv3_score_empty_is_none(self): + # Row 6 has empty CVSS Score + finding = self._findings("many_vulns.csv")[6] + self.assertIsNone(finding.cvssv3_score) + + def test_static_dynamic_flags_set_explicitly(self): + for finding in self._findings("many_vulns.csv"): + self.assertEqual(True, finding.static_finding) + self.assertEqual(False, finding.dynamic_finding) + + def test_bom_handling(self): + # All fixtures have a UTF-8 BOM; the parser must consume it without + # producing a phantom field name with the BOM prefix. + finding = self._findings("one_vuln.csv")[0] + self.assertEqual("CVE-2023-44487 - HTTP/2 Rapid Reset Attack", finding.title) + # If BOM were not stripped, the first column key would be "Vulnerability" + # and finding.title would be empty. + + def test_multiline_field_preserved_in_description(self): + # Row 0 (Log4Shell) has a multi-line Description field + finding = self._findings("many_vulns.csv")[0] + self.assertIn("Log4Shell", finding.description) + self.assertIn("\n", finding.description) diff --git a/unittests/tools/test_dependency_check_parser.py b/unittests/tools/test_dependency_check_parser.py index 31e1394ec51..67349f25871 100644 --- a/unittests/tools/test_dependency_check_parser.py +++ b/unittests/tools/test_dependency_check_parser.py @@ -58,11 +58,11 @@ def test_parse_file_with_multiple_vulnerabilities_has_multiple_findings(self): parser = DependencyCheckParser() findings = parser.get_findings(testfile, Test()) items = findings - self.assertEqual(11, len(items)) + self.assertEqual(9, len(items)) # test also different component_name formats with self.subTest(i=0): - # identifier -> package url java + 2 relateddependencies + # identifier -> package url java + 2 relateddependencies (now folded into description) self.assertEqual(items[0].title, "org.dom4j:dom4j:2.1.1.redhat-00001 | CVE-0000-0001") self.assertEqual(items[0].component_name, "org.dom4j:dom4j") self.assertEqual(items[0].component_version, "2.1.1.redhat-00001") @@ -74,6 +74,15 @@ def test_parse_file_with_multiple_vulnerabilities_has_multiple_findings(self): "/var/lib/adapter-ear1.ear/dom4j-2.1.1.jar", items[0].description, ) + self.assertIn("**Related Filepaths:**", items[0].description) + self.assertIn( + "adapter-ear8.ear: dom4j-2.1.1.jar (/var/lib/adapter-ear8.ear/dom4j-2.1.1.jar)", + items[0].description, + ) + self.assertIn( + "adapter-ear1.ear: dom4j-extensions-2.1.1.jar (/var/lib/adapter-ear1.ear/dom4j-extensions-2.1.1.jar)", + items[0].description, + ) self.assertEqual(items[0].severity, "High") self.assertEqual(items[0].cvssv3, None) self.assertEqual(items[0].cvssv3_score, None) @@ -89,192 +98,143 @@ def test_parse_file_with_multiple_vulnerabilities_has_multiple_findings(self): self.assertEqual("CVE-0000-0001", items[0].unsaved_vulnerability_ids[0]) with self.subTest(i=1): - self.assertEqual(items[1].title, "org.dom4j:dom4j:2.1.1.redhat-00001 | CVE-0000-0001") - self.assertEqual(items[1].component_name, "org.dom4j:dom4j") - self.assertEqual(items[1].component_version, "2.1.1.redhat-00001") - self.assertIn( - "Description of a bad vulnerability.", - items[1].description, - ) - self.assertIn( - "/var/lib/adapter-ear8.ear/dom4j-2.1.1.jar", - items[1].description, + # identifier -> package url javascript, no vulnerabilitids, 3 vulnerabilities, relateddependencies without filename (pre v6.0.0) + self.assertEqual( + items[1].title, "yargs-parser:5.0.0 | 1500", ) - self.assertEqual(items[1].severity, "High") + self.assertEqual(items[1].component_name, "yargs-parser") + self.assertEqual(items[1].component_version, "5.0.0") + self.assertEqual(items[1].severity, "Low") self.assertEqual(items[1].cvssv3, None) self.assertEqual(items[1].cvssv3_score, None) - self.assertEqual(items[1].file_path, "adapter-ear8.ear: dom4j-2.1.1.jar") + self.assertEqual(items[1].file_path, "yargs-parser:5.0.0") self.assertEqual( - items[1].mitigation, - "Update org.dom4j:dom4j:2.1.1.redhat-00001 to at least the version recommended in the description", - ) - self.assertEqual(items[1].unsaved_tags, ["related"]) - self.assertEqual(1, len(items[1].unsaved_vulnerability_ids)) - self.assertEqual("CVE-0000-0001", items[1].unsaved_vulnerability_ids[0]) - - with self.subTest(i=2): - self.assertEqual(items[2].title, "org.dom4j:dom4j:2.1.1.redhat-00001 | CVE-0000-0001") - self.assertEqual(items[2].component_name, "org.dom4j:dom4j") - self.assertEqual(items[2].component_version, "2.1.1.redhat-00001") - self.assertIn( - "Description of a bad vulnerability.", - items[2].description, - ) - self.assertIn( - "/var/lib/adapter-ear1.ear/dom4j-extensions-2.1.1.jar", - items[2].description, - ) - self.assertEqual(items[2].severity, "High") - self.assertEqual(items[2].cvssv3, None) - self.assertEqual(items[2].cvssv3_score, None) - self.assertEqual(items[2].file_path, "adapter-ear1.ear: dom4j-extensions-2.1.1.jar") - self.assertEqual( - items[2].mitigation, - "Update org.dom4j:dom4j:2.1.1.redhat-00001 to at least the version recommended in the description", - ) - self.assertEqual(1, len(items[2].unsaved_vulnerability_ids)) - self.assertEqual("CVE-0000-0001", items[2].unsaved_vulnerability_ids[0]) - - with self.subTest(i=3): - # identifier -> package url javascript, no vulnerabilitids, 3 vulnerabilities, relateddependencies without filename (pre v6.0.0) - self.assertEqual( - items[3].title, "yargs-parser:5.0.0 | 1500", - ) - self.assertEqual(items[3].component_name, "yargs-parser") - self.assertEqual(items[3].component_version, "5.0.0") - # assert fails due to special characters, not too important - # self.assertEqual(items[1].description, "Affected versions of `yargs-parser` are vulnerable to prototype pollution. Arguments are not properly sanitized, allowing an attacker to modify the prototype of `Object`, causing the addition or modification of an existing property that will exist on all objects.Parsing the argument `--foo.__proto__.bar baz'` adds a `bar` property with value `baz` to all objects. This is only exploitable if attackers have control over the arguments being passed to `yargs-parser`.") - self.assertEqual(items[3].severity, "Low") - self.assertEqual(items[3].cvssv3, None) - self.assertEqual(items[3].cvssv3_score, None) - self.assertEqual(items[3].file_path, "yargs-parser:5.0.0") - self.assertEqual( - items[3].mitigation, "Update yargs-parser:5.0.0 to at least the version recommended in the description", + items[1].mitigation, "Update yargs-parser:5.0.0 to at least the version recommended in the description", ) self.assertIn( "**Source:** NPM", - items[3].description, + items[1].description, ) - self.assertIsNone(items[3].unsaved_vulnerability_ids) + self.assertIsNone(items[1].unsaved_vulnerability_ids) - with self.subTest(i=4): + with self.subTest(i=2): self.assertEqual( - items[4].title, + items[2].title, "yargs-parser:5.0.0 | CVE-2020-7608", ) - self.assertEqual(items[4].component_name, "yargs-parser") - self.assertEqual(items[4].component_version, "5.0.0") + self.assertEqual(items[2].component_name, "yargs-parser") + self.assertEqual(items[2].component_version, "5.0.0") self.assertIn( 'yargs-parser could be tricked into adding or modifying properties\n of Object.prototype using a "__proto__" payload.\n**Source:** OSSINDEX\n**Filepath:** \n /var/lib/jenkins/workspace/nl-selfservice_-_metrics_develop/package-lock.json?yargs-parser', - items[4].description, + items[2].description, ) self.assertIn( "/var/lib/jenkins/workspace/nl-selfservice_-_metrics_develop/package-lock.json?yargs-parser", - items[4].description, + items[2].description, ) - self.assertEqual(items[4].severity, "High") - self.assertEqual(items[4].cvssv3, "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N") - self.assertEqual(items[4].cvssv3_score, 7.5) - self.assertEqual(items[4].file_path, "yargs-parser:5.0.0") + self.assertEqual(items[2].severity, "High") + self.assertEqual(items[2].cvssv3, "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N") + self.assertEqual(items[2].cvssv3_score, 7.5) + self.assertEqual(items[2].file_path, "yargs-parser:5.0.0") self.assertEqual( - items[4].mitigation, "Update yargs-parser:5.0.0 to at least the version recommended in the description", + items[2].mitigation, "Update yargs-parser:5.0.0 to at least the version recommended in the description", ) - self.assertEqual(1, len(items[4].unsaved_vulnerability_ids)) - self.assertEqual("CVE-2020-7608", items[4].unsaved_vulnerability_ids[0]) + self.assertEqual(1, len(items[2].unsaved_vulnerability_ids)) + self.assertEqual("CVE-2020-7608", items[2].unsaved_vulnerability_ids[0]) - with self.subTest(i=5): + with self.subTest(i=3): self.assertEqual( - items[5].title, + items[3].title, "yargs-parser:5.0.0 | CWE-400: Uncontrolled Resource Consumption ('Resource Exhaustion')", ) - self.assertEqual(items[5].component_name, "yargs-parser") - self.assertEqual(items[5].component_version, "5.0.0") + self.assertEqual(items[3].component_name, "yargs-parser") + self.assertEqual(items[3].component_version, "5.0.0") self.assertIn( "The software does not properly restrict the size or amount of resources that are requested or influenced by an actor, which can be used to consume more resources than intended.", - items[5].description, + items[3].description, ) # check that the filepath is in the description self.assertIn( "/var/lib/jenkins/workspace/nl-selfservice_-_metrics_develop/package-lock.json?yargs-parser", - items[5].description, + items[3].description, ) - self.assertEqual(items[5].severity, "High") - self.assertEqual(items[5].cvssv3, "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H") - self.assertEqual(items[5].cvssv3_score, 7.5) - self.assertEqual(items[5].file_path, "yargs-parser:5.0.0") + self.assertEqual(items[3].severity, "High") + self.assertEqual(items[3].cvssv3, "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H") + self.assertEqual(items[3].cvssv3_score, 7.5) + self.assertEqual(items[3].file_path, "yargs-parser:5.0.0") self.assertEqual( - items[5].mitigation, "Update yargs-parser:5.0.0 to at least the version recommended in the description", + items[3].mitigation, "Update yargs-parser:5.0.0 to at least the version recommended in the description", ) - self.assertIsNone(items[5].unsaved_vulnerability_ids) + self.assertIsNone(items[3].unsaved_vulnerability_ids) - with self.subTest(i=6): + with self.subTest(i=4): # identifier -> cpe java - self.assertEqual(items[6].title, "org.dom4j:dom4j:2.1.1.redhat-00001 | CVE-0000-0001") - self.assertEqual(items[6].component_name, "org.dom4j:dom4j") - self.assertEqual(items[6].component_version, "2.1.1.redhat-00001") - self.assertEqual(items[6].severity, "High") - self.assertEqual(items[6].cvssv3, None) - self.assertEqual(items[6].cvssv3_score, None) - self.assertEqual(items[6].file_path, "adapter-ear2.ear: dom4j-2.1.1.jar") + self.assertEqual(items[4].title, "org.dom4j:dom4j:2.1.1.redhat-00001 | CVE-0000-0001") + self.assertEqual(items[4].component_name, "org.dom4j:dom4j") + self.assertEqual(items[4].component_version, "2.1.1.redhat-00001") + self.assertEqual(items[4].severity, "High") + self.assertEqual(items[4].cvssv3, None) + self.assertEqual(items[4].cvssv3_score, None) + self.assertEqual(items[4].file_path, "adapter-ear2.ear: dom4j-2.1.1.jar") self.assertEqual( - items[6].mitigation, + items[4].mitigation, "Update org.dom4j:dom4j:2.1.1.redhat-00001 to at least the version recommended in the description", ) - self.assertEqual(1, len(items[6].unsaved_vulnerability_ids)) - self.assertEqual("CVE-0000-0001", items[6].unsaved_vulnerability_ids[0]) + self.assertEqual(1, len(items[4].unsaved_vulnerability_ids)) + self.assertEqual("CVE-0000-0001", items[4].unsaved_vulnerability_ids[0]) - with self.subTest(i=7): + with self.subTest(i=5): # identifier -> maven java - self.assertEqual(items[7].title, "dom4j:2.1.1 | CVE-0000-0001") - self.assertEqual(items[7].component_name, "dom4j") - self.assertEqual(items[7].component_version, "2.1.1") - self.assertEqual(items[7].severity, "High") - self.assertEqual(items[7].cvssv3, None) - self.assertEqual(items[7].cvssv3_score, None) + self.assertEqual(items[5].title, "dom4j:2.1.1 | CVE-0000-0001") + self.assertEqual(items[5].component_name, "dom4j") + self.assertEqual(items[5].component_version, "2.1.1") + self.assertEqual(items[5].severity, "High") + self.assertEqual(items[5].cvssv3, None) + self.assertEqual(items[5].cvssv3_score, None) self.assertEqual( - items[7].mitigation, "Update dom4j:2.1.1 to at least the version recommended in the description", + items[5].mitigation, "Update dom4j:2.1.1 to at least the version recommended in the description", ) - with self.subTest(i=8): + with self.subTest(i=6): # evidencecollected -> single product + single verison javascript self.assertEqual( - items[8].title, + items[6].title, "jquery:3.1.1 | CVE-0000-0001", ) - self.assertEqual(items[8].component_name, "jquery") - self.assertEqual(items[8].component_version, "3.1.1") - self.assertEqual(items[8].severity, "High") - self.assertEqual(items[8].cvssv3, None) - self.assertEqual(items[8].cvssv3_score, None) + self.assertEqual(items[6].component_name, "jquery") + self.assertEqual(items[6].component_version, "3.1.1") + self.assertEqual(items[6].severity, "High") + self.assertEqual(items[6].cvssv3, None) + self.assertEqual(items[6].cvssv3_score, None) self.assertEqual( - items[8].mitigation, "Update jquery:3.1.1 to at least the version recommended in the description", + items[6].mitigation, "Update jquery:3.1.1 to at least the version recommended in the description", ) - with self.subTest(i=9): + with self.subTest(i=7): # Tests for two suppressed vulnerabilities, # One for Suppressed with notes, the other is without. - self.assertEqual(items[9].active, False) + self.assertEqual(items[7].active, False) self.assertEqual( - items[9].mitigation, + items[7].mitigation, "**This vulnerability is mitigated and/or suppressed:** Document on why we are suppressing this vulnerability is missing!\nUpdate jquery:3.1.1 to at least the version recommended in the description", ) - self.assertEqual(items[9].unsaved_tags, ["no_suppression_document", "suppressed"]) - self.assertEqual(items[9].severity, "Critical") - self.assertEqual(items[9].cvssv3, "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H") - self.assertEqual(items[9].cvssv3_score, 9.8) - self.assertEqual(items[9].is_mitigated, True) + self.assertEqual(items[7].unsaved_tags, ["no_suppression_document", "suppressed"]) + self.assertEqual(items[7].severity, "Critical") + self.assertEqual(items[7].cvssv3, "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H") + self.assertEqual(items[7].cvssv3_score, 9.8) + self.assertEqual(items[7].is_mitigated, True) - with self.subTest(i=10): - self.assertEqual(items[10].active, False) + with self.subTest(i=8): + self.assertEqual(items[8].active, False) self.assertEqual( - items[10].mitigation, + items[8].mitigation, "**This vulnerability is mitigated and/or suppressed:** This is our reason for not to upgrade it.\nUpdate jquery:3.1.1 to at least the version recommended in the description", ) - self.assertEqual(items[10].unsaved_tags, ["suppressed"]) - self.assertEqual(items[10].severity, "Critical") - self.assertEqual(items[10].cvssv3, "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H") - self.assertEqual(items[10].cvssv3_score, 9.8) - self.assertEqual(items[10].is_mitigated, True) + self.assertEqual(items[8].unsaved_tags, ["suppressed"]) + self.assertEqual(items[8].severity, "Critical") + self.assertEqual(items[8].cvssv3, "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H") + self.assertEqual(items[8].cvssv3_score, 9.8) + self.assertEqual(items[8].is_mitigated, True) def test_parse_java_6_5_3(self): """Test with version 6.5.3""" @@ -303,7 +263,7 @@ def test_parse_file_pr6439(self): parser = DependencyCheckParser() findings = parser.get_findings(testfile, Test()) items = findings - self.assertEqual(37, len(items)) + self.assertEqual(34, len(items)) # test also different component_name formats with self.subTest(i=0): diff --git a/unittests/tools/test_github_vulnerability_parser.py b/unittests/tools/test_github_vulnerability_parser.py index 2e5869476bf..803ac8a5f02 100644 --- a/unittests/tools/test_github_vulnerability_parser.py +++ b/unittests/tools/test_github_vulnerability_parser.py @@ -313,6 +313,26 @@ def test_parser_version(self): self.assertEqual(finding.component_version, "5.3.29") self.assertAlmostEqual(finding.epss_score, 0.00212, places=5) self.assertAlmostEqual(finding.epss_percentile, 0.44035, places=5) + self.assertTrue(finding.fix_available) + + def test_parse_no_fix_available(self): + """Finding with null firstPatchedVersion should have fix_available=False""" + with (get_unit_tests_scans_path("github_vulnerability") / "github-1-vuln-no-fix.json").open( + encoding="utf-8", + ) as testfile: + parser = GithubVulnerabilityParser() + findings = parser.get_findings(testfile, Test()) + self.assertEqual(1, len(findings)) + for finding in findings: + finding.clean() + + with self.subTest(i=0): + finding = findings[0] + self.assertEqual(finding.title, "Prototype pollution in example-package") + self.assertEqual(finding.severity, "High") + self.assertEqual(finding.component_name, "example-package") + self.assertEqual(finding.component_version, "1.5.0") + self.assertFalse(finding.fix_available) def test_parse_file_issue_9582(self): with (get_unit_tests_scans_path("github_vulnerability") / "issue_9582.json").open(encoding="utf-8") as testfile: