Commit 6e791b72 authored by Brian Williams's avatar Brian Williams

Add starboard_vulnerability endpoint

These changes add an internal API endpoint for creating vulnerabilities
from Starboard. This will be used by the GitLab Kubernetes Agent in
order to track vulnerabilities found in running containers.

Since the service needed to do this is very similar to
ManuallyCreateService, I've also introduced a new subclass
(CreateServiceBase) which is now used as the base for
ManuallyCreateService and StarboardVulnerabilityCreateService.
parent 1d5e32e8
......@@ -501,6 +501,56 @@ curl --request POST --header "Gitlab-Kas-Api-Request: <JWT token>" \
"http://localhost:3000/api/v4/internal/kubernetes/modules/cilium_alert"
```
### Create Starboard vulnerability
Called from the GitLab Kubernetes Agent Server (`kas`) to create a security vulnerability
from a Starboard vulnerability report. This request is idempotent. Multiple requests with the same data
create a single vulnerability.
| Attribute | Type | Required | Description |
|:----------------|:-------|:---------|:------------|
| `vulnerability` | Hash | yes | Vulnerability data matching the security report schema [`vulnerability` field](https://gitlab.com/gitlab-org/security-products/security-report-schemas/-/blob/master/src/security-report-format.json). |
| `scanner` | Hash | yes | Scanner data matching the security report schmea [`scanner` field](https://gitlab.com/gitlab-org/security-products/security-report-schemas/-/blob/master/src/security-report-format.json). |
```plaintext
PUT internal/kubernetes/modules/starboard_vulnerability
```
Example Request:
```shell
curl --request PUT --header "Gitlab-Kas-Api-Request: <JWT token>" \
--header "Authorization: Bearer <agent token>" --header "Content-Type: application/json" \
--url "http://localhost:3000/api/v4/internal/kubernetes/modules/starboard_vulnerability" \
--data '{
"vulnerability": {
"name": "CVE-123-4567 in libc",
"severity": "high",
"confidence": "unknown",
"location": {
"kubernetes_resource": {
"namespace": "production",
"kind": "deployment",
"name": "nginx",
"container": "nginx"
}
},
"identifiers": [
{
"type": "cve",
"name": "CVE-123-4567",
"value": "CVE-123-4567"
}
]
},
"scanner": {
"id": "starboard_trivy",
"name": "Trivy (via Starboard Operator)",
"vendor": "GitLab"
}
}'
```
## Subscriptions
The subscriptions endpoint is used by [CustomersDot](https://gitlab.com/gitlab-org/customers-gitlab-com) (`customers.gitlab.com`)
......
# frozen_string_literal: true
module Vulnerabilities
class CreateServiceBase
include Gitlab::Allowable
GENERIC_REPORT_TYPE = ::Enums::Vulnerability.report_types[:generic]
def initialize(project, author, params:)
@project = project
@author = author
@params = params
end
private
def authorized?
can?(@author, :create_vulnerability, @project)
end
def location_fingerprint(_location_hash)
raise NotImplmentedError, "location_fingerprint should be implemented by subclass"
end
def metadata_version
raise NotImplmentedError, "metadata_version should be implemented by subclass"
end
def report_type
GENERIC_REPORT_TYPE
end
def initialize_vulnerability(vulnerability_hash)
attributes = vulnerability_hash
.slice(*%i[
state
severity
confidence
detected_at
confirmed_at
resolved_at
dismissed_at
])
.merge(
project: @project,
author: @author,
title: vulnerability_hash[:title]&.truncate(::Issuable::TITLE_LENGTH_MAX),
report_type: report_type
)
vulnerability = Vulnerability.new(**attributes)
vulnerability.confirmed_by = @author if vulnerability.confirmed?
vulnerability.resolved_by = @author if vulnerability.resolved?
vulnerability.dismissed_by = @author if vulnerability.dismissed?
vulnerability
end
# rubocop: disable CodeReuse/ActiveRecord
def initialize_identifiers(identifier_hashes)
identifier_hashes.map do |identifier|
name = identifier[:name]
external_type = map_external_type_from_name(name)
external_id = name
fingerprint = Digest::SHA1.hexdigest("#{external_type}:#{external_id}")
url = identifier[:url]
Vulnerabilities::Identifier.find_or_initialize_by(name: name) do |i|
i.fingerprint = fingerprint
i.project = @project
i.external_type = external_type
i.external_id = external_id
i.url = url
end
end
end
# rubocop: enable CodeReuse/ActiveRecord
def map_external_type_from_name(name)
return 'cve' if name.match?(/CVE/i)
return 'cwe' if name.match?(/CWE/i)
'other'
end
# rubocop: disable CodeReuse/ActiveRecord
def initialize_scanner(scanner_hash)
name = scanner_hash[:name]
Vulnerabilities::Scanner.find_or_initialize_by(name: name) do |s|
s.project = @project
s.external_id = scanner_hash[:id]
end
end
# rubocop: enable CodeReuse/ActiveRecord
def initialize_finding(vulnerability:, identifiers:, scanner:, message:, description:, solution:)
location = @params[:vulnerability][:location]
loc_fingerprint = location_fingerprint(location)
uuid = ::Security::VulnerabilityUUID.generate(
report_type: report_type,
primary_identifier_fingerprint: identifiers.first.fingerprint,
location_fingerprint: loc_fingerprint,
project_id: @project.id
)
Vulnerabilities::Finding.new(
project: @project,
identifiers: identifiers,
primary_identifier: identifiers.first,
vulnerability: vulnerability,
name: vulnerability.title,
severity: vulnerability.severity,
confidence: vulnerability.confidence,
report_type: vulnerability.report_type,
project_fingerprint: Digest::SHA1.hexdigest(identifiers.first.name),
location_fingerprint: loc_fingerprint,
metadata_version: metadata_version,
raw_metadata: {
location: location
},
scanner: scanner,
uuid: uuid,
message: message,
description: description,
solution: solution
)
end
end
end
# frozen_string_literal: true
module Vulnerabilities
class ManuallyCreateService
class ManuallyCreateService < CreateServiceBase
include Gitlab::Allowable
METADATA_VERSION = "manual:1.0"
GENERIC_REPORT_TYPE = ::Enums::Vulnerability.report_types[:generic]
MANUAL_LOCATION_FINGERPRINT = Digest::SHA1.hexdigest("manually added").freeze
CONFIRMED_MESSAGE = "confirmed_at can only be set when state is confirmed"
......@@ -22,7 +21,7 @@ module Vulnerabilities
return ServiceResponse.error(message: "create_vulnerabilities_via_api feature flag is not enabled for this project")
end
raise Gitlab::Access::AccessDeniedError unless can?(@author, :create_vulnerability, @project)
raise Gitlab::Access::AccessDeniedError unless authorized?
timestamps_dont_match_state_message = match_state_fields_with_state
return ServiceResponse.error(message: timestamps_dont_match_state_message) if timestamps_dont_match_state_message
......@@ -30,7 +29,14 @@ module Vulnerabilities
vulnerability = initialize_vulnerability(@params[:vulnerability])
identifiers = initialize_identifiers(@params[:vulnerability][:identifiers])
scanner = initialize_scanner(@params[:vulnerability][:scanner])
finding = initialize_finding(vulnerability, identifiers, scanner, @params[:message], @params[:solution])
finding = initialize_finding(
vulnerability: vulnerability,
identifiers: identifiers,
scanner: scanner,
message: @params[:message],
description: @params[:description],
solution: @params[:solution]
)
Vulnerability.transaction do
vulnerability.save!
......@@ -50,6 +56,25 @@ module Vulnerabilities
private
def location_fingerprint(_location_hash)
MANUAL_LOCATION_FINGERPRINT
end
def metadata_version
METADATA_VERSION
end
# rubocop: disable CodeReuse/ActiveRecord
def initialize_scanner(scanner_hash)
name = scanner_hash[:name]
Vulnerabilities::Scanner.find_or_initialize_by(name: name) do |s|
s.project = @project
s.external_id = Gitlab::Utils.slugify(name)
end
end
# rubocop: enable CodeReuse/ActiveRecord
def match_state_fields_with_state
state = @params.dig(:vulnerability, :state)
......@@ -70,98 +95,5 @@ module Vulnerabilities
def exists_in_vulnerability_params?(column_name)
@params.dig(:vulnerability, column_name.to_sym).present?
end
def initialize_vulnerability(vulnerability_hash)
attributes = vulnerability_hash
.slice(*%i[
state
severity
confidence
detected_at
confirmed_at
resolved_at
dismissed_at
])
.merge(
project: @project,
author: @author,
title: vulnerability_hash[:title]&.truncate(::Issuable::TITLE_LENGTH_MAX),
report_type: GENERIC_REPORT_TYPE
)
vulnerability = Vulnerability.new(**attributes)
vulnerability.confirmed_by = @author if vulnerability.confirmed?
vulnerability.resolved_by = @author if vulnerability.resolved?
vulnerability.dismissed_by = @author if vulnerability.dismissed?
vulnerability
end
# rubocop: disable CodeReuse/ActiveRecord
def initialize_identifiers(identifier_hashes)
identifier_hashes.map do |identifier|
name = identifier.dig(:name)
external_type = map_external_type_from_name(name)
external_id = name
fingerprint = Digest::SHA1.hexdigest("#{external_type}:#{external_id}")
url = identifier.dig(:url)
Vulnerabilities::Identifier.find_or_initialize_by(name: name) do |i|
i.fingerprint = fingerprint
i.project = @project
i.external_type = external_type
i.external_id = external_id
i.url = url
end
end
end
# rubocop: enable CodeReuse/ActiveRecord
def map_external_type_from_name(name)
return 'cve' if name.match?(/CVE/i)
return 'cwe' if name.match?(/CWE/i)
'other'
end
# rubocop: disable CodeReuse/ActiveRecord
def initialize_scanner(scanner_hash)
name = scanner_hash.dig(:name)
Vulnerabilities::Scanner.find_or_initialize_by(name: name) do |s|
s.project = @project
s.external_id = Gitlab::Utils.slugify(name)
end
end
# rubocop: enable CodeReuse/ActiveRecord
def initialize_finding(vulnerability, identifiers, scanner, message, solution)
uuid = ::Security::VulnerabilityUUID.generate(
report_type: GENERIC_REPORT_TYPE,
primary_identifier_fingerprint: identifiers.first.fingerprint,
location_fingerprint: MANUAL_LOCATION_FINGERPRINT,
project_id: @project.id
)
Vulnerabilities::Finding.new(
project: @project,
identifiers: identifiers,
primary_identifier: identifiers.first,
vulnerability: vulnerability,
name: vulnerability.title,
severity: vulnerability.severity,
confidence: vulnerability.confidence,
report_type: vulnerability.report_type,
project_fingerprint: Digest::SHA1.hexdigest(identifiers.first.name),
location_fingerprint: MANUAL_LOCATION_FINGERPRINT,
metadata_version: METADATA_VERSION,
raw_metadata: {},
scanner: scanner,
uuid: uuid,
message: message,
solution: solution
)
end
end
end
# frozen_string_literal: true
module Vulnerabilities
class StarboardVulnerabilityCreateService < CreateServiceBase
include Gitlab::Allowable
CLUSTER_IMAGE_SCANNING_REPORT_TYPE = ::Enums::Vulnerability.report_types[:cluster_image_scanning]
METADATA_VERSION = "cluster_image_scanning:1.0"
def initialize(agent, params:)
@agent = agent
@project = agent.project
@author = agent.created_by_user
@params = params
end
def execute
raise Gitlab::Access::AccessDeniedError unless authorized?
vulnerability_hash = @params[:vulnerability]
vulnerability_hash[:state] = :detected
vulnerability = initialize_vulnerability(vulnerability_hash)
vulnerability.title = vulnerability_hash[:name]&.truncate(::Issuable::TITLE_LENGTH_MAX)
identifiers = initialize_identifiers(@params.dig(:vulnerability, :identifiers))
scanner = initialize_scanner(@params[:scanner])
finding = initialize_finding(
vulnerability: vulnerability,
identifiers: identifiers,
scanner: scanner,
message: vulnerability_hash[:message],
description: vulnerability_hash[:description],
solution: vulnerability_hash[:solution]
)
Vulnerability.transaction do
vulnerability.save!
finding.save!
Statistics::UpdateService.update_for(vulnerability)
HistoricalStatistics::UpdateService.update_for(@project)
ServiceResponse.success(payload: { vulnerability: vulnerability })
end
rescue ActiveRecord::RecordNotUnique
# Requests to this service should be idempotent, so we will return success and do nothing.
ServiceResponse.success
rescue ActiveRecord::RecordInvalid => e
ServiceResponse.error(message: e.message)
end
private
def report_type
CLUSTER_IMAGE_SCANNING_REPORT_TYPE
end
def metadata_version
METADATA_VERSION
end
def location_fingerprint(location_hash)
kubernetes_resource = location_hash[:kubernetes_resource]
fingerprint_data = [
@agent.id,
kubernetes_resource[:namespace],
kubernetes_resource[:kind],
kubernetes_resource[:name],
kubernetes_resource[:container],
location_hash.dig(:dependency, :package, :name)
].join(':')
Digest::SHA1.hexdigest(fingerprint_data)
end
end
end
......@@ -30,6 +30,53 @@ module EE
status result.http_status
end
end
namespace 'modules/starboard_vulnerability' do
desc 'PUT starboard vulnerability' do
detail 'Idempotently creates a security vulnerability from starboard'
end
params do
requires :vulnerability, type: Hash, desc: 'Vulnerability details matching the `vulnerability` object on the security report schema' do
requires :name, type: String
requires :severity, type: String
requires :confidence, type: String
requires :location, type: Hash
requires :identifiers, type: Array do
requires :type, type: String
requires :name, type: String
optional :value, type: String
optional :url, type: String
end
optional :message, type: String
optional :description, type: String
optional :solution, type: String
optional :links, type: Array
end
requires :scanner, type: Hash, desc: 'Scanner details matching the `.scan.scanner` field on the security report schema' do
requires :id, type: String
requires :name, type: String
optional :vendor, type: String
end
end
route_setting :authentication, cluster_agent_token_allowed: true
put '/' do
not_found! if agent.project.nil?
result = ::Vulnerabilities::StarboardVulnerabilityCreateService.new(
agent,
params: params
).execute
if result.success?
status result.http_status
else
render_api_error!(result.message, result.http_status)
end
end
end
end
end
end
......
......@@ -10,6 +10,19 @@ RSpec.describe API::Internal::Kubernetes do
end
let(:jwt_secret) { SecureRandom.random_bytes(Gitlab::Kas::SECRET_LENGTH) }
let(:agent_token) { create(:cluster_agent_token) }
let(:agent_token_headers) { { 'Authorization' => "Bearer #{agent_token.token}" } }
let(:agent) { agent_token.agent }
let(:project) { agent.project }
def send_request(params: {}, headers: agent_token_headers)
case method
when :post
post api(api_url), params: params, headers: headers.reverse_merge(jwt_auth_headers)
when :put
put api(api_url), params: params, headers: headers.reverse_merge(jwt_auth_headers)
end
end
before do
allow(Gitlab::Kas).to receive(:secret).and_return(jwt_secret)
......@@ -39,7 +52,7 @@ RSpec.describe API::Internal::Kubernetes do
shared_examples 'agent authentication' do
it 'returns 401 if Authorization header not sent' do
send_request
send_request(headers: {})
expect(response).to have_gitlab_http_status(:unauthorized)
end
......@@ -52,17 +65,13 @@ RSpec.describe API::Internal::Kubernetes do
end
describe 'POST /internal/kubernetes/modules/cilium_alert' do
def send_request(headers: {}, params: {})
post api('/internal/kubernetes/modules/cilium_alert'), params: params, headers: headers.reverse_merge(jwt_auth_headers)
end
let(:method) { :post }
let(:api_url) { '/internal/kubernetes/modules/cilium_alert' }
include_examples 'authorization'
include_examples 'agent authentication'
context 'is authenticated for an agent' do
let!(:agent_token) { create(:cluster_agent_token) }
let!(:agent) { agent_token.agent }
before do
stub_licensed_features(cilium_alerts: true)
end
......@@ -70,7 +79,7 @@ RSpec.describe API::Internal::Kubernetes do
let(:payload) { build(:network_alert_payload) }
it 'returns no_content for valid alert payload' do
send_request(params: { alert: payload }, headers: { 'Authorization' => "Bearer #{agent_token.token}" })
send_request(params: { alert: payload })
expect(AlertManagement::Alert.count).to eq(1)
expect(AlertManagement::Alert.all.first.project).to eq(agent.project)
......@@ -81,7 +90,7 @@ RSpec.describe API::Internal::Kubernetes do
let(:payload) { { temp: {} } }
it 'returns bad request' do
send_request(params: { alert: payload }, headers: { 'Authorization' => "Bearer #{agent_token.token}" })
send_request(params: { alert: payload })
expect(response).to have_gitlab_http_status(:bad_request)
end
end
......@@ -92,7 +101,82 @@ RSpec.describe API::Internal::Kubernetes do
end
it 'returns forbidden for non licensed project' do
send_request(params: { alert: payload }, headers: { 'Authorization' => "Bearer #{agent_token.token}" })
send_request(params: { alert: payload })
expect(response).to have_gitlab_http_status(:forbidden)
end
end
end
end
describe 'PUT /internal/kubernetes/modules/starboard_vulnerability' do
let(:method) { :put }
let(:api_url) { '/internal/kubernetes/modules/starboard_vulnerability' }
include_examples 'authorization'
include_examples 'agent authentication'
context 'is authenticated for an agent' do
before do
stub_licensed_features(security_dashboard: true)
project.add_maintainer(agent.created_by_user)
end
let(:payload) do
{
vulnerability: {
name: 'CVE-123-4567 in libc',
severity: 'high',
confidence: 'unknown',
location: {
kubernetes_resource: {
namespace: 'production',
kind: 'deployment',
name: 'nginx',
container: 'nginx'
}
},
identifiers: [
{
type: 'cve',
name: 'CVE-123-4567',
value: 'CVE-123-4567'
}
]
},
scanner: {
id: 'starboard_trivy',
name: 'Trivy (via Starboard Operator)',
vendor: 'GitLab'
}
}
end
it 'returns ok when a vulnerability is created' do
send_request(params: payload)
expect(response).to have_gitlab_http_status(:ok)
expect(Vulnerability.count).to eq(1)
expect(Vulnerability.all.first.finding.name).to eq(payload[:vulnerability][:name])
end
context 'when payload is invalid' do
let(:payload) { { vulnerability: 'invalid' } }
it 'returns bad request' do
send_request(params: payload)
expect(response).to have_gitlab_http_status(:bad_request)
end
end
context 'when feature is not available' do
before do
stub_licensed_features(security_dashboard: false)
end
it 'returns forbidden for non licensed project' do
send_request(params: payload)
expect(response).to have_gitlab_http_status(:forbidden)
end
......
......@@ -136,6 +136,8 @@ RSpec.describe Vulnerabilities::ManuallyCreateService do
finding = vulnerability.finding
expect(finding.report_type).to eq("generic")
expect(finding.message).to eq(params.dig(:message))
expect(finding.description).to eq(params.dig(:description))
expect(finding.severity).to eq(params.dig(:vulnerability, :severity))
expect(finding.confidence).to eq(params.dig(:vulnerability, :confidence))
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Vulnerabilities::StarboardVulnerabilityCreateService do
let(:agent) { create(:cluster_agent) }
let(:project) { agent.project }
let(:user) { agent.created_by_user }
let(:params) do
{
vulnerability: {
name: 'CVE-123-4567 in libc',
message: 'Vulnerability message',
description: 'Vulnerability description',
severity: 'high',
confidence: 'unknown',
location: {
kubernetes_resource: {
namespace: 'production',
kind: 'deployment',
name: 'nginx',
container: 'nginx'
}
},
identifiers: [
{
type: 'cve',
name: 'CVE-123-4567',
value: 'CVE-123-4567'
}
]
},
scanner: {
id: 'starboard_trivy',
name: 'Trivy (via Starboard Operator)',
vendor: 'GitLab'
}
}
end
subject { described_class.new(agent, params: params).execute }
context 'with authorized user' do
before do
project.add_developer(user)
end
context 'with feature enabled' do
let(:vulnerability) { subject.payload[:vulnerability] }
before do
stub_licensed_features(security_dashboard: true)
end
it 'creates Vulnerability' do
expect { subject }.to change(Vulnerability, :count).by(1)
end
it 'has correct data' do
expect(vulnerability.report_type).to eq("cluster_image_scanning")
expect(vulnerability.title).to eq(params.dig(:vulnerability, :name))
finding = vulnerability.finding
expect(finding.message).to eq(params.dig(:vulnerability, :message))
expect(finding.description).to eq(params.dig(:vulnerability, :description))
expect(finding.severity).to eq(params.dig(:vulnerability, :severity))
expect(finding.confidence).to eq(params.dig(:vulnerability, :confidence))
scanner = finding.scanner
expect(scanner.external_id).to eq(params.dig(:scanner, :id))
expect(scanner.name).to eq(params.dig(:scanner, :name))
end
end
context 'with feature disabled' do
before do
stub_licensed_features(security_dashboard: false)
end
it 'raises AccessDeniedError' do
expect { subject }.to raise_error(Gitlab::Access::AccessDeniedError)
end
end
context 'with unauthorized user' do
before do
project.add_reporter(user)
end
it 'raises AccessDeniedError' do
expect { subject }.to raise_error(Gitlab::Access::AccessDeniedError)
end
end
end
end
......@@ -3,6 +3,7 @@
FactoryBot.define do
factory :cluster_agent, class: 'Clusters::Agent' do
project
association :created_by_user, factory: :user
sequence(:name) { |n| "agent-#{n}" }
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