diff --git a/.github/workflows/build-docker-images-for-testing.yml b/.github/workflows/build-docker-images-for-testing.yml index de040266a13..a8a570a9f8c 100644 --- a/.github/workflows/build-docker-images-for-testing.yml +++ b/.github/workflows/build-docker-images-for-testing.yml @@ -37,6 +37,8 @@ jobs: id: docker_build uses: docker/build-push-action@v6 timeout-minutes: 10 + env: + DOCKER_BUILD_CHECKS_ANNOTATIONS: false with: context: . push: false @@ -53,4 +55,4 @@ jobs: with: name: ${{ matrix.docker-image }} path: ${{ matrix.docker-image }}-${{ matrix.os }}_img - retention-days: 1 \ No newline at end of file + retention-days: 1 diff --git a/.github/workflows/k8s-tests.yml b/.github/workflows/k8s-tests.yml index 80418582052..837b461c15d 100644 --- a/.github/workflows/k8s-tests.yml +++ b/.github/workflows/k8s-tests.yml @@ -70,11 +70,13 @@ jobs: echo "pgsql=${{ env.HELM_PG_DATABASE_SETTINGS }}" >> $GITHUB_ENV echo "redis=${{ env.HELM_REDIS_BROKER_SETTINGS }}" >> $GITHUB_ENV - - name: Deploying Djano application with ${{ matrix.databases }} ${{ matrix.brokers }} - timeout-minutes: 10 + - name: Deploying Django application with ${{ matrix.databases }} ${{ matrix.brokers }} + timeout-minutes: 15 run: |- helm install \ --timeout 800s \ + --wait \ + --wait-for-jobs \ defectdojo \ ./helm/defectdojo \ --set django.ingress.enabled=true \ @@ -82,14 +84,14 @@ jobs: ${{ env[matrix.databases] }} \ ${{ env[matrix.brokers] }} \ --set createSecret=true \ - --set tag=${{ matrix.os }} \ - # --set imagePullSecrets=defectdojoregistrykey + --set tag=${{ matrix.os }} - name: Check deployment status + if: always() run: |- - kubectl get pods - kubectl get ingress - kubectl get services + kubectl get all,ingress # all = pods, services, deployments, replicasets, statefulsets, jobs + helm status defectdojo + helm history defectdojo - name: Check Application timeout-minutes: 10 diff --git a/.github/workflows/release-x-manual-docker-containers.yml b/.github/workflows/release-x-manual-docker-containers.yml index 6e167143783..bae585d2388 100644 --- a/.github/workflows/release-x-manual-docker-containers.yml +++ b/.github/workflows/release-x-manual-docker-containers.yml @@ -65,6 +65,7 @@ jobs: if: ${{ matrix.os == 'debian' }} uses: docker/build-push-action@v6 env: + DOCKER_BUILD_CHECKS_ANNOTATIONS: false REPO_ORG: ${{ env.repoorg }} docker-image: ${{ matrix.docker-image }} with: @@ -79,6 +80,7 @@ jobs: if: ${{ matrix.os == 'alpine' }} uses: docker/build-push-action@v6 env: + DOCKER_BUILD_CHECKS_ANNOTATIONS: false REPO_ORG: ${{ env.repoorg }} docker-image: ${{ matrix.docker-image }} with: diff --git a/.github/workflows/rest-framework-tests.yml b/.github/workflows/rest-framework-tests.yml index 907ecf92968..f153a368ba9 100644 --- a/.github/workflows/rest-framework-tests.yml +++ b/.github/workflows/rest-framework-tests.yml @@ -34,8 +34,8 @@ jobs: run: docker/setEnv.sh unit_tests_cicd # phased startup so we can use the exit code from unit test container - - name: Start Postgres - run: docker compose up -d postgres + - name: Start Postgres and webhook.endpoint + run: docker compose up -d postgres webhook.endpoint # no celery or initializer needed for unit tests - name: Unit tests diff --git a/Dockerfile.integration-tests-debian b/Dockerfile.integration-tests-debian index 0ff85f7c2a0..3a01815f820 100644 --- a/Dockerfile.integration-tests-debian +++ b/Dockerfile.integration-tests-debian @@ -1,7 +1,7 @@ # code: language=Dockerfile -FROM openapitools/openapi-generator-cli:v7.7.0@sha256:99924315933d49e7b33a7d2074bb2b64fc8def8f74519939036e24eb48f00336 AS openapitools +FROM openapitools/openapi-generator-cli:v7.8.0@sha256:c409bfa9b276faf27726d2884b859d18269bf980cb63546e80b72f3b2648c492 AS openapitools FROM python:3.11.9-slim-bookworm@sha256:8c1036ec919826052306dfb5286e4753ffd9d5f6c24fbc352a5399c3b405b57e AS build WORKDIR /app RUN \ @@ -25,8 +25,13 @@ RUN pip install --no-cache-dir selenium==4.9.0 requests # Install the latest Google Chrome stable release WORKDIR /opt/chrome + +# TODO: figure out whatever fix is necessary to use Chrome >= 128 and put this back in the RUN below so we stay +# up-to-date +# chrome_url=$(curl https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json | jq -r '.channels[] | select(.channel == "Stable") | .downloads.chrome[] | select(.platform == "linux64").url') && \ + RUN \ - chrome_url=$(curl https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json | jq -r '.channels[] | select(.channel == "Stable") | .downloads.chrome[] | select(.platform == "linux64").url') && \ + chrome_url="https://storage.googleapis.com/chrome-for-testing-public/127.0.6533.119/linux64/chrome-linux64.zip" && \ wget $chrome_url && \ unzip chrome-linux64.zip && \ rm -rf chrome-linux64.zip && \ @@ -49,8 +54,12 @@ RUN apt-get install -y libxi6 libgconf-2-4 jq libjq1 libonig5 libxkbcommon0 libx # Installing the latest stable Google Chrome driver release WORKDIR /opt/chrome-driver +# TODO: figure out whatever fix is necessary to use Chrome >= 128 and put this back in the RUN below so we stay +# up-to-date +# chromedriver_url=$(curl https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json | jq -r '.channels[] | select(.channel == "Stable") | .downloads.chromedriver[] | select(.platform == "linux64").url') && \ + RUN \ - chromedriver_url=$(curl https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json | jq -r '.channels[] | select(.channel == "Stable") | .downloads.chromedriver[] | select(.platform == "linux64").url') && \ + chromedriver_url="https://storage.googleapis.com/chrome-for-testing-public/127.0.6533.119/linux64/chromedriver-linux64.zip" && \ wget $chromedriver_url && \ unzip -j chromedriver-linux64.zip chromedriver-linux64/chromedriver && \ rm -rf chromedriver-linux64.zip && \ diff --git a/components/package.json b/components/package.json index 59405890712..b1a047f22bc 100644 --- a/components/package.json +++ b/components/package.json @@ -1,6 +1,6 @@ { "name": "defectdojo", - "version": "2.38.0-dev", + "version": "2.39.0-dev", "license" : "BSD-3-Clause", "private": true, "dependencies": { @@ -26,7 +26,7 @@ "google-code-prettify": "^1.0.0", "jquery": "^3.7.1", "jquery-highlight": "3.5.0", - "jquery-ui": "1.13.3", + "jquery-ui": "1.14.0", "jquery.cookie": "1.4.1", "jquery.flot.tooltip": "^0.9.0", "jquery.hotkeys": "jeresig/jquery.hotkeys#master", diff --git a/components/yarn.lock b/components/yarn.lock index b4bfb09a423..8bd8311e89b 100644 --- a/components/yarn.lock +++ b/components/yarn.lock @@ -678,12 +678,12 @@ jquery-highlight@3.5.0: dependencies: jquery ">= 1.0.0" -jquery-ui@1.13.3: - version "1.13.3" - resolved "https://registry.yarnpkg.com/jquery-ui/-/jquery-ui-1.13.3.tgz#d9f5292b2857fa1f2fdbbe8f2e66081664eb9bc5" - integrity sha512-D2YJfswSJRh/B8M/zCowDpNFfwsDmtfnMPwjJTyvl+CBqzpYwQ+gFYIbUUlzijy/Qvoy30H1YhoSui4MNYpRwA== +jquery-ui@1.14.0: + version "1.14.0" + resolved "https://registry.yarnpkg.com/jquery-ui/-/jquery-ui-1.14.0.tgz#b75d417826f0bab38125f907356d2e3313a9c6d5" + integrity sha512-mPfYKBoRCf0MzaT2cyW5i3IuZ7PfTITaasO5OFLAQxrHuI+ZxruPa+4/K1OMNT8oElLWGtIxc9aRbyw20BKr8g== dependencies: - jquery ">=1.8.0 <4.0.0" + jquery ">=1.12.0 <5.0.0" jquery.cookie@1.4.1: version "1.4.1" @@ -699,7 +699,7 @@ jquery.hotkeys@jeresig/jquery.hotkeys#master: version "0.2.0" resolved "https://codeload.github.com/jeresig/jquery.hotkeys/tar.gz/f24f1da275aab7881ab501055c256add6f690de4" -"jquery@>= 1.0.0", jquery@>=1.7, jquery@>=1.7.0, "jquery@>=1.8.0 <4.0.0", jquery@^3.7.1: +"jquery@>= 1.0.0", "jquery@>=1.12.0 <5.0.0", jquery@>=1.7, jquery@>=1.7.0, jquery@^3.7.1: version "3.7.1" resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.7.1.tgz#083ef98927c9a6a74d05a6af02806566d16274de" integrity sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg== diff --git a/docker-compose.override.debug.yml b/docker-compose.override.debug.yml deleted file mode 100644 index 58af41549f7..00000000000 --- a/docker-compose.override.debug.yml +++ /dev/null @@ -1,60 +0,0 @@ ---- -services: - uwsgi: - entrypoint: ['/wait-for-it.sh', '${DD_DATABASE_HOST:-postgres}:${DD_DATABASE_PORT:-5432}', '-t', '30', '--', '/entrypoint-uwsgi-dev.sh'] - volumes: - - '.:/app:z' - environment: - PYTHONWARNINGS: error # We are strict about Warnings during debugging - DD_DEBUG: 'True' - DD_ADMIN_USER: "${DD_ADMIN_USER:-admin}" - DD_ADMIN_PASSWORD: "${DD_ADMIN_PASSWORD:-admin}" - DD_EMAIL_URL: "smtp://mailhog:1025" - ports: - - target: ${DD_DEBUG_PORT:-3000} - published: ${DD_DEBUG_PORT:-3000} - protocol: tcp - mode: host - celeryworker: - volumes: - - '.:/app:z' - environment: - PYTHONWARNINGS: error # We are strict about Warnings during debugging - DD_DEBUG: 'True' - DD_EMAIL_URL: "smtp://mailhog:1025" - celerybeat: - environment: - PYTHONWARNINGS: error # We are strict about Warnings during debugging - DD_DEBUG: 'True' - volumes: - - '.:/app:z' - initializer: - volumes: - - '.:/app:z' - environment: - PYTHONWARNINGS: error # We are strict about Warnings during debugging - DD_DEBUG: 'True' - DD_ADMIN_USER: "${DD_ADMIN_USER:-admin}" - DD_ADMIN_PASSWORD: "${DD_ADMIN_PASSWORD:-admin}" - nginx: - volumes: - - './dojo/static/dojo:/usr/share/nginx/html/static/dojo' - postgres: - ports: - - target: ${DD_DATABASE_PORT:-5432} - published: ${DD_DATABASE_PORT:-5432} - protocol: tcp - mode: host - mailhog: - image: mailhog/mailhog:v1.0.1@sha256:8d76a3d4ffa32a3661311944007a415332c4bb855657f4f6c57996405c009bea - entrypoint: [ "/bin/sh", "-c", "MailHog &>/dev/null" ] - # inspired by https://github.com/mailhog/MailHog/issues/56#issuecomment-291968642 - ports: - - target: 1025 - published: 1025 - protocol: tcp - mode: host - - target: 8025 - published: 8025 - protocol: tcp - mode: host diff --git a/docker-compose.override.dev.yml b/docker-compose.override.dev.yml index 185ff0748f7..cf60d8d00a3 100644 --- a/docker-compose.override.dev.yml +++ b/docker-compose.override.dev.yml @@ -5,7 +5,8 @@ services: volumes: - '.:/app:z' environment: - PYTHONWARNINGS: always # We are strict during development so Warnings needs to be more verbose + PYTHONWARNINGS: error # We are strict about Warnings during development + DD_DEBUG: 'True' DD_ADMIN_USER: "${DD_ADMIN_USER:-admin}" DD_ADMIN_PASSWORD: "${DD_ADMIN_PASSWORD:-admin}" DD_EMAIL_URL: "smtp://mailhog:1025" @@ -13,18 +14,21 @@ services: volumes: - '.:/app:z' environment: - PYTHONWARNINGS: always # We are strict during development so Warnings needs to be more verbose + PYTHONWARNINGS: error # We are strict about Warnings during development + DD_DEBUG: 'True' DD_EMAIL_URL: "smtp://mailhog:1025" celerybeat: volumes: - '.:/app:z' environment: - PYTHONWARNINGS: always # We are strict during development so Warnings needs to be more verbose + PYTHONWARNINGS: error # We are strict about Warnings during development + DD_DEBUG: 'True' initializer: volumes: - '.:/app:z' environment: - PYTHONWARNINGS: always # We are strict during development so Warnings needs to be more verbose + PYTHONWARNINGS: error # We are strict about Warnings during development + DD_DEBUG: 'True' DD_ADMIN_USER: "${DD_ADMIN_USER:-admin}" DD_ADMIN_PASSWORD: "${DD_ADMIN_PASSWORD:-admin}" nginx: @@ -49,3 +53,5 @@ services: published: 8025 protocol: tcp mode: host + "webhook.endpoint": + image: mccutchen/go-httpbin:v2.14.0@sha256:e0f398a0a29e7cf00a2467326344d70b4d89d0786d8f9a3287c2a0371c804823 diff --git a/docker-compose.override.unit_tests.yml b/docker-compose.override.unit_tests.yml index 164d7a87084..ccf3c84030a 100644 --- a/docker-compose.override.unit_tests.yml +++ b/docker-compose.override.unit_tests.yml @@ -51,6 +51,8 @@ services: redis: image: busybox:1.36.1-musl entrypoint: ['echo', 'skipping', 'redis'] + "webhook.endpoint": + image: mccutchen/go-httpbin:v2.14.0@sha256:e0f398a0a29e7cf00a2467326344d70b4d89d0786d8f9a3287c2a0371c804823 volumes: defectdojo_postgres_unit_tests: {} defectdojo_media_unit_tests: {} diff --git a/docker-compose.override.unit_tests_cicd.yml b/docker-compose.override.unit_tests_cicd.yml index b39f4cf034d..141ad7227dc 100644 --- a/docker-compose.override.unit_tests_cicd.yml +++ b/docker-compose.override.unit_tests_cicd.yml @@ -50,6 +50,8 @@ services: redis: image: busybox:1.36.1-musl entrypoint: ['echo', 'skipping', 'redis'] + "webhook.endpoint": + image: mccutchen/go-httpbin:v2.14.0@sha256:e0f398a0a29e7cf00a2467326344d70b4d89d0786d8f9a3287c2a0371c804823 volumes: defectdojo_postgres_unit_tests: {} defectdojo_media_unit_tests: {} diff --git a/docker-compose.yml b/docker-compose.yml index df2182f72ef..095e69f6dca 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -103,7 +103,7 @@ services: source: ./docker/extra_settings target: /app/docker/extra_settings postgres: - image: postgres:16.4-alpine@sha256:492898505cb45f9835acc327e98711eaa9298ed804e0bb36f29e08394229550d + image: postgres:16.4-alpine@sha256:d898b0b78a2627cb4ee63464a14efc9d296884f1b28c841b0ab7d7c42f1fffdf environment: POSTGRES_DB: ${DD_DATABASE_NAME:-defectdojo} POSTGRES_USER: ${DD_DATABASE_USER:-defectdojo} @@ -111,7 +111,7 @@ services: volumes: - defectdojo_postgres:/var/lib/postgresql/data redis: - image: redis:7.2.5-alpine@sha256:0bc09d9f486508aa42ecc2f18012bb1e3a1b2744ef3a6ad30942fa12579f0b03 + image: redis:7.2.5-alpine@sha256:6aaf3f5e6bc8a592fbfe2cccf19eb36d27c39d12dab4f4b01556b7449e7b1f44 volumes: - defectdojo_redis:/data volumes: diff --git a/docker/install_chrome_dependencies.py b/docker/install_chrome_dependencies.py index 1b8f29585ea..c17fabbc8be 100644 --- a/docker/install_chrome_dependencies.py +++ b/docker/install_chrome_dependencies.py @@ -18,7 +18,7 @@ def find_packages(library_name): def run_command(cmd, cwd=None, env=None): - result = subprocess.run(cmd, cwd=cwd, env=env, capture_output=True, text=True) + result = subprocess.run(cmd, cwd=cwd, env=env, capture_output=True, text=True, check=False) return result.stdout @@ -27,7 +27,7 @@ def ldd(file_path): # For simplicity, I'm assuming if we get an error, the code is non-zero. try: result = subprocess.run( - ["ldd", file_path], capture_output=True, text=True, + ["ldd", file_path], capture_output=True, text=True, check=False, ) stdout = result.stdout code = result.returncode diff --git a/docker/setEnv.sh b/docker/setEnv.sh index c4c6b9d7ef2..b9336535e39 100755 --- a/docker/setEnv.sh +++ b/docker/setEnv.sh @@ -5,7 +5,6 @@ target_dir="${0%/*}/.." override_link='docker-compose.override.yml' override_file_dev='docker-compose.override.dev.yml' -override_file_debug='docker-compose.override.debug.yml' override_file_unit_tests='docker-compose.override.unit_tests.yml' override_file_unit_tests_cicd='docker-compose.override.unit_tests_cicd.yml' override_file_integration_tests='docker-compose.override.integration_tests.yml' @@ -77,19 +76,6 @@ function set_dev { fi } -function set_debug { - get_current - if [ "${current_env}" != debug ] - then - docker compose down - rm -f ${override_link} - ln -s ${override_file_debug} ${override_link} - echo "Now using 'debug' configuration." - else - echo "Already using 'debug' configuration." - fi -} - function set_unit_tests { get_current if [ "${current_env}" != unit_tests ] diff --git a/docs/content/en/_index.md b/docs/content/en/_index.md index ce75fcc5b88..7dceb1bf342 100644 --- a/docs/content/en/_index.md +++ b/docs/content/en/_index.md @@ -40,7 +40,7 @@ The open-source edition is [available on GitHub](https://github.com/DefectDojo/django-DefectDojo). A running example is available on [our demo server](https://demo.defectdojo.org), -using the credentials `admin` / `defectdojo@demo#appsec`. Note: The demo +using the credentials `admin` / `1Defectdojo@demo#appsec`. Note: The demo server is refreshed regularly and provisioned with some sample data. ### DefectDojo Pro and Enterprise diff --git a/docs/content/en/getting_started/upgrading/2.39.md b/docs/content/en/getting_started/upgrading/2.39.md new file mode 100644 index 00000000000..0f179d7b5d1 --- /dev/null +++ b/docs/content/en/getting_started/upgrading/2.39.md @@ -0,0 +1,7 @@ +--- +title: 'Upgrading to DefectDojo Version 2.39.x' +toc_hide: true +weight: -20240903 +description: No special instructions. +--- +There are no special instructions for upgrading to 2.39.x. Check the [Release Notes](https://github.com/DefectDojo/django-DefectDojo/releases/tag/2.39.0) for the contents of the release. diff --git a/docs/content/en/integrations/api-v2-docs.md b/docs/content/en/integrations/api-v2-docs.md index 7b8d1f7956c..a1134522429 100644 --- a/docs/content/en/integrations/api-v2-docs.md +++ b/docs/content/en/integrations/api-v2-docs.md @@ -47,6 +47,7 @@ For example: : If you use [an alternative authentication method](../social-authentication/) for users, you may want to disable DefectDojo API tokens because it could bypass your authentication concept. \ Using of DefectDojo API tokens can be disabled by specifying the environment variable `DD_API_TOKENS_ENABLED` to `False`. +Or only `api/v2/api-token-auth/` endpoint can be disabled by setting `DD_API_TOKEN_AUTH_ENDPOINT_ENABLED` to `False`. ## Sample Code diff --git a/docs/content/en/integrations/burp-plugin.md b/docs/content/en/integrations/burp-plugin.md index 400b37c0f2a..ab3285ceda4 100644 --- a/docs/content/en/integrations/burp-plugin.md +++ b/docs/content/en/integrations/burp-plugin.md @@ -2,7 +2,7 @@ title: "Defect Dojo Burp plugin" description: "Export findings directly from Burp to DefectDojo." draft: false -weight: 8 +weight: 9 --- **Please note: The DefectDojo Burp Plugin has been sunset and is no longer a supported feature.** diff --git a/docs/content/en/integrations/exporting.md b/docs/content/en/integrations/exporting.md index da17df7d93b..7a42d27b17e 100644 --- a/docs/content/en/integrations/exporting.md +++ b/docs/content/en/integrations/exporting.md @@ -2,7 +2,7 @@ title: "Exporting" description: "DefectDojo has the ability to export findings." draft: false -weight: 11 +weight: 12 --- diff --git a/docs/content/en/integrations/google-sheets-sync.md b/docs/content/en/integrations/google-sheets-sync.md index b6e97f72f84..456a694fc6e 100644 --- a/docs/content/en/integrations/google-sheets-sync.md +++ b/docs/content/en/integrations/google-sheets-sync.md @@ -2,7 +2,7 @@ title: "Google Sheets synchronisation" description: "Export finding details to Google Sheets and upload changes from Google Sheets." draft: false -weight: 7 +weight: 8 --- **Please note - the Google Sheets feature has been deprecated as of DefectDojo version 2.21.0 - these documents are for reference only.** diff --git a/docs/content/en/integrations/languages.md b/docs/content/en/integrations/languages.md index 17a322c8f90..a78ed137e69 100644 --- a/docs/content/en/integrations/languages.md +++ b/docs/content/en/integrations/languages.md @@ -2,7 +2,7 @@ title: "Languages and lines of code" description: "You can import an analysis of languages used in a project, including lines of code." draft: false -weight: 9 +weight: 10 --- ## Import of languages for a project diff --git a/docs/content/en/integrations/notification_webhooks/_index.md b/docs/content/en/integrations/notification_webhooks/_index.md new file mode 100644 index 00000000000..d8fe606cffa --- /dev/null +++ b/docs/content/en/integrations/notification_webhooks/_index.md @@ -0,0 +1,79 @@ +--- +title: "Notification Webhooks (experimental)" +description: "How to setup and use webhooks" +weight: 7 +chapter: true +--- + +Webhooks are HTTP requests coming from the DefectDojo instance towards user-defined webserver which expects this kind of incoming traffic. + +## Transition graph: + +It is not unusual that in some cases webhook can not be performed. It is usually connected to network issues, server misconfiguration, or running upgrades on the server. DefectDojo needs to react to these outages. It might temporarily or permanently disable related endpoints. The following graph shows how it might change the status of the webhook definition based on HTTP responses (or manual user interaction). + +```mermaid +flowchart TD + + START{{Endpoint created}} + ALL{All states} + STATUS_ACTIVE([STATUS_ACTIVE]) + STATUS_INACTIVE_TMP + STATUS_INACTIVE_PERMANENT + STATUS_ACTIVE_TMP([STATUS_ACTIVE_TMP]) + END{{Endpoint removed}} + + START ==> STATUS_ACTIVE + STATUS_ACTIVE --HTTP 200 or 201 --> STATUS_ACTIVE + STATUS_ACTIVE --HTTP 5xx
or HTTP 429
or Timeout--> STATUS_INACTIVE_TMP + STATUS_ACTIVE --Any HTTP 4xx response
or any other HTTP response
or non-HTTP error--> STATUS_INACTIVE_PERMANENT + STATUS_INACTIVE_TMP -.After 60s.-> STATUS_ACTIVE_TMP + STATUS_ACTIVE_TMP --HTTP 5xx
or HTTP 429
or Timeout
within 24h
from the first error-->STATUS_INACTIVE_TMP + STATUS_ACTIVE_TMP -.After 24h.-> STATUS_ACTIVE + STATUS_ACTIVE_TMP --HTTP 200 or 201 --> STATUS_ACTIVE_TMP + STATUS_ACTIVE_TMP --HTTP 5xx
or HTTP 429
or Timeout
within 24h from the first error
or any other HTTP response or error--> STATUS_INACTIVE_PERMANENT + ALL ==Activation by user==> STATUS_ACTIVE + ALL ==Deactivation by user==> STATUS_INACTIVE_PERMANENT + ALL ==Removal of endpoint by user==> END +``` + +Notes: + +1. Transitions: + - bold: manual changes by user + - dotted: automated by celery + - others: based on responses on webhooks +1. Nodes: + - Stadium-shaped: Active - following webhook can be sent + - Rectangles: Inactive - performing of webhook will fail (and not retried) + - Hexagonal: Initial and final states + - Rhombus: All states (meta node to make the graph more readable) + +## Body and Headers + +The body of each request is JSON which contains data about related events like names and IDs of affected elements. +Examples of bodies are on pages related to each event (see below). + +Each request contains the following headers. They might be useful for better handling of events by server this process events. + +```yaml +User-Agent: DefectDojo- +X-DefectDojo-Event: +X-DefectDojo-Instance: +``` +## Disclaimer + +This functionality is new and in experimental mode. This means Functionality might generate breaking changes in following DefectDojo releases and might not be considered final. + +However, the community is open to feedback to make this functionality better and transform it stable as soon as possible. + +## Roadmap + +There are a couple of known issues that are expected to be implemented as soon as core functionality is considered ready. + +- Support events - Not only adding products, product types, engagements, tests, or upload of new scans but also events around SLA +- User webhook - right now only admins can define webhooks; in the future also users will be able to define their own +- Improvement in UI - add filtering and pagination of webhook endpoints + +## Events + + \ No newline at end of file diff --git a/docs/content/en/integrations/notification_webhooks/engagement_added.md b/docs/content/en/integrations/notification_webhooks/engagement_added.md new file mode 100644 index 00000000000..64fd7746ec2 --- /dev/null +++ b/docs/content/en/integrations/notification_webhooks/engagement_added.md @@ -0,0 +1,38 @@ +--- +title: "Event: engagement_added" +weight: 3 +chapter: true +--- + +## Event HTTP header +```yaml +X-DefectDojo-Event: engagement_added +``` + +## Event HTTP body +```json +{ + "description": null, + "engagement": { + "id": 7, + "name": "notif eng", + "url_api": "http://localhost:8080/api/v2/engagements/7/", + "url_ui": "http://localhost:8080/engagement/7" + }, + "product": { + "id": 4, + "name": "notif prod", + "url_api": "http://localhost:8080/api/v2/products/4/", + "url_ui": "http://localhost:8080/product/4" + }, + "product_type": { + "id": 4, + "name": "notif prod type", + "url_api": "http://localhost:8080/api/v2/product_types/4/", + "url_ui": "http://localhost:8080/product/type/4" + }, + "url_api": "http://localhost:8080/api/v2/engagements/7/", + "url_ui": "http://localhost:8080/engagement/7", + "user": null +} +``` \ No newline at end of file diff --git a/docs/content/en/integrations/notification_webhooks/product_added.md b/docs/content/en/integrations/notification_webhooks/product_added.md new file mode 100644 index 00000000000..2d90a6a681f --- /dev/null +++ b/docs/content/en/integrations/notification_webhooks/product_added.md @@ -0,0 +1,32 @@ +--- +title: "Event: product_added" +weight: 2 +chapter: true +--- + +## Event HTTP header +```yaml +X-DefectDojo-Event: product_added +``` + +## Event HTTP body +```json +{ + "description": null, + "product": { + "id": 4, + "name": "notif prod", + "url_api": "http://localhost:8080/api/v2/products/4/", + "url_ui": "http://localhost:8080/product/4" + }, + "product_type": { + "id": 4, + "name": "notif prod type", + "url_api": "http://localhost:8080/api/v2/product_types/4/", + "url_ui": "http://localhost:8080/product/type/4" + }, + "url_api": "http://localhost:8080/api/v2/products/4/", + "url_ui": "http://localhost:8080/product/4", + "user": null +} +``` \ No newline at end of file diff --git a/docs/content/en/integrations/notification_webhooks/product_type_added.md b/docs/content/en/integrations/notification_webhooks/product_type_added.md new file mode 100644 index 00000000000..1171f513831 --- /dev/null +++ b/docs/content/en/integrations/notification_webhooks/product_type_added.md @@ -0,0 +1,26 @@ +--- +title: "Event: product_type_added" +weight: 1 +chapter: true +--- + +## Event HTTP header +```yaml +X-DefectDojo-Event: product_type_added +``` + +## Event HTTP body +```json +{ + "description": null, + "product_type": { + "id": 4, + "name": "notif prod type", + "url_api": "http://localhost:8080/api/v2/product_types/4/", + "url_ui": "http://localhost:8080/product/type/4" + }, + "url_api": "http://localhost:8080/api/v2/product_types/4/", + "url_ui": "http://localhost:8080/product/type/4", + "user": null +} +``` \ No newline at end of file diff --git a/docs/content/en/integrations/notification_webhooks/scan_added.md b/docs/content/en/integrations/notification_webhooks/scan_added.md new file mode 100644 index 00000000000..27a40e6cab1 --- /dev/null +++ b/docs/content/en/integrations/notification_webhooks/scan_added.md @@ -0,0 +1,90 @@ +--- +title: "Event: scan_added and scan_added_empty" +weight: 5 +chapter: true +--- + +Event `scan_added_empty` describes a situation when reimport did not affect the existing test (no finding has been created or closed). + +## Event HTTP header for scan_added +```yaml +X-DefectDojo-Event: scan_added +``` + +## Event HTTP header for scan_added_empty +```yaml +X-DefectDojo-Event: scan_added_empty +``` + +## Event HTTP body +```json +{ + "description": null, + "engagement": { + "id": 7, + "name": "notif eng", + "url_api": "http://localhost:8080/api/v2/engagements/7/", + "url_ui": "http://localhost:8080/engagement/7" + }, + "finding_count": 4, + "findings": { + "mitigated": [ + { + "id": 233, + "severity": "Medium", + "title": "Mitigated Finding", + "url_api": "http://localhost:8080/api/v2/findings/233/", + "url_ui": "http://localhost:8080/finding/233" + } + ], + "new": [ + { + "id": 232, + "severity": "Critical", + "title": "New Finding", + "url_api": "http://localhost:8080/api/v2/findings/232/", + "url_ui": "http://localhost:8080/finding/232" + } + ], + "reactivated": [ + { + "id": 234, + "severity": "Low", + "title": "Reactivated Finding", + "url_api": "http://localhost:8080/api/v2/findings/234/", + "url_ui": "http://localhost:8080/finding/234" + } + ], + "untouched": [ + { + "id": 235, + "severity": "Info", + "title": "Untouched Finding", + "url_api": "http://localhost:8080/api/v2/findings/235/", + "url_ui": "http://localhost:8080/finding/235" + } + ] + }, + "product": { + "id": 4, + "name": "notif prod", + "url_api": "http://localhost:8080/api/v2/products/4/", + "url_ui": "http://localhost:8080/product/4" + }, + "product_type": { + "id": 4, + "name": "notif prod type", + "url_api": "http://localhost:8080/api/v2/product_types/4/", + "url_ui": "http://localhost:8080/product/type/4" + }, + "test": { + "id": 90, + "title": "notif test", + "url_api": "http://localhost:8080/api/v2/tests/90/", + "url_ui": "http://localhost:8080/test/90" + }, + "url_api": "http://localhost:8080/api/v2/tests/90/", + "url_ui": "http://localhost:8080/test/90", + "user": null +} +``` \ No newline at end of file diff --git a/docs/content/en/integrations/notification_webhooks/test_added.md b/docs/content/en/integrations/notification_webhooks/test_added.md new file mode 100644 index 00000000000..8614a80e0a6 --- /dev/null +++ b/docs/content/en/integrations/notification_webhooks/test_added.md @@ -0,0 +1,44 @@ +--- +title: "Event: test_added" +weight: 4 +chapter: true +--- + +## Event HTTP header +```yaml +X-DefectDojo-Event: test_added +``` + +## Event HTTP body +```json +{ + "description": null, + "engagement": { + "id": 7, + "name": "notif eng", + "url_api": "http://localhost:8080/api/v2/engagements/7/", + "url_ui": "http://localhost:8080/engagement/7" + }, + "product": { + "id": 4, + "name": "notif prod", + "url_api": "http://localhost:8080/api/v2/products/4/", + "url_ui": "http://localhost:8080/product/4" + }, + "product_type": { + "id": 4, + "name": "notif prod type", + "url_api": "http://localhost:8080/api/v2/product_types/4/", + "url_ui": "http://localhost:8080/product/type/4" + }, + "test": { + "id": 90, + "title": "notif test", + "url_api": "http://localhost:8080/api/v2/tests/90/", + "url_ui": "http://localhost:8080/test/90" + }, + "url_api": "http://localhost:8080/api/v2/tests/90/", + "url_ui": "http://localhost:8080/test/90", + "user": null +} +``` \ No newline at end of file diff --git a/docs/content/en/integrations/notifications.md b/docs/content/en/integrations/notifications.md index d5af295f0eb..803388797cd 100644 --- a/docs/content/en/integrations/notifications.md +++ b/docs/content/en/integrations/notifications.md @@ -18,6 +18,7 @@ The following notification methods currently exist: - Email - Slack - Microsoft Teams + - Webhooks - Alerts within DefectDojo (default) You can set these notifications on a global scope (if you have @@ -124,4 +125,8 @@ However, there is a specific use-case when the user decides to disable notificat The scope of this setting is customizable (see environmental variable `DD_NOTIFICATIONS_SYSTEM_LEVEL_TRUMP`). -For more information about this behavior see the [related pull request #9699](https://github.com/DefectDojo/django-DefectDojo/pull/9699/) \ No newline at end of file +For more information about this behavior see the [related pull request #9699](https://github.com/DefectDojo/django-DefectDojo/pull/9699/) + +## Webhooks (experimental) + +DefectDojo also supports webhooks that follow the same events as other notifications (you can be notified in the same situations). Details about setup are described in [related page](../notification_webhooks/). diff --git a/docs/content/en/integrations/parsers/file/legitify.md b/docs/content/en/integrations/parsers/file/legitify.md new file mode 100644 index 00000000000..bb9b2970aee --- /dev/null +++ b/docs/content/en/integrations/parsers/file/legitify.md @@ -0,0 +1,9 @@ +--- +title: "Legitify" +toc_hide: true +--- +### File Types +This DefectDojo parser accepts JSON files (in flattened format) from Legitify. For further details regarding the results, please consult the relevant [documentation](https://github.com/Legit-Labs/legitify?tab=readme-ov-file#output-options). + +### Sample Scan Data +Sample scan data for testing purposes can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/legitify). \ No newline at end of file diff --git a/docs/content/en/integrations/parsers/file/threat_composer.md b/docs/content/en/integrations/parsers/file/threat_composer.md new file mode 100644 index 00000000000..a5097f90066 --- /dev/null +++ b/docs/content/en/integrations/parsers/file/threat_composer.md @@ -0,0 +1,9 @@ +--- +title: "Threat Composer" +toc_hide: true +--- +### File Types +This DefectDojo parser accepts JSON files from Threat Composer. The tool supports the [export](https://github.com/awslabs/threat-composer/tree/main?#features) of JSON report out of the browser local storage to a local file. + +### Sample Scan Data +Sample scan data for testing purposes can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/threat_composer). \ No newline at end of file diff --git a/docs/content/en/integrations/rate_limiting.md b/docs/content/en/integrations/rate_limiting.md index 0cac784c5f5..1ea76ace5b3 100644 --- a/docs/content/en/integrations/rate_limiting.md +++ b/docs/content/en/integrations/rate_limiting.md @@ -2,7 +2,7 @@ title: "Rate Limiting" description: "Configurable rate limiting on the login page to mitigate brute force attacks" draft: false -weight: 9 +weight: 11 --- diff --git a/docs/package-lock.json b/docs/package-lock.json index 56ef63cc01b..d3d81bb0ec9 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -6,7 +6,7 @@ "": { "devDependencies": { "autoprefixer": "10.4.20", - "postcss": "8.4.41", + "postcss": "8.4.45", "postcss-cli": "11.0.0" } }, @@ -612,9 +612,9 @@ } }, "node_modules/postcss": { - "version": "8.4.41", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.41.tgz", - "integrity": "sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==", + "version": "8.4.45", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.45.tgz", + "integrity": "sha512-7KTLTdzdZZYscUc65XmjFiB73vBhBfbPztCYdUNvlaso9PrzjzcmjqBPR0lNGkcVlcO4BjiO5rK/qNz+XAen1Q==", "dev": true, "funding": [ { @@ -1390,9 +1390,9 @@ "dev": true }, "postcss": { - "version": "8.4.41", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.41.tgz", - "integrity": "sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==", + "version": "8.4.45", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.45.tgz", + "integrity": "sha512-7KTLTdzdZZYscUc65XmjFiB73vBhBfbPztCYdUNvlaso9PrzjzcmjqBPR0lNGkcVlcO4BjiO5rK/qNz+XAen1Q==", "dev": true, "requires": { "nanoid": "^3.3.7", diff --git a/docs/package.json b/docs/package.json index 9eb98f0f32b..a892ece5668 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,6 +1,6 @@ { "devDependencies": { - "postcss": "8.4.41", + "postcss": "8.4.45", "autoprefixer": "10.4.20", "postcss-cli": "11.0.0" } diff --git a/dojo/__init__.py b/dojo/__init__.py index 3b67d86fe5f..82fc1241506 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.38.0-dev" +__version__ = "2.39.0-dev" __url__ = "https://github.com/DefectDojo/django-DefectDojo" __docs__ = "https://documentation.defectdojo.com" diff --git a/dojo/api_v2/serializers.py b/dojo/api_v2/serializers.py index 5bed7935f94..dc8acb40285 100644 --- a/dojo/api_v2/serializers.py +++ b/dojo/api_v2/serializers.py @@ -77,6 +77,7 @@ Note_Type, NoteHistory, Notes, + Notification_Webhooks, Notifications, Product, Product_API_Scan_Configuration, @@ -1411,7 +1412,7 @@ class TestTypeSerializer(TaggitSerializer, serializers.ModelSerializer): class Meta: model = Test_Type - fields = "__all__" + exclude = ("dynamically_generated",) class TestToNotesSerializer(serializers.Serializer): @@ -1761,10 +1762,10 @@ def validate(self, data): is_risk_accepted = data.get("risk_accepted", False) if (is_active or is_verified) and is_duplicate: - msg = "Duplicate findings cannot be" " verified or active" + msg = "Duplicate findings cannot be verified or active" raise serializers.ValidationError(msg) if is_false_p and is_verified: - msg = "False positive findings cannot " "be verified." + msg = "False positive findings cannot be verified." raise serializers.ValidationError(msg) if is_risk_accepted and not self.instance.risk_accepted: @@ -3172,3 +3173,9 @@ def create(self, validated_data): raise serializers.ValidationError(msg) else: raise + + +class NotificationWebhooksSerializer(serializers.ModelSerializer): + class Meta: + model = Notification_Webhooks + fields = "__all__" diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index 05d16521069..7ae9925479a 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -111,6 +111,7 @@ Network_Locations, Note_Type, Notes, + Notification_Webhooks, Notifications, Product, Product_API_Scan_Configuration, @@ -3332,3 +3333,13 @@ class AnnouncementViewSet( def get_queryset(self): return Announcement.objects.all().order_by("id") + + +class NotificationWebhooksViewSet( + PrefetchDojoModelViewSet, +): + serializer_class = serializers.NotificationWebhooksSerializer + queryset = Notification_Webhooks.objects.all() + filter_backends = (DjangoFilterBackend,) + filterset_fields = "__all__" + permission_classes = (permissions.IsSuperUser, DjangoModelPermissions) # TODO: add permission also for other users diff --git a/dojo/context_processors.py b/dojo/context_processors.py index 12168d9ea64..782cf767ce2 100644 --- a/dojo/context_processors.py +++ b/dojo/context_processors.py @@ -25,6 +25,7 @@ def globalize_vars(request): "SAML2_LOGOUT_URL": settings.SAML2_LOGOUT_URL, "DOCUMENTATION_URL": settings.DOCUMENTATION_URL, "API_TOKENS_ENABLED": settings.API_TOKENS_ENABLED, + "API_TOKEN_AUTH_ENDPOINT_ENABLED": settings.API_TOKEN_AUTH_ENDPOINT_ENABLED, } diff --git a/dojo/db_migrations/0214_test_type_dynamically_generated.py b/dojo/db_migrations/0214_test_type_dynamically_generated.py new file mode 100644 index 00000000000..80219377e7f --- /dev/null +++ b/dojo/db_migrations/0214_test_type_dynamically_generated.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.8 on 2024-09-04 19:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dojo', '0213_system_settings_enable_ui_table_based_searching'), + ] + + operations = [ + migrations.AddField( + model_name='test_type', + name='dynamically_generated', + field=models.BooleanField(default=False, help_text='Set to True for test types that are created at import time'), + ), + ] diff --git a/dojo/db_migrations/0215_webhooks_notifications.py b/dojo/db_migrations/0215_webhooks_notifications.py new file mode 100644 index 00000000000..cc65ce43f1b --- /dev/null +++ b/dojo/db_migrations/0215_webhooks_notifications.py @@ -0,0 +1,130 @@ +# Generated by Django 5.0.8 on 2024-08-16 17:07 + +import django.db.models.deletion +import multiselectfield.db.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dojo', '0214_test_type_dynamically_generated'), + ] + + operations = [ + migrations.AddField( + model_name='system_settings', + name='enable_webhooks_notifications', + field=models.BooleanField(default=False, verbose_name='Enable Webhook notifications'), + ), + migrations.AddField( + model_name='system_settings', + name='webhooks_notifications_timeout', + field=models.IntegerField(default=10, help_text='How many seconds will DefectDojo waits for response from webhook endpoint'), + ), + migrations.AlterField( + model_name='notifications', + name='auto_close_engagement', + field=multiselectfield.db.fields.MultiSelectField(blank=True, choices=[('slack', 'slack'), ('msteams', 'msteams'), ('mail', 'mail'), ('webhooks', 'webhooks'), ('alert', 'alert')], default=('alert', 'alert'), max_length=33), + ), + migrations.AlterField( + model_name='notifications', + name='close_engagement', + field=multiselectfield.db.fields.MultiSelectField(blank=True, choices=[('slack', 'slack'), ('msteams', 'msteams'), ('mail', 'mail'), ('webhooks', 'webhooks'), ('alert', 'alert')], default=('alert', 'alert'), max_length=33), + ), + migrations.AlterField( + model_name='notifications', + name='code_review', + field=multiselectfield.db.fields.MultiSelectField(blank=True, choices=[('slack', 'slack'), ('msteams', 'msteams'), ('mail', 'mail'), ('webhooks', 'webhooks'), ('alert', 'alert')], default=('alert', 'alert'), max_length=33), + ), + migrations.AlterField( + model_name='notifications', + name='engagement_added', + field=multiselectfield.db.fields.MultiSelectField(blank=True, choices=[('slack', 'slack'), ('msteams', 'msteams'), ('mail', 'mail'), ('webhooks', 'webhooks'), ('alert', 'alert')], default=('alert', 'alert'), max_length=33), + ), + migrations.AlterField( + model_name='notifications', + name='jira_update', + field=multiselectfield.db.fields.MultiSelectField(blank=True, choices=[('slack', 'slack'), ('msteams', 'msteams'), ('mail', 'mail'), ('webhooks', 'webhooks'), ('alert', 'alert')], default=('alert', 'alert'), help_text='JIRA sync happens in the background, errors will be shown as notifications/alerts so make sure to subscribe', max_length=33, verbose_name='JIRA problems'), + ), + migrations.AlterField( + model_name='notifications', + name='other', + field=multiselectfield.db.fields.MultiSelectField(blank=True, choices=[('slack', 'slack'), ('msteams', 'msteams'), ('mail', 'mail'), ('webhooks', 'webhooks'), ('alert', 'alert')], default=('alert', 'alert'), max_length=33), + ), + migrations.AlterField( + model_name='notifications', + name='product_added', + field=multiselectfield.db.fields.MultiSelectField(blank=True, choices=[('slack', 'slack'), ('msteams', 'msteams'), ('mail', 'mail'), ('webhooks', 'webhooks'), ('alert', 'alert')], default=('alert', 'alert'), max_length=33), + ), + migrations.AlterField( + model_name='notifications', + name='product_type_added', + field=multiselectfield.db.fields.MultiSelectField(blank=True, choices=[('slack', 'slack'), ('msteams', 'msteams'), ('mail', 'mail'), ('webhooks', 'webhooks'), ('alert', 'alert')], default=('alert', 'alert'), max_length=33), + ), + migrations.AlterField( + model_name='notifications', + name='review_requested', + field=multiselectfield.db.fields.MultiSelectField(blank=True, choices=[('slack', 'slack'), ('msteams', 'msteams'), ('mail', 'mail'), ('webhooks', 'webhooks'), ('alert', 'alert')], default=('alert', 'alert'), max_length=33), + ), + migrations.AlterField( + model_name='notifications', + name='risk_acceptance_expiration', + field=multiselectfield.db.fields.MultiSelectField(blank=True, choices=[('slack', 'slack'), ('msteams', 'msteams'), ('mail', 'mail'), ('webhooks', 'webhooks'), ('alert', 'alert')], default=('alert', 'alert'), help_text='Get notified of (upcoming) Risk Acceptance expiries', max_length=33, verbose_name='Risk Acceptance Expiration'), + ), + migrations.AlterField( + model_name='notifications', + name='scan_added', + field=multiselectfield.db.fields.MultiSelectField(blank=True, choices=[('slack', 'slack'), ('msteams', 'msteams'), ('mail', 'mail'), ('webhooks', 'webhooks'), ('alert', 'alert')], default=('alert', 'alert'), help_text='Triggered whenever an (re-)import has been done that created/updated/closed findings.', max_length=33), + ), + migrations.AlterField( + model_name='notifications', + name='scan_added_empty', + field=multiselectfield.db.fields.MultiSelectField(blank=True, choices=[('slack', 'slack'), ('msteams', 'msteams'), ('mail', 'mail'), ('webhooks', 'webhooks'), ('alert', 'alert')], default=[], help_text='Triggered whenever an (re-)import has been done (even if that created/updated/closed no findings).', max_length=33), + ), + migrations.AlterField( + model_name='notifications', + name='sla_breach', + field=multiselectfield.db.fields.MultiSelectField(blank=True, choices=[('slack', 'slack'), ('msteams', 'msteams'), ('mail', 'mail'), ('webhooks', 'webhooks'), ('alert', 'alert')], default=('alert', 'alert'), help_text='Get notified of (upcoming) SLA breaches', max_length=33, verbose_name='SLA breach'), + ), + migrations.AlterField( + model_name='notifications', + name='sla_breach_combined', + field=multiselectfield.db.fields.MultiSelectField(blank=True, choices=[('slack', 'slack'), ('msteams', 'msteams'), ('mail', 'mail'), ('webhooks', 'webhooks'), ('alert', 'alert')], default=('alert', 'alert'), help_text='Get notified of (upcoming) SLA breaches (a message per project)', max_length=33, verbose_name='SLA breach (combined)'), + ), + migrations.AlterField( + model_name='notifications', + name='stale_engagement', + field=multiselectfield.db.fields.MultiSelectField(blank=True, choices=[('slack', 'slack'), ('msteams', 'msteams'), ('mail', 'mail'), ('webhooks', 'webhooks'), ('alert', 'alert')], default=('alert', 'alert'), max_length=33), + ), + migrations.AlterField( + model_name='notifications', + name='test_added', + field=multiselectfield.db.fields.MultiSelectField(blank=True, choices=[('slack', 'slack'), ('msteams', 'msteams'), ('mail', 'mail'), ('webhooks', 'webhooks'), ('alert', 'alert')], default=('alert', 'alert'), max_length=33), + ), + migrations.AlterField( + model_name='notifications', + name='upcoming_engagement', + field=multiselectfield.db.fields.MultiSelectField(blank=True, choices=[('slack', 'slack'), ('msteams', 'msteams'), ('mail', 'mail'), ('webhooks', 'webhooks'), ('alert', 'alert')], default=('alert', 'alert'), max_length=33), + ), + migrations.AlterField( + model_name='notifications', + name='user_mentioned', + field=multiselectfield.db.fields.MultiSelectField(blank=True, choices=[('slack', 'slack'), ('msteams', 'msteams'), ('mail', 'mail'), ('webhooks', 'webhooks'), ('alert', 'alert')], default=('alert', 'alert'), max_length=33), + ), + migrations.CreateModel( + name='Notification_Webhooks', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(default='', help_text='Name of the incoming webhook', max_length=100, unique=True)), + ('url', models.URLField(default='', help_text='The full URL of the incoming webhook')), + ('header_name', models.CharField(blank=True, default='', help_text='Name of the header required for interacting with Webhook endpoint', max_length=100, null=True)), + ('header_value', models.CharField(blank=True, default='', help_text='Content of the header required for interacting with Webhook endpoint', max_length=100, null=True)), + ('status', models.CharField(choices=[('active', 'Active'), ('active_tmp', 'Active but 5xx (or similar) error detected'), ('inactive_tmp', 'Temporary inactive because of 5xx (or similar) error'), ('inactive_permanent', 'Permanently inactive')], default='active', editable=False, help_text='Status of the incoming webhook', max_length=20)), + ('first_error', models.DateTimeField(blank=True, editable=False, help_text='If endpoint is active, when error happened first time', null=True)), + ('last_error', models.DateTimeField(blank=True, editable=False, help_text='If endpoint is active, when error happened last time', null=True)), + ('note', models.CharField(blank=True, default='', editable=False, help_text='Description of the latest error', max_length=1000, null=True)), + ('owner', models.ForeignKey(blank=True, help_text='Owner/receiver of notification, if empty processed as system notification', null=True, on_delete=django.db.models.deletion.CASCADE, to='dojo.dojo_user')), + ], + ), + ] diff --git a/dojo/engagement/signals.py b/dojo/engagement/signals.py index c2f09c9abbd..7b95d6fe87b 100644 --- a/dojo/engagement/signals.py +++ b/dojo/engagement/signals.py @@ -16,7 +16,7 @@ def engagement_post_save(sender, instance, created, **kwargs): if created: title = _('Engagement created for "%(product)s": %(name)s') % {"product": instance.product, "name": instance.name} create_notification(event="engagement_added", title=title, engagement=instance, product=instance.product, - url=reverse("view_engagement", args=(instance.id,))) + url=reverse("view_engagement", args=(instance.id,)), url_api=reverse("engagement-detail", args=(instance.id,))) @receiver(pre_save, sender=Engagement) diff --git a/dojo/engagement/views.py b/dojo/engagement/views.py index 29af77bf268..777a5f7a118 100644 --- a/dojo/engagement/views.py +++ b/dojo/engagement/views.py @@ -68,6 +68,7 @@ TypedNoteForm, UploadThreatForm, ) +from dojo.importers.base_importer import BaseImporter from dojo.importers.default_importer import DefaultImporter from dojo.models import ( Check_List, @@ -922,6 +923,15 @@ def create_engagement( # Return the engagement return engagement + def get_importer( + self, + context: dict, + ) -> BaseImporter: + """ + Gets the importer to use + """ + return DefaultImporter(**context) + def import_findings( self, context: dict, @@ -930,7 +940,7 @@ def import_findings( Attempt to import with all the supplied information """ try: - importer_client = DefaultImporter(**context) + importer_client = self.get_importer(context) context["test"], _, finding_count, closed_finding_count, _, _, _ = importer_client.process_scan( context.pop("scan", None), ) diff --git a/dojo/filters.py b/dojo/filters.py index 7a1f3b4f96b..1461966c19e 100644 --- a/dojo/filters.py +++ b/dojo/filters.py @@ -605,7 +605,7 @@ def __init__(self, *args, **kwargs): class DateRangeFilter(ChoiceFilter): options = { - None: (_("Any date"), lambda qs, name: qs.all()), + None: (_("Any date"), lambda qs, _: qs.all()), 1: (_("Today"), lambda qs, name: qs.filter(**{ f"{name}__year": now().year, f"{name}__month": now().month, @@ -651,7 +651,7 @@ def filter(self, qs, value): class DateRangeOmniFilter(ChoiceFilter): options = { - None: (_("Any date"), lambda qs, name: qs.all()), + None: (_("Any date"), lambda qs, _: qs.all()), 1: (_("Today"), lambda qs, name: qs.filter(**{ f"{name}__year": now().year, f"{name}__month": now().month, @@ -713,7 +713,7 @@ def filter(self, qs, value): class ReportBooleanFilter(ChoiceFilter): options = { - None: (_("Either"), lambda qs, name: qs.all()), + None: (_("Either"), lambda qs, _: qs.all()), 1: (_("Yes"), lambda qs, name: qs.filter(**{ f"{name}": True, })), @@ -1420,13 +1420,16 @@ class ApiFindingFilter(DojoFilter): # DateRangeFilter created = DateRangeFilter() date = DateRangeFilter() - on = DateFilter(field_name="date", lookup_expr="exact") - before = DateFilter(field_name="date", lookup_expr="lt") - after = DateFilter(field_name="date", lookup_expr="gt") + discovered_on = DateFilter(field_name="date", lookup_expr="exact") + discovered_before = DateFilter(field_name="date", lookup_expr="lt") + discovered_after = DateFilter(field_name="date", lookup_expr="gt") jira_creation = DateRangeFilter(field_name="jira_issue__jira_creation") jira_change = DateRangeFilter(field_name="jira_issue__jira_change") last_reviewed = DateRangeFilter() mitigated = DateRangeFilter() + mitigated_on = DateFilter(field_name="mitigated", lookup_expr="exact") + mitigated_before = DateFilter(field_name="mitigated", lookup_expr="lt") + mitigated_after = DateFilter(field_name="mitigated", lookup_expr="gt") # NumberInFilter cwe = NumberInFilter(field_name="cwe", lookup_expr="in") defect_review_requested_by = NumberInFilter(field_name="defect_review_requested_by", lookup_expr="in") @@ -1543,10 +1546,10 @@ def filter(self, qs, value): class FindingFilterHelper(FilterSet): title = CharFilter(lookup_expr="icontains") - date = DateFromToRangeFilter(field_name="date", label="Date Discovered") - on = DateFilter(field_name="date", lookup_expr="exact", label="On") - before = DateFilter(field_name="date", lookup_expr="lt", label="Before") - after = DateFilter(field_name="date", lookup_expr="gt", label="After") + date = DateRangeFilter(field_name="date", label="Date Discovered") + on = DateFilter(field_name="date", lookup_expr="exact", label="Discovered On") + before = DateFilter(field_name="date", lookup_expr="lt", label="Discovered Before") + after = DateFilter(field_name="date", lookup_expr="gt", label="Discovered After") last_reviewed = DateRangeFilter() last_status_update = DateRangeFilter() cwe = MultipleChoiceFilter(choices=[]) @@ -1554,7 +1557,10 @@ class FindingFilterHelper(FilterSet): severity = MultipleChoiceFilter(choices=SEVERITY_CHOICES) duplicate = ReportBooleanFilter() is_mitigated = ReportBooleanFilter() - mitigated = DateRangeFilter(label="Mitigated Date") + mitigated = DateRangeFilter(field_name="mitigated", label="Mitigated Date") + mitigated_on = DateFilter(field_name="mitigated", lookup_expr="exact", label="Mitigated On") + mitigated_before = DateFilter(field_name="mitigated", lookup_expr="lt", label="Mitigated Before") + mitigated_after = DateFilter(field_name="mitigated", lookup_expr="gt", label="Mitigated After") planned_remediation_date = DateRangeOmniFilter() planned_remediation_version = CharFilter(lookup_expr="icontains", label=_("Planned remediation version")) file_path = CharFilter(lookup_expr="icontains") @@ -1663,6 +1669,9 @@ def set_date_fields(self, *args: list, **kwargs: dict): self.form.fields["on"].widget = date_input_widget self.form.fields["before"].widget = date_input_widget self.form.fields["after"].widget = date_input_widget + self.form.fields["mitigated_on"].widget = date_input_widget + self.form.fields["mitigated_before"].widget = date_input_widget + self.form.fields["mitigated_after"].widget = date_input_widget self.form.fields["cwe"].choices = cwe_options(self.queryset) @@ -2874,6 +2883,7 @@ class Meta: class ReportFindingFilterHelper(FilterSet): title = CharFilter(lookup_expr="icontains", label="Name") date = DateFromToRangeFilter(field_name="date", label="Date Discovered") + date_recent = DateRangeFilter(field_name="date", label="Relative Date") severity = MultipleChoiceFilter(choices=SEVERITY_CHOICES) active = ReportBooleanFilter() is_mitigated = ReportBooleanFilter() @@ -3228,7 +3238,7 @@ class Meta: filter_overrides = { JSONField: { "filter_class": CharFilter, - "extra": lambda f: { + "extra": lambda _: { "lookup_expr": "icontains", }, }, diff --git a/dojo/finding/helper.py b/dojo/finding/helper.py index b900017b1da..d52857f2291 100644 --- a/dojo/finding/helper.py +++ b/dojo/finding/helper.py @@ -573,18 +573,18 @@ def fix_loop_duplicates(): loop_count = len(candidates) if loop_count > 0: - deduplicationLogger.info("Identified %d Findings with Loops" % len(candidates)) + deduplicationLogger.info(f"Identified {len(candidates)} Findings with Loops") for find_id in candidates.values_list("id", flat=True): removeLoop(find_id, 50) new_originals = Finding.objects.filter(duplicate_finding__isnull=True, duplicate=True) for f in new_originals: - deduplicationLogger.info("New Original: %d " % f.id) + deduplicationLogger.info(f"New Original: {f.id}") f.duplicate = False super(Finding, f).save() loop_count = Finding.objects.filter(duplicate_finding__isnull=False, original_finding__isnull=False).count() - deduplicationLogger.info("%d Finding found which still has Loops, please run fix loop duplicates again" % loop_count) + deduplicationLogger.info(f"{loop_count} Finding found which still has Loops, please run fix loop duplicates again") return loop_count diff --git a/dojo/finding/views.py b/dojo/finding/views.py index f880aaf1567..4b37ebc8a9a 100644 --- a/dojo/finding/views.py +++ b/dojo/finding/views.py @@ -2862,9 +2862,8 @@ def finding_bulk_update_all(request, pid=None): messages.add_message( request, messages.WARNING, - ("Skipped simple risk acceptance of %i findings, " - "simple risk acceptance is disabled on the related products") - % skipped_risk_accept_count, + (f"Skipped simple risk acceptance of {skipped_risk_accept_count} findings, " + "simple risk acceptance is disabled on the related products"), extra_tags="alert-warning", ) @@ -2963,8 +2962,7 @@ def finding_bulk_update_all(request, pid=None): if grouped: add_success_message_to_response( - "Grouped %d findings into %d (%d newly created) finding groups" - % (grouped, len(finding_groups), groups_created), + f"Grouped {grouped} findings into {len(finding_groups)} ({groups_created} newly created) finding groups", ) if skipped: @@ -3042,15 +3040,10 @@ def finding_bulk_update_all(request, pid=None): success_count += 1 for error_message, error_count in error_counts.items(): - add_error_message_to_response( - "%i finding groups could not be pushed to JIRA: %s" - % (error_count, error_message), - ) + add_error_message_to_response("{error_count} finding groups could not be pushed to JIRA: {error_message}") if success_count > 0: - add_success_message_to_response( - "%i finding groups pushed to JIRA successfully" % success_count, - ) + add_success_message_to_response(f"{success_count} finding groups pushed to JIRA successfully") groups_pushed_to_jira = True # refresh from db @@ -3102,15 +3095,10 @@ def finding_bulk_update_all(request, pid=None): success_count += 1 for error_message, error_count in error_counts.items(): - add_error_message_to_response( - "%i findings could not be pushed to JIRA: %s" - % (error_count, error_message), - ) + add_error_message_to_response(f"{error_count} findings could not be pushed to JIRA: {error_message}") if success_count > 0: - add_success_message_to_response( - "%i findings pushed to JIRA successfully" % success_count, - ) + add_success_message_to_response(f"{success_count} findings pushed to JIRA successfully") if updated_find_count > 0: messages.add_message( diff --git a/dojo/fixtures/dojo_testdata.json b/dojo/fixtures/dojo_testdata.json index 62486cb90cf..ae550f8bf81 100644 --- a/dojo/fixtures/dojo_testdata.json +++ b/dojo/fixtures/dojo_testdata.json @@ -227,6 +227,7 @@ "url_prefix": "", "enable_slack_notifications": false, "enable_mail_notifications": false, + "enable_webhooks_notifications": true, "email_from": "no-reply@example.com", "false_positive_history": false, "msteams_url": "", @@ -2926,11 +2927,27 @@ "pk": 1, "model": "dojo.notifications", "fields": { - "product": 1, - "user": 2, - "product_type_added": [ - "slack" - ] + "product": null, + "user": null, + "template": false, + "product_type_added": "webhooks,alert", + "product_added": "webhooks,alert", + "engagement_added": "webhooks,alert", + "test_added": "webhooks,alert", + "scan_added": "webhooks,alert", + "scan_added_empty": "webhooks", + "jira_update": "alert", + "upcoming_engagement": "alert", + "stale_engagement": "alert", + "auto_close_engagement": "alert", + "close_engagement": "alert", + "user_mentioned": "alert", + "code_review": "alert", + "review_requested": "alert", + "other": "alert", + "sla_breach": "alert", + "risk_acceptance_expiration": "alert", + "sla_breach_combined": "alert" } }, { @@ -3045,5 +3062,35 @@ "dismissable": true, "style": "danger" } + }, + { + "model": "dojo.notification_webhooks", + "pk": 1, + "fields": { + "name": "My webhook endpoint", + "url": "http://webhook.endpoint:8080/post", + "header_name": "Auth", + "header_value": "Token xxx", + "status": "active", + "first_error": null, + "last_error": null, + "note": null, + "owner": null + } + }, + { + "model": "dojo.notification_webhooks", + "pk": 2, + "fields": { + "name": "My personal webhook endpoint", + "url": "http://webhook.endpoint:8080/post", + "header_name": "Auth", + "header_value": "Token secret", + "status": "active", + "first_error": null, + "last_error": null, + "note": null, + "owner": 2 + } } ] \ No newline at end of file diff --git a/dojo/forms.py b/dojo/forms.py index 075c2eb0cb9..acf3546285b 100644 --- a/dojo/forms.py +++ b/dojo/forms.py @@ -72,6 +72,7 @@ JIRA_Project, Note_Type, Notes, + Notification_Webhooks, Notifications, Objects_Product, Product, @@ -285,7 +286,7 @@ def __init__(self, *args, **kwargs): class Test_TypeForm(forms.ModelForm): class Meta: model = Test_Type - exclude = [""] + exclude = ["dynamically_generated"] class Development_EnvironmentForm(forms.ModelForm): @@ -321,6 +322,8 @@ class ProductForm(forms.ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields["prod_type"].queryset = get_authorized_product_types(Permissions.Product_Type_Add_Product) + if prod_type_id := getattr(kwargs.get("instance", Product()), "prod_type_id"): # we are editing existing instance + self.fields["prod_type"].queryset |= Product_Type.objects.filter(pk=prod_type_id) # even if user does not have permission for any other ProdType we need to add at least assign ProdType to make form submittable (otherwise empty list was here which generated invalid form) # if this product has findings being asynchronously updated, disable the sla config field if self.instance.async_updating: @@ -2776,6 +2779,32 @@ class Meta: exclude = ["template"] +class NotificationsWebhookForm(forms.ModelForm): + class Meta: + model = Notification_Webhooks + exclude = [] + + def __init__(self, *args, **kwargs): + is_superuser = kwargs.pop("is_superuser", False) + super().__init__(*args, **kwargs) + if not is_superuser: # Only superadmins can edit owner + self.fields["owner"].disabled = True # TODO: needs to be tested + + +class DeleteNotificationsWebhookForm(forms.ModelForm): + id = forms.IntegerField(required=True, + widget=forms.widgets.HiddenInput()) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["name"].disabled = True + self.fields["url"].disabled = True + + class Meta: + model = Notification_Webhooks + fields = ["id", "name", "url"] + + class ProductNotificationsForm(forms.ModelForm): def __init__(self, *args, **kwargs): diff --git a/dojo/importers/base_importer.py b/dojo/importers/base_importer.py index 54f18071c26..22e9ee5cbfe 100644 --- a/dojo/importers/base_importer.py +++ b/dojo/importers/base_importer.py @@ -491,6 +491,8 @@ def get_or_create_test_type( test_type, created = Test_Type.objects.get_or_create(name=test_type_name) if created: logger.info(f"Created new Test_Type with name {test_type.name} because a report is being imported") + test_type.dynamically_generated = True + test_type.save() return test_type def verify_tool_configuration_from_test(self): diff --git a/dojo/importers/default_importer.py b/dojo/importers/default_importer.py index 1163ae172a6..41e91bc12de 100644 --- a/dojo/importers/default_importer.py +++ b/dojo/importers/default_importer.py @@ -108,7 +108,7 @@ def process_scan( new_findings = self.determine_process_method(self.parsed_findings, **kwargs) # Close any old findings in the processed list if the the user specified for that # to occur in the form that is then passed to the kwargs - closed_findings = self.close_old_findings(self.test.finding_set.values(), **kwargs) + closed_findings = self.close_old_findings(self.test.finding_set.all(), **kwargs) # Update the timestamps of the test object by looking at the findings imported self.update_timestamps() # Update the test meta @@ -247,11 +247,12 @@ def close_old_findings( logger.debug("REIMPORT_SCAN: Closing findings no longer present in scan report") # Close old active findings that are not reported by this scan. # Refactoring this to only call test.finding_set.values() once. + findings = findings.values() mitigated_hash_codes = [] new_hash_codes = [] for finding in findings: new_hash_codes.append(finding["hash_code"]) - if getattr(finding, "is_mitigated", None): + if finding.get("is_mitigated", None): mitigated_hash_codes.append(finding["hash_code"]) for hash_code in new_hash_codes: if hash_code == finding["hash_code"]: diff --git a/dojo/importers/default_reimporter.py b/dojo/importers/default_reimporter.py index 29f84bed7c9..290e13f6ac5 100644 --- a/dojo/importers/default_reimporter.py +++ b/dojo/importers/default_reimporter.py @@ -147,6 +147,13 @@ def process_scan( test_import_history, ) + def determine_deduplication_algorithm(self) -> str: + """ + Determines what dedupe algorithm to use for the Test being processed. + :return: A string representing the dedupe algorithm to use. + """ + return self.test.deduplication_algorithm + def process_findings( self, parsed_findings: List[Finding], @@ -160,7 +167,7 @@ def process_findings( at import time """ - self.deduplication_algorithm = self.test.deduplication_algorithm + self.deduplication_algorithm = self.determine_deduplication_algorithm() self.original_items = list(self.test.finding_set.all()) self.new_items = [] self.reactivated_items = [] @@ -469,6 +476,13 @@ def process_matched_special_status_finding( ): self.unchanged_items.append(existing_finding) return existing_finding, True + # If the finding is risk accepted and inactive in Defectdojo we do not sync the status from the scanner + # We also need to add the finding to 'unchanged_items' as otherwise it will get mitigated by the reimporter + # (Risk accepted findings are not set to mitigated by Defectdojo) + # We however do not exit the loop as we do want to update the endpoints (in case some endpoints were fixed) + elif existing_finding.risk_accepted and not existing_finding.active: + self.unchanged_items.append(existing_finding) + return existing_finding, False # The finding was not an exact match, so we need to add more details about from the # new finding to the existing. Return False here to make process further return existing_finding, False @@ -690,6 +704,8 @@ def finding_post_processing( finding.unsaved_files = finding_from_report.unsaved_files self.process_files(finding) # Process vulnerability IDs + if finding_from_report.unsaved_vulnerability_ids: + finding.unsaved_vulnerability_ids = finding_from_report.unsaved_vulnerability_ids finding = self.process_vulnerability_ids(finding) return finding diff --git a/dojo/jira_link/helper.py b/dojo/jira_link/helper.py index 0fea4b0b5c2..b5e3ba8b219 100644 --- a/dojo/jira_link/helper.py +++ b/dojo/jira_link/helper.py @@ -391,7 +391,7 @@ def get_jira_connection_raw(jira_server, jira_username, jira_password): connect_method = get_jira_connect_method() jira = connect_method(jira_server, jira_username, jira_password) - logger.debug("logged in to JIRA ""%s"" successfully", jira_server) + logger.debug("logged in to JIRA %s successfully", jira_server) return jira except JIRAError as e: @@ -553,7 +553,7 @@ def get_labels(obj): if prod_name_label not in labels: labels.append(prod_name_label) - if system_settings.add_vulnerability_id_to_jira_label or jira_project and jira_project.add_vulnerability_id_to_jira_label: + if system_settings.add_vulnerability_id_to_jira_label or (jira_project and jira_project.add_vulnerability_id_to_jira_label): if isinstance(obj, Finding) and obj.vulnerability_ids: for id in obj.vulnerability_ids: labels.append(id) diff --git a/dojo/management/commands/csv_findings_export.py b/dojo/management/commands/csv_findings_export.py index 5c561e18cea..b55c993559d 100644 --- a/dojo/management/commands/csv_findings_export.py +++ b/dojo/management/commands/csv_findings_export.py @@ -26,7 +26,7 @@ def handle(self, *args, **options): findings = Finding.objects.filter(verified=True, active=True).select_related( "test__engagement__product") - writer = csv.writer(open(file_path, "w")) + writer = csv.writer(open(file_path, "w", encoding="utf-8")) headers = [ "product_name", diff --git a/dojo/management/commands/import_surveys.py b/dojo/management/commands/import_surveys.py index 0eec21a81c5..0f242cbd2ea 100644 --- a/dojo/management/commands/import_surveys.py +++ b/dojo/management/commands/import_surveys.py @@ -29,7 +29,7 @@ def handle(self, *args, **options): # Find the current id in the surveys file path = os.path.dirname(os.path.abspath(__file__)) path = path[:-19] + "fixtures/initial_surveys.json" - contents = open(path).readlines() + contents = open(path, encoding="utf-8").readlines() for line in contents: if '"polymorphic_ctype": ' in line: matchedLine = line @@ -38,7 +38,7 @@ def handle(self, *args, **options): old_id = "".join(c for c in matchedLine if c.isdigit()) new_line = matchedLine.replace(old_id, str(ctype_id)) # Replace the all lines in the file - with open(path, "w") as fout: + with open(path, "w", encoding="utf-8") as fout: for line in contents: fout.write(line.replace(matchedLine, new_line)) # Delete the temp question diff --git a/dojo/management/commands/jira_status_reconciliation.py b/dojo/management/commands/jira_status_reconciliation.py index 66b3cd6ab3b..6ca72dbe1f1 100644 --- a/dojo/management/commands/jira_status_reconciliation.py +++ b/dojo/management/commands/jira_status_reconciliation.py @@ -59,8 +59,8 @@ def jira_status_reconciliation(*args, **kwargs): issue_from_jira = jira_helper.get_jira_issue_from_jira(find) if not issue_from_jira: - message = "%s;%s/finding/%d;%s;%s;%s;%s;%s;%s;%s;%s;%s;%s;%s;unable to retrieve JIRA Issue;%s" % \ - (find.jira_issue.jira_key, settings.SITE_URL, find.id, find.status(), None, None, None, None, + message = "{};{}/finding/{};{};{};{};{};{};{};{};{};{};{};{};unable to retrieve JIRA Issue;{}".format( + find.jira_issue.jira_key, settings.SITE_URL, find.id, find.status(), None, None, None, None, find.jira_issue.jira_change, None, find.last_status_update, None, find.last_reviewed, None, "error") messages.append(message) logger.info(message) @@ -80,27 +80,27 @@ def jira_status_reconciliation(*args, **kwargs): flag1, flag2, flag3 = None, None, None if mode == "reconcile" and not find.last_status_update: - message = "%s; %s/finding/%d;%s;%s;%s;%s;%s;%s;%s;%s;%s;%s;%s;skipping finding with no last_status_update;%s" % \ - (find.jira_issue.jira_key, settings.SITE_URL, find.id, find.status(), None, None, None, None, + message = "{}; {}/finding/{};{};{};{};{};{};{};{};{};{};{};{};skipping finding with no last_status_update;{}".format( + find.jira_issue.jira_key, settings.SITE_URL, find.id, find.status(), None, None, None, None, find.jira_issue.jira_change, issue_from_jira.fields.updated, find.last_status_update, issue_from_jira.fields.updated, find.last_reviewed, issue_from_jira.fields.updated, "skipped") messages.append(message) logger.info(message) continue elif find.risk_accepted: - message = "%s; %s/finding/%d;%s;%s;%s;%s;%s;%s;%s;%s;%s;%s;%sskipping risk accepted findings;%s" % \ - (find.jira_issue.jira_key, settings.SITE_URL, find.id, find.status(), resolution_name, None, None, None, + message = "{}; {}/finding/{};{};{};{};{};{};{};{};{};{};{};{}skipping risk accepted findings;{}".format( + find.jira_issue.jira_key, settings.SITE_URL, find.id, find.status(), resolution_name, None, None, None, find.jira_issue.jira_change, issue_from_jira.fields.updated, find.last_status_update, issue_from_jira.fields.updated, find.last_reviewed, issue_from_jira.fields.updated, "skipped") messages.append(message) logger.info(message) elif jira_helper.issue_from_jira_is_active(issue_from_jira) and find.active: - message = "%s; %s/finding/%d;%s;%s;%s;%s;%s;%s;%s;%s;%s;%s;%s;no action both sides are active/open;%s" % \ - (find.jira_issue.jira_key, settings.SITE_URL, find.id, find.status(), resolution_name, None, None, None, + message = "{}; {}/finding/{};{};{};{};{};{};{};{};{};{};{};{};no action both sides are active/open;{}".format( + find.jira_issue.jira_key, settings.SITE_URL, find.id, find.status(), resolution_name, None, None, None, find.jira_issue.jira_change, issue_from_jira.fields.updated, find.last_status_update, issue_from_jira.fields.updated, find.last_reviewed, issue_from_jira.fields.updated, "equal") messages.append(message) logger.info(message) elif not jira_helper.issue_from_jira_is_active(issue_from_jira) and not find.active: - message = "%s; %s/finding/%d;%s;%s;%s;%s;%s;%s;%s;%s;%s;%s;%s;no action both sides are inactive/closed;%s" % \ - (find.jira_issue.jira_key, settings.SITE_URL, find.id, find.status(), resolution_name, None, None, None, + message = "{}; {}/finding/{};{};{};{};{};{};{};{};{};{};{};{};no action both sides are inactive/closed;{}".format( + find.jira_issue.jira_key, settings.SITE_URL, find.id, find.status(), resolution_name, None, None, None, find.jira_issue.jira_change, issue_from_jira.fields.updated, find.last_status_update, issue_from_jira.fields.updated, find.last_reviewed, issue_from_jira.fields.updated, "equal") messages.append(message) logger.info(message) @@ -148,15 +148,11 @@ def jira_status_reconciliation(*args, **kwargs): status_changed = jira_helper.process_resolution_from_jira(find, resolution_id, resolution_name, assignee_name, issue_from_jira.fields.updated, find.jira_issue) if not dryrun else "dryrun" if status_changed: - message = "%s; %s/finding/%d;%s;%s;%s;%s;%s;%s;%s;%s;%s;%s;%s;%s finding in defectdojo;%s" % \ - (find.jira_issue.jira_key, settings.SITE_URL, find.id, find.status(), resolution_name, flag1, flag2, flag3, - find.jira_issue.jira_change, issue_from_jira.fields.updated, find.last_status_update, issue_from_jira.fields.updated, find.last_reviewed, issue_from_jira.fields.updated, message_action, status_changed) + message = f"{find.jira_issue.jira_key}; {settings.SITE_URL}/finding/{find.id};{find.status()};{resolution_name};{flag1};{flag2};{flag3};{find.jira_issue.jira_change};{issue_from_jira.fields.updated};{find.last_status_update};{issue_from_jira.fields.updated};{find.last_reviewed};{issue_from_jira.fields.updated};{message_action} finding in defectdojo;{status_changed}" messages.append(message) logger.info(message) else: - message = "%s; %s/finding/%d;%s;%s;%s;%s;%s;%s;%s;%s;%s;%s;%s;no changes made from jira resolution;%s" % \ - (find.jira_issue.jira_key, settings.SITE_URL, find.id, find.status(), resolution_name, flag1, flag2, flag3, - find.jira_issue.jira_change, issue_from_jira.fields.updated, find.last_status_update, issue_from_jira.fields.updated, find.last_reviewed, issue_from_jira.fields.updated, status_changed) + message = f"{find.jira_issue.jira_key}; {settings.SITE_URL}/finding/{find.id};{find.status()};{resolution_name};{flag1};{flag2};{flag3};{find.jira_issue.jira_change};{issue_from_jira.fields.updated};{find.last_status_update};{issue_from_jira.fields.updated};{find.last_reviewed};{issue_from_jira.fields.updated};no changes made from jira resolution;{status_changed}" messages.append(message) logger.info(message) @@ -171,24 +167,18 @@ def jira_status_reconciliation(*args, **kwargs): status_changed = jira_helper.push_status_to_jira(find, jira_instance, jira, issue_from_jira, save=True) if not dryrun else "dryrun" if status_changed: - message = "%s; %s/finding/%d;%s;%s;%s;%s;%s;%s;%s;%s;%s;%s;%s;%s jira issue;%s;" % \ - (find.jira_issue.jira_key, settings.SITE_URL, find.id, find.status(), resolution_name, flag1, flag2, flag3, message_action, - find.jira_issue.jira_change, issue_from_jira.fields.updated, find.last_status_update, issue_from_jira.fields.updated, find.last_reviewed, issue_from_jira.fields.updated, status_changed) + message = f"{find.jira_issue.jira_key}; {settings.SITE_URL}/finding/{find.id};{find.status()};{resolution_name};{flag1};{flag2};{flag3};{message_action};{find.jira_issue.jira_change};{issue_from_jira.fields.updated};{find.last_status_update};{issue_from_jira.fields.updated};{find.last_reviewed};{issue_from_jira.fields.updated} jira issue;{status_changed};" messages.append(message) logger.info(message) else: if status_changed is None: status_changed = "Error" - message = "%s; %s/finding/%d;%s;%s;%s;%s;%s;%s;%s;%s;%s;%s;%s;no changes made while pushing status to jira;%s" % \ - (find.jira_issue.jira_key, settings.SITE_URL, find.id, find.status(), resolution_name, flag1, flag2, flag3, - find.jira_issue.jira_change, issue_from_jira.fields.updated, find.last_status_update, issue_from_jira.fields.updated, find.last_reviewed, issue_from_jira.fields.updated, status_changed) + message = f"{find.jira_issue.jira_key}; {settings.SITE_URL}/finding/{find.id};{find.status()};{resolution_name};{flag1};{flag2};{flag3};{find.jira_issue.jira_change};{issue_from_jira.fields.updated};{find.last_status_update};{issue_from_jira.fields.updated};{find.last_reviewed};{issue_from_jira.fields.updated};no changes made while pushing status to jira;{status_changed}" messages.append(message) logger.info(message) else: - message = "%s; %s/finding/%d;%s;%s;%s;%s;%s;%s;%s;%s;%s;%s;%s;unable to determine source of truth;%s" % \ - (find.jira_issue.jira_key, settings.SITE_URL, find.id, find.status(), resolution_name, flag1, flag2, flag3, - find.jira_issue.jira_change, issue_from_jira.fields.updated, find.last_status_update, issue_from_jira.fields.updated, find.last_reviewed, issue_from_jira.fields.updated, status_changed) + message = f"{find.jira_issue.jira_key}; {settings.SITE_URL}/finding/{find.id};{find.status()};{resolution_name};{flag1};{flag2};{flag3};{find.jira_issue.jira_change};{issue_from_jira.fields.updated};{find.last_status_update};{issue_from_jira.fields.updated};{find.last_reviewed};{issue_from_jira.fields.updated};unable to determine source of truth;{status_changed}" messages.append(message) logger.info(message) diff --git a/dojo/middleware.py b/dojo/middleware.py index 5cc06588494..2a892a9e9c1 100644 --- a/dojo/middleware.py +++ b/dojo/middleware.py @@ -92,7 +92,7 @@ def get_system_settings(cls): return None @classmethod - def cleanup(cls, *args, **kwargs): + def cleanup(cls, *args, **kwargs): # noqa: ARG003 if hasattr(cls._thread_local, "system_settings"): del cls._thread_local.system_settings diff --git a/dojo/models.py b/dojo/models.py index 299abf4a88d..308db965228 100644 --- a/dojo/models.py +++ b/dojo/models.py @@ -353,6 +353,13 @@ class System_Settings(models.Model): mail_notifications_to = models.CharField(max_length=200, default="", blank=True) + enable_webhooks_notifications = \ + models.BooleanField(default=False, + verbose_name=_("Enable Webhook notifications"), + blank=False) + webhooks_notifications_timeout = models.IntegerField(default=10, + help_text=_("How many seconds will DefectDojo waits for response from webhook endpoint")) + false_positive_history = models.BooleanField( default=False, help_text=_( "(EXPERIMENTAL) DefectDojo will automatically mark the finding as a " @@ -817,6 +824,9 @@ class Test_Type(models.Model): static_tool = models.BooleanField(default=False) dynamic_tool = models.BooleanField(default=False) active = models.BooleanField(default=True) + dynamically_generated = models.BooleanField( + default=False, + help_text=_("Set to True for test types that are created at import time")) class Meta: ordering = ("name",) @@ -1476,7 +1486,7 @@ class Meta: ] def __str__(self): - return "Engagement %i: %s (%s)" % (self.id if id else 0, self.name or "", + return "Engagement {}: {} ({})".format(self.id if id else 0, self.name or "", self.target_start.strftime( "%b %d, %Y")) @@ -2244,7 +2254,7 @@ class Meta: ordering = ("test_import", "action", "finding") def __str__(self): - return "%i: %s" % (self.finding.id, self.action) + return f"{self.finding.id}: {self.action}" class Finding(models.Model): @@ -2640,14 +2650,7 @@ def save(self, dedupe_option=True, rules_option=True, product_grading_option=Tru except Exception as ex: logger.error("Can't compute cvssv3 score for finding id %i. Invalid cvssv3 vector found: '%s'. Exception: %s", self.id, self.cvssv3, ex) - # Finding.save is called once from serializers.py with dedupe_option=False because the finding is not ready yet, for example the endpoints are not built - # It is then called a second time with dedupe_option defaulted to true; now we can compute the hash_code and run the deduplication - if dedupe_option: - if (self.hash_code is not None): - deduplicationLogger.debug("Hash_code already computed for finding") - else: - self.hash_code = self.compute_hash_code() - deduplicationLogger.debug("Hash_code computed for finding: %s", self.hash_code) + self.set_hash_code(dedupe_option) if self.pk is None: # We enter here during the first call from serializers.py @@ -3346,6 +3349,20 @@ def inherit_tags(self, potentially_existing_tags): def violates_sla(self): return (self.sla_expiration_date and self.sla_expiration_date < timezone.now().date()) + def set_hash_code(self, dedupe_option): + from dojo.utils import get_custom_method + if hash_method := get_custom_method("FINDING_HASH_METHOD"): + hash_method(self, dedupe_option) + else: + # Finding.save is called once from serializers.py with dedupe_option=False because the finding is not ready yet, for example the endpoints are not built + # It is then called a second time with dedupe_option defaulted to true; now we can compute the hash_code and run the deduplication + if dedupe_option: + if self.hash_code is not None: + deduplicationLogger.debug("Hash_code already computed for finding") + else: + self.hash_code = self.compute_hash_code() + deduplicationLogger.debug("Hash_code computed for finding: %s", self.hash_code) + class FindingAdmin(admin.ModelAdmin): # For efficiency with large databases, display many-to-many fields with raw @@ -4005,12 +4022,14 @@ def set_obj(self, obj): NOTIFICATION_CHOICE_SLACK = ("slack", "slack") NOTIFICATION_CHOICE_MSTEAMS = ("msteams", "msteams") NOTIFICATION_CHOICE_MAIL = ("mail", "mail") +NOTIFICATION_CHOICE_WEBHOOKS = ("webhooks", "webhooks") NOTIFICATION_CHOICE_ALERT = ("alert", "alert") NOTIFICATION_CHOICES = ( NOTIFICATION_CHOICE_SLACK, NOTIFICATION_CHOICE_MSTEAMS, NOTIFICATION_CHOICE_MAIL, + NOTIFICATION_CHOICE_WEBHOOKS, NOTIFICATION_CHOICE_ALERT, ) @@ -4099,6 +4118,33 @@ def get_list_display(self, request): return list_fields +class Notification_Webhooks(models.Model): + class Status(models.TextChoices): + __STATUS_ACTIVE = "active" + __STATUS_INACTIVE = "inactive" + STATUS_ACTIVE = f"{__STATUS_ACTIVE}", _("Active") + STATUS_ACTIVE_TMP = f"{__STATUS_ACTIVE}_tmp", _("Active but 5xx (or similar) error detected") + STATUS_INACTIVE_TMP = f"{__STATUS_INACTIVE}_tmp", _("Temporary inactive because of 5xx (or similar) error") + STATUS_INACTIVE_PERMANENT = f"{__STATUS_INACTIVE}_permanent", _("Permanently inactive") + + name = models.CharField(max_length=100, default="", blank=False, unique=True, + help_text=_("Name of the incoming webhook")) + url = models.URLField(max_length=200, default="", blank=False, + help_text=_("The full URL of the incoming webhook")) + header_name = models.CharField(max_length=100, default="", blank=True, null=True, + help_text=_("Name of the header required for interacting with Webhook endpoint")) + header_value = models.CharField(max_length=100, default="", blank=True, null=True, + help_text=_("Content of the header required for interacting with Webhook endpoint")) + status = models.CharField(max_length=20, choices=Status, default="active", blank=False, + help_text=_("Status of the incoming webhook"), editable=False) + first_error = models.DateTimeField(help_text=_("If endpoint is active, when error happened first time"), blank=True, null=True, editable=False) + last_error = models.DateTimeField(help_text=_("If endpoint is active, when error happened last time"), blank=True, null=True, editable=False) + note = models.CharField(max_length=1000, default="", blank=True, null=True, help_text=_("Description of the latest error"), editable=False) + owner = models.ForeignKey(Dojo_User, editable=True, null=True, blank=True, on_delete=models.CASCADE, + help_text=_("Owner/receiver of notification, if empty processed as system notification")) + # TODO: Test that `editable` will block editing via API + + class Tool_Product_Settings(models.Model): name = models.CharField(max_length=200, null=False) description = models.CharField(max_length=2000, null=True, blank=True) @@ -4571,6 +4617,7 @@ def __str__(self): auditlog.register(Risk_Acceptance) auditlog.register(Finding_Template) auditlog.register(Cred_User, exclude_fields=["password"]) + auditlog.register(Notification_Webhooks, exclude_fields=["header_name", "header_value"]) from dojo.utils import calculate_grade, to_str_typed # noqa: E402 # there is issue due to a circular import @@ -4632,6 +4679,7 @@ def __str__(self): admin.site.register(GITHUB_Details_Cache) admin.site.register(GITHUB_PKey) admin.site.register(Tool_Configuration, Tool_Configuration_Admin) +admin.site.register(Notification_Webhooks) admin.site.register(Tool_Product_Settings) admin.site.register(Tool_Type) admin.site.register(Cred_User) diff --git a/dojo/notifications/helper.py b/dojo/notifications/helper.py index 5a7ccf0dc60..9acbf94d215 100644 --- a/dojo/notifications/helper.py +++ b/dojo/notifications/helper.py @@ -1,6 +1,9 @@ +import json import logging +from datetime import timedelta import requests +import yaml from django.conf import settings from django.core.exceptions import FieldDoesNotExist from django.core.mail import EmailMessage @@ -10,10 +13,19 @@ from django.urls import reverse from django.utils.translation import gettext as _ +from dojo import __version__ as dd_version from dojo.authorization.roles_permissions import Permissions from dojo.celery import app from dojo.decorators import dojo_async_task, we_want_async -from dojo.models import Alerts, Dojo_User, Notifications, System_Settings, UserContactInfo +from dojo.models import ( + Alerts, + Dojo_User, + Notification_Webhooks, + Notifications, + System_Settings, + UserContactInfo, + get_current_datetime, +) from dojo.user.queries import get_authorized_users_for_product_and_product_type, get_authorized_users_for_product_type logger = logging.getLogger(__name__) @@ -144,8 +156,9 @@ def create_notification_message(event, user, notification_type, *args, **kwargs) try: notification_message = render_to_string(template, kwargs) logger.debug("Rendering from the template %s", template) - except TemplateDoesNotExist: - logger.debug("template not found or not implemented yet: %s", template) + except TemplateDoesNotExist as e: + # In some cases, template includes another templates, if the interior one is missing, we will see it in "specifically" section + logger.debug(f"template not found or not implemented yet: {template} (specifically: {e.args})") except Exception as e: logger.error("error during rendering of template %s exception is %s", template, e) finally: @@ -170,6 +183,7 @@ def process_notifications(event, notifications=None, **kwargs): slack_enabled = get_system_setting("enable_slack_notifications") msteams_enabled = get_system_setting("enable_msteams_notifications") mail_enabled = get_system_setting("enable_mail_notifications") + webhooks_enabled = get_system_setting("enable_webhooks_notifications") if slack_enabled and "slack" in getattr(notifications, event, getattr(notifications, "other")): logger.debug("Sending Slack Notification") @@ -183,6 +197,10 @@ def process_notifications(event, notifications=None, **kwargs): logger.debug("Sending Mail Notification") send_mail_notification(event, notifications.user, **kwargs) + if webhooks_enabled and "webhooks" in getattr(notifications, event, getattr(notifications, "other")): + logger.debug("Sending Webhooks Notification") + send_webhooks_notification(event, notifications.user, **kwargs) + if "alert" in getattr(notifications, event, getattr(notifications, "other")): logger.debug(f"Sending Alert to {notifications.user}") send_alert_notification(event, notifications.user, **kwargs) @@ -309,6 +327,157 @@ def send_mail_notification(event, user=None, *args, **kwargs): log_alert(e, "Email Notification", title=kwargs["title"], description=str(e), url=kwargs["url"]) +def webhooks_notification_request(endpoint, event, *args, **kwargs): + from dojo.utils import get_system_setting + + headers = { + "User-Agent": f"DefectDojo-{dd_version}", + "X-DefectDojo-Event": event, + "X-DefectDojo-Instance": settings.SITE_URL, + "Accept": "application/json", + } + if endpoint.header_name is not None: + headers[endpoint.header_name] = endpoint.header_value + yaml_data = create_notification_message(event, endpoint.owner, "webhooks", *args, **kwargs) + data = yaml.safe_load(yaml_data) + + timeout = get_system_setting("webhooks_notifications_timeout") + + res = requests.request( + method="POST", + url=endpoint.url, + headers=headers, + json=data, + timeout=timeout, + ) + return res + + +def test_webhooks_notification(endpoint): + res = webhooks_notification_request(endpoint, "ping", description="Test webhook notification") + res.raise_for_status() + # in "send_webhooks_notification", we are doing deeper analysis, why it failed + # for now, "raise_for_status" should be enough + + +@app.task(ignore_result=True) +def webhook_reactivation(endpoint_id: int, *args, **kwargs): + endpoint = Notification_Webhooks.objects.get(pk=endpoint_id) + + # User already changed status of endpoint + if endpoint.status != Notification_Webhooks.Status.STATUS_INACTIVE_TMP: + return + + endpoint.status = Notification_Webhooks.Status.STATUS_ACTIVE_TMP + endpoint.save() + logger.debug(f"Webhook endpoint '{endpoint.name}' reactivated to '{Notification_Webhooks.Status.STATUS_ACTIVE_TMP}'") + + +@app.task(ignore_result=True) +def webhook_status_cleanup(*args, **kwargs): + # If some endpoint was affected by some outage (5xx, 429, Timeout) but it was clean during last 24 hours, + # we consider this endpoint as healthy so need to reset it + endpoints = Notification_Webhooks.objects.filter( + status=Notification_Webhooks.Status.STATUS_ACTIVE_TMP, + last_error__lt=get_current_datetime() - timedelta(hours=24), + ) + for endpoint in endpoints: + endpoint.status = Notification_Webhooks.Status.STATUS_ACTIVE + endpoint.first_error = None + endpoint.last_error = None + endpoint.note = f"Reactivation from {Notification_Webhooks.Status.STATUS_ACTIVE_TMP}" + endpoint.save() + logger.debug(f"Webhook endpoint '{endpoint.name}' reactivated from '{Notification_Webhooks.Status.STATUS_ACTIVE_TMP}' to '{Notification_Webhooks.Status.STATUS_ACTIVE}'") + + # Reactivation of STATUS_INACTIVE_TMP endpoints. + # They should reactive automatically in 60s, however in case of some unexpected event (e.g. start of whole stack), + # endpoints should not be left in STATUS_INACTIVE_TMP state + broken_endpoints = Notification_Webhooks.objects.filter( + status=Notification_Webhooks.Status.STATUS_INACTIVE_TMP, + last_error__lt=get_current_datetime() - timedelta(minutes=5), + ) + for endpoint in broken_endpoints: + webhook_reactivation(endpoint_id=endpoint.pk) + + +@dojo_async_task +@app.task +def send_webhooks_notification(event, user=None, *args, **kwargs): + + ERROR_PERMANENT = "permanent" + ERROR_TEMPORARY = "temporary" + + endpoints = Notification_Webhooks.objects.filter(owner=user) + + if not endpoints: + if user: + logger.info(f"URLs for Webhooks not configured for user '{user}': skipping user notification") + else: + logger.info("URLs for Webhooks not configured: skipping system notification") + return + + for endpoint in endpoints: + + error = None + if endpoint.status not in [Notification_Webhooks.Status.STATUS_ACTIVE, Notification_Webhooks.Status.STATUS_ACTIVE_TMP]: + logger.info(f"URL for Webhook '{endpoint.name}' is not active: {endpoint.get_status_display()} ({endpoint.status})") + continue + + try: + logger.debug(f"Sending webhook message to endpoint '{endpoint.name}'") + res = webhooks_notification_request(endpoint, event, *args, **kwargs) + + if 200 <= res.status_code < 300: + logger.debug(f"Message sent to endpoint '{endpoint.name}' successfully.") + continue + + # HTTP request passed successfully but we still need to check status code + if 500 <= res.status_code < 600 or res.status_code == 429: + error = ERROR_TEMPORARY + else: + error = ERROR_PERMANENT + + endpoint.note = f"Response status code: {res.status_code}" + logger.error(f"Error when sending message to Webhooks '{endpoint.name}' (status: {res.status_code}): {res.text}") + + except requests.exceptions.Timeout as e: + error = ERROR_TEMPORARY + endpoint.note = f"Requests exception: {e}" + logger.error(f"Timeout when sending message to Webhook '{endpoint.name}'") + + except Exception as e: + error = ERROR_PERMANENT + endpoint.note = f"Exception: {e}"[:1000] + logger.exception(e) + log_alert(e, "Webhooks Notification") + + now = get_current_datetime() + + if error == ERROR_TEMPORARY: + + # If endpoint is unstable for more then one day, it needs to be deactivated + if endpoint.first_error is not None and (now - endpoint.first_error).total_seconds() > 60 * 60 * 24: + endpoint.status = Notification_Webhooks.Status.STATUS_INACTIVE_PERMANENT + + else: + # We need to monitor when outage started + if endpoint.status == Notification_Webhooks.Status.STATUS_ACTIVE: + endpoint.first_error = now + + endpoint.status = Notification_Webhooks.Status.STATUS_INACTIVE_TMP + + # In case of failure within one day, endpoint can be deactivated temporally only for one minute + webhook_reactivation.apply_async(kwargs={"endpoint_id": endpoint.pk}, countdown=60) + + # There is no reason to keep endpoint active if it is returning 4xx errors + else: + endpoint.status = Notification_Webhooks.Status.STATUS_INACTIVE_PERMANENT + endpoint.first_error = now + + endpoint.last_error = now + endpoint.save() + + def send_alert_notification(event, user=None, *args, **kwargs): logger.debug("sending alert notification to %s", user) try: @@ -335,7 +504,6 @@ def send_alert_notification(event, user=None, *args, **kwargs): def get_slack_user_id(user_email): - import json from dojo.utils import get_system_setting @@ -390,7 +558,7 @@ def log_alert(e, notification_type=None, *args, **kwargs): def notify_test_created(test): title = "Test created for " + str(test.engagement.product) + ": " + str(test.engagement.name) + ": " + str(test) create_notification(event="test_added", title=title, test=test, engagement=test.engagement, product=test.engagement.product, - url=reverse("view_test", args=(test.id,))) + url=reverse("view_test", args=(test.id,)), url_api=reverse("test-detail", args=(test.id,))) def notify_scan_added(test, updated_count, new_findings=[], findings_mitigated=[], findings_reactivated=[], findings_untouched=[]): @@ -410,4 +578,4 @@ def notify_scan_added(test, updated_count, new_findings=[], findings_mitigated=[ create_notification(event=event, title=title, findings_new=new_findings, findings_mitigated=findings_mitigated, findings_reactivated=findings_reactivated, finding_count=updated_count, test=test, engagement=test.engagement, product=test.engagement.product, findings_untouched=findings_untouched, - url=reverse("view_test", args=(test.id,))) + url=reverse("view_test", args=(test.id,)), url_api=reverse("test-detail", args=(test.id,))) diff --git a/dojo/notifications/urls.py b/dojo/notifications/urls.py index dc91f7a04e2..6f4cba7bb64 100644 --- a/dojo/notifications/urls.py +++ b/dojo/notifications/urls.py @@ -7,4 +7,8 @@ re_path(r"^notifications/system$", views.SystemNotificationsView.as_view(), name="system_notifications"), re_path(r"^notifications/personal$", views.PersonalNotificationsView.as_view(), name="personal_notifications"), re_path(r"^notifications/template$", views.TemplateNotificationsView.as_view(), name="template_notifications"), + re_path(r"^notifications/webhooks$", views.ListNotificationWebhooksView.as_view(), name="notification_webhooks"), + re_path(r"^notifications/webhooks/add$", views.AddNotificationWebhooksView.as_view(), name="add_notification_webhook"), + re_path(r"^notifications/webhooks/(?P\d+)/edit$", views.EditNotificationWebhooksView.as_view(), name="edit_notification_webhook"), + re_path(r"^notifications/webhooks/(?P\d+)/delete$", views.DeleteNotificationWebhooksView.as_view(), name="delete_notification_webhook"), ] diff --git a/dojo/notifications/views.py b/dojo/notifications/views.py index 8a94d2ad7c5..6a2495330d7 100644 --- a/dojo/notifications/views.py +++ b/dojo/notifications/views.py @@ -1,15 +1,18 @@ import logging +import requests from django.contrib import messages from django.core.exceptions import PermissionDenied -from django.http import HttpRequest -from django.shortcuts import render +from django.http import Http404, HttpRequest, HttpResponseRedirect +from django.shortcuts import get_object_or_404, render +from django.urls import reverse from django.utils.translation import gettext as _ from django.views import View -from dojo.forms import NotificationsForm -from dojo.models import Notifications -from dojo.utils import add_breadcrumb, get_enabled_notifications_list +from dojo.forms import DeleteNotificationsWebhookForm, NotificationsForm, NotificationsWebhookForm +from dojo.models import Notification_Webhooks, Notifications +from dojo.notifications.helper import test_webhooks_notification +from dojo.utils import add_breadcrumb, get_enabled_notifications_list, get_system_setting logger = logging.getLogger(__name__) @@ -129,3 +132,295 @@ def get_scope(self): def set_breadcrumbs(self, request: HttpRequest): add_breadcrumb(title=_("Template notification settings"), top_level=False, request=request) return request + + +class NotificationWebhooksView(View): + + def check_webhooks_enabled(self): + if not get_system_setting("enable_webhooks_notifications"): + raise Http404 + + def check_user_permissions(self, request: HttpRequest): + if not request.user.is_superuser: + raise PermissionDenied + # TODO: finished access for other users + # if not user_has_configuration_permission(request.user, self.permission): + # raise PermissionDenied() + + def set_breadcrumbs(self, request: HttpRequest): + add_breadcrumb(title=self.breadcrumb, top_level=False, request=request) + return request + + def get_form( + self, + request: HttpRequest, + **kwargs: dict, + ) -> NotificationsWebhookForm: + if request.method == "POST": + return NotificationsWebhookForm(request.POST, is_superuser=request.user.is_superuser, **kwargs) + else: + return NotificationsWebhookForm(is_superuser=request.user.is_superuser, **kwargs) + + def preprocess_request(self, request: HttpRequest): + # Check Webhook notifications are enabled + self.check_webhooks_enabled() + # Check permissions + self.check_user_permissions(request) + + +class ListNotificationWebhooksView(NotificationWebhooksView): + template = "dojo/view_notification_webhooks.html" + permission = "dojo.view_notification_webhooks" + breadcrumb = "Notification Webhook List" + + def get_initial_context(self, request: HttpRequest, nwhs: Notification_Webhooks): + return { + "name": "Notification Webhook List", + "metric": False, + "user": request.user, + "nwhs": nwhs, + } + + def get_notification_webhooks(self, request: HttpRequest): + nwhs = Notification_Webhooks.objects.all().order_by("name") + # TODO: finished pagination + # TODO: restrict based on user - not only superadmins have access and they see everything + return nwhs + + def get(self, request: HttpRequest): + # Run common checks + super().preprocess_request(request) + # Get Notification Webhooks + nwhs = self.get_notification_webhooks(request) + # Set up the initial context + context = self.get_initial_context(request, nwhs) + # Add any breadcrumbs + request = self.set_breadcrumbs(request) + # Render the page + return render(request, self.template, context) + + +class AddNotificationWebhooksView(NotificationWebhooksView): + template = "dojo/add_notification_webhook.html" + permission = "dojo.add_notification_webhooks" + breadcrumb = "Add Notification Webhook" + + # TODO: Disable Owner if not superadmin + + def get_initial_context(self, request: HttpRequest): + return { + "name": "Add Notification Webhook", + "user": request.user, + "form": self.get_form(request), + } + + def process_form(self, request: HttpRequest, context: dict): + form = context["form"] + if form.is_valid(): + try: + test_webhooks_notification(form.instance) + except requests.exceptions.RequestException as e: + messages.add_message( + request, + messages.ERROR, + _("Test of endpoint was not successful: %(error)s") % {"error": str(e)}, + extra_tags="alert-danger", + ) + return request, False + else: + # User can put here what ever he want + # we override it with our only valid defaults + nwh = form.save(commit=False) + nwh.status = Notification_Webhooks.Status.STATUS_ACTIVE + nwh.first_error = None + nwh.last_error = None + nwh.note = None + nwh.save() + messages.add_message( + request, + messages.SUCCESS, + _("Notification Webhook added successfully."), + extra_tags="alert-success", + ) + return request, True + return request, False + + def get(self, request: HttpRequest): + # Run common checks + super().preprocess_request(request) + # Set up the initial context + context = self.get_initial_context(request) + # Add any breadcrumbs + request = self.set_breadcrumbs(request) + # Render the page + return render(request, self.template, context) + + def post(self, request: HttpRequest): + # Run common checks + super().preprocess_request(request) + # Set up the initial context + context = self.get_initial_context(request) + # Determine the validity of the form + request, success = self.process_form(request, context) + if success: + return HttpResponseRedirect(reverse("notification_webhooks")) + # Add any breadcrumbs + request = self.set_breadcrumbs(request) + # Render the page + return render(request, self.template, context) + + +class EditNotificationWebhooksView(NotificationWebhooksView): + template = "dojo/edit_notification_webhook.html" + permission = "dojo.change_notification_webhooks" + # TODO: this could be better: @user_is_authorized(Finding, Permissions.Finding_Delete, 'fid') + breadcrumb = "Edit Notification Webhook" + + def get_notification_webhook(self, nwhid: int): + return get_object_or_404(Notification_Webhooks, id=nwhid) + + # TODO: Disable Owner if not superadmin + + def get_initial_context(self, request: HttpRequest, nwh: Notification_Webhooks): + return { + "name": "Edit Notification Webhook", + "user": request.user, + "form": self.get_form(request, instance=nwh), + "nwh": nwh, + } + + def process_form(self, request: HttpRequest, nwh: Notification_Webhooks, context: dict): + form = context["form"] + if "deactivate_webhook" in request.POST: # TODO: add this to API as well + nwh.status = Notification_Webhooks.Status.STATUS_INACTIVE_PERMANENT + nwh.first_error = None + nwh.last_error = None + nwh.note = "Deactivate from UI" + nwh.save() + messages.add_message( + request, + messages.SUCCESS, + _("Notification Webhook deactivated successfully."), + extra_tags="alert-success", + ) + return request, True + + if form.is_valid(): + try: + test_webhooks_notification(form.instance) + except requests.exceptions.RequestException as e: + messages.add_message( + request, + messages.ERROR, + _("Test of endpoint was not successful: %(error)s") % {"error": str(e)}, + extra_tags="alert-danger") + return request, False + else: + # correct definition reset defaults + nwh = form.save(commit=False) + nwh.status = Notification_Webhooks.Status.STATUS_ACTIVE + nwh.first_error = None + nwh.last_error = None + nwh.note = None + nwh.save() + messages.add_message( + request, + messages.SUCCESS, + _("Notification Webhook updated successfully."), + extra_tags="alert-success", + ) + return request, True + return request, False + + def get(self, request: HttpRequest, nwhid: int): + # Run common checks + super().preprocess_request(request) + nwh = self.get_notification_webhook(nwhid) + # Set up the initial context + context = self.get_initial_context(request, nwh) + # Add any breadcrumbs + request = self.set_breadcrumbs(request) + # Render the page + return render(request, self.template, context) + + def post(self, request: HttpRequest, nwhid: int): + # Run common checks + super().preprocess_request(request) + nwh = self.get_notification_webhook(nwhid) + # Set up the initial context + context = self.get_initial_context(request, nwh) + # Determine the validity of the form + request, success = self.process_form(request, nwh, context) + if success: + return HttpResponseRedirect(reverse("notification_webhooks")) + # Add any breadcrumbs + request = self.set_breadcrumbs(request) + # Render the page + return render(request, self.template, context) + + +class DeleteNotificationWebhooksView(NotificationWebhooksView): + template = "dojo/delete_notification_webhook.html" + permission = "dojo.delete_notification_webhooks" + # TODO: this could be better: @user_is_authorized(Finding, Permissions.Finding_Delete, 'fid') + breadcrumb = "Edit Notification Webhook" + + def get_notification_webhook(self, nwhid: int): + return get_object_or_404(Notification_Webhooks, id=nwhid) + + # TODO: Disable Owner if not superadmin + + def get_form( + self, + request: HttpRequest, + **kwargs: dict, + ) -> NotificationsWebhookForm: + if request.method == "POST": + return DeleteNotificationsWebhookForm(request.POST, **kwargs) + else: + return DeleteNotificationsWebhookForm(**kwargs) + + def get_initial_context(self, request: HttpRequest, nwh: Notification_Webhooks): + return { + "form": self.get_form(request, instance=nwh), + "nwh": nwh, + } + + def process_form(self, request: HttpRequest, nwh: Notification_Webhooks, context: dict): + form = context["form"] + if form.is_valid(): + nwh.delete() + messages.add_message( + request, + messages.SUCCESS, + _("Notification Webhook deleted successfully."), + extra_tags="alert-success", + ) + return request, True + return request, False + + def get(self, request: HttpRequest, nwhid: int): + # Run common checks + super().preprocess_request(request) + nwh = self.get_notification_webhook(nwhid) + # Set up the initial context + context = self.get_initial_context(request, nwh) + # Add any breadcrumbs + request = self.set_breadcrumbs(request) + # Render the page + return render(request, self.template, context) + + def post(self, request: HttpRequest, nwhid: int): + # Run common checks + super().preprocess_request(request) + nwh = self.get_notification_webhook(nwhid) + # Set up the initial context + context = self.get_initial_context(request, nwh) + # Determine the validity of the form + request, success = self.process_form(request, nwh, context) + if success: + return HttpResponseRedirect(reverse("notification_webhooks")) + # Add any breadcrumbs + request = self.set_breadcrumbs(request) + # Render the page + return render(request, self.template, context) diff --git a/dojo/product/signals.py b/dojo/product/signals.py index 6871f5490d2..72e9771e82c 100644 --- a/dojo/product/signals.py +++ b/dojo/product/signals.py @@ -16,7 +16,9 @@ def product_post_save(sender, instance, created, **kwargs): create_notification(event="product_added", title=instance.name, product=instance, - url=reverse("view_product", args=(instance.id,))) + url=reverse("view_product", args=(instance.id,)), + url_api=reverse("product-detail", args=(instance.id,)), + ) @receiver(post_delete, sender=Product) diff --git a/dojo/product_type/signals.py b/dojo/product_type/signals.py index dde3ff502cd..743995768eb 100644 --- a/dojo/product_type/signals.py +++ b/dojo/product_type/signals.py @@ -16,7 +16,9 @@ def product_type_post_save(sender, instance, created, **kwargs): create_notification(event="product_type_added", title=instance.name, product_type=instance, - url=reverse("view_product_type", args=(instance.id,))) + url=reverse("view_product_type", args=(instance.id,)), + url_api=reverse("product_type-detail", args=(instance.id,)), + ) @receiver(post_delete, sender=Product_Type) diff --git a/dojo/risk_acceptance/helper.py b/dojo/risk_acceptance/helper.py index d9f0b1584b2..a1d628b33df 100644 --- a/dojo/risk_acceptance/helper.py +++ b/dojo/risk_acceptance/helper.py @@ -175,30 +175,30 @@ def expiration_handler(*args, **kwargs): def expiration_message_creator(risk_acceptance, heads_up_days=0): - return "Risk acceptance [(%s)|%s] with %i findings has expired" % \ - (escape_for_jira(risk_acceptance.name), + return "Risk acceptance [({})|{}] with {} findings has expired".format( + escape_for_jira(risk_acceptance.name), get_full_url(reverse("view_risk_acceptance", args=(risk_acceptance.engagement.id, risk_acceptance.id))), len(risk_acceptance.accepted_findings.all())) def expiration_warning_message_creator(risk_acceptance, heads_up_days=0): - return "Risk acceptance [(%s)|%s] with %i findings will expire in %i days" % \ - (escape_for_jira(risk_acceptance.name), + return "Risk acceptance [({})|{}] with {} findings will expire in {} days".format( + escape_for_jira(risk_acceptance.name), get_full_url(reverse("view_risk_acceptance", args=(risk_acceptance.engagement.id, risk_acceptance.id))), len(risk_acceptance.accepted_findings.all()), heads_up_days) def reinstation_message_creator(risk_acceptance, heads_up_days=0): - return "Risk acceptance [(%s)|%s] with %i findings has been reinstated (expires on %s)" % \ - (escape_for_jira(risk_acceptance.name), + return "Risk acceptance [({})|{}] with {} findings has been reinstated (expires on {})".format( + escape_for_jira(risk_acceptance.name), get_full_url(reverse("view_risk_acceptance", args=(risk_acceptance.engagement.id, risk_acceptance.id))), len(risk_acceptance.accepted_findings.all()), timezone.localtime(risk_acceptance.expiration_date).strftime("%b %d, %Y")) def accepted_message_creator(risk_acceptance, heads_up_days=0): if risk_acceptance: - return "Finding has been added to risk acceptance [(%s)|%s] with %i findings (expires on %s)" % \ - (escape_for_jira(risk_acceptance.name), + return "Finding has been added to risk acceptance [({})|{}] with {} findings (expires on {})".format( + escape_for_jira(risk_acceptance.name), get_full_url(reverse("view_risk_acceptance", args=(risk_acceptance.engagement.id, risk_acceptance.id))), len(risk_acceptance.accepted_findings.all()), timezone.localtime(risk_acceptance.expiration_date).strftime("%b %d, %Y")) else: diff --git a/dojo/search/views.py b/dojo/search/views.py index 469cedec3a8..3e3a75923ca 100644 --- a/dojo/search/views.py +++ b/dojo/search/views.py @@ -154,7 +154,7 @@ def simple_search(request): tags = operators["tags"] if "tags" in operators else keywords not_tag = operators["not-tag"] if "not-tag" in operators else keywords not_tags = operators["not-tags"] if "not-tags" in operators else keywords - if search_tags and tag or tags or not_tag or not_tags: + if (search_tags and tag) or tags or not_tag or not_tags: logger.debug("searching tags") Q1, Q2, Q3, Q4 = Q(), Q(), Q(), Q() diff --git a/dojo/settings/.settings.dist.py.sha256sum b/dojo/settings/.settings.dist.py.sha256sum index f3047cd7eeb..a9470da626b 100644 --- a/dojo/settings/.settings.dist.py.sha256sum +++ b/dojo/settings/.settings.dist.py.sha256sum @@ -1 +1 @@ -d47510ff327c701251e96ef4f6145ed2cdad639180912876bfb2f5d1780e833d +d026c33432c4e67f3e3b7b292fdce92847eb1b895fe4037744b05228a2c5ff84 diff --git a/dojo/settings/settings.dist.py b/dojo/settings/settings.dist.py index 05aa96f3afa..89485de7d1e 100644 --- a/dojo/settings/settings.dist.py +++ b/dojo/settings/settings.dist.py @@ -282,6 +282,9 @@ # When disabled, existing user tokens will not be removed but it will not be # possible to create new and it will not be possible to use exising. DD_API_TOKENS_ENABLED=(bool, True), + # Enable endpoint which allow user to get API token when user+pass is provided + # It is useful to disable when non-local authentication (like SAML, Azure, ...) is in place + DD_API_TOKEN_AUTH_ENDPOINT_ENABLED=(bool, True), # You can set extra Jira headers by suppling a dictionary in header: value format (pass as env var like "headr_name=value,another_header=anohter_value") DD_ADDITIONAL_HEADERS=(dict, {}), # Set fields used by the hashcode generator for deduplication, via en env variable that contains a JSON string @@ -1272,6 +1275,8 @@ def saml2_attrib_map_format(dict): "Kiuwan SCA Scan": ["description", "severity", "component_name", "component_version", "cwe"], "Rapplex Scan": ["title", "endpoints", "severity"], "AppCheck Web Application Scanner": ["title", "severity"], + "Legitify Scan": ["title", "endpoints", "severity"], + "ThreatComposer Scan": ["title", "description"], "Checkmarx CxFlow SAST": ["vuln_id_from_tool", "file_path", "line"], } @@ -1495,6 +1500,8 @@ def saml2_attrib_map_format(dict): "Kiuwan SCA Scan": DEDUPE_ALGO_HASH_CODE, "Rapplex Scan": DEDUPE_ALGO_HASH_CODE, "AppCheck Web Application Scanner": DEDUPE_ALGO_HASH_CODE, + "Legitify Scan": DEDUPE_ALGO_HASH_CODE, + "ThreatComposer Scan": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, "Checkmarx CxFlow SAST": DEDUPE_ALGO_HASH_CODE, } diff --git a/dojo/templates/base.html b/dojo/templates/base.html index 765ec10dc55..722656ae6a9 100644 --- a/dojo/templates/base.html +++ b/dojo/templates/base.html @@ -541,6 +541,13 @@ {% trans "Notifications" %} + {% if system_settings.enable_webhooks_notifications and "dojo.view_notification_webhooks"|has_configuration_permission:request %} +
  • + + {% trans "Notification Webhooks" %} + +
  • + {% endif %}
  • {% trans "Regulations" %} diff --git a/dojo/templates/dojo/add_notification_webhook.html b/dojo/templates/dojo/add_notification_webhook.html new file mode 100644 index 00000000000..12056373af4 --- /dev/null +++ b/dojo/templates/dojo/add_notification_webhook.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% block content %} + {{ block.super }} +

    Add a new Notification Webhook

    +
    {% csrf_token %} + {% include "dojo/form_fields.html" with form=form %} +
    +
    + +
    +
    +
    +{% endblock %} diff --git a/dojo/templates/dojo/api_v2_key.html b/dojo/templates/dojo/api_v2_key.html index 71b9dd2d620..6b4d56e9338 100644 --- a/dojo/templates/dojo/api_v2_key.html +++ b/dojo/templates/dojo/api_v2_key.html @@ -15,9 +15,11 @@

    {{ name }}


    + {% if API_TOKEN_AUTH_ENDPOINT_ENABLED %}

    {% trans "Alternatively, you can use /api/v2/api-token-auth/ to get your token. Example:" %}

     curl -X POST -H 'content-type: application/json' {% if request.is_secure %}https{% else %}http{% endif %}://{{ request.META.HTTP_HOST }}/api/v2/api-token-auth/ -d '{"username": "<YOURUSERNAME>", "password": "<YOURPASSWORD>"}'
    + {% endif %}

    {% trans "To use your API Key you need to specify an Authorization header. Example:" %}

     # As a header
    diff --git a/dojo/templates/dojo/custom_html_report_endpoint_list.html b/dojo/templates/dojo/custom_html_report_endpoint_list.html
    index e259a2d4b46..aca9cd3bef9 100644
    --- a/dojo/templates/dojo/custom_html_report_endpoint_list.html
    +++ b/dojo/templates/dojo/custom_html_report_endpoint_list.html
    @@ -151,7 +151,7 @@ 
    References
    {{ finding.references|markdown_render }}
    {% endif %} {% if include_finding_images %} - {% include "dojo/snippets/file_images.html" with size='original' obj=finding format="HTML" %} + {% include "dojo/snippets/file_images.html" with size='original' obj=finding format="INLINE" %} {% endif %} {% if include_finding_notes %} {% with notes=finding.notes.all|get_public_notes %} diff --git a/dojo/templates/dojo/custom_html_report_finding_list.html b/dojo/templates/dojo/custom_html_report_finding_list.html index f92d180c9b3..13f33d03dca 100644 --- a/dojo/templates/dojo/custom_html_report_finding_list.html +++ b/dojo/templates/dojo/custom_html_report_finding_list.html @@ -154,7 +154,7 @@
    References
    {% endif %} {% if include_finding_images %} - {% include "dojo/snippets/file_images.html" with size='original' obj=finding format="HTML" %} + {% include "dojo/snippets/file_images.html" with size='original' obj=finding format="INLINE" %} {% endif %} {% if include_finding_notes %} {% with notes=finding.notes.all|get_public_notes %} diff --git a/dojo/templates/dojo/delete_notification_webhook.html b/dojo/templates/dojo/delete_notification_webhook.html new file mode 100644 index 00000000000..f196ad94fc9 --- /dev/null +++ b/dojo/templates/dojo/delete_notification_webhook.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} +{% block content %} +

    Delete Notification Webhook

    +
    {% csrf_token %} + {% include "dojo/form_fields.html" with form=form %} +
    +
    + +
    +
    +
    +{% endblock %} diff --git a/dojo/templates/dojo/edit_notification_webhook.html b/dojo/templates/dojo/edit_notification_webhook.html new file mode 100644 index 00000000000..94bd56c2307 --- /dev/null +++ b/dojo/templates/dojo/edit_notification_webhook.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} + {% block content %} + {{ block.super }} +

    Edit Notification Webhook

    +
    {% csrf_token %} + {% include "dojo/form_fields.html" with form=form %} +
    +
    + + +
    +
    +
    + {% endblock %} + \ No newline at end of file diff --git a/dojo/templates/dojo/notifications.html b/dojo/templates/dojo/notifications.html index 52d87393c45..81fac49d5cc 100644 --- a/dojo/templates/dojo/notifications.html +++ b/dojo/templates/dojo/notifications.html @@ -89,6 +89,9 @@

    {% if 'mail' in enabled_notifications %} {% trans "Mail" %} {% endif %} + {% if 'webhooks' in enabled_notifications %} + {% trans "Webhooks" %} + {% endif %} {% trans "Alert" %} diff --git a/dojo/templates/dojo/snippets/file_images.html b/dojo/templates/dojo/snippets/file_images.html index 1c7481e9162..8a559282e03 100644 --- a/dojo/templates/dojo/snippets/file_images.html +++ b/dojo/templates/dojo/snippets/file_images.html @@ -9,6 +9,15 @@

    Images

    No images found.

    {% endfor %} {% endwith %} +{% elif format == "INLINE" %} + {% with images=obj|file_images %} +
    Images
    + {% for pic in images %} +

    Finding Image

    + {% empty %} +

    No images found.

    + {% endfor %} + {% endwith %} {% else %} {% with images=obj|file_images %} {% for pic in images %} diff --git a/dojo/templates/dojo/system_settings.html b/dojo/templates/dojo/system_settings.html index 693abe712f0..02510452e16 100644 --- a/dojo/templates/dojo/system_settings.html +++ b/dojo/templates/dojo/system_settings.html @@ -62,7 +62,7 @@

    System Settings

    } $(function () { - $.each(['slack','msteams','mail', 'grade'], function (index, value) { + $.each(['slack','msteams','mail','webhooks','grade'], function (index, value) { updatenotificationsgroup(value); $('#id_enable_' + value + '_notifications').change(function() { updatenotificationsgroup(value)}); }); diff --git a/dojo/templates/dojo/view_notification_webhooks.html b/dojo/templates/dojo/view_notification_webhooks.html new file mode 100644 index 00000000000..6b02c0888d3 --- /dev/null +++ b/dojo/templates/dojo/view_notification_webhooks.html @@ -0,0 +1,101 @@ +{% extends "base.html" %} +{% load navigation_tags %} +{% load display_tags %} +{% load i18n %} +{% load authorization_tags %} +{% block content %} + {{ block.super }} +
    +{% endblock %} +{% block postscript %} + {{ block.super }} + {% include "dojo/filter_js_snippet.html" %} +{% endblock %} diff --git a/dojo/templates/dojo/view_product_details.html b/dojo/templates/dojo/view_product_details.html index b9c5a067fe8..076215121f5 100644 --- a/dojo/templates/dojo/view_product_details.html +++ b/dojo/templates/dojo/view_product_details.html @@ -461,7 +461,7 @@

    {% trans "Product Type" %} - {{ prod.prod_type|notspecified }} + {{ prod.prod_type }} {% trans "Platform" %} @@ -668,7 +668,7 @@