Commit ac950c18 authored by Olivier Gonzalez's avatar Olivier Gonzalez Committed by Robert Speicher

Introduce security report diffs

Compare two reports to get added, existing, and fixed occurrences.
Use git diff to improve matching for SAST reports.
parent bd0d32bc
...@@ -146,7 +146,7 @@ module EE ...@@ -146,7 +146,7 @@ module EE
end end
def security_reports def security_reports
::Gitlab::Ci::Reports::Security::Reports.new.tap do |security_reports| ::Gitlab::Ci::Reports::Security::Reports.new(sha).tap do |security_reports|
builds.latest.with_reports(::Ci::JobArtifact.security_reports).each do |build| builds.latest.with_reports(::Ci::JobArtifact.security_reports).each do |build|
build.collect_security_reports!(security_reports) build.collect_security_reports!(security_reports)
end end
......
# frozen_string_literal: true
module Security
class CompareReportsBaseService
attr_reader :base_report, :head_report, :report_diff
def initialize(base_report, head_report)
@base_report = base_report
@head_report = head_report
@report_diff = ::Gitlab::Ci::Reports::Security::ReportsDiff.new
end
def execute
# If there is nothing to compare with, just consider all
# head occurrences as added
if base_report.occurrences.blank?
report_diff.added = head_report.occurrences
return report_diff
end
update_base_occurrence_locations
report_diff.added = head_report.occurrences - base_report.occurrences
report_diff.fixed = base_report.occurrences - head_report.occurrences
report_diff.existing = base_report.occurrences & head_report.occurrences
report_diff
end
private
def update_base_occurrence_locations
# Override this method with an update strategy in subclass if any?
end
end
end
# frozen_string_literal: true
module Security
class CompareReportsSastService < CompareReportsBaseService
include Gitlab::Utils::StrongMemoize
attr_reader :project
def initialize(base_report, head_report, project)
@project = project
super(base_report, head_report)
end
private
# Update location for base report occurrences by leveraging the git diff
def update_base_occurrence_locations
return unless git_diff
# Group by file path to optimize the usage of Diff::File and Diff::LineMapper
base_report.occurrences.group_by(&:file_path).each do |file_path, occurrences|
update_locations_by_file(git_diff, file_path, occurrences)
end
end
def update_locations_by_file(git_diff, file_path, occurrences)
diff_file = git_diff.diff_file_with_old_path(file_path)
return if diff_file.nil? || diff_file.deleted_file?
update_locations(diff_file, occurrences)
end
def update_locations(diff_file, occurrences)
line_mapper = Gitlab::Diff::LineMapper.new(diff_file)
occurrences.each do |occurrence|
new_path = line_mapper.diff_file.new_path
new_start_line = line_mapper.old_to_new(occurrence.start_line)
new_end_line = occurrence.end_line.present? ? line_mapper.old_to_new(occurrence.end_line) : nil
# skip if the line has been removed
# NB: if the line's content has changed it will be nil too
next if new_start_line.nil?
new_location = Gitlab::Ci::Reports::Security::Locations::Sast.new(
file_path: new_path,
start_line: new_start_line,
end_line: new_end_line
)
occurrence.update_location(new_location)
end
end
def git_diff
strong_memoize(:git_diff) do
compare = CompareService.new(project, head_report.commit_sha).execute(project, base_report.commit_sha, straight: false)
next unless compare
compare.diffs(expanded: true)
end
end
end
end
...@@ -11,6 +11,7 @@ module Gitlab ...@@ -11,6 +11,7 @@ module Gitlab
attr_reader :location attr_reader :location
attr_reader :metadata_version attr_reader :metadata_version
attr_reader :name attr_reader :name
attr_reader :old_location
attr_reader :project_fingerprint attr_reader :project_fingerprint
attr_reader :raw_metadata attr_reader :raw_metadata
attr_reader :report_type attr_reader :report_type
...@@ -18,6 +19,8 @@ module Gitlab ...@@ -18,6 +19,8 @@ module Gitlab
attr_reader :severity attr_reader :severity
attr_reader :uuid attr_reader :uuid
delegate :file_path, :start_line, :end_line, to: :location
def initialize(compare_key:, identifiers:, location:, metadata_version:, name:, raw_metadata:, report_type:, scanner:, uuid:, confidence: nil, severity: nil) # rubocop:disable Metrics/ParameterLists def initialize(compare_key:, identifiers:, location:, metadata_version:, name:, raw_metadata:, report_type:, scanner:, uuid:, confidence: nil, severity: nil) # rubocop:disable Metrics/ParameterLists
@compare_key = compare_key @compare_key = compare_key
@confidence = confidence @confidence = confidence
...@@ -57,11 +60,17 @@ module Gitlab ...@@ -57,11 +60,17 @@ module Gitlab
identifiers.first identifiers.first
end end
def update_location(new_location)
@old_location = location
@location = new_location
end
def ==(other) def ==(other)
other.report_type == report_type && other.report_type == report_type &&
other.location == location && other.location == location &&
other.primary_identifier == primary_identifier other.primary_identifier == primary_identifier
end end
alias_method :eql?, :== # eql? is necessary in some cases like array intersection
private private
......
...@@ -6,13 +6,16 @@ module Gitlab ...@@ -6,13 +6,16 @@ module Gitlab
module Security module Security
class Report class Report
attr_reader :type attr_reader :type
attr_reader :commit_sha
attr_reader :occurrences attr_reader :occurrences
attr_reader :scanners attr_reader :scanners
attr_reader :identifiers attr_reader :identifiers
attr_accessor :error attr_accessor :error
def initialize(type) def initialize(type, commit_sha)
@type = type @type = type
@commit_sha = commit_sha
@occurrences = [] @occurrences = []
@scanners = {} @scanners = {}
@identifiers = {} @identifiers = {}
......
...@@ -5,14 +5,15 @@ module Gitlab ...@@ -5,14 +5,15 @@ module Gitlab
module Reports module Reports
module Security module Security
class Reports class Reports
attr_reader :reports attr_reader :reports, :commit_sha
def initialize def initialize(commit_sha)
@reports = {} @reports = {}
@commit_sha = commit_sha
end end
def get_report(report_type) def get_report(report_type)
reports[report_type] ||= Report.new(report_type) reports[report_type] ||= Report.new(report_type, commit_sha)
end end
end end
end end
......
# frozen_string_literal: true
module Gitlab
module Ci
module Reports
module Security
class ReportsDiff
attr_accessor :added, :existing, :fixed
def initialize
@added = []
@existing = []
@fixed = []
end
end
end
end
end
end
...@@ -13,5 +13,11 @@ FactoryBot.define do ...@@ -13,5 +13,11 @@ FactoryBot.define do
initialize_with do initialize_with do
::Gitlab::Ci::Reports::Security::Locations::Sast.new(attributes) ::Gitlab::Ci::Reports::Security::Locations::Sast.new(attributes)
end end
trait :dynamic do
sequence(:file_path, 'a') { |n| "path/#{n}" }
start_line { Random.rand(20) }
end_line { start_line + Random.rand(5) }
end
end end
end end
...@@ -34,6 +34,10 @@ FactoryBot.define do ...@@ -34,6 +34,10 @@ FactoryBot.define do
skip_create skip_create
trait :dynamic do
location { FactoryBot.build(:ci_reports_security_locations_sast, :dynamic) }
end
initialize_with do initialize_with do
::Gitlab::Ci::Reports::Security::Occurrence.new(attributes) ::Gitlab::Ci::Reports::Security::Occurrence.new(attributes)
end end
......
# frozen_string_literal: true
FactoryBot.define do
factory :ci_reports_security_report, class: ::Gitlab::Ci::Reports::Security::Report do
type :sast
commit_sha { Digest::SHA1.hexdigest(SecureRandom.hex) }
transient do
occurrences []
end
after :build do |report, evaluator|
evaluator.occurrences.each { |o| report.add_occurrence(o) }
end
skip_create
initialize_with do
::Gitlab::Ci::Reports::Security::Report.new(type, commit_sha)
end
end
end
...@@ -17,7 +17,7 @@ describe Gitlab::Ci::Parsers::Security::ContainerScanning do ...@@ -17,7 +17,7 @@ describe Gitlab::Ci::Parsers::Security::ContainerScanning do
let(:project) { artifact.project } let(:project) { artifact.project }
let(:pipeline) { artifact.job.pipeline } let(:pipeline) { artifact.job.pipeline }
let(:artifact) { create(:ee_ci_job_artifact, :container_scanning) } let(:artifact) { create(:ee_ci_job_artifact, :container_scanning) }
let(:report) { Gitlab::Ci::Reports::Security::Report.new(artifact.file_type) } let(:report) { Gitlab::Ci::Reports::Security::Report.new(artifact.file_type, pipeline.sha) }
before do before do
artifact.each_blob do |blob| artifact.each_blob do |blob|
......
...@@ -9,7 +9,7 @@ describe Gitlab::Ci::Parsers::Security::Dast do ...@@ -9,7 +9,7 @@ describe Gitlab::Ci::Parsers::Security::Dast do
let(:project) { artifact.project } let(:project) { artifact.project }
let(:pipeline) { artifact.job.pipeline } let(:pipeline) { artifact.job.pipeline }
let(:artifact) { create(:ee_ci_job_artifact, :dast) } let(:artifact) { create(:ee_ci_job_artifact, :dast) }
let(:report) { Gitlab::Ci::Reports::Security::Report.new(artifact.file_type) } let(:report) { Gitlab::Ci::Reports::Security::Report.new(artifact.file_type, pipeline.sha) }
before do before do
artifact.each_blob do |blob| artifact.each_blob do |blob|
......
...@@ -9,7 +9,7 @@ describe Gitlab::Ci::Parsers::Security::DependencyScanning do ...@@ -9,7 +9,7 @@ describe Gitlab::Ci::Parsers::Security::DependencyScanning do
let(:project) { artifact.project } let(:project) { artifact.project }
let(:pipeline) { artifact.job.pipeline } let(:pipeline) { artifact.job.pipeline }
let(:artifact) { create(:ee_ci_job_artifact, :dependency_scanning) } let(:artifact) { create(:ee_ci_job_artifact, :dependency_scanning) }
let(:report) { Gitlab::Ci::Reports::Security::Report.new(artifact.file_type) } let(:report) { Gitlab::Ci::Reports::Security::Report.new(artifact.file_type, pipeline.sha) }
let(:parser) { described_class.new } let(:parser) { described_class.new }
where(:report_format, :occurrence_count, :identifier_count, :scanner_count, :file_path, :package_name, :package_version, :version) do where(:report_format, :occurrence_count, :identifier_count, :scanner_count, :file_path, :package_name, :package_version, :version) do
......
...@@ -6,7 +6,7 @@ describe Gitlab::Ci::Parsers::Security::Sast do ...@@ -6,7 +6,7 @@ describe Gitlab::Ci::Parsers::Security::Sast do
describe '#parse!' do describe '#parse!' do
let(:project) { artifact.project } let(:project) { artifact.project }
let(:pipeline) { artifact.job.pipeline } let(:pipeline) { artifact.job.pipeline }
let(:report) { Gitlab::Ci::Reports::Security::Report.new(artifact.file_type) } let(:report) { Gitlab::Ci::Reports::Security::Report.new(artifact.file_type, pipeline.sha) }
let(:parser) { described_class.new } let(:parser) { described_class.new }
where(report_format: %i(sast sast_deprecated)) where(report_format: %i(sast sast_deprecated))
......
...@@ -61,6 +61,15 @@ describe Gitlab::Ci::Reports::Security::Occurrence do ...@@ -61,6 +61,15 @@ describe Gitlab::Ci::Reports::Security::Occurrence do
end end
end end
describe "delegation" do
subject { create(:ci_reports_security_occurrence) }
%i[file_path start_line end_line].each do |attribute|
it "delegates attribute #{attribute} to location" do
expect(subject.public_send(attribute)).to eq(subject.location.public_send(attribute))
end
end
end
describe '#to_hash' do describe '#to_hash' do
let(:occurrence) { create(:ci_reports_security_occurrence) } let(:occurrence) { create(:ci_reports_security_occurrence) }
...@@ -97,7 +106,29 @@ describe Gitlab::Ci::Reports::Security::Occurrence do ...@@ -97,7 +106,29 @@ describe Gitlab::Ci::Reports::Security::Occurrence do
end end
end end
describe '#==' do describe '#update_location' do
let(:old_location) { create(:ci_reports_security_locations_sast, file_path: 'old_file.rb') }
let(:new_location) { create(:ci_reports_security_locations_sast, file_path: 'new_file.rb') }
let(:occurrence) { create(:ci_reports_security_occurrence, location: old_location) }
subject { occurrence.update_location(new_location) }
it 'assigns the new location and returns it' do
subject
expect(occurrence.location).to eq(new_location)
is_expected.to eq(new_location)
end
it 'assigns the old location' do
subject
expect(occurrence.old_location).to eq(old_location)
end
end
describe '#== and eql?' do
using RSpec::Parameterized::TableSyntax using RSpec::Parameterized::TableSyntax
let(:identifier) { create(:ci_reports_security_identifier) } let(:identifier) { create(:ci_reports_security_identifier) }
...@@ -116,9 +147,13 @@ describe Gitlab::Ci::Reports::Security::Occurrence do ...@@ -116,9 +147,13 @@ describe Gitlab::Ci::Reports::Security::Occurrence do
let(:occurrence_1) { create(:ci_reports_security_occurrence, report_type: report_type_1, location: location_1.call, identifiers: [identifier_1.call]) } let(:occurrence_1) { create(:ci_reports_security_occurrence, report_type: report_type_1, location: location_1.call, identifiers: [identifier_1.call]) }
let(:occurrence_2) { create(:ci_reports_security_occurrence, report_type: report_type_2, location: location_2.call, identifiers: [identifier_2.call]) } let(:occurrence_2) { create(:ci_reports_security_occurrence, report_type: report_type_2, location: location_2.call, identifiers: [identifier_2.call]) }
it "returns #{params[:equal]}" do it "returns #{params[:equal]} for ==" do
expect(occurrence_1 == occurrence_2).to eq(equal) expect(occurrence_1 == occurrence_2).to eq(equal)
end end
it "returns #{params[:equal]} for eq?" do
expect(occurrence_1.eql?(occurrence_2)).to eq(equal)
end
end end
end end
end end
...@@ -4,7 +4,7 @@ require 'spec_helper' ...@@ -4,7 +4,7 @@ require 'spec_helper'
describe Gitlab::Ci::Reports::Security::Report do describe Gitlab::Ci::Reports::Security::Report do
let(:pipeline) { create(:ci_pipeline) } let(:pipeline) { create(:ci_pipeline) }
let(:report) { described_class.new('sast') } let(:report) { described_class.new('sast', pipeline.sha) }
it { expect(report.type).to eq('sast') } it { expect(report.type).to eq('sast') }
......
...@@ -3,7 +3,8 @@ ...@@ -3,7 +3,8 @@
require 'spec_helper' require 'spec_helper'
describe Gitlab::Ci::Reports::Security::Reports do describe Gitlab::Ci::Reports::Security::Reports do
let(:security_reports) { described_class.new } let(:commit_sha) { '20410773a37f49d599e5f0d45219b39304763538' }
let(:security_reports) { described_class.new(commit_sha) }
describe '#get_report' do describe '#get_report' do
subject { security_reports.get_report(report_type) } subject { security_reports.get_report(report_type) }
...@@ -12,10 +13,11 @@ describe Gitlab::Ci::Reports::Security::Reports do ...@@ -12,10 +13,11 @@ describe Gitlab::Ci::Reports::Security::Reports do
let(:report_type) { 'sast' } let(:report_type) { 'sast' }
it { expect(subject.type).to eq('sast') } it { expect(subject.type).to eq('sast') }
it { expect(subject.commit_sha).to eq(commit_sha) }
it 'initializes a new report and returns it' do it 'initializes a new report and returns it' do
expect(Gitlab::Ci::Reports::Security::Report).to receive(:new) expect(Gitlab::Ci::Reports::Security::Report).to receive(:new)
.with('sast').and_call_original .with('sast', commit_sha).and_call_original
is_expected.to be_a(Gitlab::Ci::Reports::Security::Report) is_expected.to be_a(Gitlab::Ci::Reports::Security::Report)
end end
......
...@@ -120,7 +120,7 @@ describe Ci::Build do ...@@ -120,7 +120,7 @@ describe Ci::Build do
end end
describe '#collect_security_reports!' do describe '#collect_security_reports!' do
let(:security_reports) { ::Gitlab::Ci::Reports::Security::Reports.new } let(:security_reports) { ::Gitlab::Ci::Reports::Security::Reports.new(pipeline.sha) }
subject { job.collect_security_reports!(security_reports) } subject { job.collect_security_reports!(security_reports) }
......
...@@ -204,6 +204,11 @@ describe Ci::Pipeline do ...@@ -204,6 +204,11 @@ describe Ci::Pipeline do
create(:ee_ci_job_artifact, :container_scanning, job: build_cs_1, project: project) create(:ee_ci_job_artifact, :container_scanning, job: build_cs_1, project: project)
end end
it 'assigns pipeline commit_sha to the reports' do
expect(subject.commit_sha).to eq(pipeline.sha)
expect(subject.reports.values.map(&:commit_sha).uniq).to contain_exactly(pipeline.sha)
end
it 'returns security reports with collected data grouped as expected' do it 'returns security reports with collected data grouped as expected' do
expect(subject.reports.keys).to contain_exactly('sast', 'dependency_scanning', 'container_scanning') expect(subject.reports.keys).to contain_exactly('sast', 'dependency_scanning', 'container_scanning')
expect(subject.get_report('sast').occurrences.size).to eq(66) expect(subject.get_report('sast').occurrences.size).to eq(66)
......
# frozen_string_literal: true
require 'spec_helper'
describe Security::CompareReportsBaseService, '#execute' do
let(:occurrence_1) { create(:ci_reports_security_occurrence, :dynamic) }
let(:occurrence_2) { create(:ci_reports_security_occurrence, :dynamic) }
let(:occurrence_3) { create(:ci_reports_security_occurrence, :dynamic) }
let(:base_report) { create(:ci_reports_security_report, occurrences: [occurrence_1, occurrence_2]) }
let(:head_report) { create(:ci_reports_security_report, occurrences: [occurrence_2, occurrence_3]) }
subject { described_class.new(base_report, head_report).execute }
it 'exposes added occurrences' do
expect(subject.added).to contain_exactly(occurrence_3)
end
it 'exposes existing occurrences' do
expect(subject.existing).to contain_exactly(occurrence_2)
end
it 'exposes fixed occurrences' do
expect(subject.fixed).to contain_exactly(occurrence_1)
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Security::CompareReportsSastService, '#execute' do
# Setup a project with 2 reports having 1 common vulnerability whose location is updated
let(:project) { create(:project, :repository) }
let(:branch_name) { 'master' }
let(:identifier) { create(:ci_reports_security_identifier) }
let(:location) { create(:ci_reports_security_locations_sast, file_path: 'a.js', start_line: 2, end_line: 4) }
let(:location_updated) { create(:ci_reports_security_locations_sast, file_path: 'a.js', start_line: 3, end_line: 5) }
let(:occurrence_1) { create(:ci_reports_security_occurrence, :dynamic, name: 'occurrence_1') }
let(:occurrence_2) { create(:ci_reports_security_occurrence, name: 'occurrence_2', location: location, identifiers: [identifier]) }
let(:occurrence_2_updated) { create(:ci_reports_security_occurrence, name: 'occurrence_2_updated', location: location_updated, identifiers: [identifier]) }
let(:occurrence_3) { create(:ci_reports_security_occurrence, :dynamic, name: 'occurrence_3') }
let(:base_report) { create(:ci_reports_security_report, commit_sha: base_sha, occurrences: [occurrence_1, occurrence_2]) }
let(:head_report) { create(:ci_reports_security_report, commit_sha: head_sha, occurrences: [occurrence_2_updated, occurrence_3]) }
let(:file_content) do
<<-DIFF.strip_heredoc
var auth = "Jane";
if (userInput == auth) {
console.log(userInput);
}
DIFF
end
let(:file_content_updated) do
<<-DIFF.strip_heredoc
var auth = "Jane";
// Add a comment
if (userInput == auth) {
console.log(userInput);
}
DIFF
end
let(:base_sha) do
create_file('a.js', file_content)
project.commit(branch_name).id
end
subject { described_class.new(base_report, head_report, project).execute }
shared_examples 'report diff with existing occurrence' do
it 'returns added, existing and fixed occurrences' do
expect(subject.added).to contain_exactly(occurrence_3)
expect(subject.existing).to contain_exactly(occurrence_2)
expect(subject.fixed).to contain_exactly(occurrence_1)
end
it 'returns existing occurrence with old location set' do
expect(subject.existing.first.old_location).to eq(location)
end
end
shared_examples 'report diff without existing occurrence' do
it 'returns added and fixed occurrences but no existing ones' do
expect(subject.added).to contain_exactly(occurrence_3, occurrence_2_updated)
expect(subject.existing).to be_empty
expect(subject.fixed).to contain_exactly(occurrence_1, occurrence_2)
end
end
context 'when commit_sha are equal' do
let(:head_sha) { base_sha }
it_behaves_like 'report diff without existing occurrence'
end
context 'when there is no git diff available' do
let(:head_sha) do
update_file('a.js', file_content_updated)
project.commit(branch_name).id
end
before do
compare_service = spy
allow(CompareService).to receive(:new).and_return(compare_service)
allow(compare_service).to receive(:execute).and_return(nil)
end
it_behaves_like 'report diff without existing occurrence'
end
context 'when vulnerability line numbers are updated' do
let(:head_sha) do
update_file('a.js', file_content_updated)
project.commit(branch_name).id
end
it_behaves_like 'report diff with existing occurrence'
context 'without end_line' do
let(:location) { create(:ci_reports_security_locations_sast, file_path: 'a.js', start_line: 2, end_line: nil) }
let(:location_updated) { create(:ci_reports_security_locations_sast, file_path: 'a.js', start_line: 3, end_line: nil) }
it_behaves_like 'report diff with existing occurrence'
end
context 'when line content is updated' do
let(:file_content_updated) do
<<-DIFF.strip_heredoc
var auth = "Jane";
// Add a comment
if (input == auth) { // Add a comment here to change line content
console.log(userInput);
}
DIFF
end
it_behaves_like 'report diff without existing occurrence'
end
end
context 'when vulnerability file path is updated' do
let(:location_updated) { create(:ci_reports_security_locations_sast, file_path: 'b.js', start_line: 2, end_line: 4) }
let(:head_sha) do
delete_file('a.js')
create_file('b.js', file_content)
project.commit(branch_name).id
end
it_behaves_like 'report diff with existing occurrence'
end
context 'when vulnerability file path and lines are updated' do
let(:location_updated) { create(:ci_reports_security_locations_sast, file_path: 'b.js', start_line: 3, end_line: 5) }
let(:head_sha) do
delete_file('a.js')
create_file('b.js', file_content_updated)
project.commit(branch_name).id
end
it_behaves_like 'report diff with existing occurrence'
end
def create_file(file_name, content)
Files::CreateService.new(
project,
project.owner,
commit_message: 'Update',
start_branch: branch_name,
branch_name: branch_name,
file_path: file_name,
file_content: content
).execute
end
def update_file(file_name, content)
Files::UpdateService.new(
project,
project.owner,
commit_message: 'Update',
start_branch: branch_name,
branch_name: branch_name,
file_path: file_name,
file_content: content
).execute
end
def delete_file(file_name)
Files::DeleteService.new(
project,
project.owner,
commit_message: 'Update',
start_branch: branch_name,
branch_name: branch_name,
file_path: file_name
).execute
end
end
...@@ -28,7 +28,7 @@ describe Security::StoreReportsService, '#execute' do ...@@ -28,7 +28,7 @@ describe Security::StoreReportsService, '#execute' do
end end
context 'when StoreReportService returns an error for a report' do context 'when StoreReportService returns an error for a report' do
let(:reports) { Gitlab::Ci::Reports::Security::Reports.new } let(:reports) { Gitlab::Ci::Reports::Security::Reports.new(pipeline.sha) }
let(:sast_report) { reports.get_report('sast') } let(:sast_report) { reports.get_report('sast') }
let(:dast_report) { reports.get_report('dast') } let(:dast_report) { reports.get_report('dast') }
let(:success) { { status: :success } } let(:success) { { status: :success } }
......
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