Commit c2f5bde5 authored by Alan (Maciej) Paruszewski's avatar Alan (Maciej) Paruszewski Committed by Robert Speicher

Create placeholder model in CE for Vulnerability

This change adds new placeholder model for Vulnerability and moves
previously created model to EE::Vulnerability module.
parent 882d8bc6
# 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