From 3d1de3329a7a3df8a02adf210f4a12c436aed038 Mon Sep 17 00:00:00 2001 From: mo khan <mo@mokhan.ca> Date: Fri, 7 Jul 2023 15:02:39 +0000 Subject: [PATCH] Add mutation to dismiss multiple vulnerabilities This change adds a new mutation that can be used to dismiss multiple vulnerabilities at the same time. The name of the new mutation is `Mutation.vulnerabilitiesDismiss`. --- doc/api/graphql/reference/index.md | 25 +++ .../security/vulnerabilities_controller.rb | 4 + .../security/vulnerabilities_controller.rb | 1 + .../vulnerability_report_controller.rb | 4 + .../security/application_controller.rb | 4 + ee/app/graphql/ee/types/mutation_type.rb | 1 + .../mutations/vulnerabilities/bulk_dismiss.rb | 62 +++++++ ee/app/models/ee/vulnerability.rb | 5 + .../system_notes/vulnerabilities_service.rb | 50 +++--- .../vulnerabilities/bulk_dismiss_service.rb | 127 +++++++++++++++ .../dismiss_multiple_vulnerabilities.yml | 8 + .../security/vulnerability_report_spec.rb | 44 +++++ .../vulnerabilities/bulk_dismiss_spec.rb | 53 ++++++ ee/spec/models/ee/vulnerability_spec.rb | 21 +++ .../vulnerabilities/bulk_dismiss_spec.rb | 125 ++++++++++++++ .../bulk_dismiss_service_spec.rb | 153 ++++++++++++++++++ 16 files changed, 669 insertions(+), 18 deletions(-) create mode 100644 ee/app/graphql/mutations/vulnerabilities/bulk_dismiss.rb create mode 100644 ee/app/services/vulnerabilities/bulk_dismiss_service.rb create mode 100644 ee/config/feature_flags/development/dismiss_multiple_vulnerabilities.yml create mode 100644 ee/spec/graphql/mutations/vulnerabilities/bulk_dismiss_spec.rb create mode 100644 ee/spec/requests/api/graphql/mutations/vulnerabilities/bulk_dismiss_spec.rb create mode 100644 ee/spec/services/vulnerabilities/bulk_dismiss_service_spec.rb diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 65c3072fa8ba4..8c21767f583e5 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -6881,6 +6881,31 @@ Input type: `UserSetNamespaceCommitEmailInput` | <a id="mutationusersetnamespacecommitemailerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | | <a id="mutationusersetnamespacecommitemailnamespacecommitemail"></a>`namespaceCommitEmail` | [`NamespaceCommitEmail`](#namespacecommitemail) | User namespace commit email after mutation. | +### `Mutation.vulnerabilitiesDismiss` + +WARNING: +**Introduced** in 16.2. +This feature is an Experiment. It can be changed or removed at any time. + +Input type: `VulnerabilitiesDismissInput` + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mutationvulnerabilitiesdismissclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationvulnerabilitiesdismisscomment"></a>`comment` | [`String`](#string) | Comment why vulnerability was dismissed (maximum 50,000 characters). | +| <a id="mutationvulnerabilitiesdismissdismissalreason"></a>`dismissalReason` | [`VulnerabilityDismissalReason`](#vulnerabilitydismissalreason) | Reason why vulnerability should be dismissed. | +| <a id="mutationvulnerabilitiesdismissvulnerabilityids"></a>`vulnerabilityIds` | [`[VulnerabilityID!]!`](#vulnerabilityid) | IDs of the vulnerabilities to be dismissed. | + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mutationvulnerabilitiesdismissclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationvulnerabilitiesdismisserrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | +| <a id="mutationvulnerabilitiesdismissvulnerabilities"></a>`vulnerabilities` **{warning-solid}** | [`[Vulnerability!]!`](#vulnerability) | **Deprecated:** This feature is an Experiment. It can be changed or removed at any time. Introduced in 16.2. | + ### `Mutation.vulnerabilityConfirm` Input type: `VulnerabilityConfirmInput` diff --git a/ee/app/controllers/groups/security/vulnerabilities_controller.rb b/ee/app/controllers/groups/security/vulnerabilities_controller.rb index a3a334b8a3732..c6c8749358cae 100644 --- a/ee/app/controllers/groups/security/vulnerabilities_controller.rb +++ b/ee/app/controllers/groups/security/vulnerabilities_controller.rb @@ -5,6 +5,10 @@ module Security class VulnerabilitiesController < Groups::ApplicationController layout 'group' + before_action do + push_frontend_feature_flag(:dismiss_multiple_vulnerabilities, @project) + end + feature_category :vulnerability_management urgency :low diff --git a/ee/app/controllers/projects/security/vulnerabilities_controller.rb b/ee/app/controllers/projects/security/vulnerabilities_controller.rb index 2b0fe774f0354..b431bc3d29fce 100644 --- a/ee/app/controllers/projects/security/vulnerabilities_controller.rb +++ b/ee/app/controllers/projects/security/vulnerabilities_controller.rb @@ -7,6 +7,7 @@ class VulnerabilitiesController < Projects::ApplicationController before_action do push_frontend_feature_flag(:create_vulnerability_jira_issue_via_graphql, @project) + push_frontend_feature_flag(:dismiss_multiple_vulnerabilities, @project) push_frontend_feature_flag(:openai_experimentation, @project) end diff --git a/ee/app/controllers/projects/security/vulnerability_report_controller.rb b/ee/app/controllers/projects/security/vulnerability_report_controller.rb index 92bf3223965d1..165ea0fd4ac4d 100644 --- a/ee/app/controllers/projects/security/vulnerability_report_controller.rb +++ b/ee/app/controllers/projects/security/vulnerability_report_controller.rb @@ -3,6 +3,10 @@ module Projects module Security class VulnerabilityReportController < Projects::ApplicationController + before_action do + push_frontend_feature_flag(:dismiss_multiple_vulnerabilities, @project) + end + before_action :authorize_read_vulnerability! feature_category :vulnerability_management diff --git a/ee/app/controllers/security/application_controller.rb b/ee/app/controllers/security/application_controller.rb index b507f9829b855..30bf5218454dd 100644 --- a/ee/app/controllers/security/application_controller.rb +++ b/ee/app/controllers/security/application_controller.rb @@ -4,6 +4,10 @@ module Security class ApplicationController < ::ApplicationController include SecurityDashboardsPermissions + before_action do + push_frontend_feature_flag(:dismiss_multiple_vulnerabilities, @project) + end + feature_category :vulnerability_management urgency :low diff --git a/ee/app/graphql/ee/types/mutation_type.rb b/ee/app/graphql/ee/types/mutation_type.rb index 35181a8e2fd36..5f50e2ba192e8 100644 --- a/ee/app/graphql/ee/types/mutation_type.rb +++ b/ee/app/graphql/ee/types/mutation_type.rb @@ -37,6 +37,7 @@ module MutationType mount_mutation ::Mutations::Security::Finding::Dismiss mount_mutation ::Mutations::Security::Finding::RevertToDetected mount_mutation ::Mutations::Vulnerabilities::Create + mount_mutation ::Mutations::Vulnerabilities::BulkDismiss, alpha: { milestone: '16.2' } mount_mutation ::Mutations::Vulnerabilities::Dismiss mount_mutation ::Mutations::Vulnerabilities::Resolve mount_mutation ::Mutations::Vulnerabilities::Confirm diff --git a/ee/app/graphql/mutations/vulnerabilities/bulk_dismiss.rb b/ee/app/graphql/mutations/vulnerabilities/bulk_dismiss.rb new file mode 100644 index 0000000000000..adbf92f0db108 --- /dev/null +++ b/ee/app/graphql/mutations/vulnerabilities/bulk_dismiss.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Mutations + module Vulnerabilities + class BulkDismiss < BaseMutation + graphql_name 'VulnerabilitiesDismiss' + authorize :admin_vulnerability + + argument :vulnerability_ids, + [::Types::GlobalIDType[::Vulnerability]], + required: true, + prepare: ->(vulnerability_ids, ctx) { + ::Mutations::Vulnerabilities::BulkDismiss.prepare(vulnerability_ids, ctx) + }, + description: "IDs of the vulnerabilities to be dismissed." + + argument :comment, + GraphQL::Types::String, + required: false, + description: "Comment why vulnerability was dismissed (maximum 50,000 characters)." + + argument :dismissal_reason, + Types::Vulnerabilities::DismissalReasonEnum, + required: false, + description: 'Reason why vulnerability should be dismissed.' + + field :vulnerabilities, [Types::VulnerabilityType], + alpha: { milestone: '16.2' }, + null: false, + description: 'Vulnerabilities after state change.' + + def resolve(comment: nil, dismissal_reason: nil, vulnerability_ids: []) + ids = vulnerability_ids.map(&:model_id).uniq + + response = ::Vulnerabilities::BulkDismissService.new( + current_user, + ids, + comment, + dismissal_reason + ).execute + + { + vulnerabilities: response[:vulnerabilities] || [], + errors: response.success? ? [] : [response.message] + } + rescue Gitlab::Access::AccessDeniedError + raise_resource_not_available_error! + end + + def self.prepare(vulnerability_ids, _ctx) + max_vulnerabilities = ::Vulnerabilities::BulkDismissService::MAX_BATCH + if vulnerability_ids.length > max_vulnerabilities + raise GraphQL::ExecutionError, "Maximum vulnerability_ids exceeded (#{max_vulnerabilities})" + elsif vulnerability_ids.empty? + raise GraphQL::ExecutionError, "At least 1 value must be provided for vulnerability_ids" + end + + vulnerability_ids + end + end + end +end diff --git a/ee/app/models/ee/vulnerability.rb b/ee/app/models/ee/vulnerability.rb index f09370d153266..341db402c7a67 100644 --- a/ee/app/models/ee/vulnerability.rb +++ b/ee/app/models/ee/vulnerability.rb @@ -91,6 +91,7 @@ def with_vulnerability_links scope :with_report_types, -> (report_types) { where(report_type: report_types) } scope :with_severities, -> (severities) { where(severity: severities) } scope :with_states, -> (states) { where(state: states) } + scope :without_states, -> (states) { where.not(state: states) } scope :with_scanner_external_ids, -> (scanner_external_ids) { joins(findings: :scanner).merge(::Vulnerabilities::Scanner.with_external_id(scanner_external_ids)) } scope :grouped_by_severity, -> { reorder(severity: :desc).group(:severity) } scope :by_primary_identifier_ids, -> (identifier_ids) do @@ -289,6 +290,10 @@ def parent_class ::Project end + def projects + ::Project.id_in(select(:project_id)) + end + def to_ability_name model_name.singular end diff --git a/ee/app/services/system_notes/vulnerabilities_service.rb b/ee/app/services/system_notes/vulnerabilities_service.rb index 6346b3bbf7b8e..d73ca41c0244d 100644 --- a/ee/app/services/system_notes/vulnerabilities_service.rb +++ b/ee/app/services/system_notes/vulnerabilities_service.rb @@ -13,16 +13,42 @@ def change_vulnerability_state(body = nil) create_note(NoteSummary.new(noteable, project, author, body, action: "vulnerability_#{to_state}")) end + class << self + def formatted_note(from_state, to_state, dismissal_reason, comment) + format( + "%{from_state} vulnerability status to %{to_state}%{reason}%{comment}", + from_state: from_state, + to_state: to_state.to_s.titleize, + comment: formatted_comment(comment), + reason: formatted_reason(dismissal_reason, to_state) + ) + end + + private + + def formatted_reason(dismissal_reason, to_state) + return if to_state.to_sym != :dismissed + return if dismissal_reason.blank? + + ": #{dismissal_reason.titleize}" + end + + def formatted_comment(comment) + return unless comment.present? + + format(' and the following comment: "%{comment}"', comment: comment) + end + end + private def state_change_body if state_transition.present? - format( - "%{from_status} vulnerability status to %{to_status}%{dismissal_reason}%{state_comment}", - from_status: from_status, - to_status: to_state.titleize, - dismissal_reason: dismissal_reason, - state_comment: state_comment + self.class.formatted_note( + from_status, + to_state, + state_transition.dismissal_reason, + state_transition.comment ) else "changed vulnerability status to Detected" @@ -37,18 +63,6 @@ def to_state @to_state ||= state_transition&.to_state || 'detected' end - def state_comment - return unless state_transition.comment.present? - - format(' and the following comment: "%{comment}"', comment: state_transition.comment) - end - - def dismissal_reason - return unless state_transition.to_state_dismissed? && state_transition.dismissal_reason.present? - - ": #{state_transition.dismissal_reason.titleize}" - end - def state_transition @state_transition ||= noteable.latest_state_transition end diff --git a/ee/app/services/vulnerabilities/bulk_dismiss_service.rb b/ee/app/services/vulnerabilities/bulk_dismiss_service.rb new file mode 100644 index 0000000000000..ee2fb76e65279 --- /dev/null +++ b/ee/app/services/vulnerabilities/bulk_dismiss_service.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +module Vulnerabilities + class BulkDismissService + include Gitlab::Allowable + MAX_BATCH = 100 + + def initialize(current_user, vulnerability_ids, comment, dismissal_reason) + @user = current_user + @vulnerability_ids = vulnerability_ids + @comment = comment + @dismissal_reason = dismissal_reason + @project_ids = {} + end + + def execute + ensure_authorized_projects! + + vulnerability_ids.each_slice(MAX_BATCH).each do |ids| + dismiss(Vulnerability.id_in(ids).without_states(:dismissed)) + end + refresh_statistics + + ServiceResponse.success(payload: { + vulnerabilities: Vulnerability.id_in(vulnerability_ids) + }) + rescue ActiveRecord::ActiveRecordError + ServiceResponse.error(message: "Could not dismiss vulnerabilities") + end + + private + + attr_reader :vulnerability_ids, :user, :comment, :dismissal_reason, :project_ids + + def ensure_authorized_projects! + raise Gitlab::Access::AccessDeniedError unless authorized_and_ff_enabled? + end + + def authorized_and_ff_enabled? + Vulnerability.id_in(vulnerability_ids) + .projects + .with_group + .with_namespace + .include_project_feature + .all? do |project| + Feature.enabled?(:dismiss_multiple_vulnerabilities, project) && + can?(user, :admin_vulnerability, project) + end + end + + def dismiss(vulnerabilities) + vulnerability_attrs = vulnerabilities.pluck(:id, :state, :project_id) # rubocop:disable CodeReuse/ActiveRecord + return if vulnerability_attrs.empty? + + state_transitions = transition_attributes_for(vulnerability_attrs) + system_notes = system_note_attributes_for(vulnerability_attrs) + + ApplicationRecord.transaction do + Note.insert_all!(system_notes) + Vulnerabilities::StateTransition.insert_all!(state_transitions) + # The `insert_or_update_vulnerability_reads` database trigger does not + # update the dismissal_reason and we are moving away from using + # database triggers to keep tables up to date. + Vulnerabilities::Read + .by_vulnerabilities(vulnerabilities) + .update_all(dismissal_reason: dismissal_reason) + + vulnerabilities.update_all( + state: :dismissed, + dismissed_by_id: user.id, + dismissed_at: now, + updated_at: now + ) + end + end + + def transition_attributes_for(attrs) + attrs.map do |id, state, _| + { + vulnerability_id: id, + from_state: state, + to_state: :dismissed, + comment: comment, + dismissal_reason: dismissal_reason, + author_id: user.id, + created_at: now, + updated_at: now + } + end + end + + def system_note_attributes_for(attrs) + attrs.map do |id, _, project_id| + project_ids[project_id] = true + { + noteable_type: "Vulnerability", + noteable_id: id, + project_id: project_id, + system: true, + note: ::SystemNotes::VulnerabilitiesService.formatted_note( + 'changed', + :dismissed, + dismissal_reason.to_s.titleize, + comment + ), + author_id: user.id, + created_at: now, + updated_at: now + } + end + end + + def refresh_statistics + return if project_ids.empty? + + Vulnerabilities::Statistics::AdjustmentWorker.perform_async(project_ids.keys) + end + + # We use this for setting the created_at and updated_at timestamps + # for the various records created by this service. + # The time is memoized on the first call to this method so all of the + # created records will have the same timestamps. + def now + @now ||= Time.current.utc + end + end +end diff --git a/ee/config/feature_flags/development/dismiss_multiple_vulnerabilities.yml b/ee/config/feature_flags/development/dismiss_multiple_vulnerabilities.yml new file mode 100644 index 0000000000000..30bf22d56739d --- /dev/null +++ b/ee/config/feature_flags/development/dismiss_multiple_vulnerabilities.yml @@ -0,0 +1,8 @@ +--- +name: dismiss_multiple_vulnerabilities +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/122815 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/415405 +milestone: '16.2' +type: development +group: group::threat insights +default_enabled: false diff --git a/ee/spec/features/projects/security/vulnerability_report_spec.rb b/ee/spec/features/projects/security/vulnerability_report_spec.rb index 4a20bbe3f7325..db0d5a8e573c0 100644 --- a/ee/spec/features/projects/security/vulnerability_report_spec.rb +++ b/ee/spec/features/projects/security/vulnerability_report_spec.rb @@ -132,4 +132,48 @@ expect(page).to have_content secret_detection_vulnerability.title end end + + context "when dismissing a vulnerability" do + let_it_be(:vulnerabilities) { create_list(:vulnerability, 5, :with_findings, project: project) } + + it "dismisses the vulnerability" do + visit project_security_vulnerability_report_index_path(project) + wait_for_all_requests + + vulnerabilities.each do |vulnerability| + expect(page).to have_content(vulnerability.title) + end + + change_all(state: :dismissed, reason: :used_in_tests, comment: "A comment") + + vulnerabilities.each do |vulnerability| + expect(page).to have_no_content(vulnerability.title) + end + + filter_by(status: :dismissed) + + vulnerabilities.each do |vulnerability| + expect(page).to have_content(vulnerability.title) + end + end + end + + def filter_by(status:) + click_on "Needs triage +1 more" + find("li[data-testid='listbox-item-#{status.to_s.upcase}']").click + find("h2[data-testid='vulnerability-report-header']").click + wait_for_all_requests + end + + def change_all(state:, reason:, comment:) + find('input[data-testid="vulnerability-checkbox-all"]').check + click_on 'Set status' + + find("li[data-testid='listbox-item-#{state}']").click + find("div[data-testid='dismissal-reason-listbox']").click + find("li[data-testid='listbox-item-#{reason}']").click + find("[data-testid='change-status-comment-textbox']").fill_in(with: comment) + click_button 'Change status' + wait_for_all_requests + end end diff --git a/ee/spec/graphql/mutations/vulnerabilities/bulk_dismiss_spec.rb b/ee/spec/graphql/mutations/vulnerabilities/bulk_dismiss_spec.rb new file mode 100644 index 0000000000000..9536bd5da967d --- /dev/null +++ b/ee/spec/graphql/mutations/vulnerabilities/bulk_dismiss_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::Vulnerabilities::BulkDismiss, feature_category: :vulnerability_management do + include GraphqlHelpers + + let_it_be(:project) { create(:project) } + let_it_be(:user) { create(:user) } + let_it_be(:vulnerabilities) { create_list(:vulnerability, 2, :with_findings, project: project) } + + let(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) } + let(:vulnerability_ids) { vulnerabilities.map(&:to_global_id) } + let(:comment) { 'Dismissal Feedback' } + let(:dismissal_reason) { 'used_in_tests' } + + before do + stub_feature_flags(dismiss_multiple_vulnerabilities: [project]) + stub_licensed_features(security_dashboard: true) + end + + before_all do + project.add_developer(user) + end + + subject do + mutation.resolve( + vulnerability_ids: vulnerability_ids, + comment: comment, + dismissal_reason: dismissal_reason + ) + end + + describe '#resolve' do + it 'does not introduce N+1 errors' do + queries = ActiveRecord::QueryRecorder.new do + subject + end + + expect( + queries.occurrences_starting_with('INSERT INTO "vulnerability_state_transitions"').values.sum + ).to eq(1) + expect(queries.occurrences_starting_with('INSERT INTO "notes"').values.sum).to eq(1) + expect(queries.occurrences_starting_with('SELECT "namespaces"').values.sum).to eq(2) + expect(queries.occurrences_starting_with('SELECT "project_features"').values.sum).to eq(1) + expect(queries.occurrences_starting_with('SELECT "vulnerabilities"').values.sum).to eq(1) + expect(queries.occurrences_starting_with('SELECT "projects"').values.sum).to eq(1) + expect(queries.occurrences_starting_with('UPDATE "vulnerabilities"').values.sum).to eq(1) + expect(queries.occurrences_starting_with('UPDATE "vulnerability_reads"').values.sum).to eq(1) + expect(queries.count).to be <= 15 + end + end +end diff --git a/ee/spec/models/ee/vulnerability_spec.rb b/ee/spec/models/ee/vulnerability_spec.rb index eb71b58c6e832..caf8a2adf05df 100644 --- a/ee/spec/models/ee/vulnerability_spec.rb +++ b/ee/spec/models/ee/vulnerability_spec.rb @@ -232,6 +232,18 @@ end end + describe '.without_states' do + let_it_be(:detected_vulnerability) { create(:vulnerability, :detected) } + let_it_be(:dismissed_vulnerability) { create(:vulnerability, :dismissed) } + let_it_be(:confirmed_vulnerability) { vulnerability } + + subject { described_class.without_states(:dismissed) } + + it 'returns vulnerabilities matching the given states' do + is_expected.to contain_exactly(detected_vulnerability, confirmed_vulnerability) + end + end + describe '.with_scanner_external_ids' do let!(:vulnerability_1) { create(:vulnerability, :with_findings) } let!(:vulnerability_2) { create(:vulnerability, :with_findings) } @@ -674,6 +686,15 @@ it { is_expected.to eq(::Project) } end + describe '.projects' do + let!(:project_2) { create(:project) } + let!(:vulnerability_2) { create(:vulnerability, :sast, :confirmed, :low, project: project_2) } + + subject(:projects) { ::Vulnerability.all.projects } + + it { is_expected.to contain_exactly(project, project_2) } + end + describe '.to_ability_name' do subject(:ability_name) { ::Vulnerability.to_ability_name } diff --git a/ee/spec/requests/api/graphql/mutations/vulnerabilities/bulk_dismiss_spec.rb b/ee/spec/requests/api/graphql/mutations/vulnerabilities/bulk_dismiss_spec.rb new file mode 100644 index 0000000000000..d8574fc8c6e9d --- /dev/null +++ b/ee/spec/requests/api/graphql/mutations/vulnerabilities/bulk_dismiss_spec.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "Mutation.vulnerabilitiesDismiss", feature_category: :vulnerability_management do + include GraphqlHelpers + + let_it_be(:project) { create(:project) } + let_it_be(:current_user) { create(:user) } + let_it_be(:vulnerability_1) { create(:vulnerability, :with_findings, project: project) } + let_it_be(:vulnerability_2) { create(:vulnerability, :with_findings, project: project) } + + let(:vulnerabilities) { [vulnerability_1, vulnerability_2] } + let(:vulnerability_ids) { vulnerabilities.map { |v| v.to_global_id.to_s } } + let(:comment) { 'Dismissal Feedback' } + let(:dismissal_reason) { 'USED_IN_TESTS' } + let(:arguments) do + { + vulnerability_ids: vulnerability_ids, + comment: comment, + dismissal_reason: dismissal_reason + } + end + + subject(:mutation) { graphql_mutation(:vulnerabilities_dismiss, arguments) } + + def mutation_response + graphql_mutation_response(:vulnerabilities_dismiss) + end + + context "when the user does not have access" do + it_behaves_like "a mutation that returns a top-level access error" + end + + context "when the user has access" do + before_all do + project.add_developer(current_user) + end + + context "when security_dashboard is disabled" do + before do + stub_licensed_features(security_dashboard: false) + end + + it_behaves_like 'a mutation that returns top-level errors', + errors: ['The resource that you are attempting to access does not ' \ + 'exist or you don\'t have permission to perform this action'] + end + + context "when security_dashboard is enabled" do + before do + stub_licensed_features(security_dashboard: true) + end + + it "dismisses the vulnerabilities" do + post_graphql_mutation(mutation, current_user: current_user) + + expect(vulnerability_1.reload).to be_dismissed + expect(vulnerability_2.reload).to be_dismissed + expect(mutation_response["errors"]).to be_empty + expect(mutation_response["vulnerabilities"].count).to eq(2) + mutation_response["vulnerabilities"].each do |vulnerability| + expect(vulnerability["state"]).to eq("DISMISSED") + expect(vulnerability["stateComment"]).to eq(comment) + expect(vulnerability["dismissedBy"]["id"]).to eq(current_user.to_global_id.to_s) + end + end + + context "without a comment" do + let(:arguments) do + { + vulnerability_ids: vulnerability_ids, + dismissal_reason: dismissal_reason + } + end + + it "dismisses the vulnerabilities" do + post_graphql_mutation(mutation, current_user: current_user) + + expect(vulnerability_1.reload).to be_dismissed + expect(vulnerability_2.reload).to be_dismissed + expect(mutation_response["errors"]).to be_empty + end + end + + context "without a dismissal reason" do + let(:arguments) do + { + vulnerability_ids: vulnerability_ids, + comment: comment + } + end + + it "dismisses the vulnerabilities" do + post_graphql_mutation(mutation, current_user: current_user) + + expect(vulnerability_1.reload).to be_dismissed + expect(vulnerability_2.reload).to be_dismissed + expect(mutation_response["errors"]).to be_empty + end + end + + context "when too many vulnerabilities are passed" do + before do + stub_const("::Vulnerabilities::BulkDismissService::MAX_BATCH", 1) + end + + it_behaves_like 'a mutation that returns top-level errors', errors: [/Maximum vulnerability_ids exceeded \(1\)/] + end + + context "when vulnerability_id is nil" do + let(:vulnerability_ids) { [nil] } + + it_behaves_like 'a mutation that returns top-level errors', errors: [/Expected value to not be null/] + end + + context "when vulnerability_ids are empty" do + let(:vulnerability_ids) { [] } + + it_behaves_like 'a mutation that returns top-level errors', + errors: ["At least 1 value must be provided for vulnerability_ids"] + end + end + end +end diff --git a/ee/spec/services/vulnerabilities/bulk_dismiss_service_spec.rb b/ee/spec/services/vulnerabilities/bulk_dismiss_service_spec.rb new file mode 100644 index 0000000000000..223b71f6c809c --- /dev/null +++ b/ee/spec/services/vulnerabilities/bulk_dismiss_service_spec.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Vulnerabilities::BulkDismissService, feature_category: :vulnerability_management do + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project) } + let_it_be(:vulnerability) { create(:vulnerability, :with_findings, :detected, :high_severity, project: project) } + let(:vulnerability_ids) { [vulnerability.id] } + let(:comment) { "i prefer lowercase." } + let(:dismissal_reason) { 'used_in_tests' } + + subject(:service) { described_class.new(user, vulnerability_ids, comment, dismissal_reason) } + + describe '#execute' do + before_all do + project.add_developer(user) + end + + before do + stub_licensed_features(security_dashboard: true) + end + + context 'when the user is not authorized to dismiss vulnerabilities from one of the projects' do + let_it_be(:other_project) { create(:project) } + let_it_be(:other_vulnerability) { create(:vulnerability, :with_findings, project: other_project) } + let(:vulnerability_ids) { [vulnerability.id, other_vulnerability.id] } + + it 'raises an error' do + expect { service.execute }.to raise_error(Gitlab::Access::AccessDeniedError) + end + end + + context 'when the user is authorized' do + it 'dismisses each vulnerability', :freeze_time do + service.execute + + vulnerability.reload + expect(vulnerability).to be_dismissed + expect(vulnerability.dismissed_by).to eq(user) + expect(vulnerability.dismissed_at).to eq(Time.current) + end + + it 'inserts a state transition for each vulnerability' do + service.execute + + vulnerability.reload + last_state = vulnerability.state_transitions.last + expect(last_state.from_state).to eq('detected') + expect(last_state.to_state).to eq('dismissed') + expect(last_state.comment).to eq(comment) + expect(last_state.dismissal_reason).to eq(dismissal_reason) + expect(last_state.author).to eq(user) + end + + it 'inserts a system note for each vulnerability' do + service.execute + + last_note = Note.last + + expect(last_note.noteable).to eq(vulnerability) + expect(last_note.author).to eq(user) + expect(last_note.project).to eq(project) + expect(last_note.note).to eq( + "changed vulnerability status to Dismissed: Used In Tests and the following comment: \"#{comment}\"" + ) + expect(last_note).to be_system + end + + it 'updates the dismissal reason for each vulnerability read record' do + service.execute + + reads = Vulnerabilities::Read.by_vulnerabilities(vulnerability_ids) + expect(reads.pluck(:dismissal_reason)).to match_array([dismissal_reason]) + end + + it 'updates the statistics', :sidekiq_inline do + _active_vulnerability = create(:vulnerability, :high_severity, project: project) + + service.execute + + expect(project.vulnerability_statistic).to be_present + expect(project.vulnerability_statistic.total).to eq(1) + expect(project.vulnerability_statistic.critical).to eq(0) + expect(project.vulnerability_statistic.high).to eq(1) + expect(project.vulnerability_statistic.medium).to eq(0) + expect(project.vulnerability_statistic.low).to eq(0) + expect(project.vulnerability_statistic.unknown).to eq(0) + expect(project.vulnerability_statistic.letter_grade).to eq('d') + end + + it 'returns a service response' do + result = service.execute + + expect(result.payload).to have_key(:vulnerabilities) + expect(result.payload[:vulnerabilities].count).to eq(vulnerability_ids.count) + end + + context 'when an error occurs' do + before do + allow(Note).to receive(:insert_all!).and_raise(ActiveRecord::RecordNotUnique) + end + + it 'does not bubble up the error' do + expect { service.execute }.not_to raise_error + end + + it 'returns an appropriate service response' do + result = service.execute + + expect(result).to be_error + expect(result.errors).to eq(['Could not dismiss vulnerabilities']) + end + + it 'does not commit any changes' do + service.execute + + expect(vulnerability.reload).not_to be_dismissed + end + end + + context 'when updating a large # of vulnerabilities' do + let_it_be(:vulnerabilities) { create_list(:vulnerability, 2, :with_findings, project: project) } + let_it_be(:vulnerability_ids) { vulnerabilities.map(&:id) } + + it 'does not introduce N+1 queries' do + queries = ActiveRecord::QueryRecorder.new do + service.execute + end + + expect(queries.count).to eq(13) + end + end + + context 'when a vulnerability has already been dismissed' do + let_it_be(:dismissed_vulnerability) { create(:vulnerability, :with_findings, :dismissed, project: project) } + let(:vulnerability_ids) { [dismissed_vulnerability.id] } + + it 'does not update the vulnerability' do + expect { service.execute }.not_to change { dismissed_vulnerability.reload.dismissed_at } + end + + it 'does not insert a system note' do + expect { service.execute }.not_to change { Note.count } + end + + it 'does not insert a state transition' do + expect { service.execute }.not_to change { dismissed_vulnerability.state_transitions.count } + end + end + end + end +end -- GitLab