Commit f91ea8e2 authored by Robert Speicher's avatar Robert Speicher

Merge branch '222483-create-vulnerability-placeholder-model' into 'master'

Create placeholder model in CE for Vulnerability

See merge request gitlab-org/gitlab!42147
parents baec1a87 c2f5bde5
# frozen_string_literal: true
# Placeholder class for model that is implemented in EE
# It reserves '+' as a reference prefix, but the table does not exist in FOSS
class Vulnerability < ApplicationRecord
include IgnorableColumns
def self.reference_prefix
'+'
end
def self.reference_prefix_escaped
'&plus;'
end
end
Vulnerability.prepend_if_ee('EE::Vulnerability')
---
title: Create placeholder model for Vulnerability to reserve + as a reference prefix
merge_request: 42147
author:
type: added
......@@ -18,7 +18,7 @@ module EE
case self
when Epic
::Gitlab::Routing.url_helpers.group_epic_notes_path(group, self)
when Vulnerability
when ::Vulnerability
::Gitlab::Routing.url_helpers.project_security_vulnerability_notes_path(project, self)
else
super
......
......@@ -14,7 +14,7 @@ module EE
scope :by_humans, -> { user.joins(:author).merge(::User.humans) }
scope :with_suggestions, -> { joins(:suggestions) }
scope :count_for_vulnerability_id, ->(vulnerability_id) do
where(noteable_type: Vulnerability.name, noteable_id: vulnerability_id)
where(noteable_type: ::Vulnerability.name, noteable_id: vulnerability_id)
.group(:noteable_id)
.count
end
......
# frozen_string_literal: true
module EE
module Vulnerability
extend ActiveSupport::Concern
prepended do
include ::CacheMarkdownField
include ::Redactable
include ::StripAttribute
include ::Noteable
include ::Awardable
include ::Referable
TooManyDaysError = Class.new(StandardError)
MAX_DAYS_OF_HISTORY = 10
ACTIVE_STATES = %w(detected confirmed).freeze
PASSIVE_STATES = %w(dismissed resolved).freeze
cache_markdown_field :title, pipeline: :single_line
cache_markdown_field :description, issuable_state_filter_enabled: true
strip_attributes :title
redact_field :description
belongs_to :project # keep this association named 'project' for correct work of markdown cache
belongs_to :milestone
belongs_to :epic
belongs_to :author, class_name: 'User' # keep this association named 'author' for correct work of markdown cache
belongs_to :updated_by, class_name: 'User'
belongs_to :last_edited_by, class_name: 'User'
belongs_to :resolved_by, class_name: 'User'
belongs_to :dismissed_by, class_name: 'User'
belongs_to :confirmed_by, class_name: 'User'
has_one :group, through: :project
has_many :findings, class_name: '::Vulnerabilities::Finding', inverse_of: :vulnerability
has_many :issue_links, class_name: '::Vulnerabilities::IssueLink', inverse_of: :vulnerability
has_many :created_issue_links, -> { created }, class_name: '::Vulnerabilities::IssueLink', inverse_of: :vulnerability
has_many :related_issues, through: :issue_links, source: :issue do
def with_vulnerability_links
select('issues.*, vulnerability_issue_links.id AS vulnerability_link_id, '\
'vulnerability_issue_links.link_type AS vulnerability_link_type')
end
end
has_many :notes, as: :noteable, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_many :user_mentions, class_name: 'VulnerabilityUserMention'
enum state: { detected: 1, dismissed: 2, resolved: 3, confirmed: 4 }
enum severity: ::Vulnerabilities::Finding::SEVERITY_LEVELS, _prefix: :severity
enum confidence: ::Vulnerabilities::Finding::CONFIDENCE_LEVELS, _prefix: :confidence
enum report_type: ::Vulnerabilities::Finding::REPORT_TYPES
validates :project, :author, :title, :severity, :confidence, :report_type, presence: true
# at this stage Vulnerability is not an Issuable, has some important attributes (and their constraints) in common
validates :title, length: { maximum: ::Issuable::TITLE_LENGTH_MAX }
validates :title_html, length: { maximum: ::Issuable::TITLE_HTML_LENGTH_MAX }, allow_blank: true
validates :description, length: { maximum: ::Issuable::DESCRIPTION_LENGTH_MAX }, allow_blank: true
validates :description_html, length: { maximum: ::Issuable::DESCRIPTION_HTML_LENGTH_MAX }, allow_blank: true
scope :with_findings, -> { includes(:findings) }
scope :with_findings_and_scanner, -> { includes(findings: :scanner) }
scope :with_findings_scanner_and_identifiers, -> { includes(findings: [:scanner, :identifiers, finding_identifiers: :identifier]) }
scope :with_created_issue_links_and_issues, -> { includes(created_issue_links: :issue) }
scope :for_projects, -> (project_ids) { where(project_id: project_ids) }
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 :with_scanners, -> (scanners) { joins(findings: :scanner).merge(::Vulnerabilities::Scanner.with_external_id(scanners)) }
scope :grouped_by_severity, -> { reorder(severity: :desc).group(:severity) }
scope :with_resolution, -> (has_resolution = true) { where(resolved_on_default_branch: has_resolution) }
scope :with_issues, -> (has_issues = true) do
exist_query = has_issues ? 'EXISTS (?)' : 'NOT EXISTS (?)'
issue_links = ::Vulnerabilities::IssueLink.arel_table
where(exist_query, ::Vulnerabilities::IssueLink.select(1).where(issue_links[:vulnerability_id].eq(arel_table[:id])))
end
scope :order_severity_asc, -> { reorder(severity: :asc, id: :desc) }
scope :order_severity_desc, -> { reorder(severity: :desc, id: :desc) }
delegate :scanner_name, :scanner_external_id, :metadata, :message, :cve, :description,
to: :finding, prefix: true, allow_nil: true
delegate :default_branch, :name, to: :project, prefix: true, allow_nil: true
delegate :name, to: :group, prefix: true, allow_nil: true
def to_reference(from = nil, full: false)
reference = "#{self.class.reference_prefix}#{id}"
"#{project.to_reference_base(from, full: full)}#{reference}"
end
# There will only be one finding associated with a vulnerability for the foreseeable future
def finding
findings.first
end
def dismissed_by_id
super || finding&.dismissal_feedback&.author_id
end
def resource_parent
project
end
def discussions_rendered_on_frontend?
true
end
def user_notes_count
user_notes_count_service.count
end
def after_note_changed(note)
user_notes_count_service.delete_cache unless note.system?
end
alias_method :after_note_created, :after_note_changed
alias_method :after_note_destroyed, :after_note_changed
def stat_diff
::Vulnerabilities::StatDiff.new(self)
end
private
def user_notes_count_service
@user_notes_count_service ||= ::Vulnerabilities::UserNotesCountService.new(self) # rubocop: disable CodeReuse/ServiceClass
end
end
class_methods do
def parent_class
::Project
end
def to_ability_name
model_name.singular
end
def counts_by_day_and_severity(start_date, end_date)
return [] unless ::Feature.enabled?(:vulnerability_history, default_enabled: true)
num_days_of_history = end_date - start_date + 1
# this clause guards against query timeouts
raise TooManyDaysError, "Cannot fetch counts for more than #{MAX_DAYS_OF_HISTORY} days" if num_days_of_history > MAX_DAYS_OF_HISTORY
quoted_start_date = connection.quote(start_date)
quoted_end_date = connection.quote(end_date)
select(
'DATE(calendar.entry) AS day, severity, COUNT(*)'
).from(
"generate_series(DATE #{quoted_start_date}, DATE #{quoted_end_date}, INTERVAL '1 day') as calendar(entry)"
).joins(
'INNER JOIN vulnerabilities ON vulnerabilities.created_at <= calendar.entry'
).where(
'(vulnerabilities.dismissed_at IS NULL OR vulnerabilities.dismissed_at > calendar.entry) AND (vulnerabilities.resolved_at IS NULL OR vulnerabilities.resolved_at > calendar.entry)'
).group(
:day, :severity
)
end
def active_states
ACTIVE_STATES
end
def passive_states
PASSIVE_STATES
end
def active_state_values
states.values_at(*active_states)
end
def order_by(method)
case method.to_s
when 'severity_desc' then order_severity_desc
when 'severity_asc' then order_severity_asc
else
order_severity_desc
end
end
end
end
end
# frozen_string_literal: true
class Vulnerability < ApplicationRecord
include CacheMarkdownField
include Redactable
include StripAttribute
include Noteable
include Awardable
TooManyDaysError = Class.new(StandardError)
MAX_DAYS_OF_HISTORY = 10
ACTIVE_STATES = %w(detected confirmed).freeze
PASSIVE_STATES = %w(dismissed resolved).freeze
cache_markdown_field :title, pipeline: :single_line
cache_markdown_field :description, issuable_state_filter_enabled: true
strip_attributes :title
redact_field :description
belongs_to :project # keep this association named 'project' for correct work of markdown cache
belongs_to :milestone
belongs_to :epic
belongs_to :author, class_name: 'User' # keep this association named 'author' for correct work of markdown cache
belongs_to :updated_by, class_name: 'User'
belongs_to :last_edited_by, class_name: 'User'
belongs_to :resolved_by, class_name: 'User'
belongs_to :dismissed_by, class_name: 'User'
belongs_to :confirmed_by, class_name: 'User'
has_one :group, through: :project
has_many :findings, class_name: 'Vulnerabilities::Finding', inverse_of: :vulnerability
has_many :issue_links, class_name: 'Vulnerabilities::IssueLink', inverse_of: :vulnerability
has_many :created_issue_links, -> { created }, class_name: 'Vulnerabilities::IssueLink', inverse_of: :vulnerability
has_many :related_issues, through: :issue_links, source: :issue do
def with_vulnerability_links
select('issues.*, vulnerability_issue_links.id AS vulnerability_link_id, '\
'vulnerability_issue_links.link_type AS vulnerability_link_type')
end
end
has_many :notes, as: :noteable, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_many :user_mentions, class_name: 'VulnerabilityUserMention'
enum state: { detected: 1, dismissed: 2, resolved: 3, confirmed: 4 }
enum severity: Vulnerabilities::Finding::SEVERITY_LEVELS, _prefix: :severity
enum confidence: Vulnerabilities::Finding::CONFIDENCE_LEVELS, _prefix: :confidence
enum report_type: Vulnerabilities::Finding::REPORT_TYPES
validates :project, :author, :title, :severity, :confidence, :report_type, presence: true
# at this stage Vulnerability is not an Issuable, has some important attributes (and their constraints) in common
validates :title, length: { maximum: Issuable::TITLE_LENGTH_MAX }
validates :title_html, length: { maximum: Issuable::TITLE_HTML_LENGTH_MAX }, allow_blank: true
validates :description, length: { maximum: Issuable::DESCRIPTION_LENGTH_MAX }, allow_blank: true
validates :description_html, length: { maximum: Issuable::DESCRIPTION_HTML_LENGTH_MAX }, allow_blank: true
scope :with_findings, -> { includes(:findings) }
scope :with_findings_and_scanner, -> { includes(findings: :scanner) }
scope :with_findings_scanner_and_identifiers, -> { includes(findings: [:scanner, :identifiers, finding_identifiers: :identifier]) }
scope :with_created_issue_links_and_issues, -> { includes(created_issue_links: :issue) }
scope :for_projects, -> (project_ids) { where(project_id: project_ids) }
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 :with_scanners, -> (scanners) { joins(findings: :scanner).merge(Vulnerabilities::Scanner.with_external_id(scanners)) }
scope :grouped_by_severity, -> { reorder(severity: :desc).group(:severity) }
scope :with_resolution, -> (has_resolution = true) { where(resolved_on_default_branch: has_resolution) }
scope :with_issues, -> (has_issues = true) do
exist_query = has_issues ? 'EXISTS (?)' : 'NOT EXISTS (?)'
issue_links = Vulnerabilities::IssueLink.arel_table
where(exist_query, Vulnerabilities::IssueLink.select(1).where(issue_links[:vulnerability_id].eq(arel_table[:id])))
end
scope :order_severity_asc, -> { reorder(severity: :asc, id: :desc) }
scope :order_severity_desc, -> { reorder(severity: :desc, id: :desc) }
class << self
def reference_prefix
'^'
end
def parent_class
::Project
end
def to_ability_name
model_name.singular
end
def counts_by_day_and_severity(start_date, end_date)
return [] unless Feature.enabled?(:vulnerability_history, default_enabled: true)
num_days_of_history = end_date - start_date + 1
# this clause guards against query timeouts
raise TooManyDaysError, "Cannot fetch counts for more than #{MAX_DAYS_OF_HISTORY} days" if num_days_of_history > MAX_DAYS_OF_HISTORY
quoted_start_date = connection.quote(start_date)
quoted_end_date = connection.quote(end_date)
select(
'DATE(calendar.entry) AS day, severity, COUNT(*)'
).from(
"generate_series(DATE #{quoted_start_date}, DATE #{quoted_end_date}, INTERVAL '1 day') as calendar(entry)"
).joins(
'INNER JOIN vulnerabilities ON vulnerabilities.created_at <= calendar.entry'
).where(
'(vulnerabilities.dismissed_at IS NULL OR vulnerabilities.dismissed_at > calendar.entry) AND (vulnerabilities.resolved_at IS NULL OR vulnerabilities.resolved_at > calendar.entry)'
).group(
:day, :severity
)
end
def active_states
ACTIVE_STATES
end
def passive_states
PASSIVE_STATES
end
def active_state_values
states.values_at(*active_states)
end
def order_by(method)
case method.to_s
when 'severity_desc' then order_severity_desc
when 'severity_asc' then order_severity_asc
else
order_severity_desc
end
end
end
def to_reference(from = nil, full: false)
reference = "#{self.class.reference_prefix}#{id}"
"#{project.to_reference_base(from, full: full)}#{reference}"
end
# There will only be one finding associated with a vulnerability for the foreseeable future
def finding
findings.first
end
def dismissed_by_id
super || finding&.dismissal_feedback&.author_id
end
delegate :scanner_name, :scanner_external_id, :metadata, :message, :cve, :description,
to: :finding, prefix: true, allow_nil: true
delegate :default_branch, :name, to: :project, prefix: true, allow_nil: true
delegate :name, to: :group, prefix: true, allow_nil: true
def resource_parent
project
end
def discussions_rendered_on_frontend?
true
end
def user_notes_count
user_notes_count_service.count
end
def after_note_changed(note)
user_notes_count_service.delete_cache unless note.system?
end
alias_method :after_note_created, :after_note_changed
alias_method :after_note_destroyed, :after_note_changed
def stat_diff
Vulnerabilities::StatDiff.new(self)
end
private
def user_notes_count_service
@user_notes_count_service ||= Vulnerabilities::UserNotesCountService.new(self) # rubocop: disable CodeReuse/ServiceClass
end
end
......@@ -15,7 +15,7 @@ module EE
instance.group_epic_url(object.group, object, **options)
when Iteration
instance.iteration_url(object, **options)
when Vulnerability
when ::Vulnerability
instance.project_security_vulnerability_url(object.project, object, **options)
else
super
......
......@@ -259,7 +259,7 @@ RSpec.describe Vulnerability do
it 'returns an empty array' do
create(:vulnerability, created_at: 1.day.ago)
counts_by_day_and_severity = Vulnerability.counts_by_day_and_severity(1.day.ago, Date.current)
counts_by_day_and_severity = ::Vulnerability.counts_by_day_and_severity(1.day.ago, Date.current)
expect(counts_by_day_and_severity).to be_empty
end
......@@ -276,7 +276,7 @@ RSpec.describe Vulnerability do
create(:vulnerability, created_at: 5.days.ago, dismissed_at: 1.day.ago, severity: :high)
create(:vulnerability, created_at: 4.days.ago, resolved_at: 2.days.ago, severity: :critical)
counts_by_day_and_severity = Vulnerability.counts_by_day_and_severity(Date.parse('2019-10-22'), Date.parse('2019-10-28'))
counts_by_day_and_severity = ::Vulnerability.counts_by_day_and_severity(Date.parse('2019-10-22'), Date.parse('2019-10-28'))
expect(counts_by_day_and_severity.order(:day, :severity).to_json).to eq([
{ 'id' => nil, 'severity' => 'high', 'day' => '2019-10-26', 'count' => 1 },
......@@ -291,8 +291,8 @@ RSpec.describe Vulnerability do
context 'there are more than 10 days between the start and end dates' do
it 'raises a TooManyDaysError' do
expect { Vulnerability.counts_by_day_and_severity(10.days.ago.to_date, Date.current) }.to raise_error(
Vulnerability::TooManyDaysError,
expect { ::Vulnerability.counts_by_day_and_severity(10.days.ago.to_date, Date.current) }.to raise_error(
::Vulnerability::TooManyDaysError,
'Cannot fetch counts for more than 10 days'
)
end
......@@ -301,7 +301,7 @@ RSpec.describe Vulnerability do
end
describe '.active_state_values' do
let(:expected_values) { Vulnerability.states.values_at('detected', 'confirmed') }
let(:expected_values) { ::Vulnerability.states.values_at('detected', 'confirmed') }
subject { described_class.active_state_values }
......@@ -326,7 +326,7 @@ RSpec.describe Vulnerability do
describe '.reference_prefix' do
subject { described_class.reference_prefix }
it { is_expected.to eq('^') }
it { is_expected.to eq('+') }
end
describe '#to_reference' do
......@@ -336,22 +336,22 @@ RSpec.describe Vulnerability do
context 'when nil argument' do
it 'returns vulnerability id' do
expect(vulnerability.to_reference).to eq '^1'
expect(vulnerability.to_reference).to eq '+1'
end
it 'returns complete path to the vulnerability with full: true' do
expect(vulnerability.to_reference(full: true)).to eq 'sample-namespace/sample-project^1'
expect(vulnerability.to_reference(full: true)).to eq 'sample-namespace/sample-project+1'
end
end
context 'when argument is a project' do
context 'when same project' do
it 'returns vulnerability id' do
expect(vulnerability.to_reference(project)).to eq('^1')
expect(vulnerability.to_reference(project)).to eq('+1')
end
it 'returns full reference with full: true' do
expect(vulnerability.to_reference(project, full: true)).to eq 'sample-namespace/sample-project^1'
expect(vulnerability.to_reference(project, full: true)).to eq 'sample-namespace/sample-project+1'
end
end
......@@ -361,11 +361,11 @@ RSpec.describe Vulnerability do
end
it 'returns a cross-project reference' do
expect(vulnerability.to_reference(another_project)).to eq 'sample-project^1'
expect(vulnerability.to_reference(another_project)).to eq 'sample-project+1'
end
it 'returns full reference with full: true' do
expect(vulnerability.to_reference(another_project, full: true)).to eq 'sample-namespace/sample-project^1'
expect(vulnerability.to_reference(another_project, full: true)).to eq 'sample-namespace/sample-project+1'
end
end
......@@ -374,11 +374,11 @@ RSpec.describe Vulnerability do
let(:another_namespace_project) { build(:project, path: 'another-project', namespace: another_namespace) }
it 'returns complete path to the vulnerability' do
expect(vulnerability.to_reference(another_namespace_project)).to eq 'sample-namespace/sample-project^1'
expect(vulnerability.to_reference(another_namespace_project)).to eq 'sample-namespace/sample-project+1'
end
it 'returns full reference with full: true' do
expect(vulnerability.to_reference(another_namespace_project, full: true)).to eq 'sample-namespace/sample-project^1'
expect(vulnerability.to_reference(another_namespace_project, full: true)).to eq 'sample-namespace/sample-project+1'
end
end
end
......@@ -386,11 +386,11 @@ RSpec.describe Vulnerability do
context 'when argument is a namespace' do
context 'when same as vulnerability' do
it 'returns path to the vulnerability with the project name' do
expect(vulnerability.to_reference(namespace)).to eq 'sample-project^1'
expect(vulnerability.to_reference(namespace)).to eq 'sample-project+1'
end
it 'returns full reference with full: true' do
expect(vulnerability.to_reference(namespace, full: true)).to eq 'sample-namespace/sample-project^1'
expect(vulnerability.to_reference(namespace, full: true)).to eq 'sample-namespace/sample-project+1'
end
end
......@@ -398,11 +398,11 @@ RSpec.describe Vulnerability do
let(:group) { build(:group, name: 'Group', path: 'sample-group') }
it 'returns full path to the vulnerability with full: true' do
expect(vulnerability.to_reference(group)).to eq 'sample-namespace/sample-project^1'
expect(vulnerability.to_reference(group)).to eq 'sample-namespace/sample-project+1'
end
it 'returns full path to the vulnerability with full: false' do
expect(vulnerability.to_reference(group, full: false)).to eq 'sample-namespace/sample-project^1'
expect(vulnerability.to_reference(group, full: false)).to eq 'sample-namespace/sample-project+1'
end
end
end
......@@ -448,13 +448,13 @@ RSpec.describe Vulnerability do
end
describe '.parent_class' do
subject(:parent_class) { Vulnerability.parent_class }
subject(:parent_class) { ::Vulnerability.parent_class }
it { is_expected.to eq(::Project) }
end
describe '.to_ability_name' do
subject(:ability_name) { Vulnerability.to_ability_name }
subject(:ability_name) { ::Vulnerability.to_ability_name }
it { is_expected.to eq('vulnerability') }
end
......@@ -560,19 +560,15 @@ RSpec.describe Vulnerability do
end
describe '#after_note_created' do
let(:after_note_changed_method) { described_class.instance_method(:after_note_changed) }
subject { described_class.instance_method(:after_note_created).original_name }
subject { described_class.instance_method(:after_note_created) }
it { is_expected.to eq(after_note_changed_method) }
it { is_expected.to eq(:after_note_changed) }
end
describe '#after_note_destroyed' do
let(:after_note_changed_method) { described_class.instance_method(:after_note_changed) }
subject { described_class.instance_method(:after_note_destroyed) }
subject { described_class.instance_method(:after_note_destroyed).original_name }
it { is_expected.to eq(after_note_changed_method) }
it { is_expected.to eq(:after_note_changed) }
end
describe '#stat_diff' do
......
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