Commit 2e0e8c8b authored by Mayra Cabrera's avatar Mayra Cabrera

Merge branch 'create-vulnerability-type' into 'master'

Create VulnerabilityType and supporting types

See merge request gitlab-org/gitlab!26136
parents b99ab983 e19b1751
......@@ -3934,6 +3934,11 @@ enum IssueState {
opened
}
"""
Represents untyped JSON
"""
scalar JSON
type Label {
"""
Background color of the label
......@@ -6060,6 +6065,31 @@ type Project {
"""
visibility: String
"""
Vulnerabilities reported on the project. Available only when feature flag `first_class_vulnerabilities` is enabled.
"""
vulnerabilities(
"""
Returns the elements in the list that come after the specified cursor.
"""
after: String
"""
Returns the elements in the list that come before the specified cursor.
"""
before: String
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
Returns the last _n_ elements from the list.
"""
last: Int
): VulnerabilityConnection
"""
Web URL of the project
"""
......@@ -8421,4 +8451,117 @@ enum VisibilityScopesEnum {
internal
private
public
}
"""
Represents a vulnerability.
"""
type Vulnerability {
"""
Description of the vulnerability
"""
description: String
"""
GraphQL ID of the vulnerability
"""
id: ID!
"""
The JSON location metadata for the vulnerability. Its format depends on the
type of the security scan that found the vulnerability
"""
location: JSON
"""
Type of the security report that found the vulnerability (SAST, DEPENDENCY_SCANNING, CONTAINER_SCANNING, DAST)
"""
reportType: VulnerabilityReportType
"""
Severity of the vulnerability (INFO, UNKNOWN, LOW, MEDIUM, HIGH, CRITICAL)
"""
severity: VulnerabilitySeverity
"""
State of the vulnerability (DETECTED, DISMISSED, RESOLVED, CONFIRMED)
"""
state: VulnerabilityState
"""
Title of the vulnerability
"""
title: String
"""
URL to the vulnerability's details page
"""
vulnerabilityPath: String
}
"""
The connection type for Vulnerability.
"""
type VulnerabilityConnection {
"""
A list of edges.
"""
edges: [VulnerabilityEdge]
"""
A list of nodes.
"""
nodes: [Vulnerability]
"""
Information to aid in pagination.
"""
pageInfo: PageInfo!
}
"""
An edge in a connection.
"""
type VulnerabilityEdge {
"""
A cursor for use in pagination.
"""
cursor: String!
"""
The item at the end of the edge.
"""
node: Vulnerability
}
"""
The type of the security scan that found the vulnerability.
"""
enum VulnerabilityReportType {
CONTAINER_SCANNING
DAST
DEPENDENCY_SCANNING
SAST
}
"""
The severity of the vulnerability.
"""
enum VulnerabilitySeverity {
CRITICAL
HIGH
INFO
LOW
MEDIUM
UNKNOWN
}
"""
The state of the vulnerability.
"""
enum VulnerabilityState {
CONFIRMED
DETECTED
DISMISSED
RESOLVED
}
\ No newline at end of file
......@@ -1354,3 +1354,18 @@ Autogenerated return type of UpdateSnippet
| Name | Type | Description |
| --- | ---- | ---------- |
| `createSnippet` | Boolean! | Indicates the user can perform `create_snippet` on this resource |
## Vulnerability
Represents a vulnerability.
| Name | Type | Description |
| --- | ---- | ---------- |
| `description` | String | Description of the vulnerability |
| `id` | ID! | GraphQL ID of the vulnerability |
| `location` | JSON | The JSON location metadata for the vulnerability. Its format depends on the type of the security scan that found the vulnerability |
| `reportType` | VulnerabilityReportType | Type of the security report that found the vulnerability (SAST, DEPENDENCY_SCANNING, CONTAINER_SCANNING, DAST) |
| `severity` | VulnerabilitySeverity | Severity of the vulnerability (INFO, UNKNOWN, LOW, MEDIUM, HIGH, CRITICAL) |
| `state` | VulnerabilityState | State of the vulnerability (DETECTED, DISMISSED, RESOLVED, CONFIRMED) |
| `title` | String | Title of the vulnerability |
| `vulnerabilityPath` | String | URL to the vulnerability's details page |
......@@ -11,6 +11,13 @@ module EE
field :service_desk_address, GraphQL::STRING_TYPE, null: true,
description: 'E-mail address of the service desk.'
field :vulnerabilities,
::Types::VulnerabilityType.connection_type,
null: true,
description: 'Vulnerabilities reported on the project',
resolver: Resolvers::VulnerabilitiesResolver,
feature_flag: :first_class_vulnerabilities
end
end
end
......
# frozen_string_literal: true
module Resolvers
class VulnerabilitiesResolver < BaseResolver
include Gitlab::Utils::StrongMemoize
type Types::VulnerabilityType, null: true
def resolve(**args)
return Vulnerability.none unless vulnerable
vulnerable.vulnerabilities.with_findings.ordered
end
private
# `vulnerable` will be a Project, Group, or InstanceSecurityDashboard
def vulnerable
# A project or group could have been loaded in batch by `BatchLoader`.
# At this point we need the `id` of the project or group to query for vulnerabilities, so
# make sure it's loaded and not `nil` before continuing.
strong_memoize(:vuln) do
object.respond_to?(:sync) ? object.sync : object
end
end
end
end
# frozen_string_literal: true
module Types
class VulnerabilityReportTypeEnum < BaseEnum
graphql_name 'VulnerabilityReportType'
description 'The type of the security scan that found the vulnerability.'
::Vulnerabilities::Occurrence::REPORT_TYPES.keys.each do |report_type|
value report_type.to_s.upcase, value: report_type.to_s
end
end
end
# frozen_string_literal: true
module Types
class VulnerabilitySeverityEnum < BaseEnum
graphql_name 'VulnerabilitySeverity'
description 'The severity of the vulnerability.'
::Vulnerabilities::Occurrence::SEVERITY_LEVELS.keys.each do |severity|
value severity.to_s.upcase, value: severity.to_s
end
end
end
# frozen_string_literal: true
module Types
class VulnerabilityStateEnum < BaseEnum
graphql_name 'VulnerabilityState'
description 'The state of the vulnerability.'
::Vulnerability.states.keys.each do |state|
value state.to_s.upcase, value: state.to_s
end
end
end
# frozen_string_literal: true
module Types
class VulnerabilityType < BaseObject
graphql_name 'Vulnerability'
description 'Represents a vulnerability.'
authorize :read_vulnerability
field :id, GraphQL::ID_TYPE, null: false,
description: 'GraphQL ID of the vulnerability'
field :title, GraphQL::STRING_TYPE, null: true,
description: 'Title of the vulnerability'
field :description, GraphQL::STRING_TYPE, null: true,
description: 'Description of the vulnerability'
field :state, VulnerabilityStateEnum, null: true,
description: "State of the vulnerability (#{::Vulnerability.states.keys.join(', ').upcase})"
field :severity, VulnerabilitySeverityEnum, null: true,
description: "Severity of the vulnerability (#{::Vulnerabilities::Occurrence::SEVERITY_LEVELS.keys.join(', ').upcase})"
field :report_type, VulnerabilityReportTypeEnum, null: true,
description: "Type of the security report that found the vulnerability (#{::Vulnerabilities::Occurrence::REPORT_TYPES.keys.join(', ').upcase})"
field :vulnerability_path, GraphQL::STRING_TYPE, null: true,
description: "URL to the vulnerability's details page",
resolve: -> (obj, _args, _ctx) { ::Gitlab::Routing.url_helpers.project_security_vulnerability_path(obj.project, obj) }
field :location, GraphQL::Types::JSON, null: true,
description: 'The JSON location metadata for the vulnerability. Its format depends on the type of the security scan that found the vulnerability',
resolve: -> (obj, _args, _ctx) { obj.finding&.location.to_json }
end
end
......@@ -45,6 +45,8 @@ class Vulnerability < ApplicationRecord
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 :ordered, -> { order(severity: :desc) }
scope :with_findings, -> { includes(:findings) }
scope :for_projects, -> (project_ids) { where(project_id: project_ids) }
......
# frozen_string_literal: true
class VulnerabilityPolicy < BasePolicy
delegate { @subject.project }
end
......@@ -29,6 +29,18 @@ FactoryBot.define do
confirmed_at { Time.current }
end
::Vulnerabilities::Occurrence::SEVERITY_LEVELS.keys.each do |severity_level|
trait severity_level do
severity { severity_level }
end
end
::Vulnerabilities::Occurrence::REPORT_TYPES.keys.each do |report_type|
trait report_type do
report_type { report_type }
end
end
trait :with_findings do
after(:build) do |vulnerability|
vulnerability.findings = build_list(
......
# frozen_string_literal: true
require 'spec_helper'
describe Resolvers::VulnerabilitiesResolver do
include GraphqlHelpers
describe '#resolve' do
let_it_be(:project) { create(:project) }
let_it_be(:low_vulnerability) { create(:vulnerability, :low, project: project) }
let_it_be(:critical_vulnerability) { create(:vulnerability, :critical, project: project) }
let_it_be(:high_vulnerability) { create(:vulnerability, :high, project: project) }
subject { resolve(described_class, obj: project) }
it "returns the project's vulnerabilities" do
is_expected.to contain_exactly(critical_vulnerability, high_vulnerability, low_vulnerability)
end
it 'orders results by severity' do
expect(subject.first).to eq(critical_vulnerability)
expect(subject.second).to eq(high_vulnerability)
expect(subject.third).to eq(low_vulnerability)
end
end
end
......@@ -4,8 +4,67 @@ require 'spec_helper'
describe GitlabSchema.types['Project'] do
it 'includes the ee specific fields' do
expected_fields = %w[service_desk_enabled service_desk_address]
expected_fields = %w[service_desk_enabled service_desk_address vulnerabilities]
is_expected.to include_graphql_fields(*expected_fields)
end
describe 'vulnerabilities' do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
let_it_be(:vulnerability) do
create(:vulnerability, :detected, :critical, project: project, title: 'A terrible one!')
end
let_it_be(:query) do
%(
query {
project(fullPath:"#{project.full_path}") {
name
vulnerabilities {
nodes {
title
severity
state
}
}
}
}
)
end
before do
project.add_developer(user)
end
subject { GitlabSchema.execute(query, context: { current_user: user }).as_json }
context 'when first_class_vulnerabilities is disabled' do
before do
stub_feature_flags(first_class_vulnerabilities: false)
end
it 'is null' do
vulnerabilities = subject.dig('data', 'project', 'vulnerabilities')
expect(vulnerabilities).to be_nil
end
end
context 'when first_class_vulnerabilities is enabled' do
before do
stub_feature_flags(first_class_vulnerabilities: true)
stub_licensed_features(security_dashboard: true)
end
it "returns the project's vulnerabilities" do
vulnerabilities = subject.dig('data', 'project', 'vulnerabilities', 'nodes')
expect(vulnerabilities.count).to be(1)
expect(vulnerabilities.first['title']).to eq('A terrible one!')
expect(vulnerabilities.first['state']).to eq('DETECTED')
expect(vulnerabilities.first['severity']).to eq('CRITICAL')
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['VulnerabilityReportType'] do
it 'exposes all vulnerability report types' do
expect(described_class.values.keys).to contain_exactly(*%w[SAST DAST CONTAINER_SCANNING DEPENDENCY_SCANNING])
end
end
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['VulnerabilitySeverity'] do
it 'exposes all vulnerability severities' do
expect(described_class.values.keys).to contain_exactly(*%w[CRITICAL HIGH MEDIUM LOW UNKNOWN INFO])
end
end
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['VulnerabilityState'] do
it 'exposes all vulnerability states' do
expect(described_class.values.keys).to contain_exactly(*%w[DETECTED CONFIRMED DISMISSED RESOLVED])
end
end
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['Vulnerability'] do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
let_it_be(:vulnerability) { create(:vulnerability, project: project) }
let(:fields) do
%i[id title description state severity report_type vulnerability_path location]
end
before do
stub_licensed_features(security_dashboard: true)
project.add_developer(user)
end
subject { GitlabSchema.execute(query, context: { current_user: user }).as_json }
it { expect(described_class).to have_graphql_fields(fields) }
it { expect(described_class).to require_graphql_authorizations(:read_vulnerability) }
describe 'vulnerability_path' do
let(:query) do
%(
query {
project(fullPath:"#{project.full_path}") {
name
vulnerabilities {
nodes {
vulnerabilityPath
}
}
}
}
)
end
it "is the path to the vulnerability's detail page" do
vulnerabilities = subject.dig('data', 'project', 'vulnerabilities', 'nodes')
expect(vulnerabilities.first['vulnerabilityPath']).to eq(
::Gitlab::Routing.url_helpers.project_security_vulnerability_path(project, vulnerability)
)
end
end
describe 'location' do
let_it_be(:location) do
{
'end_line' => 666,
'file' => 'vulnerable-file.js',
'start_line' => 420
}
end
let(:query) do
%(
query {
project(fullPath:"#{project.full_path}") {
name
vulnerabilities {
nodes {
location
}
}
}
}
)
end
before do
create(
:vulnerabilities_occurrence,
vulnerability: vulnerability,
raw_metadata: {
location: location
}.to_json
)
end
it "is the JSON metadata for the vulnerability's location" do
vulnerabilities = subject.dig('data', 'project', 'vulnerabilities', 'nodes')
expect(JSON.parse(vulnerabilities.first['location'])).to eq(location)
end
end
end
......@@ -97,9 +97,9 @@ describe Vulnerability do
end
describe '.with_report_types' do
let!(:sast_vulnerability) { create(:vulnerability, report_type: :sast) }
let!(:dast_vulnerability) { create(:vulnerability, report_type: :dast) }
let!(:dependency_scanning_vulnerability) { create(:vulnerability, report_type: :dependency_scanning) }
let!(:sast_vulnerability) { create(:vulnerability, :sast) }
let!(:dast_vulnerability) { create(:vulnerability, :dast) }
let!(:dependency_scanning_vulnerability) { create(:vulnerability, :dependency_scanning) }
let(:report_types) { %w[sast dast] }
subject { described_class.with_report_types(report_types) }
......@@ -110,9 +110,9 @@ describe Vulnerability do
end
describe '.with_severities' do
let!(:high_vulnerability) { create(:vulnerability, severity: :high) }
let!(:medium_vulnerability) { create(:vulnerability, severity: :medium) }
let!(:low_vulnerability) { create(:vulnerability, severity: :low) }
let!(:high_vulnerability) { create(:vulnerability, :high) }
let!(:medium_vulnerability) { create(:vulnerability, :medium) }
let!(:low_vulnerability) { create(:vulnerability, :low) }
let(:severities) { %w[medium low] }
subject { described_class.with_severities(severities) }
......@@ -135,6 +135,18 @@ describe Vulnerability do
end
end
describe '.ordered' do
subject { described_class.ordered }
it 'returns vulnerabilities ordered by severity' do
low_vulnerability = create(:vulnerability, :low)
critical_vulnerability = create(:vulnerability, :critical)
medium_vulnerability = create(:vulnerability, :medium)
is_expected.to eq([critical_vulnerability, medium_vulnerability, low_vulnerability])
end
end
describe '#finding' do
let_it_be(:project) { create(:project, :with_vulnerabilities) }
let_it_be(:vulnerability) { project.vulnerabilities.first }
......
# frozen_string_literal: true
require 'spec_helper'
describe VulnerabilityPolicy do
describe 'read_vulnerability' do
let(:project) { create(:project) }
let(:user) { create(:user) }
let(:vulnerability) { create(:vulnerability, project: project) }
subject { described_class.new(user, vulnerability) }
context 'when the security_dashboard feature is enabled' do
before do
stub_licensed_features(security_dashboard: true)
end
context "when the current user has developer access to the vulnerability's project" do
before do
project.add_developer(user)
end
it { is_expected.to be_allowed(:read_vulnerability) }
end
context "when the current user does not have developer access to the vulnerability's project" do
it { is_expected.to be_disallowed(:read_vulnerability) }
end
end
context 'when the security_dashboard feature is disabled' do
before do
stub_licensed_features(security_dashboard: false)
project.add_developer(user)
end
it { is_expected.to be_disallowed(:read_vulnerability) }
end
end
end
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