Commit 3798cbea authored by Mark Chao's avatar Mark Chao

Merge branch '334951-vulnerability-slack-notification' into 'master'

[RUN AS-IF-FOSS] Add option to send slack notifications when a new vulnerability is detected

See merge request gitlab-org/gitlab!65245
parents 18e636ed 49e788b9
......@@ -253,3 +253,5 @@ module Integrations
end
end
end
Integrations::BaseChatNotification.prepend_mod_with('Integrations::BaseChatNotification')
# 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
ac14aa49830a3af9a1445c0c7680f5660247a8104c8e4c1ae542c4b368f7c9bf
\ No newline at end of file
......@@ -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))
);
......@@ -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
......
......@@ -60,7 +60,7 @@ 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. |
......@@ -72,6 +72,7 @@ The following triggers are available for Slack notifications:
| **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
......
......@@ -58,5 +58,12 @@ module EE
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
......@@ -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
......
# 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
......@@ -172,8 +172,16 @@ module EE
::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
......
# 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
......@@ -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 @@ module Security
@report = report
@project = @pipeline.project
@vulnerability_finding_to_finding_map = {}
@new_vulnerabilities = []
end
def execute
......@@ -25,6 +26,7 @@ module Security
vulnerability_ids = create_all_vulnerabilities!
mark_as_resolved_except(vulnerability_ids)
execute_new_vulnerabilities_hooks
start_auto_fix
......@@ -66,6 +68,10 @@ module Security
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 @@ module Security
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)
......
......@@ -42,6 +42,32 @@ module EE
*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
......
# 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
# 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
......@@ -30,4 +30,18 @@ RSpec.describe Integration do
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
# 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('<https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-1234|CVE-2021-1234>, <https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-5678|CVE-2021-5678>')
end
end
end
......@@ -428,6 +428,14 @@ RSpec.describe Security::StoreReportService, '#execute', :snowplow do
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
......
......@@ -26255,6 +26255,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 ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment