Commit 617203d8 authored by Luke Duncalfe's avatar Luke Duncalfe

Merge branch '349910-add-backend-interface-for-provider' into 'master'

Add backend interface to look up for security training urls

See merge request gitlab-org/gitlab!81861
parents c85aa31d 72812ce3
......@@ -15418,6 +15418,18 @@ Represents a list of security scanners.
| <a id="securityscannersenabled"></a>`enabled` | [`[SecurityScannerType!]`](#securityscannertype) | List of analyzers which are enabled for the project. |
| <a id="securityscannerspipelinerun"></a>`pipelineRun` | [`[SecurityScannerType!]`](#securityscannertype) | List of analyzers which ran successfully in the latest pipeline. |
### `SecurityTrainingUrl`
Represents a URL related to a security training.
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="securitytrainingurlname"></a>`name` | [`String`](#string) | Name of the training provider. |
| <a id="securitytrainingurlstatus"></a>`status` | [`TrainingUrlRequestStatus`](#trainingurlrequeststatus) | Status of the request to training provider. |
| <a id="securitytrainingurlurl"></a>`url` | [`String`](#string) | URL of the link for security training content. |
### `SentryDetailedError`
A Sentry error.
......@@ -16389,6 +16401,7 @@ Represents a vulnerability.
| <a id="vulnerabilityresolvedby"></a>`resolvedBy` | [`UserCore`](#usercore) | User that resolved the vulnerability. |
| <a id="vulnerabilityresolvedondefaultbranch"></a>`resolvedOnDefaultBranch` | [`Boolean!`](#boolean) | Indicates whether the vulnerability is fixed on the default branch or not. |
| <a id="vulnerabilityscanner"></a>`scanner` | [`VulnerabilityScanner`](#vulnerabilityscanner) | Scanner metadata for the vulnerability. |
| <a id="vulnerabilitysecuritytrainingurls"></a>`securityTrainingUrls` | [`[SecurityTrainingUrl!]`](#securitytrainingurl) | Security training URLs for the vulnerability. |
| <a id="vulnerabilityseverity"></a>`severity` | [`VulnerabilitySeverity`](#vulnerabilityseverity) | Severity of the vulnerability (INFO, UNKNOWN, LOW, MEDIUM, HIGH, CRITICAL). |
| <a id="vulnerabilitystate"></a>`state` | [`VulnerabilityState`](#vulnerabilitystate) | State of the vulnerability (DETECTED, CONFIRMED, RESOLVED, DISMISSED). |
| <a id="vulnerabilitytitle"></a>`title` | [`String`](#string) | Title of the vulnerability. |
......@@ -18398,6 +18411,15 @@ State of a test report.
| <a id="todotargetenumissue"></a>`ISSUE` | Issue. |
| <a id="todotargetenummergerequest"></a>`MERGEREQUEST` | Merge request. |
### `TrainingUrlRequestStatus`
Status of the request to the training provider. The URL of a TrainingUrl is calculated asynchronously. When PENDING, the URL of the TrainingUrl will be null. When COMPLETED, the URL of the TrainingUrl will be available.
| Value | Description |
| ----- | ----------- |
| <a id="trainingurlrequeststatuscompleted"></a>`COMPLETED` | Completed request. |
| <a id="trainingurlrequeststatuspending"></a>`PENDING` | Pending request. |
### `TypeEnum`
| Value | Description |
# frozen_string_literal: true
module Security
module TrainingProviders
class BaseUrlFinder
include Gitlab::Utils::StrongMemoize
include ReactiveCaching
self.reactive_cache_refresh_interval = 1.minute
self.reactive_cache_lifetime = 10.minutes
self.reactive_cache_work_type = :external_dependency
def initialize(provider, identifier)
@provider = provider
@identifier = identifier
end
def execute
if response_url.nil?
{ name: provider.name, url: response_url, status: "pending" }
else
{ name: provider.name, url: response_url[:url], status: "completed" } if response_url[:url]
end
end
def self.from_cache(id)
project_id, provider_id, identifier_id = id.split('-')
project = Project.find(project_id)
provider = ::Security::TrainingProvider.find(provider_id)
identifier = project.vulnerability_identifiers.find(identifier_id)
new(provider, identifier)
end
private
attr_reader :provider, :identifier
def response_url
strong_memoize(:response_url) do
with_reactive_cache(full_url) {|data| data}
end
end
def full_url
raise NotImplementedError, 'full_url must be overwritten to return training url'
end
# Required for ReactiveCaching; Usage overridden by
# self.reactive_cache_worker_finder
def id
"#{identifier.project.id}-#{provider.id}-#{identifier.id}"
end
end
end
end
# frozen_string_literal: true
module Security
module TrainingProviders
class KontraUrlFinder < BaseUrlFinder
self.reactive_cache_key = ->(finder) { finder.full_url }
self.reactive_cache_worker_finder = ->(id, *args) { from_cache(id) }
def calculate_reactive_cache(full_url)
bearer_token = "sbdMsxcgW2Xs75Q2uHc9FhUCZSEV3fSg" # To improve the authentication/integration https://gitlab.com/gitlab-org/gitlab/-/issues/354070
response = Gitlab::HTTP.try_get(
full_url,
headers: {
"Authorization" => "Bearer #{bearer_token}"
}
)
{ url: response.parsed_response["link"] } if response
end
def full_url
Gitlab::Utils.append_path(provider.url, "?#{identifier.external_type}=#{identifier.external_id}")
end
end
end
end
# frozen_string_literal: true
module Security
module TrainingProviders
class SecureCodeWarriorUrlFinder < BaseUrlFinder
self.reactive_cache_key = ->(finder) { finder.full_url }
self.reactive_cache_worker_finder = ->(id, *args) { from_cache(id) }
def calculate_reactive_cache(full_url)
response = Gitlab::HTTP.try_get(full_url)
{ url: response.parsed_response["url"] } if response
end
def full_url
Gitlab::Utils.append_path(provider.url, "?Id=gitlab&MappingList=#{identifier.external_type}&MappingKey=#{identifier.external_id}")
end
end
end
end
# frozen_string_literal: true
module Security
class TrainingUrlsFinder
def initialize(vulnerability)
@vulnerability = vulnerability
end
def execute
cwe_identifiers = @vulnerability.identifiers&.with_external_type('cwe')
return [] if cwe_identifiers.blank?
security_training_urls(cwe_identifiers)
end
private
def security_training_urls(cwe_identifiers)
[].tap do |content_urls|
training_providers.each do |provider|
cwe_identifiers.each do |identifier|
class_name = "::Security::TrainingProviders::#{provider.name.delete(' ')}UrlFinder".safe_constantize
content_url = class_name.new(provider, identifier).execute if class_name
content_urls << content_url if content_url
end
end
end
end
def training_providers
::Security::TrainingProvider.for_project(@vulnerability.project, only_enabled: true).ordered_by_is_primary_desc
end
end
end
# frozen_string_literal: true
module Resolvers
class SecurityTrainingUrlsResolver < BaseResolver
type [::Types::Security::TrainingUrlType], null: true
def resolve
::Security::TrainingUrlsFinder.new(object).execute
end
end
end
# frozen_string_literal: true
module Types
module Security
class TrainingUrlRequestStatusEnum < BaseEnum
graphql_name 'TrainingUrlRequestStatus'
description 'Status of the request to the training provider. The URL of a TrainingUrl is calculated asynchronously. When PENDING, the URL of the TrainingUrl will be null. When COMPLETED, the URL of the TrainingUrl will be available.'
value 'PENDING', value: 'pending', description: 'Pending request.'
value 'COMPLETED', value: 'completed', description: 'Completed request.'
end
end
end
# frozen_string_literal: true
module Types
module Security
class TrainingUrlType < BaseObject # rubocop:disable Graphql/AuthorizeTypes (This can be only accessed through VulnerabilityType)
graphql_name 'SecurityTrainingUrl'
description 'Represents a URL related to a security training'
field :status, Types::Security::TrainingUrlRequestStatusEnum, null: true,
description: 'Status of the request to training provider.'
field :name, GraphQL::Types::String, null: true,
description: 'Name of the training provider.'
field :url, GraphQL::Types::String, null: true,
description: 'URL of the link for security training content.'
end
end
end
......@@ -106,6 +106,10 @@ module Types
description: 'Indicates whether the vulnerability is a false positive.',
resolver_method: :false_positive?
field :security_training_urls, [::Types::Security::TrainingUrlType], null: true,
description: 'Security training URLs for the vulnerability.',
resolver: ::Resolvers::SecurityTrainingUrlsResolver
def confirmed_by
::Gitlab::Graphql::Loaders::BatchModelLoader.new(::User, object.confirmed_by_id).find
end
......
......@@ -23,5 +23,7 @@ module Security
.select('COALESCE(st.is_primary, FALSE) AS is_primary')
.tap { |relation| relation.where!('st.id IS NOT NULL') if only_enabled }
end
scope :ordered_by_is_primary_desc, -> { order(is_primary: :desc) }
end
end
......@@ -25,6 +25,7 @@ module Vulnerabilities
validates :name, presence: true
scope :with_fingerprint, -> (fingerprints) { where(fingerprint: fingerprints) }
scope :with_external_type, -> (external_type) { where('LOWER(external_type) = LOWER(?)', external_type)}
def cve?
external_type.casecmp?('cve')
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Security::TrainingProviders::BaseUrlFinder do
let_it_be(:provider_name) { 'Kontra' }
let_it_be(:provider) { create(:security_training_provider, name: provider_name) }
let_it_be(:identifier) { create(:vulnerabilities_identifier, external_type: 'cwe', external_id: 2) }
let_it_be(:dummy_url) { 'http://test.host/test' }
describe '#execute' do
it 'raises an error if full_url is not implemented' do
expect { described_class.new(nil, nil).execute }.to raise_error(
NotImplementedError,
'full_url must be overwritten to return training url'
)
end
context 'when response_url is nil' do
let_it_be(:finder) { described_class.new(provider, identifier) }
before do
allow_next_instance_of(described_class) do |instance|
allow(instance).to receive(:response_url).and_return(nil)
end
end
it 'returns a nil url with status pending' do
expect(described_class.new(provider, identifier).execute).to eq({ name: provider.name, url: nil, status: 'pending' })
end
end
context 'when response_url is not nil' do
let_it_be(:finder) { described_class.new(provider, identifier) }
before do
allow_next_instance_of(described_class) do |instance|
allow(instance).to receive(:response_url).and_return({ url: dummy_url })
end
end
it 'returns a url with status completed' do
expect(described_class.new(provider, identifier).execute).to eq({ name: provider.name, url: dummy_url, status: 'completed' })
end
end
context 'when response_url is not nil, but the url is' do
let_it_be(:finder) { described_class.new(provider, identifier) }
before do
allow_next_instance_of(described_class) do |instance|
allow(instance).to receive(:response_url).and_return({ url: nil })
end
end
it 'returns nil' do
expect(described_class.new(provider, identifier).execute).to be_nil
end
end
end
describe '.from_cache' do
it 'returns instance of finder object' do
expect(described_class.from_cache("#{identifier.project.id}-#{provider.id}-#{identifier.id}")).to be_an_instance_of(described_class)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Security::TrainingProviders::KontraUrlFinder do
include ReactiveCachingHelpers
let_it_be(:provider_name) { 'Kontra' }
let_it_be(:provider) { create(:security_training_provider, name: provider_name) }
let_it_be(:identifier) { create(:vulnerabilities_identifier, external_type: 'cwe', external_id: 2) }
let_it_be(:dummy_url) { 'http://test.host/test' }
describe '#calculate_reactive_cache' do
context 'when response is nil' do
let_it_be(:finder) { described_class.new(provider, identifier) }
before do
synchronous_reactive_cache(finder)
allow(Gitlab::HTTP).to receive(:try_get).and_return(nil)
end
it 'returns nil' do
expect(finder.calculate_reactive_cache(dummy_url)).to be_nil
end
end
context 'when response is not nil' do
let_it_be(:finder) { described_class.new(provider, identifier) }
let_it_be(:response) { { 'link' => dummy_url } }
before do
synchronous_reactive_cache(finder)
allow(Gitlab::HTTP).to receive_message_chain(:try_get, :parsed_response).and_return(response)
end
it 'returns content url hash' do
expect(finder.calculate_reactive_cache(dummy_url)).to eq({ url: dummy_url })
end
end
end
describe '#full_url' do
it 'returns full url path' do
expect(described_class.new(provider, identifier).full_url).to eq('example.com/?cwe=2')
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Security::TrainingProviders::SecureCodeWarriorUrlFinder do
include ReactiveCachingHelpers
let_it_be(:provider_name) { 'Secure Code Warrior' }
let_it_be(:provider) { create(:security_training_provider, name: provider_name) }
let_it_be(:identifier) { create(:vulnerabilities_identifier, external_type: 'cwe', external_id: 2) }
let_it_be(:dummy_url) { 'http://test.host/test' }
describe '#execute' do
context 'when response is nil' do
let_it_be(:finder) { described_class.new(provider, identifier) }
before do
synchronous_reactive_cache(finder)
allow(Gitlab::HTTP).to receive(:try_get).and_return(nil)
end
it 'returns nil' do
expect(finder.calculate_reactive_cache(dummy_url)).to be_nil
end
end
context 'when response is not nil' do
let_it_be(:finder) { described_class.new(provider, identifier) }
let_it_be(:response) { { 'url' => dummy_url } }
before do
synchronous_reactive_cache(finder)
allow(Gitlab::HTTP).to receive_message_chain(:try_get, :parsed_response).and_return(response)
end
it 'returns content url hash' do
expect(finder.calculate_reactive_cache(dummy_url)).to eq({ url: dummy_url })
end
end
end
describe '#full_url' do
it 'returns full url path' do
expect(described_class.new(provider, identifier).full_url).to eq('example.com/?Id=gitlab&MappingList=cwe&MappingKey=2')
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Security::TrainingUrlsFinder do
let_it_be(:project) { create(:project) }
let_it_be(:vulnerability) { create(:vulnerability, :with_findings, project: project) }
subject { described_class.new(vulnerability).execute }
context 'no identifier with cwe external type' do
it 'returns empty list' do
is_expected.to be_empty
end
end
context 'identifiers with cwe external type' do
let_it_be(:identifier) { create(:vulnerabilities_identifier, external_type: "cwe") }
before do
vulnerability.identifiers << identifier
end
context 'when there is no training provider enabled for project' do
it 'returns empty list' do
is_expected.to be_empty
end
end
context 'when there is training provider enabled for project' do
let_it_be(:security_training_provider) { create(:security_training_provider, name: 'Kontra') }
before do
create(:security_training, :primary, project: project, provider: security_training_provider)
end
it 'calls Security::TrainingProviders::KontraUrlFinder#execute' do
expect_next_instance_of(::Security::TrainingProviders::KontraUrlFinder) do |finder|
expect(finder).to receive(:execute)
end
subject
end
context 'when training url has been reactively cached' do
before do
allow_next_instance_of(::Security::TrainingProviders::KontraUrlFinder) do |finder|
allow(finder).to receive(:response_url).and_return(url: 'http://test.host/test')
end
end
it 'returns training urls list with status completed' do
is_expected.to match_array([{ name: 'Kontra', url: 'http://test.host/test', status: 'completed' }])
end
end
context 'when training url has not yet been reactively cached' do
before do
allow_next_instance_of(::Security::TrainingProviders::KontraUrlFinder) do |finder|
allow(finder).to receive(:response_url).and_return(nil)
end
end
it 'returns training urls list with status pending' do
is_expected.to match_array([{ name: 'Kontra', url: nil, status: 'pending' }])
end
end
context 'when training urls finder returns nil url' do
before do
allow_next_instance_of(::Security::TrainingProviders::KontraUrlFinder) do |finder|
allow(finder).to receive(:response_url).and_return(url: nil)
end
end
it 'returns empty list when training urls finder returns nil' do
is_expected.to be_empty
end
end
context 'when sub class in not defined for provider' do
before do
security_training_provider.update_attribute(:name, "Notdefined")
end
it 'returns empty list' do
is_expected.to be_empty
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Resolvers::SecurityTrainingUrlsResolver do
include GraphqlHelpers
describe '#resolve' do
subject { resolve(described_class, obj: vulnerability) }
let_it_be(:vulnerability) { create(:vulnerability, :with_findings) }
it 'calls TrainingUrlsFinder#execute' do
expect_next_instance_of(::Security::TrainingUrlsFinder) do |finder|
expect(finder).to receive(:execute)
end
subject
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['TrainingUrlRequestStatus'] do
specify { expect(described_class.graphql_name).to eq('TrainingUrlRequestStatus') }
describe 'statuses' do
using RSpec::Parameterized::TableSyntax
where(:status_name, :status_value) do
'PENDING' | 'pending'
'COMPLETED' | 'completed'
end
with_them do
it 'exposes a status with the correct value' do
expect(described_class.values[status_name].value).to eq(status_value)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['SecurityTrainingUrl'] do
let(:fields) { %i[name url status] }
it { expect(described_class).to have_graphql_fields(fields) }
end
......@@ -39,7 +39,8 @@ RSpec.describe GitlabSchema.types['Vulnerability'] do
confirmed_by
resolved_by
dismissed_by
details]
details
security_training_urls]
end
before do
......
......@@ -25,8 +25,8 @@ RSpec.describe Security::TrainingProvider do
subject { described_class.for_project(project, only_enabled: only_enabled) }
before_all do
create(:security_training, :primary, project: project, provider: security_training_provider_1)
create(:security_training, project: project, provider: security_training_provider_2)
create(:security_training, :primary, project: project, provider: security_training_provider_2)
create(:security_training, project: project, provider: security_training_provider_1)
end
context 'when the `only_enabled` flag is provided as `false`' do
......@@ -52,5 +52,13 @@ RSpec.describe Security::TrainingProvider do
])
end
end
describe '.ordered_by_is_primary_desc' do
let(:only_enabled) { false }
it "returns primary providers first" do
expect(subject.ordered_by_is_primary_desc).to eq([security_training_provider_2, security_training_provider_1, security_training_provider_3])
end
end
end
end
......@@ -33,7 +33,7 @@ RSpec.describe Vulnerabilities::Identifier do
let!(:identifier) { create(:vulnerabilities_identifier, fingerprint: fingerprint) }
it 'selects the identifier' do
is_expected.to eq([identifier])
is_expected.to match_array([identifier])
end
end
......@@ -46,6 +46,29 @@ RSpec.describe Vulnerabilities::Identifier do
end
end
describe '.with_external_type' do
let(:external_type_scope) { 'cwe' }
let(:external_type_not_in_scope) { 'cve' }
subject { described_class.with_external_type(external_type_scope) }
context 'when identifier has the corresponding external_type' do
let!(:identifier) { create(:vulnerabilities_identifier, external_type: external_type_scope) }
it 'selects the identifier' do
is_expected.to match_array([identifier])
end
end
context 'when identifier does not have the corresponding external_type' do
let!(:identifier) { create(:vulnerabilities_identifier, external_type: external_type_not_in_scope) }
it 'does not select the identifier' do
is_expected.to be_empty
end
end
end
describe 'type check methods' do
shared_examples_for 'type check method' do |method:|
with_them 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