Commit a68b5371 authored by Craig Smith's avatar Craig Smith Committed by James Lopez

Add scanned resources to security report

Add the first 20 scanned resources for the
pipeline to securityReportSummary in GraphQL
parent a055c03a
...@@ -11310,6 +11310,56 @@ type RunDASTScanPayload { ...@@ -11310,6 +11310,56 @@ type RunDASTScanPayload {
pipelineUrl: String pipelineUrl: String
} }
"""
Represents a resource scanned by a security scan
"""
type ScannedResource {
"""
The HTTP request method used to access the URL
"""
requestMethod: String
"""
The URL scanned by the scanner
"""
url: String
}
"""
The connection type for ScannedResource.
"""
type ScannedResourceConnection {
"""
A list of edges.
"""
edges: [ScannedResourceEdge]
"""
A list of nodes.
"""
nodes: [ScannedResource]
"""
Information to aid in pagination.
"""
pageInfo: PageInfo!
}
"""
An edge in a connection.
"""
type ScannedResourceEdge {
"""
A cursor for use in pagination.
"""
cursor: String!
"""
The item at the end of the edge.
"""
node: ScannedResource
}
""" """
Represents summary of a security report Represents summary of a security report
""" """
...@@ -11349,6 +11399,31 @@ type SecurityReportSummary { ...@@ -11349,6 +11399,31 @@ type SecurityReportSummary {
Represents a section of a summary of a security report Represents a section of a summary of a security report
""" """
type SecurityReportSummarySection { type SecurityReportSummarySection {
"""
A list of the first 20 scanned resources
"""
scannedResources(
"""
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
): ScannedResourceConnection
""" """
Total number of scanned resources Total number of scanned resources
""" """
......
...@@ -33217,6 +33217,159 @@ ...@@ -33217,6 +33217,159 @@
"enumValues": null, "enumValues": null,
"possibleTypes": null "possibleTypes": null
}, },
{
"kind": "OBJECT",
"name": "ScannedResource",
"description": "Represents a resource scanned by a security scan",
"fields": [
{
"name": "requestMethod",
"description": "The HTTP request method used to access the URL",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "url",
"description": "The URL scanned by the scanner",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "ScannedResourceConnection",
"description": "The connection type for ScannedResource.",
"fields": [
{
"name": "edges",
"description": "A list of edges.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "ScannedResourceEdge",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "nodes",
"description": "A list of nodes.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "ScannedResource",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "pageInfo",
"description": "Information to aid in pagination.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "PageInfo",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "ScannedResourceEdge",
"description": "An edge in a connection.",
"fields": [
{
"name": "cursor",
"description": "A cursor for use in pagination.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "node",
"description": "The item at the end of the edge.",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "ScannedResource",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{ {
"kind": "OBJECT", "kind": "OBJECT",
"name": "SecurityReportSummary", "name": "SecurityReportSummary",
...@@ -33319,6 +33472,59 @@ ...@@ -33319,6 +33472,59 @@
"name": "SecurityReportSummarySection", "name": "SecurityReportSummarySection",
"description": "Represents a section of a summary of a security report", "description": "Represents a section of a summary of a security report",
"fields": [ "fields": [
{
"name": "scannedResources",
"description": "A list of the first 20 scanned resources",
"args": [
{
"name": "after",
"description": "Returns the elements in the list that come after the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "before",
"description": "Returns the elements in the list that come before the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "first",
"description": "Returns the first _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
},
{
"name": "last",
"description": "Returns the last _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "ScannedResourceConnection",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "scannedResourcesCount", "name": "scannedResourcesCount",
"description": "Total number of scanned resources", "description": "Total number of scanned resources",
...@@ -1637,6 +1637,15 @@ Autogenerated return type of RunDASTScan ...@@ -1637,6 +1637,15 @@ Autogenerated return type of RunDASTScan
| `errors` | String! => Array | Errors encountered during execution of the mutation. | | `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `pipelineUrl` | String | URL of the pipeline that was created. | | `pipelineUrl` | String | URL of the pipeline that was created. |
## ScannedResource
Represents a resource scanned by a security scan
| Name | Type | Description |
| --- | ---- | ---------- |
| `requestMethod` | String | The HTTP request method used to access the URL |
| `url` | String | The URL scanned by the scanner |
## SecurityReportSummary ## SecurityReportSummary
Represents summary of a security report Represents summary of a security report
......
# frozen_string_literal: true
module Types
# rubocop: disable Graphql/AuthorizeTypes
class ScannedResourceType < BaseObject
graphql_name 'ScannedResource'
description 'Represents a resource scanned by a security scan'
field :url, GraphQL::STRING_TYPE, null: true, description: 'The URL scanned by the scanner'
field :request_method, GraphQL::STRING_TYPE, null: true, description: 'The HTTP request method used to access the URL'
end
end
...@@ -8,5 +8,6 @@ module Types ...@@ -8,5 +8,6 @@ module Types
field :vulnerabilities_count, GraphQL::INT_TYPE, null: true, description: 'Total number of vulnerabilities' field :vulnerabilities_count, GraphQL::INT_TYPE, null: true, description: 'Total number of vulnerabilities'
field :scanned_resources_count, GraphQL::INT_TYPE, null: true, description: 'Total number of scanned resources' field :scanned_resources_count, GraphQL::INT_TYPE, null: true, description: 'Total number of scanned resources'
field :scanned_resources, ::Types::ScannedResourceType.connection_type, null: true, description: 'A list of the first 20 scanned resources'
end end
end end
...@@ -28,6 +28,8 @@ module Security ...@@ -28,6 +28,8 @@ module Security
response[:vulnerabilities_count] = vulnerability_counts[report_type.to_s] response[:vulnerabilities_count] = vulnerability_counts[report_type.to_s]
when :scanned_resources_count when :scanned_resources_count
response[:scanned_resources_count] = scanned_resources_counts[report_type.to_s] response[:scanned_resources_count] = scanned_resources_counts[report_type.to_s]
when :scanned_resources
response[:scanned_resources] = scanned_resources[report_type.to_s]
end end
end end
end end
...@@ -37,6 +39,13 @@ module Security ...@@ -37,6 +39,13 @@ module Security
@report_types_for_summary_type[summary_type].map(&:to_s) @report_types_for_summary_type[summary_type].map(&:to_s)
end end
def scanned_resources
strong_memoize(:scanned_resources) do
scanned_resources_limit = 20
::Security::ScannedResourcesService.new(@pipeline, requested_report_types(:scanned_resources), scanned_resources_limit).execute
end
end
def vulnerability_counts def vulnerability_counts
strong_memoize(:vulnerability_counts) do strong_memoize(:vulnerability_counts) do
::Security::VulnerabilityCountingService.new(@pipeline, requested_report_types(:vulnerabilities_count)).execute ::Security::VulnerabilityCountingService.new(@pipeline, requested_report_types(:vulnerabilities_count)).execute
......
# frozen_string_literal: true
module Security
# Service for getting the scanned resources for
# an array of report types within a pipeline
#
class ScannedResourcesService
# @param [Ci::Pipeline] pipeline
# @param Array[Symbol] report_types Summary report types. Valid values are members of Vulnerabilities::Occurrence::REPORT_TYPES
# @param [Int] The maximum number of scanned resources to return
def initialize(pipeline, report_types, limit = nil)
@pipeline = pipeline
@report_types = report_types
@limit = limit
end
def execute
reports = @pipeline&.security_reports&.reports || {}
@report_types.each_with_object({}) do |type, acc|
scanned_resources = reports[type]&.scanned_resources || []
scanned_resources = scanned_resources.first(@limit) if @limit
acc[type] = scanned_resources.map do |resource|
{
'request_method' => resource['method'],
'url' => resource['url']
}
end
end
end
end
end
---
title: Add scanned resources to security report
merge_request: 35695
author:
type: added
...@@ -109,6 +109,16 @@ FactoryBot.define do ...@@ -109,6 +109,16 @@ FactoryBot.define do
end end
end end
trait :dast_large_scanned_resources_field do
file_format { :raw }
file_type { :dast }
after(:build) do |artifact, _|
artifact.file = fixture_file_upload(
Rails.root.join('ee/spec/fixtures/security_reports/master/gl-dast-large-scanned-resources.json'), 'application/json')
end
end
trait :low_severity_dast_report do trait :low_severity_dast_report do
file_format { :raw } file_format { :raw }
file_type { :dast } file_type { :dast }
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['ScannedResource'] do
specify { expect(described_class.graphql_name).to eq('ScannedResource') }
it 'has specific fields' do
expected_fields = %w[url request_method]
expect(described_class).to include_graphql_fields(*expected_fields)
end
end
...@@ -6,7 +6,7 @@ RSpec.describe GitlabSchema.types['SecurityReportSummarySection'] do ...@@ -6,7 +6,7 @@ RSpec.describe GitlabSchema.types['SecurityReportSummarySection'] do
specify { expect(described_class.graphql_name).to eq('SecurityReportSummarySection') } specify { expect(described_class.graphql_name).to eq('SecurityReportSummarySection') }
it 'has specific fields' do it 'has specific fields' do
expected_fields = %w[vulnerabilities_count scanned_resources_count] expected_fields = %w[vulnerabilities_count scanned_resources_count scanned_resources]
expect(described_class).to include_graphql_fields(*expected_fields) expect(described_class).to include_graphql_fields(*expected_fields)
end end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Query.project(fullPath).pipeline(iid).securityReportSummary' do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:pipeline) { create(:ci_pipeline, :success, project: project) }
let_it_be(:user) { create(:user) }
before_all do
create(:ci_build, :success, name: 'dast_job', pipeline: pipeline, project: project) do |job|
create(:ee_ci_job_artifact, :dast_large_scanned_resources_field, job: job, project: project)
create(:security_scan, scan_type: 'dast', scanned_resources_count: 26, build: job)
end
create(:ci_build, :success, name: 'sast_job', pipeline: pipeline, project: project) do |job|
create(:ee_ci_job_artifact, :sast, job: job, project: project)
end
end
let_it_be(:query) do
%(
query {
project(fullPath:"#{project.full_path}") {
pipeline(iid:"#{pipeline.iid}") {
securityReportSummary {
dast {
scannedResourcesCount
vulnerabilitiesCount
scannedResources {
nodes {
url
requestMethod
}
}
}
sast {
vulnerabilitiesCount
}
}
}
}
}
)
end
before do
stub_licensed_features(sast: true, dependency_scanning: true, container_scanning: true, dast: true)
project.add_developer(user)
end
subject { GitlabSchema.execute(query, context: { current_user: user }).as_json }
let(:security_report_summary) { subject.dig('data', 'project', 'pipeline', 'securityReportSummary') }
it 'shows the vulnerabilitiesCount and scannedResourcesCount' do
expect(security_report_summary.dig('dast', 'vulnerabilitiesCount')).to eq(20)
expect(security_report_summary.dig('dast', 'scannedResourcesCount')).to eq(26)
expect(security_report_summary.dig('sast', 'vulnerabilitiesCount')).to eq(33)
end
it 'shows the first 20 scanned resources' do
dast_scanned_resources = security_report_summary.dig('dast', 'scannedResources', 'nodes')
expect(dast_scanned_resources.length).to eq(20)
end
end
...@@ -8,7 +8,7 @@ RSpec.describe Security::ReportSummaryService, '#execute' do ...@@ -8,7 +8,7 @@ RSpec.describe Security::ReportSummaryService, '#execute' do
before_all do before_all do
create(:ci_build, :success, name: 'dast_job', pipeline: pipeline, project: project) do |job| create(:ci_build, :success, name: 'dast_job', pipeline: pipeline, project: project) do |job|
create(:ee_ci_job_artifact, :dast, job: job, project: project) create(:ee_ci_job_artifact, :dast_large_scanned_resources_field, job: job, project: project)
end end
create(:ci_build, :success, name: 'sast_job', pipeline: pipeline, project: project) do |job| create(:ci_build, :success, name: 'sast_job', pipeline: pipeline, project: project) do |job|
create(:ee_ci_job_artifact, :sast, job: job, project: project) create(:ee_ci_job_artifact, :sast, job: job, project: project)
...@@ -21,7 +21,7 @@ RSpec.describe Security::ReportSummaryService, '#execute' do ...@@ -21,7 +21,7 @@ RSpec.describe Security::ReportSummaryService, '#execute' do
create(:ee_ci_job_artifact, :dependency_scanning, job: job, project: project) create(:ee_ci_job_artifact, :dependency_scanning, job: job, project: project)
end end
create_security_scan(project, pipeline, 'dast', 34) create_security_scan(project, pipeline, 'dast', 26)
create_security_scan(project, pipeline, 'sast', 12) create_security_scan(project, pipeline, 'sast', 12)
end end
...@@ -46,7 +46,7 @@ RSpec.describe Security::ReportSummaryService, '#execute' do ...@@ -46,7 +46,7 @@ RSpec.describe Security::ReportSummaryService, '#execute' do
it 'returns only the request fields' do it 'returns only the request fields' do
expect(result).to eq({ expect(result).to eq({
dast: { scanned_resources_count: 34, vulnerabilities_count: 20 }, dast: { scanned_resources_count: 26, vulnerabilities_count: 20 },
container_scanning: { vulnerabilities_count: 8 } container_scanning: { vulnerabilities_count: 8 }
}) })
end end
...@@ -73,7 +73,7 @@ RSpec.describe Security::ReportSummaryService, '#execute' do ...@@ -73,7 +73,7 @@ RSpec.describe Security::ReportSummaryService, '#execute' do
context 'All fields are requested' do context 'All fields are requested' do
let(:selection_information) do let(:selection_information) do
{ {
dast: [:scanned_resources_count, :vulnerabilities_count], dast: [:scanned_resources_count, :vulnerabilities_count, :scanned_resources],
sast: [:scanned_resources_count, :vulnerabilities_count], sast: [:scanned_resources_count, :vulnerabilities_count],
container_scanning: [:scanned_resources_count, :vulnerabilities_count], container_scanning: [:scanned_resources_count, :vulnerabilities_count],
dependency_scanning: [:scanned_resources_count, :vulnerabilities_count] dependency_scanning: [:scanned_resources_count, :vulnerabilities_count]
...@@ -82,7 +82,7 @@ RSpec.describe Security::ReportSummaryService, '#execute' do ...@@ -82,7 +82,7 @@ RSpec.describe Security::ReportSummaryService, '#execute' do
it 'returns the scanned_resources_count' do it 'returns the scanned_resources_count' do
expect(result).to match(a_hash_including( expect(result).to match(a_hash_including(
dast: a_hash_including(scanned_resources_count: 34), dast: a_hash_including(scanned_resources_count: 26),
sast: a_hash_including(scanned_resources_count: 12), sast: a_hash_including(scanned_resources_count: 12),
container_scanning: a_hash_including(scanned_resources_count: 0), container_scanning: a_hash_including(scanned_resources_count: 0),
dependency_scanning: a_hash_including(scanned_resources_count: 0) dependency_scanning: a_hash_including(scanned_resources_count: 0)
...@@ -98,6 +98,10 @@ RSpec.describe Security::ReportSummaryService, '#execute' do ...@@ -98,6 +98,10 @@ RSpec.describe Security::ReportSummaryService, '#execute' do
)) ))
end end
it 'returns the scanned resources limited to 20' do
expect(result[:dast][:scanned_resources].length).to eq(20)
end
context 'When no security scans ran' do context 'When no security scans ran' do
let(:pipeline) { create(:ci_pipeline, :success) } let(:pipeline) { create(:ci_pipeline, :success) }
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Security::ScannedResourcesService, '#execute' do
before do
stub_licensed_features(sast: true, dependency_scanning: true, container_scanning: true, dast: true)
end
let_it_be(:project) { create(:project, :repository) }
let_it_be(:pipeline) { create(:ci_pipeline, :success, project: project) }
before_all do
create(:ci_build, :success, name: 'dast_job', pipeline: pipeline, project: project) do |job|
create(:ee_ci_job_artifact, :dast, job: job, project: project)
end
create(:ci_build, :success, name: 'sast_job', pipeline: pipeline, project: project) do |job|
create(:ee_ci_job_artifact, :sast, job: job, project: project)
end
end
context 'The pipeline has security builds' do
context 'Report types are requested' do
subject { described_class.new(pipeline, %w[sast dast]).execute }
it 'only returns the requested scans' do
expect(subject.keys).to contain_exactly('sast', 'dast')
end
it 'returns the scanned resources' do
expect(subject['sast']).to be_empty
expect(subject['dast'].length).to eq(6)
expect(subject['dast']).to include(
{
'url' => 'http://api-server/',
'request_method' => 'GET'
}
)
end
end
end
context 'A limited number of scanned resources are requested' do
subject { described_class.new(pipeline, %w[dast], 2).execute }
it 'returns the scanned resources' do
expect(subject['dast'].length).to eq(2)
end
end
context 'The Pipeline has no security builds' do
let_it_be(:pipeline) { create(:ci_pipeline, :success) }
subject { described_class.new(pipeline, %w[sast dast]).execute }
it {
is_expected.to match(
a_hash_including('sast' => [], 'dast' => [])
)
}
end
context 'Pipeline is nil' do
subject { described_class.new(nil, %w[sast dast]).execute }
it {
is_expected.to match(
a_hash_including('sast' => [], 'dast' => [])
)
}
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