diff --git a/ee/app/controllers/ee/projects/merge_requests_controller.rb b/ee/app/controllers/ee/projects/merge_requests_controller.rb index ef50d31521afea30a1d93fd46a9146c3b1e93ddd..88ab092b134df1cde21f9bd9bca8bd86543b9abd 100644 --- a/ee/app/controllers/ee/projects/merge_requests_controller.rb +++ b/ee/app/controllers/ee/projects/merge_requests_controller.rb @@ -24,9 +24,12 @@ module MergeRequestsController :metrics_reports] before_action :authorize_read_licenses!, only: [:license_scanning_reports, :license_scanning_reports_collapsed] + before_action :authorize_read_security_reports!, only: [:security_reports] + feature_category :vulnerability_management, [:container_scanning_reports, :dependency_scanning_reports, - :sast_reports, :secret_detection_reports, - :dast_reports, :coverage_fuzzing_reports, :api_fuzzing_reports] + :sast_reports, :secret_detection_reports, :dast_reports, + :coverage_fuzzing_reports, :api_fuzzing_reports, + :security_reports] feature_category :metrics, [:metrics_reports] feature_category :license_compliance, [:license_scanning_reports, :license_scanning_reports_collapsed] feature_category :code_review_workflow, [:delete_description_version, :description_diff] @@ -37,7 +40,8 @@ module MergeRequestsController :secret_detection_reports, :dast_reports, :coverage_fuzzing_reports, :api_fuzzing_reports, :metrics_reports, :description_diff, - :license_scanning_reports, :license_scanning_reports_collapsed] + :license_scanning_reports, :license_scanning_reports_collapsed, + :security_reports] end def can_run_sast_experiments_on?(project) @@ -76,6 +80,20 @@ def coverage_fuzzing_reports def api_fuzzing_reports reports_response(merge_request.compare_api_fuzzing_reports(current_user), head_pipeline) end + + def security_reports + report = ::Security::MergeRequestSecurityReportGenerationService.execute(merge_request, params[:type]) + + reports_response(report, head_pipeline) + rescue ::Security::MergeRequestSecurityReportGenerationService::InvalidReportTypeError + head :bad_request + end + + private + + def authorize_read_security_reports! + return render_404 unless can?(current_user, :read_security_resource, merge_request.project) + end end end end diff --git a/ee/app/services/security/merge_request_security_report_generation_service.rb b/ee/app/services/security/merge_request_security_report_generation_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..c4218e44cf41d0aefa52d17a20d59cfed7d5f99f --- /dev/null +++ b/ee/app/services/security/merge_request_security_report_generation_service.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +module Security + class MergeRequestSecurityReportGenerationService + include Gitlab::Utils::StrongMemoize + + DEFAULT_FINDING_STATE = 'detected' + FINDING_KEYS = %w[name state severity uuid].freeze + + InvalidReportTypeError = Class.new(ArgumentError) + + def self.execute(merge_request, report_type) + new(merge_request, report_type).execute + end + + def initialize(merge_request, report_type) + @merge_request = merge_request + @report_type = report_type + end + + def execute + return report unless report_available? + + set_states_of!(added_findings) + set_states_of!(fixed_findings) + remove_redundant_keys_of!(added_findings) + remove_redundant_keys_of!(fixed_findings) + + report + end + + private + + attr_reader :merge_request, :report_type + + def report_available? + report[:status] == :parsed + end + + def set_states_of!(findings) + findings.each { |finding| finding['state'] = state_for(finding['uuid']) } + end + + def remove_redundant_keys_of!(findings) + findings.each { |finding| finding.slice!(*FINDING_KEYS) } + end + + def state_for(uuid) + existing_vulnerabilities[uuid] || DEFAULT_FINDING_STATE + end + + def existing_vulnerabilities + @existing_vulnerabilities ||= Vulnerability.with_findings_by_uuid(finding_uuids) + .pluck(:uuid, :state) # rubocop:disable CodeReuse/ActiveRecord + .to_h + end + + def finding_uuids + (added_findings + fixed_findings).pluck('uuid') # rubocop:disable CodeReuse/ActiveRecord + end + + def added_findings + @added_findings ||= report.dig(:data, 'added') + end + + def fixed_findings + @fixed_findings ||= report.dig(:data, 'fixed') + end + + strong_memoize_attr def report + case report_type + when 'sast' + merge_request.compare_sast_reports(nil) + when 'secret_detection' + merge_request.compare_secret_detection_reports(nil) + when 'container_scanning' + merge_request.compare_container_scanning_reports(nil) + when 'dependency_scanning' + merge_request.compare_dependency_scanning_reports(nil) + when 'dast' + merge_request.compare_dast_reports(nil) + when 'coverage_fuzzing' + merge_request.compare_coverage_fuzzing_reports(nil) + when 'api_fuzzing' + merge_request.compare_api_fuzzing_reports(nil) + else + raise InvalidReportTypeError + end + end + end +end diff --git a/ee/config/routes/merge_requests.rb b/ee/config/routes/merge_requests.rb index 5851c29eef3681be5bbdc056a811b837d5630f70..517e8a8075db265dbe5ea3e070502212bb843884 100644 --- a/ee/config/routes/merge_requests.rb +++ b/ee/config/routes/merge_requests.rb @@ -14,6 +14,7 @@ get :dast_reports get :coverage_fuzzing_reports get :api_fuzzing_reports + get :security_reports post :rebase end diff --git a/ee/spec/requests/projects/merge_requests_controller_spec.rb b/ee/spec/requests/projects/merge_requests_controller_spec.rb index e920ed9fe22a12e7b16914636d5ba6ef92860d1a..8a265a88d4d21b35491b731a8cb4e3b3c4026c29 100644 --- a/ee/spec/requests/projects/merge_requests_controller_spec.rb +++ b/ee/spec/requests/projects/merge_requests_controller_spec.rb @@ -136,4 +136,105 @@ def get_index expect { get_index }.not_to exceed_query_limit(control_count) end end + + describe 'security_reports' do + let_it_be(:merge_request) { create(:merge_request, :with_head_pipeline) } + let_it_be(:user) { create(:user) } + + subject(:request_report) { get security_reports_project_merge_request_path(project, merge_request, type: :sast, format: :json) } + + before do + stub_licensed_features(security_dashboard: true) + end + + context 'when the user can not read project security resources' do + before do + project.add_guest(user) + end + + it 'responds with 404' do + request_report + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'when the user can read project security resources' do + before do + project.add_developer(user) + end + + context 'when the pipeline is pending' do + it 'returns 204 HTTP status along with the `Poll-Interval` header' do + request_report + + expect(response).to have_gitlab_http_status(:no_content) + expect(response.headers['Poll-Interval']).to eq('3000') + end + end + + context 'when the pipeline is not pending' do + before do + merge_request.head_pipeline.reload.succeed! + end + + context 'when the given type is invalid' do + let(:error) { ::Security::MergeRequestSecurityReportGenerationService::InvalidReportTypeError } + + before do + allow(::Security::MergeRequestSecurityReportGenerationService).to receive(:execute).and_raise(error) + end + + it 'responds with 400' do + request_report + + expect(response).to have_gitlab_http_status(:bad_request) + expect(response.header).not_to include('Poll-Interval') + end + end + + context 'when the given type is valid' do + before do + allow(::Security::MergeRequestSecurityReportGenerationService) + .to receive(:execute).with(an_instance_of(MergeRequest), 'sast').and_return(report_payload) + end + + context 'when comparison is being processed' do + let(:report_payload) { { status: :parsing } } + + it 'returns 204 HTTP status along with the `Poll-Interval` header' do + request_report + + expect(response).to have_gitlab_http_status(:no_content) + expect(response.headers['Poll-Interval']).to eq('3000') + end + end + + context 'when comparison is done' do + context 'when the comparison is errored' do + let(:report_payload) { { status: :error } } + + it 'responds with 400' do + request_report + + expect(response).to have_gitlab_http_status(:bad_request) + expect(response.header).not_to include('Poll-Interval') + end + end + + context 'when the comparision is succeeded' do + let(:report_payload) { { status: :parsed, data: { added: ['foo'], fixed: ['bar'] } } } + + it 'responds with 200 along with the report payload' do + request_report + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to eq({ 'added' => ['foo'], 'fixed' => ['bar'] }) + end + end + end + end + end + end + end end diff --git a/ee/spec/services/security/merge_request_security_report_generation_service_spec.rb b/ee/spec/services/security/merge_request_security_report_generation_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..c0c02618d4ce01f05174bdc11a3e281ed8fe6772 --- /dev/null +++ b/ee/spec/services/security/merge_request_security_report_generation_service_spec.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Security::MergeRequestSecurityReportGenerationService, feature_category: :vulnerability_management do + describe '#execute' do + let_it_be(:merge_request) { create(:merge_request) } + + let(:service_object) { described_class.new(merge_request, report_type) } + + subject(:report) { service_object.execute } + + context 'when the given report type is invalid' do + let(:report_type) { 'foo' } + + it 'raises InvalidReportTypeError' do + expect { report }.to raise_error(described_class::InvalidReportTypeError) + end + end + + context 'when the given report type is valid' do + using RSpec::Parameterized::TableSyntax + + let_it_be(:confirmed_finding) { create(:vulnerabilities_finding, :confirmed) } + let_it_be(:dismissed_finding) { create(:vulnerabilities_finding, :dismissed) } + + let(:new_uuid) { SecureRandom.uuid } + let(:confirmed_uuid) { confirmed_finding.uuid } + let(:dismissed_uuid) { dismissed_finding.uuid } + + where(:report_type, :mr_report_method) do + 'sast' | :compare_sast_reports + 'secret_detection' | :compare_secret_detection_reports + 'container_scanning' | :compare_container_scanning_reports + 'dependency_scanning' | :compare_dependency_scanning_reports + 'dast' | :compare_dast_reports + 'coverage_fuzzing' | :compare_coverage_fuzzing_reports + 'api_fuzzing' | :compare_api_fuzzing_reports + end + + with_them do + let(:mock_report) do + { + status: report_status, + data: { + 'base_report_created_at' => nil, + 'base_report_out_of_date' => false, + 'head_report_created_at' => '2023-01-18T11:30:01.035Z', + 'added' => [ + { + 'id' => nil, + 'name' => 'Test vulnerability 1', + 'uuid' => new_uuid, + 'severity' => 'critical' + }, + { + 'id' => nil, + 'name' => 'Test vulnerability 2', + 'uuid' => confirmed_uuid, + 'severity' => 'critical' + } + ], + 'fixed' => [ + { + 'id' => nil, + 'name' => 'Test vulnerability 3', + 'uuid' => dismissed_uuid, + 'severity' => 'low' + } + ] + } + } + end + + before do + allow(merge_request).to receive(mr_report_method).with(nil).and_return(mock_report) + end + + context 'when the report status is `parsing`' do + let(:report_status) { :parsing } + + it 'returns the report' do + expect(report).to eq(mock_report) + end + end + + context 'when the report status is `parsed`' do + let(:report_status) { :parsed } + let(:expected_report) do + { + status: report_status, + data: { + 'base_report_created_at' => nil, + 'base_report_out_of_date' => false, + 'head_report_created_at' => '2023-01-18T11:30:01.035Z', + 'added' => [ + { + 'name' => 'Test vulnerability 1', + 'uuid' => new_uuid, + 'severity' => 'critical', + 'state' => 'detected' + }, + { + 'name' => 'Test vulnerability 2', + 'uuid' => confirmed_uuid, + 'severity' => 'critical', + 'state' => 'confirmed' + } + ], + 'fixed' => [ + { + 'name' => 'Test vulnerability 3', + 'uuid' => dismissed_uuid, + 'severity' => 'low', + 'state' => 'dismissed' + } + ] + } + } + end + + it 'returns `title`, `severity`, `uuid`, and `state` of the findings' do + expect(report).to eq(expected_report) + expect(merge_request).to have_received(mr_report_method).with(nil) + end + end + end + end + end +end diff --git a/lib/gitlab/ci/reports/security/vulnerability_reports_comparer.rb b/lib/gitlab/ci/reports/security/vulnerability_reports_comparer.rb index 4be4cf62e7b1858d362af98f94d352856416eb0c..adc666d99872881e42ada4249556837cbbd7dbb5 100644 --- a/lib/gitlab/ci/reports/security/vulnerability_reports_comparer.rb +++ b/lib/gitlab/ci/reports/security/vulnerability_reports_comparer.rb @@ -10,6 +10,7 @@ class VulnerabilityReportsComparer attr_reader :base_report, :head_report ACCEPTABLE_REPORT_AGE = 1.week + MAX_FINDINGS_COUNT = 25 def initialize(project, base_report, head_report) @base_report = base_report @@ -40,21 +41,13 @@ def base_report_out_of_date def added strong_memoize(:added) do - if @signatures_enabled - @added_findings - else - head_report.findings - base_report.findings - end + all_added_findings.take(MAX_FINDINGS_COUNT) # rubocop:disable CodeReuse/ActiveRecord (This is Array#take) end end def fixed strong_memoize(:fixed) do - if @signatures_enabled - @fixed_findings - else - base_report.findings - head_report.findings - end + all_fixed_findings.take(MAX_FINDINGS_COUNT) # rubocop:disable CodeReuse/ActiveRecord (This is Array#take) end end @@ -89,6 +82,22 @@ def calculate_changes @added_findings = matcher.unmatched_head_findings.values end + + def all_added_findings + if @signatures_enabled + @added_findings + else + head_report.findings - base_report.findings + end + end + + def all_fixed_findings + if @signatures_enabled + @fixed_findings + else + base_report.findings - head_report.findings + end + end end class FindingMatcher diff --git a/spec/lib/gitlab/ci/reports/security/vulnerability_reports_comparer_spec.rb b/spec/lib/gitlab/ci/reports/security/vulnerability_reports_comparer_spec.rb index 6f75e2c55e87126aaaff6d372c0a03c6f25a8fb4..393d65ff1023a10cb6066df14352eea995b88586 100644 --- a/spec/lib/gitlab/ci/reports/security/vulnerability_reports_comparer_spec.rb +++ b/spec/lib/gitlab/ci/reports/security/vulnerability_reports_comparer_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::Ci::Reports::Security::VulnerabilityReportsComparer do +RSpec.describe Gitlab::Ci::Reports::Security::VulnerabilityReportsComparer, feature_category: :vulnerability_management do let(:identifier) { build(:ci_reports_security_identifier) } let_it_be(:project) { create(:project, :repository) } @@ -90,6 +90,18 @@ expect(subject.added).to eq([vuln, low_vuln]) end end + + describe 'number of findings' do + let(:head_report) { build(:ci_reports_security_aggregated_reports, findings: [head_vulnerability, vuln, low_vuln]) } + + before do + stub_const("#{described_class}::MAX_FINDINGS_COUNT", 1) + end + + it 'returns no more than `MAX_FINDINGS_COUNT`' do + expect(subject.added).to eq([vuln]) + end + end end describe '#fixed' do @@ -123,6 +135,18 @@ expect(subject.fixed).to eq(vul_findings) end end + + describe 'number of findings' do + let(:base_report) { build(:ci_reports_security_aggregated_reports, findings: [vuln, medium_vuln, base_vulnerability]) } + + before do + stub_const("#{described_class}::MAX_FINDINGS_COUNT", 1) + end + + it 'returns no more than `MAX_FINDINGS_COUNT`' do + expect(subject.fixed).to eq([vuln]) + end + end end describe 'with empty vulnerabilities' do