diff --git a/.github/workflows/update-sample-data.yml b/.github/workflows/update-sample-data.yml index 4b174f1211b..27a5ef9ac31 100644 --- a/.github/workflows/update-sample-data.yml +++ b/.github/workflows/update-sample-data.yml @@ -24,30 +24,20 @@ jobs: run: | scripts/fixture-updater.py dojo/fixtures/defect_dojo_sample_data.json mv output.json dojo/fixtures/defect_dojo_sample_data.json + ./fixture-updater dojo/fixtures/defect_dojo_sample_data_locations.json + mv output.json dojo/fixtures/defect_dojo_sample_data_locations.json - name: Configure git run: | git config --global user.name "${{ env.GIT_USERNAME }}" git config --global user.email "${{ env.GIT_EMAIL }}" - - name: Create and switch to a new branch - run: | - git checkout -b update-file-$(date +%Y%m%d%H%M%S) - git add dojo/fixtures/defect_dojo_sample_data.json - git commit -m "Update sample data" - - - name: Push branch - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - git push --set-upstream origin $(git rev-parse --abbrev-ref HEAD) - - name: Create Pull Request uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 with: token: ${{ secrets.GITHUB_TOKEN }} commit-message: "Update sample data" - branch: ${{ github.ref_name || 'dev'}} + branch: update-sample-data base: dev title: "Update sample data" body: "This pull request updates the sample data." \ No newline at end of file diff --git a/README.md b/README.md index 418226bf8ef..629ec93115c 100644 --- a/README.md +++ b/README.md @@ -40,25 +40,19 @@ and reset every day. Do not put sensitive data in the demo. An easy way to test ## Quick Start for Docker Compose ```sh -# Clone the project -git clone https://github.com/DefectDojo/django-DefectDojo -cd django-DefectDojo +git clone https://github.com/DefectDojo/django-DefectDojo && cd django-DefectDojo && docker compose up +``` -# Check if your installed toolkit is compatible -./docker/docker-compose-check.sh +This quick start guide will do the following -# Building Docker images -docker compose build +- Clone the repository and change directories +- Start the application +- Obtain admin credentials in the initializer logs. The first initialization can take up to 3 minutes to run. -# Run the application -# (see https://github.com/DefectDojo/django-DefectDojo/blob/dev/readme-docs/DOCKER.md for more info) -docker compose up -d -# Obtain admin credentials. The initializer can take up to 3 minutes to run. -# Use docker compose logs -f initializer to track its progress. -docker compose logs initializer | grep "Admin password:" -``` +if running DefectDojo in detached mode via `docker compose up -d`, obtain admin credentials from the initializer logs with the command below. Please note, the initializer can take up to 3 minutes to run. +`docker compose logs initializer | grep "Admin password:"` ## Documentation @@ -70,6 +64,7 @@ docker compose logs initializer | grep "Admin password:" * [LDAP](https://docs.defectdojo.com/en/open_source/ldap-authentication/) * [Supported tools](https://docs.defectdojo.com/en/connecting_your_tools/parsers/) * [How to Write Documentation Locally](/docs/README.md) +* [Development](readme-docs/DOCKER.md#run-with-docker-compose-in-development-mode-with-hot-reloading) ## Supported Installation Options diff --git a/docs/assets/images/pq_ss1.png b/docs/assets/images/pq_ss1.png new file mode 100644 index 00000000000..81e0b3c4f69 Binary files /dev/null and b/docs/assets/images/pq_ss1.png differ diff --git a/docs/assets/images/pq_ss10.png b/docs/assets/images/pq_ss10.png new file mode 100644 index 00000000000..329ea9516c2 Binary files /dev/null and b/docs/assets/images/pq_ss10.png differ diff --git a/docs/assets/images/pq_ss11.png b/docs/assets/images/pq_ss11.png new file mode 100644 index 00000000000..cb5a4487abf Binary files /dev/null and b/docs/assets/images/pq_ss11.png differ diff --git a/docs/assets/images/pq_ss12.png b/docs/assets/images/pq_ss12.png new file mode 100644 index 00000000000..ba99a4e717e Binary files /dev/null and b/docs/assets/images/pq_ss12.png differ diff --git a/docs/assets/images/pq_ss13.png b/docs/assets/images/pq_ss13.png new file mode 100644 index 00000000000..1083a32798b Binary files /dev/null and b/docs/assets/images/pq_ss13.png differ diff --git a/docs/assets/images/pq_ss2.png b/docs/assets/images/pq_ss2.png new file mode 100644 index 00000000000..4bac6328b74 Binary files /dev/null and b/docs/assets/images/pq_ss2.png differ diff --git a/docs/assets/images/pq_ss3.png b/docs/assets/images/pq_ss3.png new file mode 100644 index 00000000000..f7dd8b38fd6 Binary files /dev/null and b/docs/assets/images/pq_ss3.png differ diff --git a/docs/assets/images/pq_ss4.png b/docs/assets/images/pq_ss4.png new file mode 100644 index 00000000000..ebb2c1650cf Binary files /dev/null and b/docs/assets/images/pq_ss4.png differ diff --git a/docs/assets/images/pq_ss5.png b/docs/assets/images/pq_ss5.png new file mode 100644 index 00000000000..837c8b4c6c1 Binary files /dev/null and b/docs/assets/images/pq_ss5.png differ diff --git a/docs/assets/images/pq_ss6.png b/docs/assets/images/pq_ss6.png new file mode 100644 index 00000000000..eb1ec9bc340 Binary files /dev/null and b/docs/assets/images/pq_ss6.png differ diff --git a/docs/assets/images/pq_ss7.png b/docs/assets/images/pq_ss7.png new file mode 100644 index 00000000000..3548c814f76 Binary files /dev/null and b/docs/assets/images/pq_ss7.png differ diff --git a/docs/assets/images/pq_ss8.png b/docs/assets/images/pq_ss8.png new file mode 100644 index 00000000000..2f5ce72ae81 Binary files /dev/null and b/docs/assets/images/pq_ss8.png differ diff --git a/docs/assets/images/pq_ss9.png b/docs/assets/images/pq_ss9.png new file mode 100644 index 00000000000..745f48be258 Binary files /dev/null and b/docs/assets/images/pq_ss9.png differ diff --git a/docs/assets/images/q_ss1.png b/docs/assets/images/q_ss1.png new file mode 100644 index 00000000000..5b1d79c8119 Binary files /dev/null and b/docs/assets/images/q_ss1.png differ diff --git a/docs/assets/images/q_ss10.png b/docs/assets/images/q_ss10.png new file mode 100644 index 00000000000..48537440a48 Binary files /dev/null and b/docs/assets/images/q_ss10.png differ diff --git a/docs/assets/images/q_ss11.png b/docs/assets/images/q_ss11.png new file mode 100644 index 00000000000..4699f599fd3 Binary files /dev/null and b/docs/assets/images/q_ss11.png differ diff --git a/docs/assets/images/q_ss12.png b/docs/assets/images/q_ss12.png new file mode 100644 index 00000000000..383898c2413 Binary files /dev/null and b/docs/assets/images/q_ss12.png differ diff --git a/docs/assets/images/q_ss13.png b/docs/assets/images/q_ss13.png new file mode 100644 index 00000000000..86eb9084dfd Binary files /dev/null and b/docs/assets/images/q_ss13.png differ diff --git a/docs/assets/images/q_ss14.png b/docs/assets/images/q_ss14.png new file mode 100644 index 00000000000..757bc2b50e4 Binary files /dev/null and b/docs/assets/images/q_ss14.png differ diff --git a/docs/assets/images/q_ss15.png b/docs/assets/images/q_ss15.png new file mode 100644 index 00000000000..6a98bbfca32 Binary files /dev/null and b/docs/assets/images/q_ss15.png differ diff --git a/docs/assets/images/q_ss16.png b/docs/assets/images/q_ss16.png new file mode 100644 index 00000000000..091d069f623 Binary files /dev/null and b/docs/assets/images/q_ss16.png differ diff --git a/docs/assets/images/q_ss17.png b/docs/assets/images/q_ss17.png new file mode 100644 index 00000000000..a187c735fe2 Binary files /dev/null and b/docs/assets/images/q_ss17.png differ diff --git a/docs/assets/images/q_ss18.png b/docs/assets/images/q_ss18.png new file mode 100644 index 00000000000..d4ae33809be Binary files /dev/null and b/docs/assets/images/q_ss18.png differ diff --git a/docs/assets/images/q_ss2.png b/docs/assets/images/q_ss2.png new file mode 100644 index 00000000000..4c2e1de5967 Binary files /dev/null and b/docs/assets/images/q_ss2.png differ diff --git a/docs/assets/images/q_ss3.png b/docs/assets/images/q_ss3.png new file mode 100644 index 00000000000..3634ef584e7 Binary files /dev/null and b/docs/assets/images/q_ss3.png differ diff --git a/docs/assets/images/q_ss4.png b/docs/assets/images/q_ss4.png new file mode 100644 index 00000000000..e30fbd57a83 Binary files /dev/null and b/docs/assets/images/q_ss4.png differ diff --git a/docs/assets/images/q_ss5.png b/docs/assets/images/q_ss5.png new file mode 100644 index 00000000000..cabfec47103 Binary files /dev/null and b/docs/assets/images/q_ss5.png differ diff --git a/docs/assets/images/q_ss6.png b/docs/assets/images/q_ss6.png new file mode 100644 index 00000000000..2890b620ce3 Binary files /dev/null and b/docs/assets/images/q_ss6.png differ diff --git a/docs/assets/images/q_ss7.png b/docs/assets/images/q_ss7.png new file mode 100644 index 00000000000..2f774fff14c Binary files /dev/null and b/docs/assets/images/q_ss7.png differ diff --git a/docs/assets/images/q_ss8.png b/docs/assets/images/q_ss8.png new file mode 100644 index 00000000000..eba28bb7c1e Binary files /dev/null and b/docs/assets/images/q_ss8.png differ diff --git a/docs/assets/images/q_ss9.png b/docs/assets/images/q_ss9.png new file mode 100644 index 00000000000..854cf2b616c Binary files /dev/null and b/docs/assets/images/q_ss9.png differ diff --git a/docs/config/development/params.toml b/docs/config/development/params.toml new file mode 100644 index 00000000000..7dddc361436 --- /dev/null +++ b/docs/config/development/params.toml @@ -0,0 +1,6 @@ +# Development overrides — reduce image processing to avoid WebPEncode OOM errors. +# The full responsive set (6 widths + LQIP) is applied in production only. +[thulite_images] + [thulite_images.defaults] + widths = [480, 1200] + lqip = "" diff --git a/docs/content/admin/user_management/user_permission_chart.md b/docs/content/admin/user_management/user_permission_chart.md index 15a63d1afae..07c4d343ce9 100644 --- a/docs/content/admin/user_management/user_permission_chart.md +++ b/docs/content/admin/user_management/user_permission_chart.md @@ -63,10 +63,12 @@ The majority of Configuration Permissions give users access to certain pages in | Login Banner | n/a | n/a | Edit the login banner, located under **⚙️Configuration \> Login Banner** | n/a | | Announcements | n/a | n/a | Configure Announcements, located under **⚙️Configuration \> Announcements** | n/a | | Note Types | Access the ⚙️Configuration \> Note Types page | Add a Note Type | Edit a Note Type | Delete a Note Type | +| Prioritization Engines | Access the Prioritization Engine configuration page | Add a new Prioritization Engine | Edit an existing Prioritization Engine | Delete a Prioritization Engine | | Product Types | n/a | Add a new Product Type (under Products \> Product Type) | n/a | n/a | | Questionnaires | Access the **Questionnaires \> All Questionnaires** page | Add a new Questionnaire | Edit an existing Questionnaire | Delete a Questionnaire | | Questions | Access the **Questionnaires \> Questions** page | Add a new Question | Edit an existing Question | n/a | | Regulations | n/a | Add a Regulation to the **⚙️Configuration \> Regulations** page | Edit an existing Regulation | Delete a Regulation | +| Scheduling Service Schedule | Access the **Scheduling** page | Superuser only | Edit an existing Schedule (change trigger, enable/disable) | Delete a Schedule | | SLA Configuration | Access the **⚙️Configuration \> SLA Configuration** page | Add a new SLA Configuration | Edit an existing SLA Configuration | Delete an SLA Configuration | | Test Types | n/a | Add a new Test Type (under **Engagements \> Test Types**) | Edit an existing Test Type | n/a | | Tool Configuration | Access the **⚙️Configuration \> Tool Configuration** page | Add a new Tool Configuration | Edit an existing Tool Configuration | Delete a Tool Configuration | diff --git a/docs/content/asset_modelling/OS_questionnaires/OS__questionnaires.md b/docs/content/asset_modelling/OS_questionnaires/OS__questionnaires.md new file mode 100644 index 00000000000..1414ce31612 --- /dev/null +++ b/docs/content/asset_modelling/OS_questionnaires/OS__questionnaires.md @@ -0,0 +1,274 @@ +--- +title: "Questionnaires" +description: "Understanding Questionnaires in OS DefectDojo" +audience: opensource +weight: 2 +--- + +In DefectDojo, a Questionnaire is a reusable set of questions that collects information from developers, teams, and both internal and external stakeholders. They can be used to gather input before work begins, ensure alignment between individuals and teams as work progresses, and enable retrospective analysis once work has been completed. + +## Questionnaire Templates + +A Questionnaire template defines the structure and content of the Questionnaire, including its name, description, and associated Questions. Creating a Questionnaire template does not automatically make it available for responses. To collect responses, a Questionnaire template must be deployed as either a **General Questionnaire** or a **Linked Questionnaire**. + +### General and Linked Questionnaires + +General and Linked Questionnaires differ in several ways, including how they are distributed, who can respond, and where responses are stored. + +| General Questionnaires | Linked Questionnaires | +|---|---| +| Require publication | Don't require publication | +| Require an expiration date | Remain active if Engagement is still active | +| Permit anonymous responses | Don't permit anonymous responses | +| Are shareable internally and externally | Are only shareable internally | +| Do not permit changing responses | Permit changing responses | +| Responses are only visible upon expiration | Responses are visible immediately | +| Responses are visible in "All Questionnaires" | Responses are visible within the Engagement | +| Can be converted into an Engagement | Is already linked to an Engagement | + +#### Questionnaire Deployment Lifecycle + +Questionnaire templates follow different lifecycles depending on deployment type: + +**General Questionnaires** +Template → Published → Accept Responses → Expire → Optional Conversion to Engagement + +**Linked Questionnaires** +Template → Linked to Engagement → Accept Responses → Remain active while Engagement is active + +#### Response Separation + +A single Questionnaire template can be deployed multiple times simultaneously, both as a General and Linked Questionnaires. Each deployment creates its own independent set of responses. + +If the same Questionnaire template is deployed as a General Questionnaire and is also linked to an Engagement, responses submitted through each deployment are stored independently and are not combined. This allows the same Questionnaire template to be reused across different contexts while separating response sets. + +## Accessing Questionnaires and Questions + +Questionnaires and Questions can be accessed from the sidebar by clicking the **Questionnaires** option. The submenu provides access to **All Questionnaires** and **All Questions**. + +![image](images/q_ss1.png) + +Notably, access to the All Questionnaires and All Questions views is restricted to Users with Superuser status. Only Superusers can create Questionnaire templates, create Questions, and deploy Questionnaires. Users without Superuser status can still respond to General Questionnaires that are shared with them and also respond to the Linked Questionnaires of Engagements they have access to, but they cannot create or manage them. + +### Questionnaires + +The view for All Questionnaires includes two tables: +- **Questionnaires** + - This section includes all existing Questionnaire templates. +- **General Questionnaires** + - This section includes all General Questionnaires that are currently open to responses. + +Both sections can be filtered by name, description, or active status. + +### Questions + +The view of All Questions includes a table of Questions that can currently be added to a Questionnaire. It can also be filtered by each Questions’ optional status, contents, or question type (e.g., text question or multiple-choice question). + +## Managing Questionnaire Templates + +### Create Questionnaires + +New Questionnaires can be created using the Create Questionnaire button in the All Questionnaires view. + +![image](images/q_ss2.png) + +After including a name and description, the Questionnaire can either be created without Questions (which can be added later) or Questions can be added immediately. + +#### Immediately Add Questions to a New Questionnaire + +If Questions are being added immediately, select all applicable Questions from the ensuing dropdown menu. You may also create a new Question to add to the Questionnaire by clicking the + sign to the right of the dropdown menu. + +![image](images/q_ss12.png) + +Once all applicable Questions have been selected, click **Update Questionnaire Questions** to add all selected Questions to the Questionnaire. + +#### Add Questions to a Pre-Existing Questionnaire + +To add Questions to a pre-existing Questionnaire, click the Questionnaire name in the Questionnaires table, click **Edit Questions**, select any new Questions to add to the Questionnaire from the dropdown menu, and then click **Update Questionnaire Questions**. + +### Create Questions + +New Questions can be created using the **Create Question** button in the All Questions view. + +![image](images/q_ss3.png) + +Additionally, Questions can also be created when deciding which Questions to add to a Questionnaire by clicking the + sign to the right of the dropdown menu. + +#### Question Types + +When creating a new Question, it can be formatted as either a text-based question or as a multiple-choice question by selecting either **Text** or **Choice** from the dropdown menu. + +#### Allowing Multiple Answers and Optional Answers + +The maximum number of allowable answers in a multiple-choice question is six. Clicking the **Multichoice** checkbox allows multiple answers to be selected (only available for multiple-choice questions). Questions may also be marked as **Optional** by clicking the corresponding checkbox. + +See the [Editing Questions](#editing-questions) section for how to add additional answers to a multiple-choice question. + +#### Question Order + +Determine the order of a Question by giving it an order number. For example, if a Question has 1 in the Order field, that Question will appear above a Question with 2 in the Order field. + +![image](images/q_ss13.png) + +### Editing Questions + +Once a Question has been created, it can be edited by accessing the All Questions submenu and clicking the Question to be changed. Questions can’t be deleted. + +It is important to avoid editing Questions that are a part of active Questionnaires. If any part of a Question is changed (e.g., order, optional status, correcting a typo, adding a possible answer, etc.) and that Question was a part of an active Questionnaire that has already had responses submitted, all previously submitted responses will be invalidated and responses will need to be resubmitted. + +#### Editing Text Questions + +After creation, the only changes that can be made to text-based Questions are the order, the optional status, and the phrasing of the question. + +#### Editing Multiple-Choice Questions + +While the default number of possible answers to a multiple-choice question is six, this can be increased after the Questionnaire has been created. To do so, click the Question in the All Questions view, click the **+** sign to the right of the Choices dropdown menu, add the new answer, and click **Submit**. + +![image](images/q_ss16.png) + +![image](images/q_ss17.png) + +The newly created option will not be added to the Questionnaire automatically. To add it, click the **Choices** dropdown menu and select the newly added option. A check mark will appear next to it indicating that it is now included as a possible answer in the Questionnaire. + +![image](images/q_ss18.png) + +## Deploying Questionnaires + +Once a Questionnaire template has been successfully created, it can be deployed to accept responses. The deployment process is slightly different depending on the Questionnaire type. + +### General Questionnaire Deployment + +In order to deploy a General Questionnaire: +1. Navigate to the All Questionnaires view. +2. Click the **+** on the right side of the General Questionnaires table. +3. Select the Questionnaire to be deployed. +4. Set the expiration date. +5. Click **Add Questionnaire**. + +#### Sharing a General Questionnaire + +Once deployed, a General Questionnaire can be shared by clicking **Share Questionnaire** from within the Actions column of the General Questionnaires table. This will generate a link that you can share with the intended recipients as well as confirm that the Questionnaire is formatted as intended before doing so. + +![image](images/q_ss14.png) + +Note the following: +- Any responses to a General Questionnaire will not be viewable until the Questionnaire has expired. +- It is not possible to change the expiration date once the Questionnaire has been published. +- The default time when a Questionnaire will expire is midnight (e.g., Questionnaire with an Expiration of December 31, 2026, will only be viewable until 11:59:59 on that date). +- It is not possible to set a custom expiration time. + +See [Enabling Anonymous Responses](#enabling-anonymous-responses) below regarding permitting responses from external Users. + +### Linked Questionnaire Deployment + +In order to deploy a Linked Questionnaire: +1. Navigate to the Engagement that will be linked to the Questionnaire. +2. Click the down arrow on the **Additional Features** table. +3. Click the **+** on the right side of the Questionnaires subtable. +4. Select the Questionnaire to be linked from the dropdown menu. +5. Click **Add Questionnaire** or **Add Questionnaire and Respond**. + +The Linked Questionnaire will now be active for any Users with access to the Engagement. + +#### Sharing a Linked Questionnaire + +To share the Linked Questionnaire directly with internal DefectDojo Users, click the ⋮ kebab menu and select **Share Questionnaire** from the dropdown. A link will appear which can be copied and forwarded to the intended recipient. + +![image](images/q_ss10.png) + +As mentioned, Linked Questionnaires can only be shared with DefectDojo Users. + +## Responding to Questionnaires + +The response workflow differs slightly depending on whether the Questionnaire is a General or Linked Questionnaire. + +### Responding to a General Questionnaire + +To respond to a General Questionnaire, non-Superusers must have the link shared with them directly by a Superuser, as outlined [here](#sharing-a-general-questionnaire). + +#### Enabling Anonymous Responses + +By default, General Questionnaires are only accessible by DefectDojo Users. To allow external parties to respond to DefectDojo Questionnaires, ensure the **Allow Anonymous Survey Responses** option has been toggled in the System Settings, which is found within the **Configurations** section of the sidebar. + +![image](images/q_ss4.png) + +![image](images/q_ss5.png) + +External responses will appear as anonymous because there is no DefectDojo user ID associated with the response. + +If the scope of a Questionnaire includes both internal and external Users, create a General Questionnaire and specify the Engagement name in the description upon creation, which will permit filtering of the results. + +![image](images/q_ss8.png) + +![image](images/q_ss9.png) + +### Responding to Linked Questionnaires + +To respond to a Linked Questionnaire: +1. Navigate to the Engagement view. +2. Expand the Additional Features table. +3. Expand the Questionnaires subtable. +4. Click the ⋮ kebab menu of the Linked Questionnaire. +5. Click **Answer Questionnaire**. + +![image](images/q_ss15.png) + +Linked Questionnaires do not permit external/anonymous responses because DefectDojo access is required in order to access the Engagement. + +## Responses + +As mentioned, each deployment of a Questionnaire template creates its own response container. Linking the same Questionnaire template to multiple Engagements results in separate response sets, and publishing a General Questionnaire does not affect response sets of Linked Questionnaires. + +### General Questionnaire Responses + +Once a General Questionnaire’s expiration has passed: +- It will no longer be possible to submit additional responses. +- All prior responses will be saved and will become viewable. +- The Questionnaire will be listed as an Unassigned Answered Engagement Questionnaire on the DefectDojo dashboard. + +There are three actions that can be taken when a Questionnaire’s response window has closed: **View Responses**, **Create Engagement**, and **Assign User**. + +#### Viewing Questionnaire Responses + +Selecting **View Responses** will display all responses from the Questionnaire. + +#### Creating an Engagement from a Questionnaire + +Upon expiration, a General Questionnaire can be connected to an Asset via an Engagement by selecting the **Create Engagement** action. Select an Asset from the ensuing dropdown list and click **Create Engagement**. A new Engagement can then be created and given specific details similar to other Engagements in DefectDojo, such as Description, Version, Status, Tags, etc. + +![image](images/q_ss6.png) + +![image](images/q_ss7.png) + +#### Assign User + +The Assign User action will prompt for a User to be selected from the dropdown of available Users. Select a User from the dropdown menu and click **Assign Questionnaire**, which will make them the owner of that Questionnaire. + +### Linked Questionnaire Responses + +Linked Questionnaires remain available while the associated Engagement is active. As such, responses are viewable at any time. + +The ⋮ kebab menu of a Linked Questionnaire includes several functions to manage the Questionnaire and any responses: +- **Answer Questionnaire**: This option will appear if a User has not yet answered the Linked Questionnaire. Once answered, View Responses and Edit Responses will appear. +- **View responses**: Permits Users to see all responses for the Questionnaire to date. +- **Edit Responses**: Allows individual Users to edit their prior Responses. +- **Assign User**: Assigns the questionnaire to a User. +- **Link to a Different Engagement**: Opens a dropdown menu of other Engagements to assign the Questionnaire to. +- **Share Questionnaire**: Generates a link to share the Questionnaire with internal Users. +- **Delete Questionnaire**: Will unlink the Questionnaire from the Engagement and delete any previously gathered responses. + +## Deleting Questionnaires + +Deleting General and Linked Questionnaires has different downstream effects depending on the intended outcome of the deletion. + +### Deleting General Questionaires + +Deleting a General Questionnaire from the General Questionnaires table in the All Questionnaires section will delete all responses that were collected from that deployment prior to deletion. Any Linked Questionnaires that used the same Questionnaire template will not be deleted. + +### Deleting Linked Questionnaires + +Deleting a Linked Questionnaire will unlink the Questionnaire from the Engagement. All responses that were collected from within the Engagement prior to deletion will be lost. General Questionnaires that had been deployed previously using the same Questionnaire template will not be affected. + +### Deleting Questionnaire Templates + +In order to fully delete a Questionnaire template, select it from the Questionnaires table in the All Questionnaires view and click **Delete Questionnaire**. This permanently deletes the Questionnaire template and all associated responses from all deployments. This action cannot be undone. \ No newline at end of file diff --git a/docs/content/asset_modelling/OS_questionnaires/_index.md b/docs/content/asset_modelling/OS_questionnaires/_index.md new file mode 100644 index 00000000000..875f7011285 --- /dev/null +++ b/docs/content/asset_modelling/OS_questionnaires/_index.md @@ -0,0 +1,9 @@ +--- +title: "Questionnaires" +date: 2021-02-02T20:46:29+01:00 +draft: false +type: docs +weight: 1 +exclude_search: true +audience: opensource +--- \ No newline at end of file diff --git a/docs/content/asset_modelling/PRO_surveys/PRO__surveys.md b/docs/content/asset_modelling/PRO_surveys/PRO__surveys.md new file mode 100644 index 00000000000..8a13f3d73e2 --- /dev/null +++ b/docs/content/asset_modelling/PRO_surveys/PRO__surveys.md @@ -0,0 +1,147 @@ +--- +title: "Surveys" +description: "Understanding Surveys in DefectDojo Pro" +audience: pro +weight: 2 +--- + +In DefectDojo, a Survey template is a reusable set of Questions that functions to collect information from developers, teams, and both internal and external stakeholders. They can be used to gather input before work begins, ensure alignment between individuals and teams as work progresses, and enable retrospective analysis once work has been completed. + +In DefectDojo, a Survey system consists of three components: +- **Survey templates**, which group and order the Questions. +- **Survey deployments**, which are active instances that collect responses. +- **Responses**, which are the answers submitted by Users. + +Creating a Survey template does not automatically make it available for responses. To collect responses, a Survey template must be deployed. + +## Permissions + +The Surveys section in the sidebar is only visible to Users with Superuser status, and only Superusers can create Survey templates, create Questions, and deploy Surveys. + +Users without Superuser status can still respond to Surveys that are shared with them, but they cannot create or manage them or their associated Questions. + +## Accessing Surveys and Questions + +Users with Superuser status can access Surveys and Questions from the sidebar by clicking the **Surveys** option. The submenu provides access to **All Surveys** and **All Questions**, as well as the option to create new Surveys and Questions. + +![image](images/pq_ss1.png) + +### Accessing Surveys + +The view for All Surveys includes a table containing all Survey templates, including their ID, name, description, and active status. The table can be filtered using keywords, and it can be reorganized by clicking the header of each column. + +### Accessing Questions + +The view of All Questions includes a table of Questions that can be added to a Survey. The table can be filtered using keywords, and it can be reorganized by clicking the header of each column. + +## Managing Survey Templates + +### Create Survey Templates + +Survey templates can either be created by clicking **New Survey** in the sidebar, or by clicking the **New Survey** button at the top of the All Surveys view. + +![image](images/pq_ss2.png) + +The Survey template must be given a name and description and have at least one Question chosen from the dropdown menu before being created. + +#### Add Questions to a Pre-Existing Survey Template + +To add Questions to a pre-existing Survey template, click the ⋮ kebab icon to the left of the desired Survey, click **Edit Survey**, select any new Questions to be added to the Survey from the dropdown menu, and then click **Submit**. + +As a best practice, it is strongly recommended to avoid modifying or adding Questions to a Survey template while it has active deployments. Adding new Questions will not affect existing Responses, but those Responses will have been submitted without answering the newly added Questions, which may result in incomplete data. + +### Create Questions + +Similar to Survey templates, Questions can either be created by clicking **New Question** in the sidebar, or by clicking the **New Question** button at the top of the All Questions view. + +#### Question Types + +When creating a new Question, it can be formatted as either a text-based question or as a multiple-choice question by selecting **Text Question** or **Choice Question** at the top of the New Question view. + +![image](images/pq_ss3.png) + +#### Question Order + +Determine the order of a Question by giving it an order number. For example, if a Question has 1 in the Order field, that Question will appear above a Question with 2 in the Order field. + +#### Optional Answers + +Both text-based questions and multiple-choice questions can be toggled as **Optional** by clicking the corresponding checkbox. + +#### Allowing Multiple Answers + +An unlimited number of potential responses can be added to a multiple-choice question. Clicking the **Allow Multiple Selections** checkbox allows multiple answers to be selected (only available for multiple-choice questions). + +### Editing Questions + +To change a Question, navigate to the All Questions view, click the ⋮ kebab icon to the left of the Question to be changed, click Edit Question, make the desired change, and finalize the change by clicking Submit. Questions can’t be deleted. + +![image](images/pq_ss4.png) + +It is important to avoid editing Questions that are a part of active Questionnaires or adding Questions to active Questionnaires. Doing so will not affect any responses that had been previously collected, but it may result in incomplete or unreliable data. + +## Deploying Surveys + +Once a Survey template has been successfully created, deploying a Survey creates an active instance that accepts responses. + +To deploy a Survey, navigate to the All Surveys view, click the ⋮ kebab icon to the left of the Survey to be deployed, click **Open Survey**, set the expiration date, and click Submit. + +If you wish to deploy the same Survey again, follow the same process. All deployments will appear within the Open Survey Instances table in the Survey’s view, and can be distinguished by their ID, creation time, and expiration date. + +![image](images/pq_ss10.png) + +A Survey will close on the chosen date at the same time it was deployed. For example, if you deploy a Survey at 8:00 am on February 1, 2026, and schedule it to close on March 1, 2026, the survey will close at 8:00 am on the morning of March 1, 2026. + +Once a Survey has been opened, its expiration date and time cannot be changed. If a different timeframe is required, a new deployment must be created. + +Once an expiration date has passed, it will no longer be possible to submit responses to that deployment of the Survey, but the deployment will still appear in the Open Survey Instances table of that Survey’s view. + +#### Sharing a Survey + +Once a Survey has been deployed, it can be shared with other Users by clicking the ↗ icon to the left of the Survey within the Open Survey Instances table in the Survey template’s view. This will reveal a link that is unique to that deployment that can be copied and shared with the intended recipients. + +![image](images/pq_ss5.png) + +![image](images/pq_ss9.png) + +#### Closing a Survey + +In order to close a Survey, click the red **X** to the left of the Survey within the Open Survey Instances table in the Survey template’s view. + +![image](images/pq_ss13.png) + +As noted in the later Responses section, this will only prevent further responses from being submitted. Responses that were submitted previously will remain visible within the Responses table at the bottom of the Survey template’s view. + +## Responding to Surveys + +To respond to a Survey, non-Superusers must have the link shared with them directly using the instructions in the [Sharing a Survey](#sharing-a-survey) section above. Superusers can also respond using the same link. + +#### Enabling Anonymous Responses + +By default, Surveys are only accessible by DefectDojo Users. To allow external parties to respond to DefectDojo Surveys, ensure the **Enable Anonymous Survey Responses** option has been toggled in the **System Settings**, which is found within the **Pro Settings** submenu within the sidebar. + +![image](images/pq_ss6.png) + +External responses will appear as anonymous because there is no DefectDojo user ID associated with the response. + +If the scope of a Survey includes both internal and external Users, specify the Engagement name in the description upon creation, which will permit filtering of the results. + +![image](images/pq_ss7.png) + +![image](images/pq_ss8.png) + +## Managing Responses + +A single Survey template can be deployed multiple times simultaneously. All responses to multiple deployments of the same Survey template will be displayed together in the Responses table at the bottom of that Survey’s view. + +![image](images/pq_ss11.png) + +Even after a Survey deployment has expired or been closed, its responses remain visible in the Responses table at the bottom of the Survey’s view, provided the Survey template itself has not been deleted. These responses are permanent and cannot be removed. + +As shown in the image below, there are no currently open Survey deployments, yet responses from prior deployments are still present in the Responses table. + +![image](images/pq_ss12.png) + +### Deleting Survey Templates + +To delete a Survey Template, navigate to the All Surveys view, click the ⋮ kebab icon to the left the chosen Survey, and click **Delete Survey**. This permanently deletes the Survey template and all associated deployments and Responses. This action cannot be undone. \ No newline at end of file diff --git a/docs/content/asset_modelling/PRO_surveys/_index.md b/docs/content/asset_modelling/PRO_surveys/_index.md new file mode 100644 index 00000000000..73922cb78af --- /dev/null +++ b/docs/content/asset_modelling/PRO_surveys/_index.md @@ -0,0 +1,9 @@ +--- +title: "Surveys" +date: 2021-02-02T20:46:29+01:00 +draft: false +type: docs +weight: 1 +exclude_search: true +audience: pro +--- \ No newline at end of file diff --git a/docs/content/automation/rules_engine/about.md b/docs/content/automation/rules_engine/about.md index 150d889ae04..95fa08f5d6d 100644 --- a/docs/content/automation/rules_engine/about.md +++ b/docs/content/automation/rules_engine/about.md @@ -14,17 +14,28 @@ Rules Engine can only be accessed through the [Pro UI](/get_started/about/ui_pro Currently, Rules can only be created for Findings, however more object types will be supported in the future. -Rules always need to be manually triggered from the **All Rules** page. When a rule is triggered, it will be applied to all existing Findings that match the filter conditions set. +Rules can be triggered manually from the **All Rules** page, or scheduled to run automatically on a recurring schedule. When a rule is triggered, it will be applied to all existing Findings that match the filter conditions set. ## Possible Rule Actions Each Rule can apply one or more of these changes to a Finding when it is triggered successfully (i.e. matches the set Filter conditions). -* Modify or append one or more informational fields on a Finding, including Title, Description, Severity, CVSSv3 Vector, Active, Verified, Risk Accepted, False Positive, Mitigated -* Set a User to Review a Finding -* Assign a Group as Owners for a Finding -* Add Tags to a Finding -* Add a Note to a Finding -* Create an Alert in DefectDojo with custom text +### Field Modifications +* **Set a field** on a Finding, including Title, Description, Severity, CVSSv3 Vector, Active, Verified, Risk Accepted, False Positive, Mitigated +* **Append or Prepend text** to a Finding's Title or Description +* **Set Priority** — override the calculated Priority value on a Finding (overrides automatic priority calculation) +* **Set Risk** — override the calculated Risk level on a Finding (overrides automatic risk calculation) +* **Add, Subtract, Multiply, or Divide** the Priority value on a Finding by a given number + +### Assignments & Ownership +* **Set a User to Review** a Finding +* **Assign a Group as Owners** for a Finding +* **Set a Mitigation Policy** on a Finding — assigns a pre-configured Mitigation Policy to the Finding +* **Add to Risk Acceptance** — adds a Finding to an existing Risk Acceptance record (sets risk_accepted=True, active=False, and handles Jira integration and endpoint statuses) + +### Tags, Notes & Alerts +* **Add Tags** to a Finding +* **Add a Note** to a Finding +* **Create an Alert** in DefectDojo with custom text ### Filter conditions Rules are automatically triggered when a Finding meets specific Filter conditions. For more information on Filters that can be used to create Rule Actions, see the [Filter Index](/navigation/pro__filter_index) page. diff --git a/docs/content/automation/rules_engine/scheduling.md b/docs/content/automation/rules_engine/scheduling.md new file mode 100644 index 00000000000..bf96d7b8469 --- /dev/null +++ b/docs/content/automation/rules_engine/scheduling.md @@ -0,0 +1,51 @@ +--- +title: "Scheduling Rules" +description: "Automatically run Rules Engine rules on a recurring or one-time schedule" +weight: 2 +audience: pro +--- +Note: Rules Engine Scheduling is a DefectDojo Pro-only feature. + +Rules can be scheduled to run automatically rather than triggered manually each time. A scheduled rule will execute against all Findings that match its filter conditions at the configured time. + +The user setting up the schedule must have the **Change Scheduling Service Schedule** configuration permission. + +## Schedule Types + +### Single Run + +A Single Run schedule executes the rule once at a specific date and time. After the run completes, the schedule is not repeated. + +### Repeated Run + +A Repeated Run schedule allows you to trigger a rule on a recurring basis — for example, every day at 9:00 AM, or every Monday at 15:00. + +**Note:** Rules Engine schedules are limited to quarter-hour marks. The minute field of a cron schedule must be one of: **0, 15, 30, or 45**. Other minute values are not permitted. + +Examples of valid schedules: +- Every hour on the hour: `0 * * * *` +- Every day at 9:15 AM: `15 9 * * *` +- Every Monday at 3:00 PM: `0 15 * * 1` +- Every 15 minutes: `0,15,30,45 * * * *` + +## Creating a Schedule for a Rule + +1. Navigate to the **All Rules** page from the **Rules Engine** menu in the sidebar. +2. Find the rule you want to schedule, and open its action menu (**⋮**). +3. Click **Schedule Rule**. This option is only visible if the Scheduling Service is enabled and you have the required permission. +4. In the **Schedule Rule** modal, fill in the following fields: + +| Field | Description | +|---|---| +| **Name** | A unique name for this schedule (required, max 100 characters). | +| **Description** | Optional description of the schedule's purpose. | +| **Trigger Type** | Choose **Single Run** for a one-time execution, or **Repeated Run** for a recurring cron schedule. | +| **Frequency** | For Repeated Run: use the cron builder to select the period (hourly, daily, weekly, etc.) and the specific minute, hour, and day values. For Single Run: select a date and time using the date picker. | +| **Enable Schedule** | Toggle to enable or disable the schedule. A disabled schedule will not run until re-enabled. | + +5. Click **Submit** to save the schedule. The rule will run automatically at the next scheduled time. + + +## Permissions + +Access to scheduling within Rules Engine requires Superuser permissions or the appropriate Configuration Permission. See [User Permission Chart](/admin/user_management/user_permission_chart) for details. diff --git a/docs/content/get_started/about/demo.md b/docs/content/get_started/about/demo.md index 4f945ff5905..f0b82c7e5ea 100644 --- a/docs/content/get_started/about/demo.md +++ b/docs/content/get_started/about/demo.md @@ -10,7 +10,7 @@ Two online demos are available for DefectDojo. Both come pre-loaded with data a Demo servers are reset on a daily basis, and are publicly accessible; do not put sensitive data in the demo. ### 🔸 DefectDojo Pro Demo -DefectDojo Pro can be tested at [pro.demo.defectdojo.org](https://pro.demo.defectdojo.org) +DefectDojo Pro can be tested at [pro.demo.defectdojo.com](https://pro.demo.defectdojo.com) Log in with `admin / 1Defectdojo@demo#appsec`. diff --git a/docs/content/import_data/import_intro/comparison.md b/docs/content/import_data/import_intro/comparison.md index 3a1cdb2d042..91df88ccd36 100644 --- a/docs/content/import_data/import_intro/comparison.md +++ b/docs/content/import_data/import_intro/comparison.md @@ -28,7 +28,7 @@ There are two main ways that DefectDojo can upload Finding reports. | | **UI Import** | **API** | **Connectors** (Pro) | **Smart Upload** (Pro)| | --- | --- | --- | --- | --- | -| **Supported Scan Types** | All: see [Supported Tools](/supported_tools/) | All: see [Supported Tools](/supported_tools/) | Anchore, AWS Security Hub, BurpSuite, Checkmarx ONE, Dependency-Track, Probely, Semgrep, SonarQube, Snyk, Tenable, Wiz | Nexpose, NMap, OpenVas, Qualys, Tenable | +| **Supported Scan Types** | All: see [Supported Tools](/supported_tools/) | All: see [Supported Tools](/supported_tools/) | Akamai API Security, Anchore, AWS Security Hub, BurpSuite, Checkmarx ONE, Dependency-Track, JFrog Xray, Probely, Semgrep, SonarQube, Snyk, Tenable, Wiz | Nexpose, NMap, OpenVas, Qualys, Tenable | | **Automation?** | Available via API: `/reimport` `/import` endpoints | Triggered from [CLI Tools](/import_data/pro/specialized_import/external_tools/) or external code | Connectors is an inherently automated feature | Available via API: `/smart_upload_import` endpoint | ### Product Hierarchy and organization diff --git a/docs/content/import_data/pro/connectors/about_connectors.md b/docs/content/import_data/pro/connectors/about_connectors.md index f3ddf75b540..e3596a423b8 100644 --- a/docs/content/import_data/pro/connectors/about_connectors.md +++ b/docs/content/import_data/pro/connectors/about_connectors.md @@ -26,11 +26,13 @@ But everyone needs a starting point, and that's where Connectors come in. Connec We currently support Connectors for the following tools, with more on the way: +* **Akamai API Security** * **Anchore** * **AWS Security Hub** * **BurpSuite** * **Checkmarx ONE** * **Dependency\-Track** +* **JFrog Xray** * **Probely** * **Semgrep** * **SonarQube** diff --git a/docs/content/import_data/pro/connectors/connectors_tool_reference.md b/docs/content/import_data/pro/connectors/connectors_tool_reference.md index 89198061147..c108a895659 100644 --- a/docs/content/import_data/pro/connectors/connectors_tool_reference.md +++ b/docs/content/import_data/pro/connectors/connectors_tool_reference.md @@ -21,6 +21,21 @@ Whenever possible, we recommend creating a new 'DefectDojo Bot' account within y # **Supported Connectors** +## **Akamai API Security** + +The Akamai API Security connector uses an API key to pull security findings from the Akamai API. DefectDojo will discover your Akamai environment and create separate Records for each **Application** and **Host** configured in your account. + +#### Prerequisites + +You will need an API key with access to the Akamai API. We recommend creating a dedicated service account for DefectDojo to clearly distinguish automated activity from manual team actions. + +#### Connector Mappings + +1. Enter your Akamai API base URL in the **Location** field. This URL is specific to your Akamai instance: for example +2. Enter a valid **API Key** in the **Secret** field. + +DefectDojo will map **Applications** and **Hosts** as separate Records. Each Application will appear as `{name} (application)` and each Host as `{name} (host)` in your Records list. + ## **Anchore** The Anchore connector uses a user's API token to pull data from Anchore Enterprise. Products will be mapped and discovered based on "Applications", which are composed of multiple Images in Anchore - see [Anchore Enterprise Documentation](https://docs.anchore.com/current/docs/sbom_management/application_groups/application_management_anchorectl/) for more information. @@ -133,6 +148,32 @@ To generate a Dependency\-Track API key: For more information, see **[Dependency\-Track Documentation](https://docs.dependencytrack.org/integrations/rest-api/)**. +## **JFrog Xray** + +The JFrog Xray connector uses the JFrog Xray REST API to fetch vulnerability data from your Artifactory repositories. DefectDojo will discover all repositories in your JFrog instance and generate vulnerability reports via Xray, importing findings on a scheduled basis. + +#### Prerequisites + +You will need an API token with access to both Artifactory and Xray APIs. We recommend creating a dedicated service account for DefectDojo. The account requires: + +* Read access to Artifactory repositories +* Permission to generate and view Xray vulnerability reports (`Apply on Watches` permission in Xray, or equivalent) + +#### Connector Mappings + +1. Enter your JFrog instance base URL in the **Location** field. This should be the root URL of your JFrog instance, for example `https://your-instance.jfrog.io`. Do not include a trailing path — DefectDojo will construct the appropriate API paths automatically. +2. Enter a valid **Reference Token** in the **Secret** field. Tokens can be generated under **User Management \> Access Tokens** in the JFrog Platform UI. +You'll need to generate a **Reference Token** and use that value. + +Required token scopes for JFrog Xray: + +- **All Services**, as DefectDojo needs access to both access to both XRay and Artifactory services +- **Manage Reports + Manage Resources** at a minimum. + +DefectDojo maps each Artifactory **repository** as a separate Record. On first Sync, DefectDojo generates a full historical vulnerability report; subsequent Syncs generate incremental (delta) reports covering new findings since the last Sync. + +See the [JFrog Xray REST API documentation](https://jfrog.com/help/r/jfrog-rest-apis/xray-rest-apis) for more information. + ## Probely This connector uses the Probely REST API to fetch data. diff --git a/docs/content/releases/pro/changelog.md b/docs/content/releases/pro/changelog.md index 18b4406de62..65b23a303ab 100644 --- a/docs/content/releases/pro/changelog.md +++ b/docs/content/releases/pro/changelog.md @@ -12,6 +12,18 @@ For Open Source release notes, please see the [Releases page on GitHub](https:// ## Feb 2026: v2.55 +### Feb 26, 2026: v2.55.5 + +* **(Rules Engine)** Rules Engine now automatically retries when encountering database lock contention or serialization conflicts, reducing the likelihood of a rule run failing due to temporary load on the system. + +### Feb 24, 2026: v2.55.4 + +* **(Connectors)** Added Akamai API Security, JFrog Xray to Connectors. +* **(Surveys)** Anonymous surveys: users can now access surveys without logging in when anonymous surveys are enabled. +* **(Pro UI)** The Pro UI editor now uses Markdown-based editing for text fields. This resolves issues with HTML-string encoding, especially when Findings were manually entered or edited. +* **(Rules Engine)** Added **Set Mitigation Policy** action type: Rules can now assign a pre-configured Mitigation Policy to matching Findings. +* **(Rules Engine)** Added **Add to Risk Acceptance** action type: Rules can now add matching Findings to an existing Risk Acceptance record, automatically setting them as risk-accepted and inactive, and handling Jira integration and endpoint statuses. + ### Feb 17, 2026: v2.55.3 * **(Pro UI)** Added “Scheduled” status to Engagements to enhances the tracking and management of Engagements. @@ -120,6 +132,7 @@ No significant UX changes. #### Oct 20, 2025: v2.51.2 * **(Connectors)** Added Anchore Enterprise Connector. +* **(Rules Engine)** Rules can now be scheduled to run automatically on a recurring or one-time basis. From the Rules list, use the **⋮** menu on any rule to open the **Schedule Rule** form. #### Oct 14, 2025: v2.51.1 diff --git a/docs/layouts/_markup/render-image.html b/docs/layouts/_markup/render-image.html new file mode 100644 index 00000000000..06120e632fb --- /dev/null +++ b/docs/layouts/_markup/render-image.html @@ -0,0 +1,188 @@ +{{- /* Based on https://www.veriphor.com/articles/link-and-image-render-hooks/#image-render-hook */}} + +{{- /* Last modified: 2023-12-09T16:29:48-08:00 */}} + +{{- /* +Copyright 2023 Veriphor LLC + +Licensed under the Apache License, Version 2.0 (the "License"); you may not +use this file except in compliance with the License. You may obtain a copy of +the License at + +https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations under +the License. +*/}} + +{{- /* +This render hook resolves internal destinations by looking for a matching: + + 1. Page resource (an image in the current page bundle) + 2. Section resource (an image in the current section) + 3. Global resource (an image in the assets directory) + +It skips the section resource lookup if the current page is a leaf bundle, and +captures external destinations as resources for local hosting. + +You must place global resources in the assets directory. If you have placed +your resources in the static directory, and you are unable or unwilling to move +them, you must mount the static directory to the assets directory by including +both of these entries in your site configuration: + + [[module.mounts]] + source = 'assets' + target = 'assets' + + [[module.mounts]] + source = 'static' + target = 'assets' + +By default, if this render hook is unable to resolve a destination, it passes +the destination through without modification. To emit a warning or error, set +the error level in your site configuration: + + [params.render_hooks.image] + errorLevel = 'warning' # ignore (default), warning, or error (fails the build) + +Image render hooks are also used to: + + - Resize, crop, rotate, filter, and convert images + - Build responsive images using srcset and sizes attributes + - Wrap images inside of a picture element + - Transform standalone images into figure elements + +To perform any of these operations, you can "hook" into this render hook with a +partial template, after the render hook has captured the resource. + +@context {map} Attributes The markdown attributes, available if (a) markup.goldmark.parser.attribute.block is true, and (b) markup.goldmark.parser.wrapStandAloneImageWithinParagraph is false in site configuration. +@context {string} Destination The image destination. +@context {bool} IsBlock Returns true if a standalone image is not wrapped within a paragraph element. +@context {int} Ordinal The zero-based ordinal of the image on the page. +@context {page} Page A reference to the page containing the image. +@context {string} PlainText The image description as plain text. +@context {string} Text The image description. +@context {string} Title The image title. + +@returns {template.html} +*/}} + +{{- /* Initialize. */}} +{{- $renderHookName := "image" }} + +{{- /* Verify minimum required version. */}} +{{- $minHugoVersion := "0.114.0" }} +{{- if lt hugo.Version $minHugoVersion }} + {{- errorf "The %q render hook requires Hugo v%s or later." $renderHookName $minHugoVersion }} +{{- end }} + +{{- /* Error level when unable to resolve destination: ignore, warning, or error. */}} +{{- $errorLevel := or site.Params.render_hooks.image.errorLevel "ignore" | lower }} + +{{- /* Validate error level. */}} +{{- if not (in (slice "ignore" "warning" "error") $errorLevel) }} + {{- errorf "The %q render hook is misconfigured. The errorLevel %q is invalid. Please check your site configuration." $renderHookName $errorLevel }} +{{- end }} + +{{- /* Determine content path for warning and error messages. */}} +{{- $contentPath := "" }} +{{- with .Page.File }} + {{- $contentPath = .Path }} +{{- else }} + {{- $contentPath = .Path }} +{{- end }} + +{{- /* Parse destination. */}} +{{- $u := urls.Parse .Destination }} + +{{- /* Set common message. */}} +{{- $msg := printf "The %q render hook was unable to resolve the destination %q in %s" $renderHookName $u.String $contentPath }} + +{{- /* Get image resource. */}} +{{- $r := "" }} +{{- if $u.IsAbs }} + {{- with try (resources.GetRemote $u.String) }} + {{- with .Err }} + {{- if eq $errorLevel "warning" }} + {{- warnf "%s. See %s" .Err $contentPath }} + {{- else if eq $errorLevel "error" }} + {{- errorf "%s. See %s" .Err $contentPath }} + {{- end }} + {{- else with .Value }} + {{- /* Destination is a remote resource. */}} + {{- $r = . }} + {{- end }} + {{- else }} + {{- if eq $errorLevel "warning" }} + {{- warnf $msg }} + {{- else if eq $errorLevel "error" }} + {{- errorf $msg }} + {{- end }} + {{- end }} +{{- else }} + {{- with .Page.Resources.Get (strings.TrimPrefix "./" $u.Path) }} + {{- /* Destination is a page resource. */}} + {{- $r = . }} + {{- else }} + {{- with (and (ne .Page.BundleType "leaf") (.Page.CurrentSection.Resources.Get (strings.TrimPrefix "./" $u.Path))) }} + {{- /* Destination is a section resource, and current page is not a leaf bundle. */}} + {{- $r = . }} + {{- else }} + {{- with resources.Get $u.Path }} + {{- /* Destination is a global resource. */}} + {{- $r = . }} + {{- else }} + {{- if eq $errorLevel "warning" }} + {{- warnf $msg }} + {{- else if eq $errorLevel "error" }} + {{- errorf $msg }} + {{- end }} + {{- end }} + {{- end }} + {{- end }} +{{- end }} + +{{- /* Determine id attribute. */}} +{{- $id := printf "h-rh-i-%d" .Ordinal }} +{{- with .Attributes.id }} + {{- $id = . }} +{{- end }} + +{{/* + Upstream template converts every image to WebP here, but that duplicates work + already done by the responsive-image pipeline (widths array) and adds + significant CPU/memory pressure for no practical benefit — the images are + already compact PNGs at ≤1400px. Skip the conversion entirely and serve the + original file. +*/}} + +{{- if ne $r.MediaType.SubType "svg" }} +{{- /* Render image element. */ -}} +{{ .PlainText }} +{{- else }} +{{ .PlainText }} +{{- end }} + +{{- /**/ -}} diff --git a/docs/package-lock.json b/docs/package-lock.json index bb8b371172a..a9caed210d1 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -1949,27 +1949,6 @@ "postcss": "^8.0.0" } }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", - "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@isaacs/cliui": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", @@ -2042,9 +2021,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], @@ -2056,9 +2035,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], @@ -2070,9 +2049,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], @@ -2084,9 +2063,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], @@ -2098,9 +2077,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], @@ -2112,9 +2091,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], @@ -2126,9 +2105,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], @@ -2140,9 +2119,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], @@ -2154,9 +2133,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], @@ -2168,9 +2147,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], @@ -2182,9 +2161,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", "cpu": [ "loong64" ], @@ -2196,9 +2175,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "cpu": [ "loong64" ], @@ -2210,9 +2189,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", "cpu": [ "ppc64" ], @@ -2224,9 +2203,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], @@ -2238,9 +2217,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], @@ -2252,9 +2231,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], @@ -2266,9 +2245,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], @@ -2280,9 +2259,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], @@ -2294,9 +2273,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], @@ -2308,9 +2287,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", "cpu": [ "x64" ], @@ -2322,9 +2301,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "cpu": [ "arm64" ], @@ -2336,9 +2315,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], @@ -2350,9 +2329,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], @@ -2364,9 +2343,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "cpu": [ "x64" ], @@ -2378,9 +2357,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], @@ -3537,9 +3516,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.4.tgz", + "integrity": "sha512-twmL+S8+7yIsE9wsqgzU3E8/LumN3M3QELrBZ20OdmQ9jB2JvW5oZtBEmft84k/Gs5CG9mqtWc6Y9vW+JEzGxw==", "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -3950,6 +3929,27 @@ "scss-parser": "1.0.3" } }, + "node_modules/purgecss/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/purgecss/node_modules/brace-expansion": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/purgecss/node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -3984,15 +3984,15 @@ } }, "node_modules/purgecss/node_modules/minimatch": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz", - "integrity": "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==", + "version": "10.2.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.3.tgz", + "integrity": "sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg==", "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.1" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -4160,9 +4160,9 @@ } }, "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", "dependencies": { @@ -4176,31 +4176,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, diff --git a/dojo/api_v2/serializers.py b/dojo/api_v2/serializers.py index 1eeb021d165..29bb2d03cba 100644 --- a/dojo/api_v2/serializers.py +++ b/dojo/api_v2/serializers.py @@ -1760,9 +1760,7 @@ class FindingSerializer(serializers.ModelSerializer): mitigated_by = serializers.PrimaryKeyRelatedField(required=False, allow_null=True, queryset=User.objects.all()) tags = TagListSerializerField(required=False) request_response = serializers.SerializerMethodField() - accepted_risks = RiskAcceptanceSerializer( - many=True, read_only=True, source="risk_acceptance_set", - ) + accepted_risks = serializers.SerializerMethodField() push_to_jira = serializers.BooleanField(default=False) found_by = serializers.PrimaryKeyRelatedField( queryset=Test_Type.objects.all(), many=True, @@ -1806,6 +1804,17 @@ def __init__(self, *args, **kwargs): many=True, required=False, queryset=Endpoint.objects.all(), ) + @extend_schema_field(RiskAcceptanceSerializer(many=True)) + def get_accepted_risks(self, obj): + request = self.context.get("request") + if request is None: + return [] + if not user_has_permission(request.user, obj, Permissions.Risk_Acceptance): + return [] + return RiskAcceptanceSerializer( + obj.risk_acceptance_set.all(), many=True, + ).data + @extend_schema_field(serializers.DateTimeField()) def get_jira_creation(self, obj): return jira_helper.get_jira_creation(obj) @@ -2711,8 +2720,11 @@ def process_scan( # Attempt to create an engagement logger.debug("reimport for non-existing test, using import to create new test") context["engagement"] = auto_create_manager.get_or_create_engagement(**context) + # Do not close old findings when creating a brand new test: there are no + # existing findings to compare against, and close_old_findings would + # incorrectly close findings from other tests in the same scope. context["test"], _, _, _, _, _, _ = self.get_importer( - **context, + **{**context, "close_old_findings": False}, ).process_scan( context.pop("scan", None), ) @@ -2856,30 +2868,36 @@ def save(self): msg = "Invalid format" raise Exception(msg) + # Filter out ignored keys + language_names = [name for name in deserialized if name not in {"header", "SUM"}] + # Prepopulate existing Language_Type objects + existing_types = { + lt.language: lt + for lt in Language_Type.objects.filter(language__in=language_names) + } + # Determine which Language_Type objects need to be created + new_language_names = [name for name in language_names if name not in existing_types] + new_types = [Language_Type(language=name) for name in new_language_names] + Language_Type.objects.bulk_create(new_types) + # Add newly created Language_Type objects to cache + for lt in Language_Type.objects.filter(language__in=new_language_names): + existing_types[lt.language] = lt + # Delete all Languages for this product Languages.objects.filter(product=product).delete() - - for name in deserialized: - if name not in {"header", "SUM"}: - element = deserialized[name] - - try: - ( - language_type, - _created, - ) = Language_Type.objects.get_or_create(language=name) - except Language_Type.MultipleObjectsReturned: - language_type = Language_Type.objects.filter( - language=name, - ).first() - - language = Languages() - language.product = product - language.language = language_type - language.files = element.get("nFiles", 0) - language.blank = element.get("blank", 0) - language.comment = element.get("comment", 0) - language.code = element.get("code", 0) - language.save() + # Prepare Languages objects for bulk insert + languages_to_create = [ + Languages( + product=product, + language=existing_types[name], + files=deserialized[name].get("nFiles", 0), + blank=deserialized[name].get("blank", 0), + comment=deserialized[name].get("comment", 0), + code=deserialized[name].get("code", 0), + ) + for name in language_names + ] + # Bulk insert all Languages in one query + Languages.objects.bulk_create(languages_to_create) def validate(self, data): if is_scan_file_too_large(data["file"]): diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index 3461e54b25a..c106d667e77 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -45,6 +45,7 @@ serializers, ) from dojo.api_v2.prefetch.prefetcher import _Prefetcher +from dojo.authorization.authorization import user_has_permission_or_403 from dojo.authorization.roles_permissions import Permissions from dojo.celery_dispatch import dojo_dispatch_task from dojo.cred.queries import get_authorized_cred_mappings @@ -351,7 +352,11 @@ def get_queryset(self): responses={status.HTTP_200_OK: serializers.ReportGenerateSerializer}, ) @action( - detail=True, methods=["post"], permission_classes=[IsAuthenticated], + detail=True, methods=["post"], + # IsAuthenticated only: report generation requires View permission, + # enforced by the permission-filtered get_queryset(). The viewset's + # permission_classes would check Edit (POST), which is too restrictive. + permission_classes=[IsAuthenticated], ) def generate_report(self, request, pk=None): endpoint = self.get_object() @@ -475,7 +480,11 @@ def reopen(self, request, pk=None): responses={status.HTTP_200_OK: serializers.ReportGenerateSerializer}, ) @action( - detail=True, methods=["post"], permission_classes=[IsAuthenticated], + detail=True, methods=["post"], + # IsAuthenticated only: report generation requires View permission, + # enforced by the permission-filtered get_queryset(). The viewset's + # permission_classes would check Edit (POST), which is too restrictive. + permission_classes=[IsAuthenticated], ) def generate_report(self, request, pk=None): engagement = self.get_object() @@ -691,7 +700,8 @@ def download_file(self, request, file_id, pk=None): responses={status.HTTP_200_OK: serializers.EngagementUpdateJiraEpicSerializer}, ) @action( - detail=True, methods=["post"], permission_classes=[IsAuthenticated], + detail=True, methods=["post"], + permission_classes=(IsAuthenticated, permissions.UserHasEngagementRelatedObjectPermission), ) def update_jira_epic(self, request, pk=None): engagement = self.get_object() @@ -1383,7 +1393,11 @@ def set_finding_as_original(self, request, pk, new_fid): responses={status.HTTP_200_OK: serializers.ReportGenerateSerializer}, ) @action( - detail=False, methods=["post"], permission_classes=[IsAuthenticated], + detail=False, methods=["post"], + # IsAuthenticated only: report generation requires View permission, + # enforced by the permission-filtered get_queryset(). The viewset's + # permission_classes would check Edit (POST), which is too restrictive. + permission_classes=[IsAuthenticated], ) def generate_report(self, request): findings = self.get_queryset() @@ -1728,38 +1742,55 @@ def batch(self, request, pk=None): serialized_data = serializers.MetaMainSerializer(data=request.data) if serialized_data.is_valid(raise_exception=True): if request.method == "POST": - self.process_post(request.data) + self.process_post(request) status_code = status.HTTP_201_CREATED if request.method == "PATCH": - self.process_patch(request.data) + self.process_patch(request) status_code = status.HTTP_200_OK return Response(status=status_code, data=serialized_data.data) - def process_post(self: object, data: dict): - product = Product.objects.filter(id=data.get("product")).first() - finding = Finding.objects.filter(id=data.get("finding")).first() - endpoint = Endpoint.objects.filter(id=data.get("endpoint")).first() + def _fetch_and_authorize_parents(self, request, permission_map): + """Fetch parent objects and verify the user has the required permissions.""" + data = request.data + parents = {} + for field, (model, permission) in permission_map.items(): + obj = model.objects.filter(id=data.get(field)).first() + if obj: + user_has_permission_or_403(request.user, obj, permission) + parents[field] = obj + return parents + + def process_post(self, request): + data = request.data + parents = self._fetch_and_authorize_parents(request, { + "product": (Product, Permissions.Product_Edit), + "finding": (Finding, Permissions.Finding_Edit), + "endpoint": (Endpoint, Permissions.Location_Edit), + }) metalist = data.get("metadata") for metadata in metalist: try: DojoMeta.objects.create( - product=product, - finding=finding, - endpoint=endpoint, + product=parents["product"], + finding=parents["finding"], + endpoint=parents["endpoint"], name=metadata.get("name"), value=metadata.get("value"), ) except (IntegrityError) as ex: # this should not happen as the data was validated in the batch call raise ValidationError(str(ex)) - def process_patch(self: object, data: dict): - product = Product.objects.filter(id=data.get("product")).first() - finding = Finding.objects.filter(id=data.get("finding")).first() - endpoint = Endpoint.objects.filter(id=data.get("endpoint")).first() + def process_patch(self, request): + data = request.data + parents = self._fetch_and_authorize_parents(request, { + "product": (Product, Permissions.Product_Edit), + "finding": (Finding, Permissions.Finding_Edit), + "endpoint": (Endpoint, Permissions.Location_Edit), + }) metalist = data.get("metadata") for metadata in metalist: - dojometa = DojoMeta.objects.filter(product=product, finding=finding, endpoint=endpoint, name=metadata.get("name")) + dojometa = DojoMeta.objects.filter(product=parents["product"], finding=parents["finding"], endpoint=parents["endpoint"], name=metadata.get("name")) if dojometa: try: dojometa.update( @@ -1815,7 +1846,11 @@ def destroy(self, request, *args, **kwargs): responses={status.HTTP_200_OK: serializers.ReportGenerateSerializer}, ) @action( - detail=True, methods=["post"], permission_classes=[IsAuthenticated], + detail=True, methods=["post"], + # IsAuthenticated only: report generation requires View permission, + # enforced by the permission-filtered get_queryset(). The viewset's + # permission_classes would check Edit (POST), which is too restrictive. + permission_classes=[IsAuthenticated], ) def generate_report(self, request, pk=None): product = self.get_object() @@ -1956,7 +1991,11 @@ def destroy(self, request, *args, **kwargs): responses={status.HTTP_200_OK: serializers.ReportGenerateSerializer}, ) @action( - detail=True, methods=["post"], permission_classes=[IsAuthenticated], + detail=True, methods=["post"], + # IsAuthenticated only: report generation requires View permission, + # enforced by the permission-filtered get_queryset(). The viewset's + # permission_classes would check Edit (POST), which is too restrictive. + permission_classes=[IsAuthenticated], ) def generate_report(self, request, pk=None): product_type = self.get_object() @@ -2143,7 +2182,11 @@ def get_serializer_class(self): responses={status.HTTP_200_OK: serializers.ReportGenerateSerializer}, ) @action( - detail=True, methods=["post"], permission_classes=[IsAuthenticated], + detail=True, methods=["post"], + # IsAuthenticated only: report generation requires View permission, + # enforced by the permission-filtered get_queryset(). The viewset's + # permission_classes would check Edit (POST), which is too restrictive. + permission_classes=[IsAuthenticated], ) def generate_report(self, request, pk=None): test = self.get_object() diff --git a/dojo/cred/views.py b/dojo/cred/views.py index 4feaddb73b6..ac6a47f2ae3 100644 --- a/dojo/cred/views.py +++ b/dojo/cred/views.py @@ -47,7 +47,7 @@ def all_cred_product(request, pid): return render(request, "dojo/view_cred_prod.html", {"product_tab": product_tab, "creds": creds, "prod": prod}) -@user_is_authorized(Cred_User, Permissions.Credential_Edit, "ttid") +@user_is_configuration_authorized(Permissions.Credential_Edit) def edit_cred(request, ttid): tool_config = Cred_User.objects.get(pk=ttid) if request.method == "POST": @@ -79,7 +79,7 @@ def edit_cred(request, ttid): }) -@user_is_authorized(Cred_User, Permissions.Credential_View, "ttid") +@user_is_configuration_authorized(Permissions.Credential_View) def view_cred_details(request, ttid): cred = Cred_User.objects.get(pk=ttid) notes = cred.notes.all() @@ -127,7 +127,7 @@ def cred(request): @user_is_authorized(Product, Permissions.Product_View, "pid") -@user_is_authorized(Cred_User, Permissions.Credential_View, "ttid") +@user_is_authorized(Cred_Mapping, Permissions.Credential_View, "ttid") def view_cred_product(request, pid, ttid): cred = get_object_or_404( Cred_Mapping.objects.select_related("cred_id"), id=ttid) @@ -182,8 +182,8 @@ def view_cred_product(request, pid, ttid): }) -@user_is_authorized(Product, Permissions.Engagement_View, "eid") -@user_is_authorized(Cred_User, Permissions.Credential_View, "ttid") +@user_is_authorized(Engagement, Permissions.Engagement_View, "eid") +@user_is_authorized(Cred_Mapping, Permissions.Credential_View, "ttid") def view_cred_product_engagement(request, eid, ttid): cred = get_object_or_404( Cred_Mapping.objects.select_related("cred_id"), id=ttid) @@ -231,8 +231,8 @@ def view_cred_product_engagement(request, eid, ttid): }) -@user_is_authorized(Product, Permissions.Test_View, "tid") -@user_is_authorized(Cred_User, Permissions.Credential_View, "ttid") +@user_is_authorized(Test, Permissions.Test_View, "tid") +@user_is_authorized(Cred_Mapping, Permissions.Credential_View, "ttid") def view_cred_engagement_test(request, tid, ttid): cred = get_object_or_404( Cred_Mapping.objects.select_related("cred_id"), id=ttid) @@ -282,8 +282,8 @@ def view_cred_engagement_test(request, tid, ttid): }) -@user_is_authorized(Product, Permissions.Finding_View, "fid") -@user_is_authorized(Cred_User, Permissions.Credential_View, "ttid") +@user_is_authorized(Finding, Permissions.Finding_View, "fid") +@user_is_authorized(Cred_Mapping, Permissions.Credential_View, "ttid") def view_cred_finding(request, fid, ttid): cred = get_object_or_404( Cred_Mapping.objects.select_related("cred_id"), id=ttid) @@ -334,7 +334,7 @@ def view_cred_finding(request, fid, ttid): @user_is_authorized(Product, Permissions.Product_Edit, "pid") -@user_is_authorized(Cred_User, Permissions.Credential_Edit, "ttid") +@user_is_authorized(Cred_Mapping, Permissions.Credential_Edit, "ttid") def edit_cred_product(request, pid, ttid): cred = get_object_or_404( Cred_Mapping.objects.select_related("cred_id"), id=ttid) @@ -362,7 +362,7 @@ def edit_cred_product(request, pid, ttid): @user_is_authorized(Engagement, Permissions.Engagement_Edit, "eid") -@user_is_authorized(Cred_User, Permissions.Credential_Edit, "ttid") +@user_is_authorized(Cred_Mapping, Permissions.Credential_Edit, "ttid") def edit_cred_product_engagement(request, eid, ttid): cred = get_object_or_404( Cred_Mapping.objects.select_related("cred_id"), id=ttid) @@ -582,7 +582,6 @@ def new_cred_finding(request, fid): }) -@user_is_authorized(Cred_User, Permissions.Credential_Delete, "ttid") def delete_cred_controller(request, destination_url, elem_id, ttid): cred = Cred_Mapping.objects.filter(pk=ttid).first() if request.method == "POST": @@ -662,30 +661,30 @@ def delete_cred_controller(request, destination_url, elem_id, ttid): }) -@user_is_authorized(Cred_User, Permissions.Credential_Delete, "ttid") +@user_is_configuration_authorized(Permissions.Credential_Delete) def delete_cred(request, ttid): return delete_cred_controller(request, "cred", 0, ttid=ttid) @user_is_authorized(Product, Permissions.Product_Edit, "pid") -@user_is_authorized(Cred_User, Permissions.Credential_Delete, "ttid") +@user_is_authorized(Cred_Mapping, Permissions.Credential_Delete, "ttid") def delete_cred_product(request, pid, ttid): return delete_cred_controller(request, "all_cred_product", pid, ttid) @user_is_authorized(Engagement, Permissions.Engagement_Edit, "eid") -@user_is_authorized(Cred_User, Permissions.Credential_Delete, "ttid") +@user_is_authorized(Cred_Mapping, Permissions.Credential_Delete, "ttid") def delete_cred_engagement(request, eid, ttid): return delete_cred_controller(request, "view_engagement", eid, ttid) @user_is_authorized(Test, Permissions.Test_Edit, "tid") -@user_is_authorized(Cred_User, Permissions.Credential_Delete, "ttid") +@user_is_authorized(Cred_Mapping, Permissions.Credential_Delete, "ttid") def delete_cred_test(request, tid, ttid): return delete_cred_controller(request, "view_test", tid, ttid) @user_is_authorized(Finding, Permissions.Finding_Edit, "fid") -@user_is_authorized(Cred_User, Permissions.Credential_Delete, "ttid") +@user_is_authorized(Cred_Mapping, Permissions.Credential_Delete, "ttid") def delete_cred_finding(request, fid, ttid): return delete_cred_controller(request, "view_finding", fid, ttid) diff --git a/dojo/finding/deduplication.py b/dojo/finding/deduplication.py index 51e34015b61..c55d473a96c 100644 --- a/dojo/finding/deduplication.py +++ b/dojo/finding/deduplication.py @@ -8,7 +8,7 @@ from django.db.models.query_utils import Q from dojo.celery import app -from dojo.models import Finding, System_Settings +from dojo.models import Endpoint_Status, Finding, System_Settings logger = logging.getLogger(__name__) deduplicationLogger = logging.getLogger("dojo.specific-loggers.deduplication") @@ -295,12 +295,19 @@ def build_candidate_scope_queryset(test, mode="deduplication", service=None): # Base prefetches for both modes prefetch_list = ["endpoints", "vulnerability_id_set", "found_by"] - # Additional prefetches for reimport mode + # Additional prefetches for reimport mode: fetch only non-special endpoint statuses with their + # endpoint joined in, so endpoint_manager can read status_finding_non_special directly without + # any extra DB queries if mode == "reimport": - prefetch_list.extend([ - "status_finding", - "status_finding__endpoint", - ]) + prefetch_list.append( + Prefetch( + "status_finding", + queryset=Endpoint_Status.objects.exclude( + Q(false_positive=True) | Q(out_of_scope=True) | Q(risk_accepted=True), + ).select_related("endpoint"), + to_attr="status_finding_non_special", + ), + ) return ( queryset diff --git a/dojo/finding/helper.py b/dojo/finding/helper.py index a43986bd7ea..4e6b8caace4 100644 --- a/dojo/finding/helper.py +++ b/dojo/finding/helper.py @@ -214,14 +214,14 @@ def create_finding_group(finds, finding_group_name): finding_group.creator = get_current_user() if finding_group_name: - finding_group.name = finding_group_name + finding_group.name = finding_group_name[:255] elif finding_group.components: - finding_group.name = finding_group.components + finding_group.name = finding_group.components[:255] try: finding_group.save() except IntegrityError as ie: if "already exists" in str(ie): - finding_group.name = finding_group_name + finding_group_name_dummy + finding_group.name = finding_group_name[:255 - len(finding_group_name_dummy)] + finding_group_name_dummy finding_group.save() else: raise @@ -362,7 +362,7 @@ def add_findings_to_auto_group(name, findings, group_by, *, create_finding_group if create_finding_groups_for_all_findings or len(findings) > 1: # Only create a finding group if we have more than one finding for a given finding group, unless configured otherwise - finding_group, created = Finding_Group.objects.get_or_create(test=test, creator=creator, name=name) + finding_group, created = Finding_Group.objects.get_or_create(test=test, creator=creator, name=name[:255]) if created: logger.debug("Created Finding Group %d:%s for test %d:%s", finding_group.id, finding_group, test.id, test) # See if we have old findings in the same test that were created without a finding group @@ -388,7 +388,7 @@ def add_findings_to_auto_group(name, findings, group_by, *, create_finding_group for f in old_findings: f_group_name = get_group_by_group_name(f, group_by) if f_group_name == name and f not in findings: - finding_group, created = Finding_Group.objects.get_or_create(test=test, creator=creator, name=name) + finding_group, created = Finding_Group.objects.get_or_create(test=test, creator=creator, name=name[:255]) finding_group.findings.add(f) if created: finding_group.findings.add(*findings) diff --git a/dojo/finding/views.py b/dojo/finding/views.py index b5b10faefc6..1be56f7e891 100644 --- a/dojo/finding/views.py +++ b/dojo/finding/views.py @@ -32,7 +32,7 @@ import dojo.finding.helper as finding_helper import dojo.jira_link.helper as jira_helper import dojo.risk_acceptance.helper as ra_helper -from dojo.authorization.authorization import user_has_permission_or_403 +from dojo.authorization.authorization import user_has_global_permission_or_403, user_has_permission_or_403 from dojo.authorization.authorization_decorators import ( user_has_global_permission, user_is_authorized, @@ -1735,6 +1735,9 @@ def mktemplate(request, fid): @user_is_authorized(Finding, Permissions.Finding_Edit, "fid") def find_template_to_apply(request, fid): + # Templates may contain sensitive data from any product; require global permission + # to match the authorization level of the /template list view + user_has_global_permission_or_403(request.user, Permissions.Finding_Edit) finding = get_object_or_404(Finding, id=fid) test = get_object_or_404(Test, id=finding.test.id) templates_by_cve = ( diff --git a/dojo/importers/default_reimporter.py b/dojo/importers/default_reimporter.py index ba530047721..03c80e00119 100644 --- a/dojo/importers/default_reimporter.py +++ b/dojo/importers/default_reimporter.py @@ -764,12 +764,8 @@ def process_matched_mitigated_finding( else: # TODO: Delete this after the move to Locations # Reactivate mitigated endpoints that are not false positives, out of scope, or risk accepted - endpoint_statuses = existing_finding.status_finding.exclude( - Q(false_positive=True) - | Q(out_of_scope=True) - | Q(risk_accepted=True), - ) - self.endpoint_manager.chunk_endpoints_and_reactivate(endpoint_statuses) + # status_finding_non_special is prefetched by build_candidate_scope_queryset + self.endpoint_manager.chunk_endpoints_and_reactivate(existing_finding.status_finding_non_special) existing_finding.notes.add(note) self.reactivated_items.append(existing_finding) # The new finding is active while the existing on is mitigated. The existing finding needs to diff --git a/dojo/importers/endpoint_manager.py b/dojo/importers/endpoint_manager.py index 8bee966e5ca..2b5883121ab 100644 --- a/dojo/importers/endpoint_manager.py +++ b/dojo/importers/endpoint_manager.py @@ -157,8 +157,9 @@ def update_endpoint_status( """Update the list of endpoints from the new finding with the list that is in the old finding""" # New endpoints are already added in serializers.py / views.py (see comment "# for existing findings: make sure endpoints are present or created") # So we only need to mitigate endpoints that are no longer present - # using `.all()` will mark as mitigated also `endpoint_status` with flags `false_positive`, `out_of_scope` and `risk_accepted`. This is a known issue. This is not a bug. This is a future. - existing_finding_endpoint_status_list = existing_finding.status_finding.all() + # status_finding_non_special is prefetched by build_candidate_scope_queryset with the + # special-status exclusion and endpoint select_related already applied at the DB level + existing_finding_endpoint_status_list = existing_finding.status_finding_non_special new_finding_endpoints_list = new_finding.unsaved_endpoints if new_finding.is_mitigated: # New finding is mitigated, so mitigate all old endpoints diff --git a/dojo/importers/location_manager.py b/dojo/importers/location_manager.py index a18726dbf91..125ff922dc0 100644 --- a/dojo/importers/location_manager.py +++ b/dojo/importers/location_manager.py @@ -145,17 +145,18 @@ def update_location_status( """Update the list of locations from the new finding with the list that is in the old finding""" # New endpoints are already added in serializers.py / views.py (see comment "# for existing findings: make sure endpoints are present or created") # So we only need to mitigate endpoints that are no longer present - # using `.all()` will mark as mitigated also `endpoint_status` with flags `false_positive`, `out_of_scope` and `risk_accepted`. This is a known issue. This is not a bug. This is a future. - + existing_location_refs: QuerySet[LocationFindingReference] = existing_finding.locations.exclude( + status__in=[ + FindingLocationStatus.FalsePositive, + FindingLocationStatus.RiskAccepted, + FindingLocationStatus.OutOfScope, + ], + ) if new_finding.is_mitigated: # New finding is mitigated, so mitigate all existing location refs - self.chunk_locations_and_mitigate(existing_finding.locations.all(), user) + self.chunk_locations_and_mitigate(existing_location_refs, user) else: - # New finding not mitigated; so, reactivate all refs - existing_location_refs: QuerySet[LocationFindingReference] = existing_finding.locations.all() - new_locations_values = [str(location) for location in type(self).clean_unsaved_locations(new_finding.unsaved_locations)] - # Reactivate endpoints in the old finding that are in the new finding location_refs_to_reactivate = existing_location_refs.filter(location__location_value__in=new_locations_values) # Mitigate endpoints in the existing finding not in the new finding diff --git a/dojo/location/models.py b/dojo/location/models.py index a3c131712a5..b0446673f33 100644 --- a/dojo/location/models.py +++ b/dojo/location/models.py @@ -119,9 +119,19 @@ def associate_with_finding( relationship_data: dict | None = None, ) -> LocationFindingReference: """ - Get or create a LocationFindingReference for this location and finding, - updating the status each time. Also associates the related product. + Get or create a LocationFindingReference for this location and finding. + Also associates the related product. """ + # Check if there is an existing reference for this finding and location + # If this method is being used to set the status + if LocationFindingReference.objects.filter( + location=self, + finding=finding, + ).exists(): + return LocationFindingReference.objects.get( + location=self, + finding=finding, + ) # Determine the status if status is None: status = self.status_from_finding(finding) @@ -158,10 +168,17 @@ def associate_with_product( relationship: str = "", relationship_data: dict | None = None, ) -> LocationProductReference: - """ - Get or create a LocationProductReference for this location and product, - updating the status each time. - """ + """Get or create a LocationProductReference for this location and product""" + # Check if there is an existing reference for this finding and location + # If this method is being used to set the status + if LocationProductReference.objects.filter( + location=self, + product=product, + ).exists(): + return LocationProductReference.objects.get( + location=self, + product=product, + ) if status is None: status = self.status_from_product(product) # Use a transaction for safety in concurrent scenarios diff --git a/dojo/object/views.py b/dojo/object/views.py index 96616c556a8..9bd443d27dd 100644 --- a/dojo/object/views.py +++ b/dojo/object/views.py @@ -1,7 +1,7 @@ import logging from django.contrib import messages -from django.core.exceptions import BadRequest +from django.core.exceptions import PermissionDenied from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404, render from django.urls import reverse @@ -63,12 +63,10 @@ def view_objects(request, pid): @user_is_authorized(Product, Permissions.Product_Tracking_Files_Edit, "pid") def edit_object(request, pid, ttid): - object_prod = Objects_Product.objects.get(pk=ttid) + object_prod = get_object_or_404(Objects_Product, pk=ttid) product = get_object_or_404(Product, id=pid) if object_prod.product != product: - msg = labels.ASSET_TRACKED_FILES_ID_MISMATCH_ERROR_MESSAGE % {"asset_id": pid, - "object_asset_id": object_prod.product.id} - raise BadRequest(msg) + raise PermissionDenied if request.method == "POST": tform = ObjectSettingsForm(request.POST, instance=object_prod) @@ -94,12 +92,10 @@ def edit_object(request, pid, ttid): @user_is_authorized(Product, Permissions.Product_Tracking_Files_Delete, "pid") def delete_object(request, pid, ttid): - object_prod = Objects_Product.objects.get(pk=ttid) + object_prod = get_object_or_404(Objects_Product, pk=ttid) product = get_object_or_404(Product, id=pid) if object_prod.product != product: - msg = labels.ASSET_TRACKED_FILES_ID_MISMATCH_ERROR_MESSAGE % {"asset_id": pid, - "object_asset_id": object_prod.product.id} - raise BadRequest(msg) + raise PermissionDenied if request.method == "POST": tform = ObjectSettingsForm(request.POST, instance=object_prod) diff --git a/dojo/settings/settings.dist.py b/dojo/settings/settings.dist.py index c453c4a169e..4bf0fbc651e 100644 --- a/dojo/settings/settings.dist.py +++ b/dojo/settings/settings.dist.py @@ -34,7 +34,7 @@ # django-jsonfield-backport raises a warning that can be ignored, # see https://github.com/laymonage/django-jsonfield-backport # debug_toolbar.E001 is raised when running tests in dev mode via run-unittests.sh - DD_SILENCED_SYSTEM_CHECKS=(list, ["debug_toolbar.E001", "django_jsonfield_backport.W001"]), + DD_SILENCED_SYSTEM_CHECKS=(list, ["debug_toolbar.E001", "django_jsonfield_backport.W001", "polymorphic.W001", "polymorphic.W002"]), DD_TEMPLATE_DEBUG=(bool, False), DD_LOG_LEVEL=(str, ""), DD_DJANGO_METRICS_ENABLED=(bool, False), diff --git a/dojo/templates/dojo/snippets/endpoints.html b/dojo/templates/dojo/snippets/endpoints.html index 893379d853e..e7d4d740ea8 100644 --- a/dojo/templates/dojo/snippets/endpoints.html +++ b/dojo/templates/dojo/snippets/endpoints.html @@ -147,7 +147,7 @@
Location
-

Vulnerable Endpoints / Systems ({{ finding.active_endpoint_count }})

+

Vulnerable Endpoints / Systems ({{ finding.active_endpoint_count }})

@@ -255,7 +255,7 @@

Mitigated Endpoints / Systems ({{ finding.mitigated_endpoint_count }}) {{ endpoint.location|url_shortener }}{% if endpoint.is_broken %} 🚩{% endif %} {% include "dojo/snippets/tags.html" with tags=endpoint.location.tags.all %} - {{ endpoint.status }} + {{ endpoint.get_status_display }} {{ endpoint.auditor|safe }} {{ endpoint.audit_time|date }} {% else %} diff --git a/dojo/templates/dojo/view_finding.html b/dojo/templates/dojo/view_finding.html index bae3abbe8b8..f577a773815 100755 --- a/dojo/templates/dojo/view_finding.html +++ b/dojo/templates/dojo/view_finding.html @@ -758,48 +758,6 @@

Similar Findings ({{ similar_findings.paginator.count }} - - {% if 'TRACK_IMPORT_HISTORY'|setting_enabled and latest_test_import_finding_action %}
@@ -907,6 +865,72 @@

{% endif %} + + {% include "dojo/snippets/endpoints.html" with finding=finding destination="UI" %}
diff --git a/dojo/tool_product/views.py b/dojo/tool_product/views.py index def26f088d2..98f56d2f5ce 100644 --- a/dojo/tool_product/views.py +++ b/dojo/tool_product/views.py @@ -2,7 +2,7 @@ import logging from django.contrib import messages -from django.core.exceptions import BadRequest +from django.core.exceptions import PermissionDenied from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404, render from django.urls import reverse @@ -60,10 +60,9 @@ def all_tool_product(request, pid): @user_is_authorized(Product, Permissions.Product_Edit, "pid") def edit_tool_product(request, pid, ttid): product = get_object_or_404(Product, id=pid) - tool_product = Tool_Product_Settings.objects.get(pk=ttid) + tool_product = get_object_or_404(Tool_Product_Settings, pk=ttid) if tool_product.product != product: - msg = f"Product {pid} does not fit to product of Tool_Product {tool_product.product.id}" - raise BadRequest(msg) + raise PermissionDenied if request.method == "POST": tform = ToolProductSettingsForm(request.POST, instance=tool_product) @@ -87,11 +86,10 @@ def edit_tool_product(request, pid, ttid): @user_is_authorized(Product, Permissions.Product_Edit, "pid") def delete_tool_product(request, pid, ttid): - tool_product = Tool_Product_Settings.objects.get(pk=ttid) + tool_product = get_object_or_404(Tool_Product_Settings, pk=ttid) product = get_object_or_404(Product, id=pid) if tool_product.product != product: - msg = f"Product {pid} does not fit to product of Tool_Product {tool_product.product.id}" - raise BadRequest(msg) + raise PermissionDenied if request.method == "POST": DeleteToolProductSettingsForm(request.POST, instance=tool_product) diff --git a/dojo/tools/blackduck/importer.py b/dojo/tools/blackduck/importer.py index 7273770c6ec..ad93cce6d01 100644 --- a/dojo/tools/blackduck/importer.py +++ b/dojo/tools/blackduck/importer.py @@ -7,6 +7,8 @@ from collections.abc import Iterable from pathlib import Path +from dojo.tools.utils import safe_open_zip + from .model import BlackduckFinding @@ -48,7 +50,7 @@ def _process_zipfile(self, report): files = {} security_issues = {} - with zipfile.ZipFile(report) as zipf: + with safe_open_zip(report) as zipf: for full_file_name in zipf.namelist(): file_name = full_file_name.split("/")[-1] # Backwards compatibility, newer versions of Blackduck have a source file rather diff --git a/dojo/tools/blackduck_component_risk/importer.py b/dojo/tools/blackduck_component_risk/importer.py index 56f04f73eb0..e35089b5f13 100644 --- a/dojo/tools/blackduck_component_risk/importer.py +++ b/dojo/tools/blackduck_component_risk/importer.py @@ -4,6 +4,8 @@ import zipfile from pathlib import Path +from dojo.tools.utils import safe_open_zip + logger = logging.getLogger(__name__) @@ -42,7 +44,7 @@ def _process_zipfile(self, report: Path) -> (dict, dict, dict): components = {} source = {} try: - with zipfile.ZipFile(report) as zipf: + with safe_open_zip(report) as zipf: c_file = False s_file = False for full_file_name in zipf.namelist(): diff --git a/dojo/tools/fortify/fpr_parser.py b/dojo/tools/fortify/fpr_parser.py index f406782bfb0..b13689e565e 100644 --- a/dojo/tools/fortify/fpr_parser.py +++ b/dojo/tools/fortify/fpr_parser.py @@ -1,12 +1,12 @@ import logging import re -import zipfile from xml.etree.ElementTree import Element from defusedxml import ElementTree from dojo.models import Finding, Test from dojo.tools.fortify.fortify_data import DescriptionData, RuleData, SnippetData, VulnerabilityData +from dojo.tools.utils import safe_read_all_zip logger = logging.getLogger(__name__) @@ -26,13 +26,9 @@ def __init__(self): pass def parse_fpr(self, filename, test): - if str(filename.__class__) == "": - input_zip = zipfile.ZipFile(filename.name, "r") - else: - input_zip = zipfile.ZipFile(filename, "r") # Read each file from the zip artifact into a dict with the format of # filename: file_content - zip_data = {name: input_zip.read(name) for name in input_zip.namelist()} + zip_data = safe_read_all_zip(filename) root, self.namespaces = self.identify_root(zip_data, "audit.fvdl", "No audit.fvdl file found in the zip") audit_log, self.namespaces_audit_log = self.identify_root(zip_data, "audit.xml") return self.convert_vulnerabilities_to_findings(root, audit_log, test) diff --git a/dojo/tools/ms_defender/parser.py b/dojo/tools/ms_defender/parser.py index 49836ecc850..6739987f856 100644 --- a/dojo/tools/ms_defender/parser.py +++ b/dojo/tools/ms_defender/parser.py @@ -1,11 +1,11 @@ import json import logging -import zipfile from django.conf import settings from dojo.models import Endpoint, Finding from dojo.tools.locations import LocationData +from dojo.tools.utils import safe_read_all_zip logger = logging.getLogger(__name__) @@ -37,12 +37,7 @@ def get_findings(self, file, test): logger.warning("Error parsing JSON file %s: %s", file.name, e) return [] elif str(file.name).endswith(".zip"): - if str(file.__class__) == "": - input_zip = zipfile.ZipFile(file.name, "r") - else: - input_zip = zipfile.ZipFile(file, "r") - - zipdata = {name: input_zip.read(name) for name in input_zip.namelist()} + zipdata = safe_read_all_zip(file) vulnerabilityfiles = [] machinefiles = [] for content in list(zipdata): diff --git a/dojo/tools/sonarqube/parser.py b/dojo/tools/sonarqube/parser.py index d9d4ecb6e55..6dd2c6285a4 100644 --- a/dojo/tools/sonarqube/parser.py +++ b/dojo/tools/sonarqube/parser.py @@ -1,6 +1,5 @@ import json import logging -import zipfile from lxml import etree @@ -8,6 +7,7 @@ from dojo.tools.sonarqube.sonarqube_restapi_zip import SonarQubeRESTAPIZIP from dojo.tools.sonarqube.soprasteria_html import SonarQubeSoprasteriaHTML from dojo.tools.sonarqube.soprasteria_json import SonarQubeSoprasteriaJSON +from dojo.tools.utils import safe_read_all_zip logger = logging.getLogger(__name__) @@ -38,11 +38,7 @@ def get_findings(self, file, test): return SonarQubeRESTAPIJSON().get_json_items(json_content, test, self.mode) return [] if file.name.endswith(".zip"): - if str(file.__class__) == "": - input_zip = zipfile.ZipFile(file.name, "r") - else: - input_zip = zipfile.ZipFile(file, "r") - zipdata = {name: input_zip.read(name) for name in input_zip.namelist()} + zipdata = safe_read_all_zip(file) return SonarQubeRESTAPIZIP().get_items(zipdata, test, self.mode) parser = etree.HTMLParser() tree = etree.parse(file, parser) diff --git a/dojo/tools/trivy_operator/checks_handler.py b/dojo/tools/trivy_operator/checks_handler.py index 86b3952c1ca..506058ef6e9 100644 --- a/dojo/tools/trivy_operator/checks_handler.py +++ b/dojo/tools/trivy_operator/checks_handler.py @@ -23,9 +23,9 @@ def handle_checks(self, labels, checks, test): for check in checks: check_title = check.get("title") check_severity = TRIVY_SEVERITIES[check.get("severity")] - check_id = check.get("checkID", "0") + check_id = check.get("checkID") or "0" check_references = "" - if check_id != 0: + if check_id != "0": check_references = ( "https://avd.aquasec.com/misconfig/kubernetes/" + check_id.lower() @@ -60,7 +60,7 @@ def handle_checks(self, labels, checks, test): ) finding_tags = [resource_namespace, check_category] finding.unsaved_tags = [tag for tag in finding_tags if tag] - if check_id: + if check_id != "0": finding.unsaved_vulnerability_ids = [UniformTrivyVulnID().return_uniformed_vulnid(check_id)] findings.append(finding) return findings diff --git a/dojo/tools/trivy_operator/compliance_handler.py b/dojo/tools/trivy_operator/compliance_handler.py index a73c60c4dcd..d529e2b0f57 100644 --- a/dojo/tools/trivy_operator/compliance_handler.py +++ b/dojo/tools/trivy_operator/compliance_handler.py @@ -31,10 +31,7 @@ def handle_compliance(self, benchmarkreport, test): check_severity = check.get("severity", "") check_target = check.get("target", "") check_title = check.get("title", "") - if not check_severity: - severity = TRIVY_SEVERITIES[check_severity] - else: - severity = TRIVY_SEVERITIES[result_severity] + severity = TRIVY_SEVERITIES[check_severity] if check_severity else TRIVY_SEVERITIES[result_severity] description += "**result description:** " + result_description + "\n" description += "**result id:** " + result_id + "\n" description += "**result name:** " + result_name + "\n" diff --git a/dojo/tools/utils.py b/dojo/tools/utils.py index 23049469cec..2b462234487 100644 --- a/dojo/tools/utils.py +++ b/dojo/tools/utils.py @@ -1,8 +1,83 @@ +import io import json import logging +import zipfile logger = logging.getLogger(__name__) +# Zip bomb protection limits +MAX_ZIP_MEMBERS = 1000 +MAX_ZIP_MEMBER_SIZE = 512 * 1024 * 1024 # 512 MB per member (uncompressed) +MAX_ZIP_TOTAL_SIZE = 1 * 1024 * 1024 * 1024 # 1 GB total (uncompressed) +MAX_ZIP_RATIO = 100 # max compression ratio (uncompressed / compressed) + + +def safe_open_zip(file): + """ + Open a zip file with protection against zip bomb attacks. + + Validates member count, per-member uncompressed size, total uncompressed + size, and compression ratios using the central-directory metadata before + any data is extracted. + + Accepts a file-like object or an io.TextIOWrapper (in which case + file.name is used as the path). + + Returns an open ZipFile. Use as a context manager or call .close() + explicitly when done. + + Raises ValueError if any limit is exceeded. + """ + zf = zipfile.ZipFile(file.name, "r") if isinstance(file, io.TextIOWrapper) else zipfile.ZipFile(file, "r") + + infos = zf.infolist() + + if len(infos) > MAX_ZIP_MEMBERS: + zf.close() + msg = f"Zip file contains {len(infos)} members, exceeding the limit of {MAX_ZIP_MEMBERS}." + raise ValueError(msg) + + total_size = 0 + for info in infos: + if info.file_size > MAX_ZIP_MEMBER_SIZE: + zf.close() + msg = ( + f"Zip member '{info.filename}' has uncompressed size {info.file_size} bytes, " + f"exceeding the per-member limit of {MAX_ZIP_MEMBER_SIZE} bytes." + ) + raise ValueError(msg) + if info.compress_size > 0 and (info.file_size / info.compress_size) > MAX_ZIP_RATIO: + zf.close() + ratio = info.file_size / info.compress_size + msg = ( + f"Zip member '{info.filename}' has a compression ratio of " + f"{ratio:.1f}:1, exceeding the limit of {MAX_ZIP_RATIO}:1." + ) + raise ValueError(msg) + total_size += info.file_size + if total_size > MAX_ZIP_TOTAL_SIZE: + zf.close() + msg = f"Zip file total uncompressed size exceeds the limit of {MAX_ZIP_TOTAL_SIZE} bytes." + raise ValueError(msg) + + return zf + + +def safe_read_all_zip(file): + """ + Open a zip file safely and read all members into a dict {name: bytes}. + + Applies the same zip bomb protections as safe_open_zip before reading + any data. + + Raises ValueError if any limit is exceeded. + """ + zf = safe_open_zip(file) + try: + return {name: zf.read(name) for name in zf.namelist()} + finally: + zf.close() + def get_npm_cwe(item_node): """ diff --git a/dojo/url/ui/views.py b/dojo/url/ui/views.py index a879867ea75..fcf2226522f 100644 --- a/dojo/url/ui/views.py +++ b/dojo/url/ui/views.py @@ -559,26 +559,14 @@ def finding_location_bulk_update(request, finding_id): if request.method == "POST": # Get the list of endpoint IDs to update and the statuses to enable finding_locations_to_update = request.POST.getlist("endpoints_to_update") - status_list = FindingLocationStatus.values - enable = [item for item in status_list if item in list(request.POST.keys())] + # Get the status + status = request.POST.get("bulk_status") # Check that endpoints and statuses are selected before proceeding - if finding_locations_to_update and len(enable) > 0: + if finding_locations_to_update and status in FindingLocationStatus: # Iterate over selected locations and update their finding location references - for location in Location.objects.filter(id__in=finding_locations_to_update): - finding_location = LocationFindingReference.objects.get(location=location, finding__id=finding_id) - for status in status_list: - # Set the status attribute based on whether it is enabled in the POST request - if status in enable: - # Enable this status - finding_location.__setattr__(status, True) # noqa: PLC2801 - # If the status is 'Mitigated', record the auditor and audit time - if status == FindingLocationStatus.Mitigated: - finding_location.auditor = request.user - finding_location.audit_time = timezone.now() - else: - # Disable this status - finding_location.__setattr__(status, False) # noqa: PLC2801 - finding_location.save() + for location_ref in LocationFindingReference.objects.filter(location__in=finding_locations_to_update, finding__id=finding_id): + # Set the status + location_ref.set_status(FindingLocationStatus(status), request.user, timezone.now()) # Add a success message after bulk editing endpoints messages.add_message( request, diff --git a/unittests/scans/trivy_operator/compliance_severity.json b/unittests/scans/trivy_operator/compliance_severity.json new file mode 100644 index 00000000000..20f401af91d --- /dev/null +++ b/unittests/scans/trivy_operator/compliance_severity.json @@ -0,0 +1,78 @@ +{ + "apiVersion": "aquasecurity.github.io/v1alpha1", + "kind": "ClusterComplianceReport", + "metadata": { + "creationTimestamp": "2024-03-05T10:38:15Z", + "generation": 1, + "labels": { + "app.kubernetes.io/instance": "trivy-operator", + "app.kubernetes.io/managed-by": "kubectl", + "app.kubernetes.io/name": "trivy-operator", + "app.kubernetes.io/version": "0.18.5" + }, + "name": "cis", + "resourceVersion": "1649372", + "uid": "test-compliance-severity" + }, + "spec": { + "compliance": { + "controls": [], + "description": "Test Compliance Severity", + "id": "test", + "title": "Test Compliance Severity Report", + "version": "1.0" + }, + "cron": "0 */6 * * *", + "reportType": "all" + }, + "status": { + "detailReport": { + "description": "Test report for compliance severity logic", + "id": "test", + "relatedResources": [], + "results": [ + { + "checks": [ + { + "category": "Kubernetes Security Check", + "checkID": "AVD-KSV-0001", + "description": "Check with its own severity", + "messages": [ + "Test message 1" + ], + "remediation": "Fix it", + "severity": "MEDIUM", + "success": false, + "target": "/test-target-1", + "title": "Check with severity" + }, + { + "category": "Kubernetes Security Check", + "checkID": "AVD-KSV-0002", + "description": "Check without severity", + "messages": [ + "Test message 2" + ], + "remediation": "Fix it too", + "severity": "", + "success": false, + "target": "/test-target-2", + "title": "Check without severity" + } + ], + "description": "Test result", + "id": "1.1.1", + "name": "Test result name", + "severity": "HIGH" + } + ], + "title": "Test Compliance Severity Report", + "version": "1.0" + }, + "summary": { + "failCount": 2, + "passCount": 0 + }, + "updateTimestamp": "2024-03-05T10:38:15Z" + } +} diff --git a/unittests/scans/trivy_operator/configauditreport_missing_checkid.json b/unittests/scans/trivy_operator/configauditreport_missing_checkid.json new file mode 100644 index 00000000000..67066415b20 --- /dev/null +++ b/unittests/scans/trivy_operator/configauditreport_missing_checkid.json @@ -0,0 +1,48 @@ +{ + "apiVersion": "aquasecurity.github.io/v1alpha1", + "kind": "ConfigAuditReport", + "metadata": { + "annotations": { + "trivy-operator.aquasecurity.github.io/report-ttl": "24h0m0s" + }, + "creationTimestamp": "2023-03-23T16:22:54Z", + "generation": 1, + "labels": { + "plugin-config-hash": "659b7b9c46", + "resource-spec-hash": "fc85b485f", + "trivy-operator.resource.kind": "ReplicaSet", + "trivy-operator.resource.name": "test-deployment-12345", + "trivy-operator.resource.namespace": "default" + }, + "name": "replicaset-test-deployment-12345", + "namespace": "default", + "resourceVersion": "1268", + "uid": "test-missing-checkid" + }, + "report": { + "checks": [ + { + "category": "Kubernetes Security Check", + "description": "A check without a checkID field", + "messages": [ + "Container 'test' of ReplicaSet 'test-deployment-12345' has an issue" + ], + "severity": "MEDIUM", + "success": false, + "title": "Missing checkID test" + } + ], + "scanner": { + "name": "Trivy", + "vendor": "Aqua Security", + "version": "dev" + }, + "summary": { + "criticalCount": 0, + "highCount": 0, + "lowCount": 0, + "mediumCount": 1 + }, + "updateTimestamp": "2023-03-23T16:22:54Z" + } +} diff --git a/unittests/test_import_reimport.py b/unittests/test_import_reimport.py index 4952854de9e..417fe0ea9ea 100644 --- a/unittests/test_import_reimport.py +++ b/unittests/test_import_reimport.py @@ -11,6 +11,7 @@ from django.test.client import Client from django.urls import reverse from django.utils import timezone +from parameterized import parameterized from dojo.models import Engagement, Finding, Product, Product_Type, Test, Test_Type, User @@ -2003,6 +2004,74 @@ def test_import_nuclei_emptyc(self): test_id2 = reimport0["test"] self.assertEqual(test_id, test_id2) + @parameterized.expand( + [ + ("Test False Positive Status (Endpoint Status)", {"false_positive": True}, "status_finding"), + ("Test Out of Scope Status (Endpoint Status)", {"out_of_scope": True}, "status_finding"), + ("Test Risk Accepted Status (Endpoint Status)", {"risk_accepted": True}, "status_finding"), + ("Test False Positive Status (Locations)", {"status": "FalsePositive"}, "locations"), + ("Test Out of Scope Status (Locations)", {"status": "OutOfScope"}, "locations"), + ("Test Risk Accepted Status (Locations)", {"status": "RiskAccepted"}, "locations"), + ], + ) + def test_import_reimport_endpoint_where_eps_reactivation_skips_special_status(self, label: str, special_status_fields: dict, m2m_key: str): + """ + When Findings are set to False Positive, Out of Scope, or Risk Accepted, they are not reactivated + because these statuses are often set by humans. The same needs to apply for the Endpoint Status as + they are an extension of the finding being partially mitigated. + """ + if settings.V3_FEATURE_LOCATIONS: + # TODO: Delete this after the move to Locations + if m2m_key == "status_finding": + # This test will fail for endpoint statuses with locations enabled + # return early here + return + context = { + "auditor": User.objects.get(username="admin"), + "audit_time": timezone.now(), + } + # TODO: Delete this after the move to Locations + else: + if m2m_key == "locations": + # This test will fail for locations with locations disabled + # return early here + return + context = { + "mitigated": True, + "mitigated_by": User.objects.get(username="admin"), + "mitigated_time": timezone.now(), + } + # Now start the test + with assertTestImportModelsCreated(self, imports=1, affected_findings=1, created=1): + import0 = self.import_scan_with_params( + self.gitlab_dast_file_name, self.scan_type_gitlab_dast, active=True, verified=True, + ) + test_id = import0["test"] + findings = self.get_test_findings_api(test_id) + self.assert_finding_count_json(1, findings) + finding = Finding.objects.get(id=findings["results"][0]["id"]) + # Get the related objects on the finding + related_obects = getattr(finding, m2m_key).all() + self.assertEqual(len(related_obects), 1) + # Update the related objects with the special status fields + related_objects_context = {**context, **special_status_fields} + related_obects.update(**related_objects_context) + # Reimport the same file + reimport0 = self.reimport_scan_with_params( + test_id, self.gitlab_dast_file_name, scan_type=self.scan_type_gitlab_dast, + ) + test_id = reimport0["test"] + findings = self.get_test_findings_api(test_id) + self.assert_finding_count_json(1, findings) + finding = Finding.objects.get(id=findings["results"][0]["id"]) + # Get the related objects on the finding + related_obects = getattr(finding, m2m_key).all() + self.assertEqual(len(related_obects), 1) + related_object = related_obects.first() + # Ensure the status is the same as the baseline + for key, value in related_objects_context.items(): + self.assertEqual(getattr(related_object, key), value) + def test_import_reimport_endpoint_where_eps_date_is_different(self): endpoint_count_before = self.db_endpoint_count() endpoint_status_count_before_active = self.db_endpoint_status_count(mitigated=False) @@ -2514,6 +2583,63 @@ def test_reimport_set_scan_date_parser_sets_date(self): date = findings["results"][0]["date"] self.assertEqual(date, "2006-12-26") + def test_reimport_auto_create_does_not_close_findings_in_existing_test(self): + """ + Regression test for #14363: when reimport with auto_create_context=True creates + a brand new test, close_old_findings must not close findings from other tests in + the same engagement scope. + + The serializer now forces close_old_findings=False when calling DefaultImporter + in this path. Without the fix, all 4 findings from the pre-existing test would be + incorrectly closed. + """ + product_type, _ = Product_Type.objects.get_or_create(name="PT CloseOld AutoCreate") + product, _ = Product.objects.get_or_create( + name="P CloseOld AutoCreate", + description="test", + prod_type=product_type, + ) + engagement = Engagement.objects.create( + name="E CloseOld AutoCreate", + product=product, + target_start=timezone.now(), + target_end=timezone.now(), + ) + + acunetix_many_findings = get_unit_tests_scans_path("acunetix") / "many_findings.xml" + + # Step 1: import 4 findings into an existing test (test1) in the engagement. + # minimum_severity="Info" is required to include all 4 findings in the file. + import1 = self.import_scan_with_params( + acunetix_many_findings, + scan_type=self.scan_type_acunetix, + engagement=engagement.id, + minimum_severity="Info", + ) + test1_id = import1["test"] + self.assert_finding_count_json(4, self.get_test_findings_api(test1_id, active=True)) + + # Step 2: call the reimport endpoint with auto_create_context=True and a + # different test_title so a new test is created. close_old_findings=True + # is the value a caller would pass (and the reimport default); the serializer + # must suppress it when auto-creating a new test. The scan uses a different + # file so its hash codes don't overlap with test1's findings, meaning the + # bug would close all 4 of test1's findings if the fix were reverted. + self.reimport_scan_with_params( + None, + self.acunetix_file_name, + scan_type=self.scan_type_acunetix, + test_title="Brand New Test From Reimport", + product_name="P CloseOld AutoCreate", + engagement_name="E CloseOld AutoCreate", + product_type_name="PT CloseOld AutoCreate", + auto_create_context=True, + close_old_findings=True, + ) + + # Step 3: test1's 4 findings must all still be active + self.assert_finding_count_json(4, self.get_test_findings_api(test1_id, active=True)) + @override_settings( IMPORT_REIMPORT_DEDUPE_BATCH_SIZE=200, IMPORT_REIMPORT_MATCH_BATCH_SIZE=200, diff --git a/unittests/test_importers_performance.py b/unittests/test_importers_performance.py index d0bd84101f5..565e04f98e6 100644 --- a/unittests/test_importers_performance.py +++ b/unittests/test_importers_performance.py @@ -270,9 +270,9 @@ def test_import_reimport_reimport_performance_pghistory_async(self): self._import_reimport_performance( expected_num_queries1=295, expected_num_async_tasks1=6, - expected_num_queries2=227, + expected_num_queries2=226, expected_num_async_tasks2=17, - expected_num_queries3=109, + expected_num_queries3=108, expected_num_async_tasks3=16, ) @@ -292,9 +292,9 @@ def test_import_reimport_reimport_performance_pghistory_no_async(self): self._import_reimport_performance( expected_num_queries1=302, expected_num_async_tasks1=6, - expected_num_queries2=234, + expected_num_queries2=233, expected_num_async_tasks2=17, - expected_num_queries3=116, + expected_num_queries3=115, expected_num_async_tasks3=16, ) @@ -315,9 +315,9 @@ def test_import_reimport_reimport_performance_pghistory_no_async_with_product_gr self._import_reimport_performance( expected_num_queries1=309, expected_num_async_tasks1=8, - expected_num_queries2=241, + expected_num_queries2=240, expected_num_async_tasks2=19, - expected_num_queries3=120, + expected_num_queries3=119, expected_num_async_tasks3=18, ) diff --git a/unittests/test_permissions_audit.py b/unittests/test_permissions_audit.py new file mode 100644 index 00000000000..7ef32860be1 --- /dev/null +++ b/unittests/test_permissions_audit.py @@ -0,0 +1,1019 @@ +""" +Security-focused permission tests for the permissions audit. + +Tests verify: +1. Risk Acceptance data is not exposed to users without Risk_Acceptance permission +2. Metadata batch operations enforce permissions on parent objects +3. Note removal verifies note-finding relationship (regression) +4. Benchmark IDOR: update_benchmark rejects bench_id from different product +5. Object/tool_product parent mismatch returns 403 +6. Risk Acceptance cross-engagement IDOR (H1 #3577434 / #3569882) +7. Engagement Presets cross-product IDOR (H1 #3577398 / #3570349) +8. Questionnaire cross-engagement IDOR (H1 #3571957) +9. Finding Templates exposure via find_template_to_apply (H1 #3577363) +10. Jira Epic BFLA - Reader cannot trigger update_jira_epic (H1 #3577193) +""" +import datetime + +from django.test import Client +from django.urls import reverse +from django.utils import timezone +from rest_framework.authtoken.models import Token +from rest_framework.test import APIClient + +from dojo.models import ( + Answered_Survey, + Benchmark_Category, + Benchmark_Product, + Benchmark_Product_Summary, + Benchmark_Requirement, + Benchmark_Type, + Dojo_User, + DojoMeta, + Engagement, + Engagement_Presets, + Engagement_Survey, + Finding, + Finding_Template, + Notes, + Objects_Product, + Objects_Review, + Product, + Product_Member, + Product_Type, + Risk_Acceptance, + Role, + Test, + Test_Type, + Tool_Configuration, + Tool_Product_Settings, + Tool_Type, +) + +from .dojo_test_case import DojoTestCase + + +class TestRiskAcceptanceExposure(DojoTestCase): + + """FindingSerializer must not expose accepted_risks to users without Risk_Acceptance permission.""" + + @classmethod + def setUpTestData(cls): + cls.reader_role = Role.objects.get(name="Reader") + cls.writer_role = Role.objects.get(name="Writer") + + # Create product type and product + cls.product_type = Product_Type.objects.create(name="RA Exposure Test PT") + cls.product = Product.objects.create( + name="RA Exposure Test Product", + description="Test", + prod_type=cls.product_type, + ) + + # Create users + cls.reader_user = Dojo_User.objects.create_user( + username="ra_test_reader", + password="testTEST1234!@#$", # noqa: S106 + is_active=True, + ) + cls.writer_user = Dojo_User.objects.create_user( + username="ra_test_writer", + password="testTEST1234!@#$", # noqa: S106 + is_active=True, + ) + + # Assign roles + Product_Member.objects.create( + product=cls.product, + user=cls.reader_user, + role=cls.reader_role, + ) + Product_Member.objects.create( + product=cls.product, + user=cls.writer_user, + role=cls.writer_role, + ) + + # Create engagement, test, finding + cls.engagement = Engagement.objects.create( + name="RA Test Engagement", + product=cls.product, + target_start=datetime.date(2024, 1, 1), + target_end=datetime.date(2024, 12, 31), + ) + test_type, _ = Test_Type.objects.get_or_create(name="Manual Code Review") + cls.test = Test.objects.create( + engagement=cls.engagement, + test_type=test_type, + target_start=timezone.now(), + target_end=timezone.now(), + ) + cls.finding = Finding.objects.create( + title="RA Test Finding", + test=cls.test, + severity="High", + numerical_severity="S1", + reporter=cls.writer_user, + ) + + # Create risk acceptance linked to the finding + cls.risk_acceptance = Risk_Acceptance.objects.create( + name="Test RA", + owner=cls.writer_user, + ) + cls.risk_acceptance.accepted_findings.add(cls.finding) + cls.engagement.risk_acceptance.add(cls.risk_acceptance) + + def _get_finding_as_user(self, user): + token, _ = Token.objects.get_or_create(user=user) + client = APIClient() + client.credentials(HTTP_AUTHORIZATION="Token " + token.key) + return client.get(reverse("finding-detail", args=(self.finding.id,))) + + def test_reader_cannot_see_accepted_risks(self): + """Reader role lacks Risk_Acceptance permission — accepted_risks must be empty.""" + response = self._get_finding_as_user(self.reader_user) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["accepted_risks"], []) + + def test_writer_can_see_accepted_risks(self): + """Writer role has Risk_Acceptance permission — accepted_risks must contain data.""" + response = self._get_finding_as_user(self.writer_user) + self.assertEqual(response.status_code, 200) + accepted = response.json()["accepted_risks"] + self.assertGreater(len(accepted), 0) + self.assertEqual(accepted[0]["name"], "Test RA") + + +class TestMetadataBatchPermissions(DojoTestCase): + + """Metadata batch endpoint must enforce permissions on parent objects.""" + + @classmethod + def setUpTestData(cls): + cls.reader_role = Role.objects.get(name="Reader") + cls.writer_role = Role.objects.get(name="Writer") + + # Product the user CAN access + cls.product_type = Product_Type.objects.create(name="Meta Batch Test PT") + cls.accessible_product = Product.objects.create( + name="Meta Batch Accessible Product", + description="Test", + prod_type=cls.product_type, + ) + + # Product the user CANNOT access + cls.inaccessible_product = Product.objects.create( + name="Meta Batch Inaccessible Product", + description="Test", + prod_type=cls.product_type, + ) + + # User with Writer on accessible product, no role on inaccessible product + cls.writer_user = Dojo_User.objects.create_user( + username="meta_batch_writer", + password="testTEST1234!@#$", # noqa: S106 + is_active=True, + ) + Product_Member.objects.create( + product=cls.accessible_product, + user=cls.writer_user, + role=cls.writer_role, + ) + + # User with Reader on accessible product (Reader lacks Product_Edit) + cls.reader_user = Dojo_User.objects.create_user( + username="meta_batch_reader", + password="testTEST1234!@#$", # noqa: S106 + is_active=True, + ) + Product_Member.objects.create( + product=cls.accessible_product, + user=cls.reader_user, + role=cls.reader_role, + ) + + def _client_for_user(self, user): + token, _ = Token.objects.get_or_create(user=user) + client = APIClient() + client.credentials(HTTP_AUTHORIZATION="Token " + token.key) + return client + + def test_batch_post_unauthorized_product(self): + """Writer should be denied when targeting a product they have no access to.""" + client = self._client_for_user(self.writer_user) + response = client.post( + reverse("metadata-batch"), + data={ + "product": self.inaccessible_product.id, + "metadata": [{"name": "hack_key", "value": "hack_val"}], + }, + format="json", + ) + self.assertIn(response.status_code, [403, 404]) + self.assertFalse( + DojoMeta.objects.filter( + product=self.inaccessible_product, name="hack_key", + ).exists(), + ) + + def test_batch_post_reader_cannot_edit(self): + """Reader lacks Product_Edit — batch POST should be denied.""" + client = self._client_for_user(self.reader_user) + response = client.post( + reverse("metadata-batch"), + data={ + "product": self.accessible_product.id, + "metadata": [{"name": "reader_key", "value": "reader_val"}], + }, + format="json", + ) + self.assertIn(response.status_code, [403, 404]) + self.assertFalse( + DojoMeta.objects.filter( + product=self.accessible_product, name="reader_key", + ).exists(), + ) + + +class TestNoteRelationshipVerification(DojoTestCase): + + """Regression: remove_note must verify the note belongs to the finding.""" + + @classmethod + def setUpTestData(cls): + cls.owner_role = Role.objects.get(name="Owner") + + cls.product_type = Product_Type.objects.create(name="Note Test PT") + cls.product = Product.objects.create( + name="Note Test Product", + description="Test", + prod_type=cls.product_type, + ) + + cls.user = Dojo_User.objects.create_user( + username="note_test_owner", + password="testTEST1234!@#$", # noqa: S106 + is_active=True, + ) + Product_Member.objects.create( + product=cls.product, + user=cls.user, + role=cls.owner_role, + ) + + cls.engagement = Engagement.objects.create( + name="Note Test Engagement", + product=cls.product, + target_start=datetime.date(2024, 1, 1), + target_end=datetime.date(2024, 12, 31), + ) + test_type, _ = Test_Type.objects.get_or_create(name="Manual Code Review") + cls.test = Test.objects.create( + engagement=cls.engagement, + test_type=test_type, + target_start=timezone.now(), + target_end=timezone.now(), + ) + + # Create two findings + cls.finding_a = Finding.objects.create( + title="Note Test Finding A", + test=cls.test, + severity="High", + numerical_severity="S1", + reporter=cls.user, + ) + cls.finding_b = Finding.objects.create( + title="Note Test Finding B", + test=cls.test, + severity="Medium", + numerical_severity="S2", + reporter=cls.user, + ) + + # Create a note on finding A + cls.note = Notes.objects.create( + entry="Test note on finding A", + author=cls.user, + ) + cls.finding_a.notes.add(cls.note) + + def test_remove_note_from_wrong_finding(self): + """Removing a note via a different finding's endpoint must fail.""" + token, _ = Token.objects.get_or_create(user=self.user) + client = APIClient() + client.credentials(HTTP_AUTHORIZATION="Token " + token.key) + + response = client.patch( + reverse("finding-remove-note", args=(self.finding_b.id,)), + data={"note_id": self.note.id}, + format="json", + ) + self.assertEqual(response.status_code, 400) + # Note should still exist + self.assertTrue(Notes.objects.filter(id=self.note.id).exists()) + + def test_remove_note_from_correct_finding(self): + """Removing a note from the correct finding must succeed for the author.""" + # Create a fresh note so we don't affect other tests + note = Notes.objects.create( + entry="Disposable test note", + author=self.user, + ) + self.finding_a.notes.add(note) + + token, _ = Token.objects.get_or_create(user=self.user) + client = APIClient() + client.credentials(HTTP_AUTHORIZATION="Token " + token.key) + + response = client.patch( + reverse("finding-remove-note", args=(self.finding_a.id,)), + data={"note_id": note.id}, + format="json", + ) + self.assertEqual(response.status_code, 204) + self.assertFalse(Notes.objects.filter(id=note.id).exists()) + + +class TestBenchmarkIDOR(DojoTestCase): + + """update_benchmark must reject bench_id belonging to a different product.""" + + @classmethod + def setUpTestData(cls): + cls.owner_role = Role.objects.get(name="Owner") + cls.product_type = Product_Type.objects.create(name="Bench IDOR Test PT") + + # Two separate products + cls.product_a = Product.objects.create( + name="Bench IDOR Product A", + description="Test", + prod_type=cls.product_type, + ) + cls.product_b = Product.objects.create( + name="Bench IDOR Product B", + description="Test", + prod_type=cls.product_type, + ) + + # User with Owner on both products + cls.user = Dojo_User.objects.create_user( + username="bench_idor_owner", + password="testTEST1234!@#$", # noqa: S106 + is_active=True, + ) + Product_Member.objects.create( + product=cls.product_a, user=cls.user, role=cls.owner_role, + ) + Product_Member.objects.create( + product=cls.product_b, user=cls.user, role=cls.owner_role, + ) + + # Create benchmark type, category, requirement + cls.bench_type = Benchmark_Type.objects.create( + name="IDOR Test Type", enabled=True, + ) + cls.bench_category = Benchmark_Category.objects.create( + type=cls.bench_type, name="V1: Test Category", enabled=True, + ) + cls.bench_requirement = Benchmark_Requirement.objects.create( + category=cls.bench_category, + objective_number="1.1", + objective="Test objective", + enabled=True, + ) + + # Create a benchmark entry for product A + cls.bench_product_a = Benchmark_Product.objects.create( + product=cls.product_a, + control=cls.bench_requirement, + ) + + # Create benchmark summary for product B (needed for URL) + cls.bench_summary_a = Benchmark_Product_Summary.objects.create( + product=cls.product_a, benchmark_type=cls.bench_type, + ) + cls.bench_summary_b = Benchmark_Product_Summary.objects.create( + product=cls.product_b, benchmark_type=cls.bench_type, + ) + + def test_update_benchmark_cross_product_rejected(self): + """POSTing a bench_id from product A via product B's URL must be denied.""" + client = Client() + client.login(username="bench_idor_owner", password="testTEST1234!@#$") # noqa: S106 + + # Try to update product A's benchmark through product B's endpoint + url = reverse( + "update_product_benchmark", + args=(self.product_b.id, self.bench_type.id), + ) + response = client.post(url, { + "bench_id": self.bench_product_a.id, + "field": "pass_fail", + "value": "true", + }) + # Scoped get_object_or_404 returns 404 for cross-product access; + # PermissionDenied would give 400/403 via custom handler403 (DD bug) + self.assertIn(response.status_code, [400, 403, 404]) + + def test_update_benchmark_summary_cross_product_rejected(self): + """POSTing a summary from product A via product B's URL must be denied.""" + client = Client() + client.login(username="bench_idor_owner", password="testTEST1234!@#$") # noqa: S106 + + url = reverse( + "update_product_benchmark_summary", + args=(self.product_b.id, self.bench_type.id, self.bench_summary_a.id), + ) + response = client.post(url, { + "field": "publish", + "value": "true", + }) + # Scoped get_object_or_404 returns 404 for cross-product access; + # PermissionDenied would give 400/403 via custom handler403 (DD bug) + self.assertIn(response.status_code, [400, 403, 404]) + + def test_update_benchmark_same_product_allowed(self): + """POSTing a bench_id for the correct product should succeed.""" + client = Client() + client.login(username="bench_idor_owner", password="testTEST1234!@#$") # noqa: S106 + + url = reverse( + "update_product_benchmark", + args=(self.product_a.id, self.bench_type.id), + ) + response = client.post(url, { + "bench_id": self.bench_product_a.id, + "field": "enabled", + "value": "true", + }) + self.assertEqual(response.status_code, 200) + + +class TestObjectProductParentCheck(DojoTestCase): + + """edit_object and delete_object must reject objects from different products.""" + + @classmethod + def setUpTestData(cls): + cls.owner_role = Role.objects.get(name="Owner") + cls.product_type = Product_Type.objects.create(name="Object Parent Test PT") + + cls.product_a = Product.objects.create( + name="Object Parent Product A", + description="Test", + prod_type=cls.product_type, + ) + cls.product_b = Product.objects.create( + name="Object Parent Product B", + description="Test", + prod_type=cls.product_type, + ) + + cls.user = Dojo_User.objects.create_user( + username="object_parent_owner", + password="testTEST1234!@#$", # noqa: S106 + is_active=True, + ) + Product_Member.objects.create( + product=cls.product_a, user=cls.user, role=cls.owner_role, + ) + Product_Member.objects.create( + product=cls.product_b, user=cls.user, role=cls.owner_role, + ) + + # Object belonging to product A + cls.review_status = Objects_Review.objects.create(name="In Review") + cls.tracked_file = Objects_Product.objects.create( + product=cls.product_a, + path="/test/path", + folder="test_folder", + artifact="test.py", + review_status=cls.review_status, + ) + + def test_edit_object_cross_product_rejected(self): + """Editing an object from product A via product B's URL must be denied.""" + client = Client() + client.login(username="object_parent_owner", password="testTEST1234!@#$") # noqa: S106 + + url = reverse("edit_object", args=(self.product_b.id, self.tracked_file.id)) + response = client.get(url) + # PermissionDenied raised; custom handler403 returns 400 (DD bug) + self.assertIn(response.status_code, [400, 403]) + + def test_delete_object_cross_product_rejected(self): + """Deleting an object from product A via product B's URL must be denied.""" + client = Client() + client.login(username="object_parent_owner", password="testTEST1234!@#$") # noqa: S106 + + url = reverse("delete_object", args=(self.product_b.id, self.tracked_file.id)) + response = client.get(url) + # PermissionDenied raised; custom handler403 returns 400 (DD bug) + self.assertIn(response.status_code, [400, 403]) + + +class TestToolProductParentCheck(DojoTestCase): + + """edit_tool_product and delete_tool_product must reject tools from different products.""" + + @classmethod + def setUpTestData(cls): + cls.owner_role = Role.objects.get(name="Owner") + cls.product_type = Product_Type.objects.create(name="Tool Parent Test PT") + + cls.product_a = Product.objects.create( + name="Tool Parent Product A", + description="Test", + prod_type=cls.product_type, + ) + cls.product_b = Product.objects.create( + name="Tool Parent Product B", + description="Test", + prod_type=cls.product_type, + ) + + cls.user = Dojo_User.objects.create_user( + username="tool_parent_owner", + password="testTEST1234!@#$", # noqa: S106 + is_active=True, + ) + Product_Member.objects.create( + product=cls.product_a, user=cls.user, role=cls.owner_role, + ) + Product_Member.objects.create( + product=cls.product_b, user=cls.user, role=cls.owner_role, + ) + + # Tool type, configuration, and tool setting belonging to product A + cls.tool_type = Tool_Type.objects.create(name="Test Tool Type Parent Check") + cls.tool_config = Tool_Configuration.objects.create( + name="Test Tool Config", + tool_type=cls.tool_type, + ) + cls.tool_setting = Tool_Product_Settings.objects.create( + name="Test Tool Setting", + product=cls.product_a, + tool_configuration=cls.tool_config, + ) + + def test_edit_tool_product_cross_product_rejected(self): + """Editing a tool setting from product A via product B's URL must be denied.""" + client = Client() + client.login(username="tool_parent_owner", password="testTEST1234!@#$") # noqa: S106 + + url = reverse("edit_tool_product", args=(self.product_b.id, self.tool_setting.id)) + response = client.get(url) + # PermissionDenied raised; custom handler403 returns 400 (DD bug) + self.assertIn(response.status_code, [400, 403]) + + def test_delete_tool_product_cross_product_rejected(self): + """Deleting a tool setting from product A via product B's URL must be denied.""" + client = Client() + client.login(username="tool_parent_owner", password="testTEST1234!@#$") # noqa: S106 + + url = reverse("delete_tool_product", args=(self.product_b.id, self.tool_setting.id)) + response = client.get(url) + # PermissionDenied raised; custom handler403 returns 400 (DD bug) + self.assertIn(response.status_code, [400, 403]) + + +class TestRiskAcceptanceCrossEngagementIDOR(DojoTestCase): + + """ + H1 #3577434 / #3569882: Risk acceptance endpoints must reject + a raid belonging to a different engagement than the eid in the URL. + """ + + @classmethod + def setUpTestData(cls): + cls.owner_role = Role.objects.get(name="Owner") + cls.product_type = Product_Type.objects.create(name="RA IDOR Test PT") + cls.product = Product.objects.create( + name="RA IDOR Test Product", + description="Test", + prod_type=cls.product_type, + ) + cls.user = Dojo_User.objects.create_user( + username="ra_idor_owner", + password="testTEST1234!@#$", # noqa: S106 + is_active=True, + ) + Product_Member.objects.create( + product=cls.product, user=cls.user, role=cls.owner_role, + ) + + # Two engagements under the same product + cls.engagement_a = Engagement.objects.create( + name="RA IDOR Engagement A", + product=cls.product, + target_start=datetime.date(2024, 1, 1), + target_end=datetime.date(2024, 12, 31), + ) + cls.engagement_b = Engagement.objects.create( + name="RA IDOR Engagement B", + product=cls.product, + target_start=datetime.date(2024, 1, 1), + target_end=datetime.date(2024, 12, 31), + ) + + # Create a risk acceptance on engagement A + test_type, _ = Test_Type.objects.get_or_create(name="Manual Code Review") + cls.test_a = Test.objects.create( + engagement=cls.engagement_a, + test_type=test_type, + target_start=timezone.now(), + target_end=timezone.now(), + ) + cls.finding_a = Finding.objects.create( + title="RA IDOR Finding", + test=cls.test_a, + severity="High", + numerical_severity="S1", + reporter=cls.user, + ) + cls.risk_acceptance = Risk_Acceptance.objects.create( + name="RA IDOR Test RA", + owner=cls.user, + ) + cls.risk_acceptance.accepted_findings.add(cls.finding_a) + cls.engagement_a.risk_acceptance.add(cls.risk_acceptance) + + def _login(self): + client = Client() + client.login(username="ra_idor_owner", password="testTEST1234!@#$") # noqa: S106 + return client + + def test_view_risk_acceptance_cross_engagement(self): + """Viewing a risk acceptance via a different engagement's URL must be denied.""" + client = self._login() + url = reverse("view_risk_acceptance", args=( + self.engagement_b.id, self.risk_acceptance.id, + )) + response = client.get(url) + self.assertEqual(response.status_code, 404) + + def test_edit_risk_acceptance_cross_engagement(self): + """Editing a risk acceptance via a different engagement's URL must be denied.""" + client = self._login() + url = reverse("edit_risk_acceptance", args=( + self.engagement_b.id, self.risk_acceptance.id, + )) + response = client.get(url) + self.assertEqual(response.status_code, 404) + + def test_expire_risk_acceptance_cross_engagement(self): + """Expiring a risk acceptance via a different engagement's URL must be denied.""" + client = self._login() + url = reverse("expire_risk_acceptance", args=( + self.engagement_b.id, self.risk_acceptance.id, + )) + response = client.get(url) + self.assertEqual(response.status_code, 404) + + def test_reinstate_risk_acceptance_cross_engagement(self): + """Reinstating a risk acceptance via a different engagement's URL must be denied.""" + client = self._login() + url = reverse("reinstate_risk_acceptance", args=( + self.engagement_b.id, self.risk_acceptance.id, + )) + response = client.get(url) + self.assertEqual(response.status_code, 404) + + def test_delete_risk_acceptance_cross_engagement(self): + """Deleting a risk acceptance via a different engagement's URL must be denied.""" + client = self._login() + url = reverse("delete_risk_acceptance", args=( + self.engagement_b.id, self.risk_acceptance.id, + )) + response = client.get(url) + self.assertEqual(response.status_code, 404) + + def test_view_risk_acceptance_same_engagement(self): + """Viewing a risk acceptance via the correct engagement's URL should work.""" + client = self._login() + url = reverse("view_risk_acceptance", args=( + self.engagement_a.id, self.risk_acceptance.id, + )) + response = client.get(url) + self.assertEqual(response.status_code, 200) + + +class TestEngagementPresetsCrossProductIDOR(DojoTestCase): + + """ + H1 #3577398 / #3570349: Engagement preset endpoints must reject + a preset belonging to a different product than the pid in the URL. + """ + + @classmethod + def setUpTestData(cls): + cls.owner_role = Role.objects.get(name="Owner") + cls.product_type = Product_Type.objects.create(name="Preset IDOR Test PT") + + cls.product_a = Product.objects.create( + name="Preset IDOR Product A", + description="Test", + prod_type=cls.product_type, + ) + cls.product_b = Product.objects.create( + name="Preset IDOR Product B", + description="Test", + prod_type=cls.product_type, + ) + + cls.user = Dojo_User.objects.create_user( + username="preset_idor_owner", + password="testTEST1234!@#$", # noqa: S106 + is_active=True, + ) + Product_Member.objects.create( + product=cls.product_a, user=cls.user, role=cls.owner_role, + ) + Product_Member.objects.create( + product=cls.product_b, user=cls.user, role=cls.owner_role, + ) + + # Preset belonging to product A + cls.preset = Engagement_Presets.objects.create( + title="IDOR Test Preset", + product=cls.product_a, + scope="Test scope", + ) + + def _login(self): + client = Client() + client.login(username="preset_idor_owner", password="testTEST1234!@#$") # noqa: S106 + return client + + def test_edit_preset_cross_product(self): + """Editing a preset from product A via product B's URL must return 404.""" + client = self._login() + url = reverse("edit_engagement_presets", args=( + self.product_b.id, self.preset.id, + )) + response = client.get(url) + # Scoped get_object_or_404 returns 404 for cross-product access + self.assertEqual(response.status_code, 404) + + def test_delete_preset_cross_product(self): + """Deleting a preset from product A via product B's URL must return 404.""" + client = self._login() + url = reverse("delete_engagement_presets", args=( + self.product_b.id, self.preset.id, + )) + response = client.get(url) + self.assertEqual(response.status_code, 404) + + def test_edit_preset_same_product(self): + """Editing a preset via the correct product's URL should work.""" + client = self._login() + url = reverse("edit_engagement_presets", args=( + self.product_a.id, self.preset.id, + )) + response = client.get(url) + self.assertEqual(response.status_code, 200) + + +class TestQuestionnaireCrossEngagementIDOR(DojoTestCase): + + """ + H1 #3571957: Survey/questionnaire endpoints must reject + a survey belonging to a different engagement than the eid in the URL. + """ + + @classmethod + def setUpTestData(cls): + cls.owner_role = Role.objects.get(name="Owner") + cls.product_type = Product_Type.objects.create(name="Survey IDOR Test PT") + cls.product = Product.objects.create( + name="Survey IDOR Test Product", + description="Test", + prod_type=cls.product_type, + ) + cls.user = Dojo_User.objects.create_user( + username="survey_idor_owner", + password="testTEST1234!@#$", # noqa: S106 + is_active=True, + ) + Product_Member.objects.create( + product=cls.product, user=cls.user, role=cls.owner_role, + ) + + cls.engagement_a = Engagement.objects.create( + name="Survey IDOR Engagement A", + product=cls.product, + target_start=datetime.date(2024, 1, 1), + target_end=datetime.date(2024, 12, 31), + ) + cls.engagement_b = Engagement.objects.create( + name="Survey IDOR Engagement B", + product=cls.product, + target_start=datetime.date(2024, 1, 1), + target_end=datetime.date(2024, 12, 31), + ) + + # Create a questionnaire (Engagement_Survey) and an Answered_Survey on engagement A + cls.survey_template = Engagement_Survey.objects.create( + name="Test Questionnaire", + description="Test description", + active=True, + ) + cls.answered_survey = Answered_Survey.objects.create( + engagement=cls.engagement_a, + survey=cls.survey_template, + responder=cls.user, + completed=False, + ) + + def _login(self): + client = Client() + client.login(username="survey_idor_owner", password="testTEST1234!@#$") # noqa: S106 + return client + + def test_view_questionnaire_cross_engagement(self): + """Viewing a survey from engagement A via engagement B's URL must return 404.""" + client = self._login() + url = reverse("view_questionnaire", args=( + self.engagement_b.id, self.answered_survey.id, + )) + response = client.get(url) + self.assertEqual(response.status_code, 404) + + def test_delete_survey_cross_engagement(self): + """Deleting a survey from engagement A via engagement B's URL must return 404.""" + client = self._login() + url = reverse("delete_engagement_survey", args=( + self.engagement_b.id, self.answered_survey.id, + )) + response = client.get(url) + self.assertEqual(response.status_code, 404) + + def test_answer_questionnaire_cross_engagement(self): + """Answering a survey from engagement A via engagement B's URL must return 404.""" + client = self._login() + url = reverse("answer_questionnaire", args=( + self.engagement_b.id, self.answered_survey.id, + )) + response = client.get(url) + self.assertEqual(response.status_code, 404) + + def test_view_questionnaire_same_engagement(self): + """Viewing a survey via the correct engagement's URL should work.""" + client = self._login() + url = reverse("view_questionnaire", args=( + self.engagement_a.id, self.answered_survey.id, + )) + response = client.get(url) + self.assertEqual(response.status_code, 200) + + +class TestFindingTemplatesGlobalPermission(DojoTestCase): + + """ + H1 #3577363: find_template_to_apply must require global Finding_Edit + permission, not just product-level Finding_Edit. + """ + + @classmethod + def setUpTestData(cls): + cls.writer_role = Role.objects.get(name="Writer") + cls.product_type = Product_Type.objects.create(name="Template Test PT") + cls.product = Product.objects.create( + name="Template Test Product", + description="Test", + prod_type=cls.product_type, + ) + + # Product-level writer (no global permission) + cls.product_writer = Dojo_User.objects.create_user( + username="template_test_writer", + password="testTEST1234!@#$", # noqa: S106 + is_active=True, + ) + Product_Member.objects.create( + product=cls.product, user=cls.product_writer, role=cls.writer_role, + ) + + # Superuser (has global permissions) + cls.superuser = Dojo_User.objects.create_user( + username="template_test_super", + password="testTEST1234!@#$", # noqa: S106 + is_active=True, + is_superuser=True, + ) + + # Create engagement, test, finding + cls.engagement = Engagement.objects.create( + name="Template Test Engagement", + product=cls.product, + target_start=datetime.date(2024, 1, 1), + target_end=datetime.date(2024, 12, 31), + ) + test_type, _ = Test_Type.objects.get_or_create(name="Manual Code Review") + cls.test = Test.objects.create( + engagement=cls.engagement, + test_type=test_type, + target_start=timezone.now(), + target_end=timezone.now(), + ) + cls.finding = Finding.objects.create( + title="Template Test Finding", + test=cls.test, + severity="High", + numerical_severity="S1", + reporter=cls.product_writer, + ) + + # Create a template (should only be visible to global permission holders) + Finding_Template.objects.create( + title="Secret Template", + severity="Critical", + ) + + def test_product_writer_cannot_access_find_template(self): + """Product-level Writer without global permission should be denied.""" + client = Client() + client.login(username="template_test_writer", password="testTEST1234!@#$") # noqa: S106 + url = reverse("find_template_to_apply", args=(self.finding.id,)) + response = client.get(url) + # PermissionDenied raised; custom handler403 returns 400 (DD bug) + self.assertIn(response.status_code, [400, 403]) + + def test_superuser_can_access_find_template(self): + """Superuser (implicit global permission) should be able to access.""" + client = Client() + client.login(username="template_test_super", password="testTEST1234!@#$") # noqa: S106 + url = reverse("find_template_to_apply", args=(self.finding.id,)) + response = client.get(url) + self.assertEqual(response.status_code, 200) + + +class TestJiraEpicBFLA(DojoTestCase): + + """ + H1 #3577193: update_jira_epic must enforce Engagement_Edit permission, + not just IsAuthenticated. Reader role should be denied. + """ + + @classmethod + def setUpTestData(cls): + cls.reader_role = Role.objects.get(name="Reader") + cls.writer_role = Role.objects.get(name="Writer") + cls.product_type = Product_Type.objects.create(name="Jira Epic BFLA Test PT") + cls.product = Product.objects.create( + name="Jira Epic BFLA Test Product", + description="Test", + prod_type=cls.product_type, + ) + + cls.reader_user = Dojo_User.objects.create_user( + username="jira_epic_reader", + password="testTEST1234!@#$", # noqa: S106 + is_active=True, + ) + cls.writer_user = Dojo_User.objects.create_user( + username="jira_epic_writer", + password="testTEST1234!@#$", # noqa: S106 + is_active=True, + ) + + Product_Member.objects.create( + product=cls.product, user=cls.reader_user, role=cls.reader_role, + ) + Product_Member.objects.create( + product=cls.product, user=cls.writer_user, role=cls.writer_role, + ) + + cls.engagement = Engagement.objects.create( + name="Jira Epic BFLA Engagement", + product=cls.product, + target_start=datetime.date(2024, 1, 1), + target_end=datetime.date(2024, 12, 31), + ) + + def _client_for_user(self, user): + token, _ = Token.objects.get_or_create(user=user) + client = APIClient() + client.credentials(HTTP_AUTHORIZATION="Token " + token.key) + return client + + def test_reader_cannot_update_jira_epic(self): + """Reader role should be denied POST to update_jira_epic.""" + client = self._client_for_user(self.reader_user) + url = reverse("engagement-update-jira-epic", args=(self.engagement.id,)) + response = client.post(url, data={}, format="json") + self.assertIn(response.status_code, [403, 404]) + + def test_writer_allowed_update_jira_epic(self): + """ + Writer role should be allowed to POST to update_jira_epic + (may fail at Jira level, but not at permission level). + """ + client = self._client_for_user(self.writer_user) + url = reverse("engagement-update-jira-epic", args=(self.engagement.id,)) + response = client.post(url, data={}, format="json") + # Writer has Engagement_Edit, so should pass permission check. + # May get 400/500 from Jira integration, but NOT 403. + self.assertNotEqual(response.status_code, 403) diff --git a/unittests/test_rest_framework.py b/unittests/test_rest_framework.py index 5666f4de1d8..5f10e4e1ad8 100644 --- a/unittests/test_rest_framework.py +++ b/unittests/test_rest_framework.py @@ -3788,26 +3788,101 @@ def __init__(self, *args, **kwargs): def __del__(self: object): self.payload["file"].close() + def _build_payload(self, data): + return { + "product": 1, + "file": SimpleUploadedFile( + "defectdojo_cloc.json", + json.dumps(data).encode("utf-8"), + content_type="application/json", + ), + } + def test_create(self): - BaseClass.CreateRequestTest.test_create(self) + self.payload["file"].close() + base_data = json.loads( + Path("unittests/files/defectdojo_cloc.json").read_text( + encoding="utf-8", + ), + ) + updated_data = json.loads(json.dumps(base_data)) + updated_data.pop("JSON", None) + updated_data["Python"]["code"] = 51057 + updated_data["Go"] = { + "nFiles": 1, + "blank": 2, + "comment": 3, + "code": 4, + } - languages = Languages.objects.filter(product=1).order_by("language") + test_cases = [ + ( + "initial", + base_data, + { + "JSON": { + "files": 21, + "blank": 7, + "comment": 0, + "code": 63996, + }, + "Python": { + "files": 432, + "blank": 10813, + "comment": 5054, + "code": 51056, + }, + }, + ), + ( + "updated", + updated_data, + { + "Go": { + "files": 1, + "blank": 2, + "comment": 3, + "code": 4, + }, + "Python": { + "files": 432, + "blank": 10813, + "comment": 5054, + "code": 51057, + }, + }, + ), + ] - self.assertEqual(2, len(languages)) + product = Product.objects.get(id=1) + for case_name, payload_data, expected in test_cases: + with self.subTest(case=case_name): + self.payload = self._build_payload(payload_data) + response = self.client.post(self.url, self.payload) + self.assertEqual(201, response.status_code, response.content[:1000]) + self.check_schema_response("post", "201", response) - self.assertEqual(languages[0].product, Product.objects.get(id=1)) - self.assertEqual(languages[0].language, Language_Type.objects.get(id=1)) - self.assertEqual(languages[0].files, 21) - self.assertEqual(languages[0].blank, 7) - self.assertEqual(languages[0].comment, 0) - self.assertEqual(languages[0].code, 63996) + languages = ( + Languages.objects.filter(product=1) + .select_related("language") + .order_by("language__language") + ) + self.assertEqual(len(expected), languages.count()) - self.assertEqual(languages[1].product, Product.objects.get(id=1)) - self.assertEqual(languages[1].language, Language_Type.objects.get(id=2)) - self.assertEqual(languages[1].files, 432) - self.assertEqual(languages[1].blank, 10813) - self.assertEqual(languages[1].comment, 5054) - self.assertEqual(languages[1].code, 51056) + languages_by_name = { + language.language.language: language + for language in languages + } + self.assertEqual(set(expected.keys()), set(languages_by_name.keys())) + + for name, counts in expected.items(): + language = languages_by_name[name] + self.assertEqual(product, language.product) + self.assertEqual(name, language.language.language) + self.assertEqual(counts["files"], language.files) + self.assertEqual(counts["blank"], language.blank) + self.assertEqual(counts["comment"], language.comment) + self.assertEqual(counts["code"], language.code) @versioned_fixtures diff --git a/unittests/tools/test_trivy_operator_parser.py b/unittests/tools/test_trivy_operator_parser.py index 33181a053c8..b99b99fb8fa 100644 --- a/unittests/tools/test_trivy_operator_parser.py +++ b/unittests/tools/test_trivy_operator_parser.py @@ -142,7 +142,7 @@ def test_cis_benchmark(self): self.assertEqual(len(findings), 795) finding = findings[0] self.assertEqual("5.1.2 AVD-KSV-0041 /clusterrole-admin", finding.title) - self.assertEqual("High", finding.severity) + self.assertEqual("Critical", finding.severity) self.assertEqual(1, len(finding.unsaved_vulnerability_ids)) self.assertEqual("AVD-KSV-0041", finding.unsaved_vulnerability_ids[0]) finding = findings[40] @@ -174,6 +174,27 @@ def test_findings_clustercompliancereport(self): findings = parser.get_findings(test_file, Test()) self.assertEqual(len(findings), 2) + def test_compliance_severity_logic(self): + with sample_path("compliance_severity.json").open(encoding="utf-8") as test_file: + parser = TrivyOperatorParser() + findings = parser.get_findings(test_file, Test()) + self.assertEqual(len(findings), 2) + # First check has severity MEDIUM, result has severity HIGH -> uses check's MEDIUM + self.assertEqual("Medium", findings[0].severity) + # Second check has empty severity, result has severity HIGH -> falls back to HIGH + self.assertEqual("High", findings[1].severity) + + def test_configauditreport_missing_checkid(self): + with sample_path("configauditreport_missing_checkid.json").open(encoding="utf-8") as test_file: + parser = TrivyOperatorParser() + findings = parser.get_findings(test_file, Test()) + self.assertEqual(len(findings), 1) + finding = findings[0] + self.assertEqual("Medium", finding.severity) + self.assertEqual("0 - Missing checkID test", finding.title) + # When checkID is "0", references should be empty (not a bogus URL) + self.assertEqual("", finding.references) + def test_configauditreport_with_remediation(self): with sample_path("configauditreport_with_remediation.json").open(encoding="utf-8") as test_file: parser = TrivyOperatorParser()