Commit cc0c4563 authored by James Lopez's avatar James Lopez

Merge branch 'add_scanned_resources_to_security_report_summary_223672' into 'master'

Add scanned resources to security report

See merge request gitlab-org/gitlab!35695
parents 0b213b0d a68b5371
...@@ -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