From 5e4e90950f52b93a5c4e6359d65c1ce2d772bc8c Mon Sep 17 00:00:00 2001
From: mo khan <mo@mokhan.ca>
Date: Tue, 24 Jan 2023 16:33:10 -0700
Subject: [PATCH] Resolve vulnerabilities from successful scans

Instead of automatically resolving all vulnerabilities we automatically
resolve vulnerabilities when the scanner that detected it completes a
successful scan.

Changelog: changed
MR: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/109923
EE: true
---
 ee/app/models/security/scan.rb                |  8 ++
 ee/app/models/vulnerabilities/read.rb         |  1 +
 .../ingestion/ingest_report_service.rb        |  6 +-
 .../ingestion/ingest_reports_service.rb       |  5 --
 .../ingestion/mark_as_resolved_service.rb     | 24 +++---
 ee/spec/models/security/scan_spec.rb          |  9 +++
 ee/spec/models/vulnerabilities/read_spec.rb   | 15 ++++
 .../ingestion/ingest_report_service_spec.rb   | 17 +++-
 .../ingestion/ingest_reports_service_spec.rb  | 11 +--
 .../mark_as_resolved_service_spec.rb          | 78 ++++++++++++++-----
 10 files changed, 129 insertions(+), 45 deletions(-)

diff --git a/ee/app/models/security/scan.rb b/ee/app/models/security/scan.rb
index ece5d709323f..d290cb99ba14 100644
--- a/ee/app/models/security/scan.rb
+++ b/ee/app/models/security/scan.rb
@@ -112,12 +112,20 @@ def remediations_proxy
       @remediations_proxy ||= RemediationsProxy.new(job_artifact&.file)
     end
 
+    def scanner
+      project.vulnerability_scanners.find_by_external_id(primary_scanner&.external_id)
+    end
+
     private
 
     def security_report
       job_artifact&.security_report
     end
 
+    def primary_scanner
+      security_report&.primary_scanner
+    end
+
     def job_artifact
       build.job_artifacts.find_by_file_type(scan_type)
     end
diff --git a/ee/app/models/vulnerabilities/read.rb b/ee/app/models/vulnerabilities/read.rb
index f4299fcb56d4..c55eec58dbdd 100644
--- a/ee/app/models/vulnerabilities/read.rb
+++ b/ee/app/models/vulnerabilities/read.rb
@@ -41,6 +41,7 @@ class Read < ApplicationRecord
     scope :order_detected_at_asc, -> { reorder(vulnerability_id: :asc) }
     scope :order_detected_at_desc, -> { reorder(vulnerability_id: :desc) }
 
+    scope :by_scanner, -> (scanner) { where(scanner: scanner) }
     scope :by_scanner_ids, -> (scanner_ids) { where(scanner_id: scanner_ids) }
     scope :for_projects, -> (project_ids) { where(project_id: project_ids) }
     scope :grouped_by_severity, -> { reorder(severity: :desc).group(:severity) }
diff --git a/ee/app/services/security/ingestion/ingest_report_service.rb b/ee/app/services/security/ingestion/ingest_report_service.rb
index 25350d589de8..360ade9b024b 100644
--- a/ee/app/services/security/ingestion/ingest_report_service.rb
+++ b/ee/app/services/security/ingestion/ingest_report_service.rb
@@ -23,7 +23,9 @@ def initialize(security_scan)
       end
 
       def execute
-        finding_map_collection.each_slice(BATCH_SIZE).flat_map { |slice| ingest_slice(slice) }
+        finding_map_collection.each_slice(BATCH_SIZE)
+          .flat_map { |slice| ingest_slice(slice) }
+          .tap { |ids| MarkAsResolvedService.execute(scanner, ids) }
       end
 
       private
@@ -31,7 +33,7 @@ def execute
       attr_reader :security_scan
       attr_accessor :errored
 
-      delegate :pipeline, to: :security_scan, private: true
+      delegate :pipeline, :scanner, to: :security_scan, private: true
 
       def finding_map_collection
         @finding_map_collection ||= FindingMapCollection.new(security_scan)
diff --git a/ee/app/services/security/ingestion/ingest_reports_service.rb b/ee/app/services/security/ingestion/ingest_reports_service.rb
index 97b7aea7d863..7f6cdd600ecc 100644
--- a/ee/app/services/security/ingestion/ingest_reports_service.rb
+++ b/ee/app/services/security/ingestion/ingest_reports_service.rb
@@ -30,7 +30,6 @@ def execute
       def store_reports
         latest_security_scans
           .flat_map { |scan| ingest(scan) }
-          .then { |ids| mark_resolved_vulnerabilities(ids) }
       end
 
       def latest_security_scans
@@ -41,10 +40,6 @@ def ingest(security_scan)
         IngestReportService.execute(security_scan)
       end
 
-      def mark_resolved_vulnerabilities(existing_ids)
-        MarkAsResolvedService.execute(project, existing_ids)
-      end
-
       def mark_project_as_vulnerable!
         project.project_setting.update!(has_vulnerabilities: true)
       end
diff --git a/ee/app/services/security/ingestion/mark_as_resolved_service.rb b/ee/app/services/security/ingestion/mark_as_resolved_service.rb
index 4bfc3a144e82..21fdb4c8324f 100644
--- a/ee/app/services/security/ingestion/mark_as_resolved_service.rb
+++ b/ee/app/services/security/ingestion/mark_as_resolved_service.rb
@@ -3,27 +3,33 @@
 module Security
   module Ingestion
     # This service class takes the IDs of recently ingested
-    # vulnerabilities for a project and marks the missing
-    # vulnerabilities as resolved on default branch.
+    # vulnerabilities for a project which had been previously
+    # detected by the same scanner, and marks them as resolved
+    # on the default branch if they were not detected again.
     class MarkAsResolvedService
-      def self.execute(project, ingested_ids)
-        new(project, ingested_ids).execute
+      def self.execute(scanner, ingested_ids)
+        new(scanner, ingested_ids).execute
       end
 
-      def initialize(project, ingested_ids)
-        @project = project
+      def initialize(scanner, ingested_ids)
+        @scanner = scanner
         @ingested_ids = ingested_ids
       end
 
       def execute
-        vulnerabilities.present_on_default_branch.each_batch { |batch| process_batch(batch) }
+        return unless scanner
+
+        vulnerability_reads
+          .by_scanner(scanner)
+          .each_batch { |batch| process_batch(batch) }
       end
 
       private
 
-      attr_reader :project, :ingested_ids
+      attr_reader :ingested_ids, :scanner
 
-      delegate :vulnerabilities, to: :project, private: true
+      delegate :project, to: :scanner, private: true
+      delegate :vulnerability_reads, to: :project, private: true
 
       def process_batch(batch)
         (batch.pluck_primary_key - ingested_ids).then { |missing_ids| mark_as_resolved(missing_ids) }
diff --git a/ee/spec/models/security/scan_spec.rb b/ee/spec/models/security/scan_spec.rb
index fe191121829f..ac2e7816c4db 100644
--- a/ee/spec/models/security/scan_spec.rb
+++ b/ee/spec/models/security/scan_spec.rb
@@ -435,4 +435,13 @@
     expect(scan.project_id).to eq(scan.build.project_id)
     expect(scan.pipeline_id).to eq(scan.build.commit_id)
   end
+
+  describe '#scanner' do
+    let_it_be(:scan) { create(:security_scan, :with_findings) }
+    let_it_be(:scanner) { create(:vulnerabilities_scanner, project: scan.project, external_id: 'a-dast-scanner') }
+
+    it 'returns the matching vulnerability scanner' do
+      expect(scan.scanner).to eql(scanner)
+    end
+  end
 end
diff --git a/ee/spec/models/vulnerabilities/read_spec.rb b/ee/spec/models/vulnerabilities/read_spec.rb
index 55c400b3fa7a..f2376cafe240 100644
--- a/ee/spec/models/vulnerabilities/read_spec.rb
+++ b/ee/spec/models/vulnerabilities/read_spec.rb
@@ -494,6 +494,21 @@
     end
   end
 
+  describe '.by_scanner' do
+    let_it_be(:scanner) { create(:vulnerabilities_scanner, project: project) }
+    let_it_be(:other_scanner) { create(:vulnerabilities_scanner, project: project) }
+    let_it_be(:finding) { create(:vulnerabilities_finding, scanner: scanner) }
+    let_it_be(:other_finding) { create(:vulnerabilities_finding, scanner: other_scanner) }
+    let_it_be(:vulnerability) { create(:vulnerability, project: project, present_on_default_branch: true, findings: [finding]) }
+    let_it_be(:vulnerability_for_another_scanner) { create(:vulnerability, project: project, present_on_default_branch: true, findings: [other_finding]) }
+
+    subject(:vulnerability_reads) { described_class.by_scanner(scanner) }
+
+    it 'returns records by given scanner' do
+      expect(vulnerability_reads.pluck(:vulnerability_id)).to match_array([vulnerability.id])
+    end
+  end
+
   private
 
   def create_vulnerability(severity: 7, confidence: 7, report_type: 0)
diff --git a/ee/spec/services/security/ingestion/ingest_report_service_spec.rb b/ee/spec/services/security/ingestion/ingest_report_service_spec.rb
index 47de752b6dfe..6531b5798234 100644
--- a/ee/spec/services/security/ingestion/ingest_report_service_spec.rb
+++ b/ee/spec/services/security/ingestion/ingest_report_service_spec.rb
@@ -2,11 +2,11 @@
 
 require 'spec_helper'
 
-RSpec.describe Security::Ingestion::IngestReportService do
+RSpec.describe Security::Ingestion::IngestReportService, feature_category: :vulnerability_management do
   let(:service_object) { described_class.new(security_scan) }
 
   describe '#execute' do
-    let(:security_scan) { create(:security_scan, scan_type: :sast) }
+    let(:security_scan) { create(:security_scan, :with_findings, scan_type: :sast) }
 
     subject(:ingest_report) { service_object.execute }
 
@@ -26,6 +26,19 @@
       expect(Security::Ingestion::IngestReportSliceService).to have_received(:execute).twice
     end
 
+    context 'when ingesting vulnerabilities' do
+      let!(:scanner) { create(:vulnerabilities_scanner, project: security_scan.project, external_id: 'find_sec_bugs') }
+
+      it 'resolves the missing vulnerabilities' do
+        allow(Security::Ingestion::MarkAsResolvedService).to receive(:execute)
+
+        ingest_report
+
+        expect(Security::Ingestion::MarkAsResolvedService)
+          .to have_received(:execute).once.with(scanner, [1, 2])
+      end
+    end
+
     context 'when ingesting a slice of vulnerabilities fails' do
       let(:exception) { RuntimeError.new }
       let(:expected_processing_error) { { 'type' => 'IngestionError', 'message' => 'Ingestion failed for some vulnerabilities' } }
diff --git a/ee/spec/services/security/ingestion/ingest_reports_service_spec.rb b/ee/spec/services/security/ingestion/ingest_reports_service_spec.rb
index 22cc24bec607..001e08a470e1 100644
--- a/ee/spec/services/security/ingestion/ingest_reports_service_spec.rb
+++ b/ee/spec/services/security/ingestion/ingest_reports_service_spec.rb
@@ -2,7 +2,7 @@
 
 require 'spec_helper'
 
-RSpec.describe Security::Ingestion::IngestReportsService do
+RSpec.describe Security::Ingestion::IngestReportsService, feature_category: :vulnerability_management do
   let(:service_object) { described_class.new(pipeline) }
 
   let_it_be(:project) { create(:project) }
@@ -22,7 +22,6 @@
 
     before do
       allow(Security::Ingestion::IngestReportService).to receive(:execute).and_return(ids_1, ids_2)
-      allow(Security::Ingestion::MarkAsResolvedService).to receive(:execute)
       allow(Security::Ingestion::ScheduleMarkDroppedAsResolvedService).to receive(:execute)
     end
 
@@ -36,13 +35,7 @@
 
     it 'sets the resolved vulnerabilities, latest pipeline ID and has_vulnerabilities flag' do
       expect { ingest_reports }.to change { project.reload.project_setting&.has_vulnerabilities }.to(true)
-                               .and change { project.reload.vulnerability_statistic&.latest_pipeline_id }.to(pipeline.id)
-    end
-
-    it 'calls MarkAsResolvedService with the recently ingested vulnerability IDs' do
-      ingest_reports
-
-      expect(Security::Ingestion::MarkAsResolvedService).to have_received(:execute).with(project, ids_1)
+        .and change { project.reload.vulnerability_statistic&.latest_pipeline_id }.to(pipeline.id)
     end
 
     it 'calls ScheduleMarkDroppedAsResolvedService with primary identifier IDs' do
diff --git a/ee/spec/services/security/ingestion/mark_as_resolved_service_spec.rb b/ee/spec/services/security/ingestion/mark_as_resolved_service_spec.rb
index a77e090c2df9..93afed379854 100644
--- a/ee/spec/services/security/ingestion/mark_as_resolved_service_spec.rb
+++ b/ee/spec/services/security/ingestion/mark_as_resolved_service_spec.rb
@@ -2,32 +2,74 @@
 
 require 'spec_helper'
 
-RSpec.describe Security::Ingestion::MarkAsResolvedService do
+RSpec.describe Security::Ingestion::MarkAsResolvedService, feature_category: :vulnerability_management do
   let_it_be(:project) { create(:project) }
-  let_it_be(:non_default_vulnerability) { create(:vulnerability, project: project, present_on_default_branch: false) }
 
-  let_it_be_with_reload(:not_ingested_vulnerability) { create(:vulnerability, project: project) }
-  let_it_be_with_reload(:ingested_vulnerability) { create(:vulnerability, project: project) }
-  let_it_be_with_reload(:generic_vulnerability) { create(:vulnerability, project: project, report_type: :generic) }
+  describe '#execute' do
+    context 'when using a vulnerability scanner' do
+      let(:command) { described_class.new(scanner, ingested_ids) }
+      let(:ingested_ids) { [] }
+      let_it_be(:scanner) { create(:vulnerabilities_scanner, project: project) }
 
-  let(:ingested_ids) { [ingested_vulnerability.id] }
-  let(:service_object) { described_class.new(project, ingested_ids) }
+      it 'resolves non-generic vulnerabilities detected by the scanner' do
+        vulnerability = create(:vulnerability, :sast,
+          project: project,
+          present_on_default_branch: true,
+          resolved_on_default_branch: false,
+          findings: [create(:vulnerabilities_finding, project: project, scanner: scanner)]
+        )
 
-  describe '#execute' do
-    subject(:mark_as_resolved) { service_object.execute }
+        command.execute
+
+        expect(vulnerability.reload).to be_resolved_on_default_branch
+      end
+
+      it 'does not resolve vulnerabilities detected by a different scanner' do
+        vulnerability = create(:vulnerability, :sast, project: project, present_on_default_branch: true)
+
+        command.execute
+
+        expect(vulnerability.reload).not_to be_resolved_on_default_branch
+      end
+
+      it 'does not resolve generic vulnerabilities' do
+        vulnerability = create(:vulnerability, :generic, project: project)
 
-    it 'marks the missing vulnerabilities as resolved on default branch except the generic ones' do
-      expect { mark_as_resolved }
-        .to change { not_ingested_vulnerability.reload.resolved_on_default_branch }.from(false).to(true)
-        .and not_change { ingested_vulnerability.reload.resolved_on_default_branch }.from(false)
-        .and not_change { generic_vulnerability.reload.resolved_on_default_branch }.from(false)
+        command.execute
+
+        expect(vulnerability.reload).not_to be_resolved_on_default_branch
+      end
+
+      context 'when a vulnerability is already ingested' do
+        let_it_be(:ingested_vulnerability) { create(:vulnerability, project: project) }
+
+        before do
+          ingested_ids << ingested_vulnerability.id
+        end
+
+        it 'does not resolve ingested vulnerabilities' do
+          command.execute
+
+          expect(ingested_vulnerability.reload).not_to be_resolved_on_default_branch
+        end
+      end
     end
 
-    it 'does not process vulnerabilities which are not present on the default branch' do
-      expect(service_object).to receive(:process_batch)
-        .with(match_array([not_ingested_vulnerability, ingested_vulnerability, generic_vulnerability]))
+    context 'when a scanner is not available' do
+      let(:command) { described_class.new(nil, []) }
+
+      it 'does not resolve any vulnerabilities' do
+        vulnerability = create(:vulnerability, :sast,
+          project: project,
+          present_on_default_branch: true,
+          resolved_on_default_branch: false,
+          findings: []
+        )
+
+        command.execute
 
-      mark_as_resolved
+        expect(vulnerability.reload).not_to be_resolved_on_default_branch
+      end
     end
   end
 end
-- 
GitLab