diff --git a/app/models/integrations/base_chat_notification.rb b/app/models/integrations/base_chat_notification.rb index 5eae8bce92a8f5be5ebd305967d0a6bed5d92ec6..c6335782b5e398b370dd2bf0fec2e8ced6530c01 100644 --- a/app/models/integrations/base_chat_notification.rb +++ b/app/models/integrations/base_chat_notification.rb @@ -253,3 +253,5 @@ def notify_for_pipeline?(data) end end end + +Integrations::BaseChatNotification.prepend_mod_with('Integrations::BaseChatNotification') diff --git a/db/migrate/20210707163659_add_vulnerability_events_to_integrations.rb b/db/migrate/20210707163659_add_vulnerability_events_to_integrations.rb new file mode 100644 index 0000000000000000000000000000000000000000..c138af486c1806b216fc5bba78581b09962e50cf --- /dev/null +++ b/db/migrate/20210707163659_add_vulnerability_events_to_integrations.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddVulnerabilityEventsToIntegrations < ActiveRecord::Migration[6.1] + include Gitlab::Database::MigrationHelpers + + def change + add_column :integrations, :vulnerability_events, :boolean, default: false, null: false + end +end diff --git a/db/schema_migrations/20210707163659 b/db/schema_migrations/20210707163659 new file mode 100644 index 0000000000000000000000000000000000000000..e0c33c79a857b67764461100bbc5291382166de8 --- /dev/null +++ b/db/schema_migrations/20210707163659 @@ -0,0 +1 @@ +ac14aa49830a3af9a1445c0c7680f5660247a8104c8e4c1ae542c4b368f7c9bf \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index ea8ab0f4ea922da53f973cd708994028ecaa4286..7cb59406ea535eef350fe99f47469cceb51febf3 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -14968,6 +14968,7 @@ CREATE TABLE integrations ( alert_events boolean, group_id bigint, type_new text, + vulnerability_events boolean DEFAULT false NOT NULL, CONSTRAINT check_a948a0aa7e CHECK ((char_length(type_new) <= 255)) ); diff --git a/doc/api/services.md b/doc/api/services.md index d0c14f57eebfe2691f8e503eb66e0ba5f0a65034..a311814ca0fcd7c2af101e05c3a8cc60c2e54964 100644 --- a/doc/api/services.md +++ b/doc/api/services.md @@ -1153,6 +1153,8 @@ Parameters: | `tag_push_events` | boolean | false | Enable notifications for tag push events | | `wiki_page_channel` | string | false | The name of the channel to receive wiki page events notifications | | `wiki_page_events` | boolean | false | Enable notifications for wiki page events | +| `vulnerability_channel` | string | false | **(ULTIMATE)** The name of the channel to receive vulnerability event notifications. | +| `vulnerability_events` | boolean | false | **(ULTIMATE)** Enable notifications for vulnerability events | ### Delete Slack service @@ -1250,6 +1252,7 @@ Parameters: | `confidential_note_events` | boolean | false | Enable notifications for confidential note events | | `pipeline_events` | boolean | false | Enable notifications for pipeline events | | `wiki_page_events` | boolean | false | Enable notifications for wiki page events | +| `vulnerability_events` | boolean | false | **(ULTIMATE)** Enable notifications for vulnerability events | | `push_channel` | string | false | The name of the channel to receive push events notifications | | `issue_channel` | string | false | The name of the channel to receive issues events notifications | | `confidential_issue_channel` | string | false | The name of the channel to receive confidential issues events notifications | @@ -1259,6 +1262,7 @@ Parameters: | `tag_push_channel` | string | false | The name of the channel to receive tag push events notifications | | `pipeline_channel` | string | false | The name of the channel to receive pipeline events notifications | | `wiki_page_channel` | string | false | The name of the channel to receive wiki page events notifications | +| `vulnerability_channel` | string | false | **(ULTIMATE)** The name of the channel to receive vulnerability events notifications | ### Delete Mattermost notifications service diff --git a/doc/user/project/integrations/slack.md b/doc/user/project/integrations/slack.md index e257dd5e43a6d4af110bc1f9196ff4d9ef8ab771..a38d2157699d7025a56f75e27433dfcb76e64c9b 100644 --- a/doc/user/project/integrations/slack.md +++ b/doc/user/project/integrations/slack.md @@ -59,19 +59,20 @@ Your Slack team now starts receiving GitLab event notifications as configured. The following triggers are available for Slack notifications: -| Trigger name | Trigger event | -|------------------------|------------------------------------------------------| -| **Push** | A push to the repository. | -| **Issue** | An issue is created, updated, or closed. | -| **Confidential issue** | A confidential issue is created, updated, or closed. | -| **Merge request** | A merge request is created, updated, or merged. | -| **Note** | A comment is added. | -| **Confidential note** | A confidential note is added. | -| **Tag push** | A new tag is pushed to the repository. | -| **Pipeline** | A pipeline status changed. | -| **Wiki page** | A wiki page is created or updated. | -| **Deployment** | A deployment starts or finishes. | -| **Alert** | A new, unique alert is recorded. | +| Trigger name | Trigger event | +| ------------------------ | ------------------------------------------------------ | +| **Push** | A push to the repository. | +| **Issue** | An issue is created, updated, or closed. | +| **Confidential issue** | A confidential issue is created, updated, or closed. | +| **Merge request** | A merge request is created, updated, or merged. | +| **Note** | A comment is added. | +| **Confidential note** | A confidential note is added. | +| **Tag push** | A new tag is pushed to the repository. | +| **Pipeline** | A pipeline status changed. | +| **Wiki page** | A wiki page is created or updated. | +| **Deployment** | A deployment starts or finishes. | +| **Alert** | A new, unique alert is recorded. | +| **Vulnerability** | **(ULTIMATE)** A new, unique vulnerability is recorded. | ## Troubleshooting diff --git a/ee/app/helpers/ee/integrations_helper.rb b/ee/app/helpers/ee/integrations_helper.rb index b504e908f9e0b6b3a6f1d228261a266c607136b6..dd2ed9e0cb7344919afc25d4670efe5510d1df81 100644 --- a/ee/app/helpers/ee/integrations_helper.rb +++ b/ee/app/helpers/ee/integrations_helper.rb @@ -58,5 +58,12 @@ def jira_issues_show_data issues_list_path: project_integrations_jira_issues_path(@project) } end + + override :default_integration_event_description + def default_integration_event_description(event) + return s_("ProjectService|Trigger event when a new, unique vulnerability is recorded. (Note: This feature requires an Ultimate plan.)") if event == 'vulnerability' + + super + end end end diff --git a/ee/app/models/ee/integration.rb b/ee/app/models/ee/integration.rb index 6adc729dc526ab7759f1d4f8a9906def76d184be..7b627abf3aa87ed6451cf8b99647c308b1baf8eb 100644 --- a/ee/app/models/ee/integration.rb +++ b/ee/app/models/ee/integration.rb @@ -4,6 +4,10 @@ module EE module Integration extend ActiveSupport::Concern + prepended do + scope :vulnerability_hooks, -> { where(vulnerability_events: true, active: true) } + end + EE_COM_PROJECT_SPECIFIC_INTEGRATION_NAMES = %w[ gitlab_slack_application ].freeze diff --git a/ee/app/models/ee/integrations/base_chat_notification.rb b/ee/app/models/ee/integrations/base_chat_notification.rb new file mode 100644 index 0000000000000000000000000000000000000000..dc8cd9ff9f998583900e0ec7eec5f6806ccfbfc8 --- /dev/null +++ b/ee/app/models/ee/integrations/base_chat_notification.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module EE + module Integrations + module BaseChatNotification + extend ActiveSupport::Concern + extend ::Gitlab::Utils::Override + + EE_SUPPORTED_EVENTS = %w[vulnerability].freeze + + ::Integration.prop_accessor(*EE_SUPPORTED_EVENTS.map { |event| "#{event}_channel" }) + + override :get_message + def get_message(object_kind, data) + return ::Integrations::ChatMessage::VulnerabilityMessage.new(data) if object_kind == 'vulnerability' + + super + end + + class_methods do + extend ::Gitlab::Utils::Override + + override :supported_events + def supported_events + super + EE_SUPPORTED_EVENTS + end + end + end + end +end diff --git a/ee/app/models/ee/vulnerability.rb b/ee/app/models/ee/vulnerability.rb index 6f0c635cf67b3aabf9e1d94892c6f7e972510da0..fe61c5111dafffa01561b6716f3a98882a25463c 100644 --- a/ee/app/models/ee/vulnerability.rb +++ b/ee/app/models/ee/vulnerability.rb @@ -172,8 +172,16 @@ def blob_path ::Gitlab::Routing.url_helpers.project_blob_path(project, File.join(finding.pipeline_branch, finding_file)) end + def execute_hooks + project.execute_integrations(integration_data, :vulnerability_hooks) + end + private + def integration_data + @integration_data ||= ::Gitlab::DataBuilder::Vulnerability.build(self) + end + def user_notes_count_service @user_notes_count_service ||= ::Vulnerabilities::UserNotesCountService.new(self) # rubocop: disable CodeReuse/ServiceClass end diff --git a/ee/app/models/integrations/chat_message/vulnerability_message.rb b/ee/app/models/integrations/chat_message/vulnerability_message.rb new file mode 100644 index 0000000000000000000000000000000000000000..88a8916fdc8bbaf987b36c3553f558d5d1384149 --- /dev/null +++ b/ee/app/models/integrations/chat_message/vulnerability_message.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module Integrations + module ChatMessage + class VulnerabilityMessage < ::Integrations::ChatMessage::BaseMessage + attr_reader :title + attr_reader :identifiers + attr_reader :severity + attr_reader :vulnerability_url + + def initialize(params) + @project_name = params[:project_name] || params.dig(:project, :path_with_namespace) + @project_url = params.dig(:project, :web_url) || params[:project_url] + @title = params.dig(:object_attributes, :title) + @identifiers = params.dig(:object_attributes, :identifiers) + @severity = params.dig(:object_attributes, :severity) + @vulnerability_url = params.dig(:object_attributes, :url) + end + + def attachments + [{ + title: title, + title_link: vulnerability_url, + color: attachment_color, + fields: attachment_fields + }] + end + + def message + "Vulnerability detected in #{project_link}" + end + + private + + def attachment_color + "#C95823" + end + + def attachment_fields + [ + { + title: "Severity", + value: severity.to_s.humanize, + short: true + }, + { + title: "Identifiers", + value: ::Slack::Messenger::Util::LinkFormatter.format(identifiers_links), + short: true + } + ] + end + + def identifiers_links + @identifiers.map { |i| identifier_link(i) }.join(I18n.t(:'support.array.words_connector')) + end + + def identifier_link(identifier) + link(identifier[:name], identifier[:url]) + end + + def project_link + link(project_name, project_url) + end + end + end +end diff --git a/ee/app/services/security/store_report_service.rb b/ee/app/services/security/store_report_service.rb index 720d0163ff473662f60900f8be9321d1b9d7ce08..974ca7b012fd7701055c142100089f1abd995b94 100644 --- a/ee/app/services/security/store_report_service.rb +++ b/ee/app/services/security/store_report_service.rb @@ -6,7 +6,7 @@ module Security class StoreReportService < ::BaseService include Gitlab::Utils::StrongMemoize - attr_reader :pipeline, :report, :project, :vulnerability_finding_to_finding_map + attr_reader :pipeline, :report, :project, :vulnerability_finding_to_finding_map, :new_vulnerabilities BATCH_SIZE = 1000 @@ -15,6 +15,7 @@ def initialize(pipeline, report) @report = report @project = @pipeline.project @vulnerability_finding_to_finding_map = {} + @new_vulnerabilities = [] end def execute @@ -25,6 +26,7 @@ def execute vulnerability_ids = create_all_vulnerabilities! mark_as_resolved_except(vulnerability_ids) + execute_new_vulnerabilities_hooks start_auto_fix @@ -66,6 +68,10 @@ def create_all_vulnerabilities! vulnerability_ids end + def execute_new_vulnerabilities_hooks + new_vulnerabilities.each { |v| v.execute_hooks } + end + def mark_as_resolved_except(vulnerability_ids) project.vulnerabilities .with_report_types(report.type) @@ -400,7 +406,9 @@ def create_vulnerability(vulnerability_finding, pipeline) vulnerability = if vulnerability_finding.vulnerability_id Vulnerabilities::UpdateService.new(vulnerability_finding.project, pipeline.user, finding: vulnerability_finding, resolved_on_default_branch: false).execute else - Vulnerabilities::CreateService.new(vulnerability_finding.project, pipeline.user, finding_id: vulnerability_finding.id).execute + Vulnerabilities::CreateService.new(vulnerability_finding.project, pipeline.user, finding_id: vulnerability_finding.id).execute.tap do |vuln| + new_vulnerabilities << vuln + end end create_vulnerability_issue_link(vulnerability) diff --git a/ee/lib/ee/api/helpers/integrations_helpers.rb b/ee/lib/ee/api/helpers/integrations_helpers.rb index c1f092682ffedfc7d6d2045b25bdb44ed7b4b1bd..b945258754084f3ae5c311bd0c2aa882ec64f1de 100644 --- a/ee/lib/ee/api/helpers/integrations_helpers.rb +++ b/ee/lib/ee/api/helpers/integrations_helpers.rb @@ -42,6 +42,32 @@ def integration_classes *super ] end + + override :chat_notification_channels + def chat_notification_channels + [ + *super, + { + required: false, + name: :vulnerability_channel, + type: String, + desc: 'The name of the channel to receive vulnerability_events notifications' + } + ].freeze + end + + override :chat_notification_events + def chat_notification_events + [ + *super, + { + required: false, + name: :vulnerability_events, + type: ::API::Services::Boolean, + desc: 'Enable notifications for vulnerability_events' + } + ].freeze + end end end end diff --git a/ee/lib/gitlab/data_builder/vulnerability.rb b/ee/lib/gitlab/data_builder/vulnerability.rb new file mode 100644 index 0000000000000000000000000000000000000000..17fe3baf9fbca24893efb6da24861f9b14118e39 --- /dev/null +++ b/ee/lib/gitlab/data_builder/vulnerability.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Gitlab + module DataBuilder + module Vulnerability + extend self + + def build(vulnerability) + { + object_kind: 'vulnerability', + object_attributes: hook_attrs(vulnerability) + } + end + + def hook_attrs(vulnerability) + { + url: ::Gitlab::Routing.url_helpers.project_security_vulnerability_url(vulnerability.project, vulnerability), + title: vulnerability.title, + state: vulnerability.state, + severity: vulnerability.severity, + severity_overridden: vulnerability.severity_overridden, + identifiers: identifiers_hook_attrs(vulnerability.identifiers), + report_type: vulnerability.report_type, + confidence: vulnerability.confidence, + confidence_overridden: vulnerability.confidence_overridden, + dismissed_at: vulnerability.dismissed_at, + dismissed_by_id: vulnerability.dismissed_by_id + } + end + + def identifiers_hook_attrs(identifiers) + return [] unless identifiers + + identifiers.map do |identifier| + { + name: identifier.name, + external_id: identifier.external_id, + external_type: identifier.external_type, + url: identifier.url + } + end + end + end + end +end diff --git a/ee/spec/lib/gitlab/data_builder/vulnerability_spec.rb b/ee/spec/lib/gitlab/data_builder/vulnerability_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..01278860b91e7e66a81edd53490708e46863b9e1 --- /dev/null +++ b/ee/spec/lib/gitlab/data_builder/vulnerability_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::DataBuilder::Vulnerability do + let(:trait) { :sast } + let(:artifact) { create(:ee_ci_job_artifact, trait) } + let(:report_type) { artifact.file_type } + let(:project) { artifact.project } + let(:pipeline) { artifact.job.pipeline } + let(:report) { pipeline.security_reports.get_report(report_type.to_s, artifact) } + + let(:finding_identifier_fingerprint) do + build(:ci_reports_security_identifier, external_id: "CIPHER_INTEGRITY").fingerprint + end + + let(:scanner) { build(:vulnerabilities_scanner, project: project, external_id: 'find_sec_bugs', name: 'Find Security Bugs') } + let(:identifier) { build(:vulnerabilities_identifier, project: project, fingerprint: finding_identifier_fingerprint) } + let(:finding_location_fingerprint) do + build( + :ci_reports_security_locations_sast, + file_path: "groovy/src/main/java/com/gitlab/security_products/tests/App.groovy", + start_line: "29", + end_line: "29" + ).fingerprint + end + + let(:finding) do + build(:vulnerabilities_finding, + pipelines: [pipeline], + identifiers: [identifier], + primary_identifier: identifier, + scanner: scanner, + project: project, + uuid: "e5388f40-18f5-566d-95c6-d64c6f46a00a", + location_fingerprint: finding_location_fingerprint + ) + end + + let(:vulnerability) { create(:vulnerability, findings: [finding], project: project) } + + describe '.build' do + let(:data) { described_class.build(vulnerability) } + + it { expect(data).to be_a(Hash) } + it { expect(data[:object_kind]).to eq('vulnerability') } + + it 'contains the correct object attributes', :aggregate_failures do + object_attributes = data[:object_attributes] + expected_attributes = { + url: ::Gitlab::Routing.url_helpers.project_security_vulnerability_url(vulnerability.project, vulnerability), + title: vulnerability.title, + state: vulnerability.state, + severity: vulnerability.severity, + severity_overridden: vulnerability.severity_overridden, + report_type: vulnerability.report_type, + confidence: vulnerability.confidence, + confidence_overridden: vulnerability.confidence_overridden, + dismissed_at: vulnerability.dismissed_at, + dismissed_by_id: vulnerability.dismissed_by_id, + identifiers: [ + { + name: identifier.name, + external_id: identifier.external_id, + external_type: identifier.external_type, + url: identifier.url + } + ] + } + + expect(object_attributes).to eq(expected_attributes) + end + end +end diff --git a/ee/spec/models/ee/integration_spec.rb b/ee/spec/models/ee/integration_spec.rb index db11e84fed0b23fc9d219c246a831adcf6e32ce7..120884ea29e291dd74df2188e46a2ca746bb3085 100644 --- a/ee/spec/models/ee/integration_spec.rb +++ b/ee/spec/models/ee/integration_spec.rb @@ -30,4 +30,18 @@ end end end + + describe '.vulnerability_hooks' do + it 'includes services where vulnerability_events is true' do + create(:service, active: true, vulnerability_events: true) + + expect(described_class.vulnerability_hooks.count).to eq 1 + end + + it 'excludes services where vulnerability_events is false' do + create(:service, active: true, vulnerability_events: false) + + expect(described_class.vulnerability_hooks.count).to eq 0 + end + end end diff --git a/ee/spec/models/integrations/chat_message/vulnerability_message_spec.rb b/ee/spec/models/integrations/chat_message/vulnerability_message_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..6078005a9721c21a03c90e1ae6b1c22b035ed01e --- /dev/null +++ b/ee/spec/models/integrations/chat_message/vulnerability_message_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::ChatMessage::VulnerabilityMessage do + subject { described_class.new(args) } + + let(:args) do + { + project_name: 'Foobar Project', + project_url: 'https://git.example.com/random/foobar', + object_attributes: { + url: 'https://git.example.com/random/foobar/-/security/vulnerabilities/1', + title: 'Foo Vulnerability', + identifiers: [ + { + name: 'CVE-2021-1234', + external_id: 'CVE-2021-1234', + external_type: 'cve', + url: 'https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-1234' + }, + { + name: 'CVE-2021-5678', + external_id: 'CVE-2021-5678', + external_type: 'cve', + url: 'https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-5678' + } + + ] + } + } + end + + describe '#message' do + it 'returns the correct message' do + expect(subject.message).to eq("Vulnerability detected in [Foobar Project](https://git.example.com/random/foobar)") + end + end + + describe '#attachments' do + it 'returns an array of one' do + expect(subject.attachments).to be_a(Array) + expect(subject.attachments.size).to eq(1) + end + + it 'contains the correct attributes' do + attachments_item = subject.attachments.first + expect(attachments_item).to have_key(:title) + expect(attachments_item).to have_key(:title_link) + expect(attachments_item).to have_key(:color) + expect(attachments_item).to have_key(:fields) + end + + it 'returns the correct color' do + expect(subject.attachments.first[:color]).to eq("#C95823") + end + + it 'returns the correct attachment fields' do + attachments_item = subject.attachments.first + fields = attachments_item[:fields].map { |h| h[:title] } + + expect(fields).to match_array(%w[Severity Identifiers]) + end + + it 'returns list of identifiers in correct form' do + identifiers_item = subject.attachments.first[:fields].detect { |i| i[:title] == 'Identifiers' } + expect(identifiers_item[:value]).to eq(', ') + end + end +end diff --git a/ee/spec/services/security/store_report_service_spec.rb b/ee/spec/services/security/store_report_service_spec.rb index 2c28e82907e0ff1a016ac9ae4267f71b0dc7f316..28e29629b8bb32eddfa9f6461aec3bed2da35130 100644 --- a/ee/spec/services/security/store_report_service_spec.rb +++ b/ee/spec/services/security/store_report_service_spec.rb @@ -428,6 +428,14 @@ def vulnerability_finding_id_to_finding_map expect { subject }.to change { Vulnerability.count }.by(4) end + it 'triggers project hooks on new vulnerabilities' do + expect_next_instances_of(Vulnerability, 4) do |vulnerability| + expect(vulnerability).to receive(:execute_hooks) + end + + subject + end + it 'updates existing findings with new data' do subject diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 40c0d9e109992474429cfbaa6fba0bd0383c0a0a..6933e1c956716bbeffeddb9a8fccb6197b00ecb3 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -26258,6 +26258,9 @@ msgstr "" msgid "ProjectService|Trigger event when a new, unique alert is recorded." msgstr "" +msgid "ProjectService|Trigger event when a new, unique vulnerability is recorded. (Note: This feature requires an Ultimate plan.)" +msgstr "" + msgid "ProjectService|Trigger event when a pipeline status changes." msgstr ""