diff options
Diffstat (limited to 'spec/lib/gitlab/ci/reports/security')
8 files changed, 687 insertions, 0 deletions
diff --git a/spec/lib/gitlab/ci/reports/security/aggregated_report_spec.rb b/spec/lib/gitlab/ci/reports/security/aggregated_report_spec.rb new file mode 100644 index 00000000000..c56177a6453 --- /dev/null +++ b/spec/lib/gitlab/ci/reports/security/aggregated_report_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Reports::Security::AggregatedReport do + subject { described_class.new(reports, findings) } + + let(:reports) { build_list(:ci_reports_security_report, 1) } + let(:findings) { build_list(:ci_reports_security_finding, 1) } + + describe '#created_at' do + context 'no reports' do + let(:reports) { [] } + + it 'has no created date' do + expect(subject.created_at).to be_nil + end + end + + context 'report with no created date' do + let(:reports) { build_list(:ci_reports_security_report, 1, created_at: nil) } + + it 'has no created date' do + expect(subject.created_at).to be_nil + end + end + + context 'has reports' do + let(:a_long_time_ago) { 2.months.ago } + let(:a_while_ago) { 2.weeks.ago } + let(:yesterday) { 1.day.ago } + + let(:reports) do + [build(:ci_reports_security_report, created_at: a_while_ago), + build(:ci_reports_security_report, created_at: a_long_time_ago), + build(:ci_reports_security_report, created_at: nil), + build(:ci_reports_security_report, created_at: yesterday)] + end + + it 'has oldest created date' do + expect(subject.created_at).to eq(a_long_time_ago) + end + end + end +end diff --git a/spec/lib/gitlab/ci/reports/security/finding_key_spec.rb b/spec/lib/gitlab/ci/reports/security/finding_key_spec.rb new file mode 100644 index 00000000000..784c1183320 --- /dev/null +++ b/spec/lib/gitlab/ci/reports/security/finding_key_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Reports::Security::FindingKey do + using RSpec::Parameterized::TableSyntax + + describe '#==' do + where(:location_fp_1, :location_fp_2, :identifier_fp_1, :identifier_fp_2, :equals?) do + nil | 'different location fp' | 'identifier fp' | 'different identifier fp' | false + 'location fp' | nil | 'identifier fp' | 'different identifier fp' | false + 'location fp' | 'different location fp' | nil | 'different identifier fp' | false + 'location fp' | 'different location fp' | 'identifier fp' | nil | false + nil | nil | 'identifier fp' | 'identifier fp' | false + 'location fp' | 'location fp' | nil | nil | false + nil | nil | nil | nil | false + 'location fp' | 'different location fp' | 'identifier fp' | 'different identifier fp' | false + 'location fp' | 'different location fp' | 'identifier fp' | 'identifier fp' | false + 'location fp' | 'location fp' | 'identifier fp' | 'different identifier fp' | false + 'location fp' | 'location fp' | 'identifier fp' | 'identifier fp' | true + end + + with_them do + let(:finding_key_1) do + build(:ci_reports_security_finding_key, + location_fingerprint: location_fp_1, + identifier_fingerprint: identifier_fp_1) + end + + let(:finding_key_2) do + build(:ci_reports_security_finding_key, + location_fingerprint: location_fp_2, + identifier_fingerprint: identifier_fp_2) + end + + subject { finding_key_1 == finding_key_2 } + + it { is_expected.to be(equals?) } + end + end +end diff --git a/spec/lib/gitlab/ci/reports/security/finding_signature_spec.rb b/spec/lib/gitlab/ci/reports/security/finding_signature_spec.rb new file mode 100644 index 00000000000..23e6b40a039 --- /dev/null +++ b/spec/lib/gitlab/ci/reports/security/finding_signature_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Reports::Security::FindingSignature do + subject { described_class.new(params.with_indifferent_access) } + + let(:params) do + { + algorithm_type: 'hash', + signature_value: 'SIGNATURE' + } + end + + describe '#initialize' do + context 'when a supported algorithm type is given' do + it 'allows itself to be created' do + expect(subject.algorithm_type).to eq(params[:algorithm_type]) + expect(subject.signature_value).to eq(params[:signature_value]) + end + + describe '#valid?' do + it 'returns true' do + expect(subject.valid?).to eq(true) + end + end + end + end + + describe '#valid?' do + context 'when supported algorithm_type is given' do + it 'is valid' do + expect(subject.valid?).to eq(true) + end + end + + context 'when an unsupported algorithm_type is given' do + let(:params) do + { + algorithm_type: 'INVALID', + signature_value: 'SIGNATURE' + } + end + + it 'is not valid' do + expect(subject.valid?).to eq(false) + end + end + end + + describe '#to_hash' do + it 'returns a hash representation of the signature' do + expect(subject.to_hash).to eq( + algorithm_type: params[:algorithm_type], + signature_sha: Digest::SHA1.digest(params[:signature_value]) + ) + end + end +end diff --git a/spec/lib/gitlab/ci/reports/security/locations/sast_spec.rb b/spec/lib/gitlab/ci/reports/security/locations/sast_spec.rb new file mode 100644 index 00000000000..effa7a60400 --- /dev/null +++ b/spec/lib/gitlab/ci/reports/security/locations/sast_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Reports::Security::Locations::Sast do + let(:params) do + { + file_path: 'src/main/App.java', + start_line: 29, + end_line: 31, + class_name: 'com.gitlab.security_products.tests.App', + method_name: 'insecureCypher' + } + end + + let(:mandatory_params) { %i[file_path start_line] } + let(:expected_fingerprint) { Digest::SHA1.hexdigest('src/main/App.java:29:31') } + let(:expected_fingerprint_path) { 'App.java' } + + it_behaves_like 'vulnerability location' +end diff --git a/spec/lib/gitlab/ci/reports/security/locations/secret_detection_spec.rb b/spec/lib/gitlab/ci/reports/security/locations/secret_detection_spec.rb new file mode 100644 index 00000000000..3b84a548713 --- /dev/null +++ b/spec/lib/gitlab/ci/reports/security/locations/secret_detection_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Reports::Security::Locations::SecretDetection do + let(:params) do + { + file_path: 'src/main/App.java', + start_line: 29, + end_line: 31, + class_name: 'com.gitlab.security_products.tests.App', + method_name: 'insecureCypher' + } + end + + let(:mandatory_params) { %i[file_path start_line] } + let(:expected_fingerprint) { Digest::SHA1.hexdigest('src/main/App.java:29:31') } + let(:expected_fingerprint_path) { 'App.java' } + + it_behaves_like 'vulnerability location' +end diff --git a/spec/lib/gitlab/ci/reports/security/report_spec.rb b/spec/lib/gitlab/ci/reports/security/report_spec.rb new file mode 100644 index 00000000000..5a85c3f19fc --- /dev/null +++ b/spec/lib/gitlab/ci/reports/security/report_spec.rb @@ -0,0 +1,224 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Reports::Security::Report do + let_it_be(:pipeline) { create(:ci_pipeline) } + + let(:created_at) { 2.weeks.ago } + + subject(:report) { described_class.new('sast', pipeline, created_at) } + + it { expect(report.type).to eq('sast') } + it { is_expected.to delegate_method(:project_id).to(:pipeline) } + + describe '#add_scanner' do + let(:scanner) { create(:ci_reports_security_scanner, external_id: 'find_sec_bugs') } + + subject { report.add_scanner(scanner) } + + it 'stores given scanner params in the map' do + subject + + expect(report.scanners).to eq({ 'find_sec_bugs' => scanner }) + end + + it 'returns the added scanner' do + expect(subject).to eq(scanner) + end + end + + describe '#add_identifier' do + let(:identifier) { create(:ci_reports_security_identifier) } + + subject { report.add_identifier(identifier) } + + it 'stores given identifier params in the map' do + subject + + expect(report.identifiers).to eq({ identifier.fingerprint => identifier }) + end + + it 'returns the added identifier' do + expect(subject).to eq(identifier) + end + end + + describe '#add_finding' do + let(:finding) { create(:ci_reports_security_finding) } + + it 'enriches given finding and stores it in the collection' do + report.add_finding(finding) + + expect(report.findings).to eq([finding]) + end + end + + describe '#clone_as_blank' do + let(:report) do + create( + :ci_reports_security_report, + findings: [create(:ci_reports_security_finding)], + scanners: [create(:ci_reports_security_scanner)], + identifiers: [create(:ci_reports_security_identifier)] + ) + end + + it 'creates a blank report with copied type and pipeline' do + clone = report.clone_as_blank + + expect(clone.type).to eq(report.type) + expect(clone.pipeline).to eq(report.pipeline) + expect(clone.created_at).to eq(report.created_at) + expect(clone.findings).to eq([]) + expect(clone.scanners).to eq({}) + expect(clone.identifiers).to eq({}) + end + end + + describe '#replace_with!' do + let(:report) do + create( + :ci_reports_security_report, + findings: [create(:ci_reports_security_finding)], + scanners: [create(:ci_reports_security_scanner)], + identifiers: [create(:ci_reports_security_identifier)] + ) + end + + let(:other_report) do + create( + :ci_reports_security_report, + findings: [create(:ci_reports_security_finding, compare_key: 'other_finding')], + scanners: [create(:ci_reports_security_scanner, external_id: 'other_scanner', name: 'Other Scanner')], + identifiers: [create(:ci_reports_security_identifier, external_id: 'other_id', name: 'other_scanner')] + ) + end + + before do + report.replace_with!(other_report) + end + + it 'replaces report contents with other reports contents' do + expect(report.findings).to eq(other_report.findings) + expect(report.scanners).to eq(other_report.scanners) + expect(report.identifiers).to eq(other_report.identifiers) + end + end + + describe '#merge!' do + let(:merged_report) { double('Report') } + + before do + merge_reports_service = double('MergeReportsService') + + allow(::Security::MergeReportsService).to receive(:new).and_return(merge_reports_service) + allow(merge_reports_service).to receive(:execute).and_return(merged_report) + allow(report).to receive(:replace_with!) + end + + subject { report.merge!(described_class.new('sast', pipeline, created_at)) } + + it 'invokes the merge with other report and then replaces this report contents by merge result' do + subject + + expect(report).to have_received(:replace_with!).with(merged_report) + end + end + + describe '#primary_scanner' do + let(:scanner_1) { create(:ci_reports_security_scanner, external_id: 'external_id_1') } + let(:scanner_2) { create(:ci_reports_security_scanner, external_id: 'external_id_2') } + + subject { report.primary_scanner } + + before do + report.add_scanner(scanner_1) + report.add_scanner(scanner_2) + end + + it { is_expected.to eq(scanner_1) } + end + + describe '#add_error' do + context 'when the message is not given' do + it 'adds a new error to report with the generic error message' do + expect { report.add_error('foo') }.to change { report.errors } + .from([]) + .to([{ type: 'foo', message: 'An unexpected error happened!' }]) + end + end + + context 'when the message is given' do + it 'adds a new error to report' do + expect { report.add_error('foo', 'bar') }.to change { report.errors } + .from([]) + .to([{ type: 'foo', message: 'bar' }]) + end + end + end + + describe 'errored?' do + subject { report.errored? } + + context 'when the report does not have any errors' do + it { is_expected.to be_falsey } + end + + context 'when the report has errors' do + before do + report.add_error('foo', 'bar') + end + + it { is_expected.to be_truthy } + end + end + + describe '#primary_scanner_order_to' do + let(:scanner_1) { build(:ci_reports_security_scanner) } + let(:scanner_2) { build(:ci_reports_security_scanner) } + let(:report_1) { described_class.new('sast', pipeline, created_at) } + let(:report_2) { described_class.new('sast', pipeline, created_at) } + + subject(:compare_based_on_primary_scanners) { report_1.primary_scanner_order_to(report_2) } + + context 'when the primary scanner of the receiver is nil' do + context 'when the primary scanner of the other is nil' do + it { is_expected.to be(1) } + end + + context 'when the primary scanner of the other is not nil' do + before do + report_2.add_scanner(scanner_2) + end + + it { is_expected.to be(1) } + end + end + + context 'when the primary scanner of the receiver is not nil' do + before do + report_1.add_scanner(scanner_1) + end + + context 'when the primary scanner of the other is nil' do + let(:scanner_2) { nil } + + it { is_expected.to be(-1) } + end + + context 'when the primary scanner of the other is not nil' do + before do + report_2.add_scanner(scanner_2) + + allow(scanner_1).to receive(:<=>).and_return(0) + end + + it 'compares two scanners' do + expect(compare_based_on_primary_scanners).to be(0) + expect(scanner_1).to have_received(:<=>).with(scanner_2) + end + end + end + end +end diff --git a/spec/lib/gitlab/ci/reports/security/reports_spec.rb b/spec/lib/gitlab/ci/reports/security/reports_spec.rb new file mode 100644 index 00000000000..9b1e02f1418 --- /dev/null +++ b/spec/lib/gitlab/ci/reports/security/reports_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Reports::Security::Reports do + let_it_be(:pipeline) { create(:ci_pipeline) } + let_it_be(:artifact) { create(:ci_job_artifact, :sast) } + + let(:security_reports) { described_class.new(pipeline) } + + describe '#get_report' do + subject { security_reports.get_report(report_type, artifact) } + + context 'when report type is sast' do + let(:report_type) { 'sast' } + + it { expect(subject.type).to eq('sast') } + it { expect(subject.created_at).to eq(artifact.created_at) } + + it 'initializes a new report and returns it' do + expect(Gitlab::Ci::Reports::Security::Report).to receive(:new) + .with('sast', pipeline, artifact.created_at).and_call_original + + is_expected.to be_a(Gitlab::Ci::Reports::Security::Report) + end + + context 'when report type is already allocated' do + before do + subject + end + + it 'does not initialize a new report' do + expect(Gitlab::Ci::Reports::Security::Report).not_to receive(:new) + + is_expected.to be_a(Gitlab::Ci::Reports::Security::Report) + end + end + end + end + + describe '#findings' do + let(:finding_1) { build(:ci_reports_security_finding, severity: 'low') } + let(:finding_2) { build(:ci_reports_security_finding, severity: 'high') } + let!(:expected_findings) { [finding_1, finding_2] } + + subject { security_reports.findings } + + before do + security_reports.get_report('sast', artifact).add_finding(finding_1) + security_reports.get_report('dependency_scanning', artifact).add_finding(finding_2) + end + + it { is_expected.to match_array(expected_findings) } + end + + describe "#violates_default_policy_against?" do + let(:high_severity_dast) { build(:ci_reports_security_finding, severity: 'high', report_type: :dast) } + let(:vulnerabilities_allowed) { 0 } + let(:severity_levels) { %w(critical high) } + + subject { security_reports.violates_default_policy_against?(target_reports, vulnerabilities_allowed, severity_levels) } + + before do + security_reports.get_report('sast', artifact).add_finding(high_severity_dast) + end + + context 'when the target_reports is `nil`' do + let(:target_reports) { nil } + + context 'with severity levels matching the existing vulnerabilities' do + it { is_expected.to be(true) } + end + + context "without any severity levels matching the existing vulnerabilities" do + let(:severity_levels) { %w(critical) } + + it { is_expected.to be(false) } + end + end + + context 'when the target_reports is not `nil`' do + let(:target_reports) { described_class.new(pipeline) } + + context "when a report has a new unsafe vulnerability" do + context 'with severity levels matching the existing vulnerabilities' do + it { is_expected.to be(true) } + end + + it { is_expected.to be(true) } + + context 'with vulnerabilities_allowed higher than the number of new vulnerabilities' do + let(:vulnerabilities_allowed) { 10000 } + + it { is_expected.to be(false) } + end + + context "without any severity levels matching the existing vulnerabilities" do + let(:severity_levels) { %w(critical) } + + it { is_expected.to be(false) } + end + end + + context "when none of the reports have a new unsafe vulnerability" do + before do + target_reports.get_report('sast', artifact).add_finding(high_severity_dast) + end + + it { is_expected.to be(false) } + end + end + end +end diff --git a/spec/lib/gitlab/ci/reports/security/vulnerability_reports_comparer_spec.rb b/spec/lib/gitlab/ci/reports/security/vulnerability_reports_comparer_spec.rb new file mode 100644 index 00000000000..44e66fd9028 --- /dev/null +++ b/spec/lib/gitlab/ci/reports/security/vulnerability_reports_comparer_spec.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Reports::Security::VulnerabilityReportsComparer do + let(:identifier) { build(:ci_reports_security_identifier) } + + let_it_be(:project) { create(:project, :repository) } + + let(:location_param) { build(:ci_reports_security_locations_sast, :dynamic) } + let(:vulnerability_params) { vuln_params(project.id, [identifier], confidence: :low, severity: :critical) } + let(:base_vulnerability) { build(:ci_reports_security_finding, location: location_param, **vulnerability_params) } + let(:base_report) { build(:ci_reports_security_aggregated_reports, findings: [base_vulnerability]) } + + let(:head_vulnerability) { build(:ci_reports_security_finding, location: location_param, uuid: base_vulnerability.uuid, **vulnerability_params) } + let(:head_report) { build(:ci_reports_security_aggregated_reports, findings: [head_vulnerability]) } + + shared_context 'comparing reports' do + let(:vul_params) { vuln_params(project.id, [identifier]) } + let(:base_vulnerability) { build(:ci_reports_security_finding, :dynamic, **vul_params) } + let(:head_vulnerability) { build(:ci_reports_security_finding, :dynamic, **vul_params) } + let(:head_vul_findings) { [head_vulnerability, vuln] } + end + + subject { described_class.new(project, base_report, head_report) } + + where(vulnerability_finding_signatures: [true, false]) + + with_them do + before do + stub_licensed_features(vulnerability_finding_signatures: vulnerability_finding_signatures) + end + + describe '#base_report_out_of_date' do + context 'no base report' do + let(:base_report) { build(:ci_reports_security_aggregated_reports, reports: [], findings: []) } + + it 'is not out of date' do + expect(subject.base_report_out_of_date).to be false + end + end + + context 'base report older than one week' do + let(:report) { build(:ci_reports_security_report, created_at: 1.week.ago - 60.seconds) } + let(:base_report) { build(:ci_reports_security_aggregated_reports, reports: [report]) } + + it 'is not out of date' do + expect(subject.base_report_out_of_date).to be true + end + end + + context 'base report less than one week old' do + let(:report) { build(:ci_reports_security_report, created_at: 1.week.ago + 60.seconds) } + let(:base_report) { build(:ci_reports_security_aggregated_reports, reports: [report]) } + + it 'is not out of date' do + expect(subject.base_report_out_of_date).to be false + end + end + end + + describe '#added' do + let(:new_location) {build(:ci_reports_security_locations_sast, :dynamic) } + let(:vul_params) { vuln_params(project.id, [identifier], confidence: :high) } + let(:vuln) { build(:ci_reports_security_finding, severity: Enums::Vulnerability.severity_levels[:critical], location: new_location, **vul_params) } + let(:low_vuln) { build(:ci_reports_security_finding, severity: Enums::Vulnerability.severity_levels[:low], location: new_location, **vul_params) } + + context 'with new vulnerability' do + let(:head_report) { build(:ci_reports_security_aggregated_reports, findings: [head_vulnerability, vuln]) } + + it 'points to source tree' do + expect(subject.added).to eq([vuln]) + end + end + + context 'when comparing reports with different fingerprints' do + include_context 'comparing reports' + + let(:head_report) { build(:ci_reports_security_aggregated_reports, findings: head_vul_findings) } + + it 'does not find any overlap' do + expect(subject.added).to eq(head_vul_findings) + end + end + + context 'order' do + let(:head_report) { build(:ci_reports_security_aggregated_reports, findings: [head_vulnerability, vuln, low_vuln]) } + + it 'does not change' do + expect(subject.added).to eq([vuln, low_vuln]) + end + end + end + + describe '#fixed' do + let(:vul_params) { vuln_params(project.id, [identifier]) } + let(:vuln) { build(:ci_reports_security_finding, :dynamic, **vul_params ) } + let(:medium_vuln) { build(:ci_reports_security_finding, confidence: ::Enums::Vulnerability.confidence_levels[:high], severity: Enums::Vulnerability.severity_levels[:medium], uuid: vuln.uuid, **vul_params) } + + context 'with fixed vulnerability' do + let(:base_report) { build(:ci_reports_security_aggregated_reports, findings: [base_vulnerability, vuln]) } + + it 'points to base tree' do + expect(subject.fixed).to eq([vuln]) + end + end + + context 'when comparing reports with different fingerprints' do + include_context 'comparing reports' + + let(:base_report) { build(:ci_reports_security_aggregated_reports, findings: [base_vulnerability, vuln]) } + + it 'does not find any overlap' do + expect(subject.fixed).to eq([base_vulnerability, vuln]) + end + end + + context 'order' do + let(:vul_findings) { [vuln, medium_vuln] } + let(:base_report) { build(:ci_reports_security_aggregated_reports, findings: [*vul_findings, base_vulnerability]) } + + it 'does not change' do + expect(subject.fixed).to eq(vul_findings) + end + end + end + + describe 'with empty vulnerabilities' do + let(:empty_report) { build(:ci_reports_security_aggregated_reports, reports: [], findings: []) } + + it 'returns empty array when reports are not present' do + comparer = described_class.new(project, empty_report, empty_report) + + expect(comparer.fixed).to eq([]) + expect(comparer.added).to eq([]) + end + + it 'returns added vulnerability when base is empty and head is not empty' do + comparer = described_class.new(project, empty_report, head_report) + + expect(comparer.fixed).to eq([]) + expect(comparer.added).to eq([head_vulnerability]) + end + + it 'returns fixed vulnerability when head is empty and base is not empty' do + comparer = described_class.new(project, base_report, empty_report) + + expect(comparer.fixed).to eq([base_vulnerability]) + expect(comparer.added).to eq([]) + end + end + end + + def vuln_params(project_id, identifiers, confidence: :high, severity: :critical) + { + project_id: project_id, + report_type: :sast, + identifiers: identifiers, + confidence: ::Enums::Vulnerability.confidence_levels[confidence], + severity: ::Enums::Vulnerability.severity_levels[severity] + } + end +end |