From 3a3e04be0d988de3d0742eae81cb34de25be3b9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Zaj=C4=85c?= <mzajac@gitlab.com> Date: Tue, 11 Jan 2022 00:41:03 +0000 Subject: [PATCH] Add vulnerabilityFindingDismiss GraphQL mutation * Add Vulnerabilities::FindingDismissService wrapping VulnerabilitiesFeedback::CreateService * Add FindingPolicy Changelog: added EE: true --- doc/api/graphql/reference/index.md | 27 +++++ ee/app/graphql/ee/types/mutation_type.rb | 1 + .../vulnerabilities/finding_dismiss.rb | 56 ++++++++++ .../vulnerabilities/finding_policy.rb | 8 ++ .../finding_dismiss_service.rb | 56 ++++++++++ .../vulnerabilities/finding_dismiss_spec.rb | 51 +++++++++ .../vulnerabilities/finding_dismiss_spec.rb | 102 ++++++++++++++++++ .../finding_dismiss_service_spec.rb | 96 +++++++++++++++++ locale/gitlab.pot | 3 + 9 files changed, 400 insertions(+) create mode 100644 ee/app/graphql/mutations/vulnerabilities/finding_dismiss.rb create mode 100644 ee/app/policies/vulnerabilities/finding_policy.rb create mode 100644 ee/app/services/vulnerabilities/finding_dismiss_service.rb create mode 100644 ee/spec/graphql/mutations/vulnerabilities/finding_dismiss_spec.rb create mode 100644 ee/spec/requests/api/graphql/mutations/vulnerabilities/finding_dismiss_spec.rb create mode 100644 ee/spec/services/vulnerabilities/finding_dismiss_service_spec.rb diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 0f5bdc9f224ad..7315d3f2701dd 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -4931,6 +4931,27 @@ Input type: `VulnerabilityExternalIssueLinkDestroyInput` | <a id="mutationvulnerabilityexternalissuelinkdestroyclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | | <a id="mutationvulnerabilityexternalissuelinkdestroyerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | +### `Mutation.vulnerabilityFindingDismiss` + +Input type: `VulnerabilityFindingDismissInput` + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mutationvulnerabilityfindingdismissclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationvulnerabilityfindingdismisscomment"></a>`comment` | [`String`](#string) | Comment why finding should be dismissed. | +| <a id="mutationvulnerabilityfindingdismissdismissalreason"></a>`dismissalReason` | [`VulnerabilityDismissalReason`](#vulnerabilitydismissalreason) | Reason why finding should be dismissed. | +| <a id="mutationvulnerabilityfindingdismissid"></a>`id` | [`VulnerabilitiesFindingID!`](#vulnerabilitiesfindingid) | ID of the finding to be dismissed. | + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mutationvulnerabilityfindingdismissclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationvulnerabilityfindingdismisserrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | +| <a id="mutationvulnerabilityfindingdismissfinding"></a>`finding` | [`PipelineSecurityReportFinding`](#pipelinesecurityreportfinding) | Finding after dismissal. | + ### `Mutation.vulnerabilityResolve` Input type: `VulnerabilityResolveInput` @@ -18181,6 +18202,12 @@ A `VulnerabilitiesExternalIssueLinkID` is a global ID. It is encoded as a string An example `VulnerabilitiesExternalIssueLinkID` is: `"gid://gitlab/Vulnerabilities::ExternalIssueLink/1"`. +### `VulnerabilitiesFindingID` + +A `VulnerabilitiesFindingID` is a global ID. It is encoded as a string. + +An example `VulnerabilitiesFindingID` is: `"gid://gitlab/Vulnerabilities::Finding/1"`. + ### `VulnerabilitiesScannerID` A `VulnerabilitiesScannerID` is a global ID. It is encoded as a string. diff --git a/ee/app/graphql/ee/types/mutation_type.rb b/ee/app/graphql/ee/types/mutation_type.rb index de99f6289cde9..0984a1bfcee4f 100644 --- a/ee/app/graphql/ee/types/mutation_type.rb +++ b/ee/app/graphql/ee/types/mutation_type.rb @@ -31,6 +31,7 @@ module MutationType mount_mutation ::Mutations::RequirementsManagement::UpdateRequirement mount_mutation ::Mutations::Vulnerabilities::Create mount_mutation ::Mutations::Vulnerabilities::Dismiss + mount_mutation ::Mutations::Vulnerabilities::FindingDismiss mount_mutation ::Mutations::Vulnerabilities::Resolve mount_mutation ::Mutations::Vulnerabilities::Confirm mount_mutation ::Mutations::Vulnerabilities::RevertToDetected diff --git a/ee/app/graphql/mutations/vulnerabilities/finding_dismiss.rb b/ee/app/graphql/mutations/vulnerabilities/finding_dismiss.rb new file mode 100644 index 0000000000000..f644617a44124 --- /dev/null +++ b/ee/app/graphql/mutations/vulnerabilities/finding_dismiss.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Mutations + module Vulnerabilities + class FindingDismiss < BaseMutation + graphql_name 'VulnerabilityFindingDismiss' + + authorize :admin_vulnerability + + field :finding, Types::PipelineSecurityReportFindingType, + null: true, + description: 'Finding after dismissal.' + + argument :id, + ::Types::GlobalIDType[::Vulnerabilities::Finding], + required: true, + description: 'ID of the finding to be dismissed.' + + argument :comment, + GraphQL::Types::String, + required: false, + description: 'Comment why finding should be dismissed.' + + argument :dismissal_reason, + Types::Vulnerabilities::DismissalReasonEnum, + required: false, + description: 'Reason why finding should be dismissed.' + + def resolve(id:, comment: nil, dismissal_reason: nil) + finding = authorized_find!(id: id) + result = dismiss_finding(finding, comment, dismissal_reason) + + { + finding: result.success? ? result.payload[:finding] : nil, + errors: result.message || [] + } + end + + private + + def dismiss_finding(finding, comment, dismissal_reason) + ::Vulnerabilities::FindingDismissService.new( + current_user, + finding, + comment, + dismissal_reason + ).execute + end + + def find_object(id:) + id = ::Types::GlobalIDType[::Vulnerabilities::Finding].coerce_isolated_input(id) + GitlabSchema.find_by_gid(id) + end + end + end +end diff --git a/ee/app/policies/vulnerabilities/finding_policy.rb b/ee/app/policies/vulnerabilities/finding_policy.rb new file mode 100644 index 0000000000000..08d857397d109 --- /dev/null +++ b/ee/app/policies/vulnerabilities/finding_policy.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true +module Vulnerabilities + class FindingPolicy < BasePolicy + delegate { @subject.project } + + rule { ~can?(:read_security_resource) }.prevent :create_note + end +end diff --git a/ee/app/services/vulnerabilities/finding_dismiss_service.rb b/ee/app/services/vulnerabilities/finding_dismiss_service.rb new file mode 100644 index 0000000000000..d30679f9d66a4 --- /dev/null +++ b/ee/app/services/vulnerabilities/finding_dismiss_service.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Vulnerabilities + class FindingDismissService < BaseProjectService + include Gitlab::Allowable + + def initialize(user, finding, comment = nil, dismissal_reason = nil) + super(project: finding.project, current_user: user) + @finding = finding + @comment = comment + @dismissal_reason = dismissal_reason + end + + def execute + return ServiceResponse.error(message: "Access denied", http_status: :forbidden) unless authorized? + + dismiss_finding + end + + private + + def authorized? + can?(@current_user, :admin_vulnerability, @project) + end + + def dismiss_finding + result = ::VulnerabilityFeedback::CreateService.new( + @project, + @current_user, + feedback_params_for(@finding, @comment, @dismissal_reason) + ).execute + + case result[:status] + when :success + ServiceResponse.success(payload: { finding: @finding }) + when :error + all_errors = result[:message].full_messages.join(",") + error_string = _("failed to dismiss finding: %{message}") % { message: all_errors } + ServiceResponse.error(message: error_string, http_status: :unprocessable_entity) + end + end + + def feedback_params_for(finding, comment, dismissal_reason) + { + category: @finding.report_type, + feedback_type: 'dismissal', + project_fingerprint: @finding.project_fingerprint, + comment: @comment, + dismissal_reason: @dismissal_reason, + pipeline: @project.latest_pipeline_with_security_reports(only_successful: true), + finding_uuid: @finding.uuid_v5, + dismiss_vulnerability: false + } + end + end +end diff --git a/ee/spec/graphql/mutations/vulnerabilities/finding_dismiss_spec.rb b/ee/spec/graphql/mutations/vulnerabilities/finding_dismiss_spec.rb new file mode 100644 index 0000000000000..1a5f260f2660c --- /dev/null +++ b/ee/spec/graphql/mutations/vulnerabilities/finding_dismiss_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe Mutations::Vulnerabilities::FindingDismiss do + include GraphqlHelpers + + let(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) } + + describe '#resolve' do + let_it_be(:finding) { create(:vulnerabilities_finding) } + let_it_be(:user) { create(:user) } + let_it_be(:finding_id) { GitlabSchema.id_from_object(finding).to_s } + + let(:comment) { 'Dismissal Feedback' } + let(:mutated_finding) { subject[:finding] } + + subject { mutation.resolve(id: finding_id, comment: comment, dismissal_reason: 'used_in_tests') } + + context 'when the user can dismiss the finding' do + before do + stub_licensed_features(security_dashboard: true) + end + + context 'when user does not have access to the project' do + it 'raises an error' do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + + context 'with invalid params' do + let(:finding_id) { global_id_of(user) } + + it 'raises an error' do + expect { subject }.to raise_error(::GraphQL::CoercionError) + end + end + + context 'when user has access to the project' do + before do + finding.project.add_developer(user) + end + + it 'returns the dismissed finding' do + expect(mutated_finding).to eq(finding) + expect(mutated_finding.state).to eq('dismissed') + expect(subject[:errors]).to be_empty + end + end + end + end +end diff --git a/ee/spec/requests/api/graphql/mutations/vulnerabilities/finding_dismiss_spec.rb b/ee/spec/requests/api/graphql/mutations/vulnerabilities/finding_dismiss_spec.rb new file mode 100644 index 0000000000000..b2246f7f93e3d --- /dev/null +++ b/ee/spec/requests/api/graphql/mutations/vulnerabilities/finding_dismiss_spec.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require 'spec_helper' +RSpec.describe 'Dismissing a Vulnerabilities::Finding object' do + include GraphqlHelpers + + let_it_be(:current_user) { create(:user) } + let_it_be(:project) { create(:project) } + let_it_be(:pipeline) { create(:ci_pipeline, :success, project: project) } + let_it_be(:finding) { create(:vulnerabilities_finding, :with_pipeline, project: project, severity: :high) } + + let(:params) do + { + id: finding.to_global_id.to_s + } + end + + let(:mutation) do + graphql_mutation( + :vulnerability_finding_dismiss, + params + ) + end + + let(:mutation_response) do + graphql_mutation_response(:vulnerability_finding_dismiss) + end + + subject { post_graphql_mutation(mutation, current_user: current_user) } + + context 'when the user does not have permission' do + before do + stub_licensed_features(security_dashboard: true) + end + + it_behaves_like 'a mutation that returns a top-level access error' + + it 'does not dismiss the Finding' do + expect { subject }.not_to change(finding, :state) + end + end + + context 'when the user has permission' do + before do + finding.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 Finding' do + expect { subject }.to change(finding, :state).from('detected').to('dismissed') + end + + context 'when comment is given' do + let(:comment) { "Used in tests" } + let(:params) do + { + id: finding.to_global_id.to_s, + comment: comment + } + end + + let(:feedback) { finding.dismissal_feedback } + + it 'saves the comment' do + expect { subject }.to change(finding, :state).from('detected').to('dismissed') + expect(feedback.comment).to eq(comment) + end + end + + context 'when dismissal reason is given' do + let(:dismissal_reason) { "USED_IN_TESTS" } + let(:params) do + { + id: finding.to_global_id.to_s, + dismissal_reason: dismissal_reason + } + end + + let(:feedback) { finding.dismissal_feedback } + + it 'saves the dismissal reason' do + expect { subject }.to change(finding, :state).from('detected').to('dismissed') + expect(feedback.dismissal_reason).to eq('used_in_tests') + end + end + end + end +end diff --git a/ee/spec/services/vulnerabilities/finding_dismiss_service_spec.rb b/ee/spec/services/vulnerabilities/finding_dismiss_service_spec.rb new file mode 100644 index 0000000000000..98ef7d9507e5f --- /dev/null +++ b/ee/spec/services/vulnerabilities/finding_dismiss_service_spec.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Vulnerabilities::FindingDismissService do + include AccessMatchersGeneric + + before do + stub_licensed_features(security_dashboard: true) + end + + let_it_be(:user) { create(:user) } + + let(:project) { create(:project) } # cannot use let_it_be here: caching causes problems with permission-related tests + let!(:pipeline) { create(:ci_pipeline, :success, project: project) } + let!(:build) { create(:ee_ci_build, :sast, pipeline: pipeline) } + let!(:finding) { create(:vulnerabilities_finding, project: project) } + let(:service) { described_class.new(user, finding) } + + subject(:dismiss_finding) { service.execute } + + context 'with an authorized user with proper permissions' do + before do + project.add_developer(user) + end + + context 'when comment is added' do + let(:comment) { 'Dismissal Comment' } + let(:service) { described_class.new(user, finding, comment) } + + it 'dismisses a finding with comment', :aggregate_failures do + freeze_time do + dismiss_finding + + aggregate_failures do + expect(finding.reload).to(have_attributes(state: 'dismissed')) + expect(finding.dismissal_feedback).to have_attributes(comment: comment, comment_author: user, comment_timestamp: be_like_time(Time.current), pipeline_id: pipeline.id) + end + end + end + end + + context 'when the dismissal_reason is added' do + let(:dismissal_reason) { 'used_in_tests' } + let(:service) { described_class.new(user, finding, nil, dismissal_reason) } + + it 'dismisses a finding', :aggregate_failures do + dismiss_finding + + expect(finding.reload).to have_attributes(state: 'dismissed') + expect(finding.dismissal_feedback).to have_attributes(dismissal_reason: dismissal_reason) + end + end + + context 'when Vulnerabilities::Feedback creation fails' do + let(:create_service_double) { instance_double("VulnerabilityFeedback::CreateService", execute: service_failure_payload) } + let(:service_failure_payload) do + { + status: :error, + message: errors_double + } + end + + let(:errors_double) { instance_double("ActiveModel::Errors", full_messages: error_messages_array) } + let(:error_messages_array) { instance_double("Array", join: "something went wrong") } + + before do + allow(VulnerabilityFeedback::CreateService).to receive(:new).and_return(create_service_double) + end + + it 'returns the error' do + expect(create_service_double).to receive(:execute).once + + result = dismiss_finding + + expect(result).not_to be_success + expect(result.http_status).to be(:unprocessable_entity) + expect(result.message).to eq("failed to dismiss finding: something went wrong") + end + end + + context 'when security dashboard feature is disabled' do + before do + stub_licensed_features(security_dashboard: false) + end + + it 'raises an "access denied" error' do + result = dismiss_finding + + expect(result).not_to be_success + expect(result.http_status).to be(:forbidden) + expect(result.message).to eq("Access denied") + end + end + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 98806ac7b0f3e..109653d4b7a28 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -42040,6 +42040,9 @@ msgstr "" msgid "failed to dismiss associated finding(id=%{finding_id}): %{message}" msgstr "" +msgid "failed to dismiss finding: %{message}" +msgstr "" + msgid "failed to revert associated finding(id=%{finding_id}) to detected" msgstr "" -- GitLab