diff --git a/updater/lib/dependabot/updater.rb b/updater/lib/dependabot/updater.rb index f3f3cf82462..6b7d8195239 100644 --- a/updater/lib/dependabot/updater.rb +++ b/updater/lib/dependabot/updater.rb @@ -25,13 +25,15 @@ require "dependabot/updater/error_handler" require "dependabot/updater/operations" +require "dependabot/updater/security_update_helpers" require "dependabot/security_advisory" require "dependabot/update_checkers" require "wildcard_matcher" -# rubocop:disable Metrics/ClassLength module Dependabot class Updater + # FIXME: Remove this once we deprecate the legacy_run code path + include SecurityUpdateHelpers class SubprocessFailed < StandardError attr_reader :raven_context @@ -321,146 +323,6 @@ def raise_on_ignored?(dependency) job.security_updates_only? || job.ignore_conditions_for(dependency).any? end - def record_security_update_not_needed_error(checker) - Dependabot.logger.info( - "no security update needed as #{checker.dependency.name} " \ - "is no longer vulnerable" - ) - - service.record_update_job_error( - error_type: "security_update_not_needed", - error_details: { - "dependency-name": checker.dependency.name - } - ) - end - - def record_security_update_ignored(checker) - Dependabot.logger.info( - "Dependabot cannot update to the required version as all versions " \ - "were ignored for #{checker.dependency.name}" - ) - - service.record_update_job_error( - error_type: "all_versions_ignored", - error_details: { - "dependency-name": checker.dependency.name - } - ) - end - - def record_dependency_file_not_supported_error(checker) - Dependabot.logger.info( - "Dependabot can't update vulnerable dependencies for projects " \ - "without a lockfile or pinned version requirement as the currently " \ - "installed version of #{checker.dependency.name} isn't known." - ) - - service.record_update_job_error( - error_type: "dependency_file_not_supported", - error_details: { - "dependency-name": checker.dependency.name - } - ) - end - - def record_security_update_not_possible_error(checker) - latest_allowed_version = - (checker.lowest_resolvable_security_fix_version || - checker.dependency.version)&.to_s - lowest_non_vulnerable_version = - checker.lowest_security_fix_version&.to_s - conflicting_dependencies = checker.conflicting_dependencies - - Dependabot.logger.info( - security_update_not_possible_message(checker, latest_allowed_version, - conflicting_dependencies) - ) - Dependabot.logger.info(earliest_fixed_version_message(lowest_non_vulnerable_version)) - - service.record_update_job_error( - error_type: "security_update_not_possible", - error_details: { - "dependency-name": checker.dependency.name, - "latest-resolvable-version": latest_allowed_version, - "lowest-non-vulnerable-version": lowest_non_vulnerable_version, - "conflicting-dependencies": conflicting_dependencies - } - ) - end - - def record_security_update_not_found(checker) - Dependabot.logger.info( - "Dependabot can't find a published or compatible non-vulnerable " \ - "version for #{checker.dependency.name}. " \ - "The latest available version is #{checker.dependency.version}" - ) - - service.record_update_job_error( - error_type: "security_update_not_found", - error_details: { - "dependency-name": checker.dependency.name, - "dependency-version": checker.dependency.version - }, - dependency: checker.dependency - ) - end - - def record_pull_request_exists_for_latest_version(checker) - service.record_update_job_error( - error_type: "pull_request_exists_for_latest_version", - error_details: { - "dependency-name": checker.dependency.name, - "dependency-version": checker.latest_version&.to_s - }, - dependency: checker.dependency - ) - end - - def record_pull_request_exists_for_security_update(existing_pull_request) - updated_dependencies = existing_pull_request.map do |dep| - { - "dependency-name": dep.fetch("dependency-name"), - "dependency-version": dep.fetch("dependency-version", nil), - "dependency-removed": dep.fetch("dependency-removed", nil) - }.compact - end - - service.record_update_job_error( - error_type: "pull_request_exists_for_security_update", - error_details: { - "updated-dependencies": updated_dependencies - } - ) - end - - def earliest_fixed_version_message(lowest_non_vulnerable_version) - if lowest_non_vulnerable_version - "The earliest fixed version is #{lowest_non_vulnerable_version}." - else - "Dependabot could not find a non-vulnerable version" - end - end - - def security_update_not_possible_message(checker, latest_allowed_version, - conflicting_dependencies) - if conflicting_dependencies.any? - dep_messages = conflicting_dependencies.map do |dep| - " #{dep['explanation']}" - end.join("\n") - - dependencies_pluralized = - conflicting_dependencies.count > 1 ? "dependencies" : "dependency" - - "The latest possible version that can be installed is " \ - "#{latest_allowed_version} because of the following " \ - "conflicting #{dependencies_pluralized}:\n\n#{dep_messages}" - else - "The latest possible version of #{checker.dependency.name} that can " \ - "be installed is #{latest_allowed_version}" - end - end - def requirements_to_unlock(checker) if job.lockfile_only? || !checker.requirements_unlocked_or_can_be? if checker.can_update?(requirements_to_unlock: :none) then :none @@ -625,4 +487,3 @@ def close_pull_request(reason:) end end end -# rubocop:enable Metrics/ClassLength diff --git a/updater/lib/dependabot/updater/operations.rb b/updater/lib/dependabot/updater/operations.rb index f432c93f0e3..c880531399a 100644 --- a/updater/lib/dependabot/updater/operations.rb +++ b/updater/lib/dependabot/updater/operations.rb @@ -2,6 +2,7 @@ require "dependabot/updater/operations/create_security_update_pull_request" require "dependabot/updater/operations/group_update_all_versions" +require "dependabot/updater/operations/refresh_security_update_pull_request" require "dependabot/updater/operations/refresh_version_update_pull_request" require "dependabot/updater/operations/update_all_versions" @@ -27,9 +28,10 @@ module Operations # that does, so these Operations should be ordered so that those with most # specific preconditions go before those with more permissive checks. OPERATIONS = [ - GroupUpdateAllVersions, CreateSecurityUpdatePullRequest, + RefreshSecurityUpdatePullRequest, RefreshVersionUpdatePullRequest, + GroupUpdateAllVersions, UpdateAllVersions ] diff --git a/updater/lib/dependabot/updater/operations/create_security_update_pull_request.rb b/updater/lib/dependabot/updater/operations/create_security_update_pull_request.rb index 0be8737ef7d..b70b4876ffd 100644 --- a/updater/lib/dependabot/updater/operations/create_security_update_pull_request.rb +++ b/updater/lib/dependabot/updater/operations/create_security_update_pull_request.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "dependabot/updater/security_update_helpers" + # This class implements our strategy for updating a single, insecure dependency # to a secure version. We attempt to make the smallest version update possible, # i.e. semver patch-level increase is preferred over minor-level increase. @@ -7,6 +9,8 @@ module Dependabot class Updater module Operations class CreateSecurityUpdatePullRequest + include SecurityUpdateHelpers + def self.applies_to?(job:) return false if job.updating_a_pull_request? # If we haven't been given data for the vulnerable dependency, @@ -212,148 +216,6 @@ def existing_pull_request(updated_dependencies) created_pull_requests.find { |pr| Set.new(pr) == new_pr_set } end - ### BEGIN: Security Update Helpers - def record_security_update_not_needed_error(checker) - Dependabot.logger.info( - "no security update needed as #{checker.dependency.name} " \ - "is no longer vulnerable" - ) - - service.record_update_job_error( - error_type: "security_update_not_needed", - error_details: { - "dependency-name": checker.dependency.name - } - ) - end - - def record_security_update_ignored(checker) - Dependabot.logger.info( - "Dependabot cannot update to the required version as all versions " \ - "were ignored for #{checker.dependency.name}" - ) - - service.record_update_job_error( - error_type: "all_versions_ignored", - error_details: { - "dependency-name": checker.dependency.name - } - ) - end - - def record_dependency_file_not_supported_error(checker) - Dependabot.logger.info( - "Dependabot can't update vulnerable dependencies for projects " \ - "without a lockfile or pinned version requirement as the currently " \ - "installed version of #{checker.dependency.name} isn't known." - ) - - service.record_update_job_error( - error_type: "dependency_file_not_supported", - error_details: { - "dependency-name": checker.dependency.name - } - ) - end - - def record_security_update_not_possible_error(checker) - latest_allowed_version = - (checker.lowest_resolvable_security_fix_version || - checker.dependency.version)&.to_s - lowest_non_vulnerable_version = - checker.lowest_security_fix_version&.to_s - conflicting_dependencies = checker.conflicting_dependencies - - Dependabot.logger.info( - security_update_not_possible_message(checker, latest_allowed_version, - conflicting_dependencies) - ) - Dependabot.logger.info(earliest_fixed_version_message(lowest_non_vulnerable_version)) - - service.record_update_job_error( - error_type: "security_update_not_possible", - error_details: { - "dependency-name": checker.dependency.name, - "latest-resolvable-version": latest_allowed_version, - "lowest-non-vulnerable-version": lowest_non_vulnerable_version, - "conflicting-dependencies": conflicting_dependencies - } - ) - end - - def record_security_update_not_found(checker) - Dependabot.logger.info( - "Dependabot can't find a published or compatible non-vulnerable " \ - "version for #{checker.dependency.name}. " \ - "The latest available version is #{checker.dependency.version}" - ) - - service.record_update_job_error( - error_type: "security_update_not_found", - error_details: { - "dependency-name": checker.dependency.name, - "dependency-version": checker.dependency.version - }, - dependency: checker.dependency - ) - end - - def record_pull_request_exists_for_latest_version(checker) - service.record_update_job_error( - error_type: "pull_request_exists_for_latest_version", - error_details: { - "dependency-name": checker.dependency.name, - "dependency-version": checker.latest_version&.to_s - }, - dependency: checker.dependency - ) - end - - def record_pull_request_exists_for_security_update(existing_pull_request) - updated_dependencies = existing_pull_request.map do |dep| - { - "dependency-name": dep.fetch("dependency-name"), - "dependency-version": dep.fetch("dependency-version", nil), - "dependency-removed": dep.fetch("dependency-removed", nil) - }.compact - end - - service.record_update_job_error( - error_type: "pull_request_exists_for_security_update", - error_details: { - "updated-dependencies": updated_dependencies - } - ) - end - - def earliest_fixed_version_message(lowest_non_vulnerable_version) - if lowest_non_vulnerable_version - "The earliest fixed version is #{lowest_non_vulnerable_version}." - else - "Dependabot could not find a non-vulnerable version" - end - end - - def security_update_not_possible_message(checker, latest_allowed_version, - conflicting_dependencies) - if conflicting_dependencies.any? - dep_messages = conflicting_dependencies.map do |dep| - " #{dep['explanation']}" - end.join("\n") - - dependencies_pluralized = - conflicting_dependencies.count > 1 ? "dependencies" : "dependency" - - "The latest possible version that can be installed is " \ - "#{latest_allowed_version} because of the following " \ - "conflicting #{dependencies_pluralized}:\n\n#{dep_messages}" - else - "The latest possible version of #{checker.dependency.name} that can " \ - "be installed is #{latest_allowed_version}" - end - end - ### END: Security Update Helpers - def requirements_to_unlock(checker) if job.lockfile_only? || !checker.requirements_unlocked_or_can_be? if checker.can_update?(requirements_to_unlock: :none) then :none diff --git a/updater/lib/dependabot/updater/operations/refresh_security_update_pull_request.rb b/updater/lib/dependabot/updater/operations/refresh_security_update_pull_request.rb new file mode 100644 index 00000000000..b0d1414ff4d --- /dev/null +++ b/updater/lib/dependabot/updater/operations/refresh_security_update_pull_request.rb @@ -0,0 +1,233 @@ +# frozen_string_literal: true + +# This class implements our strategy for 'refreshing' an existing Pull Request +# that updates an insecure dependency. +# +# TODO: copyedit +# +# It will determine if the existing diff is still relevant, in which case it +# functions similar to a "rebase", but in the case where the project folder's +# dependencies have changed or a newer version is available, it will supersede +# the existing pull request with a new one for clarity. +module Dependabot + class Updater + module Operations + class RefreshSecurityUpdatePullRequest + include SecurityUpdateHelpers + + def self.applies_to?(job:) + return false unless job.security_updates_only? + # If we haven't been given metadata about the dependencies present + # in the pull request, this strategy cannot act. + return false if job.dependencies&.none? + + job.updating_a_pull_request? + end + + def self.tag_name + :update_security_pr + end + + def initialize(service:, job:, dependency_snapshot:, error_handler:) + @service = service + @job = job + @dependency_snapshot = dependency_snapshot + @error_handler = error_handler + end + + def perform + dependency = dependencies.last + check_and_update_pull_request(dependencies) + rescue StandardError => e + error_handler.handle_dependabot_error(error: e, dependency: dependency) + end + + private + + attr_reader :job, + :service, + :dependency_snapshot, + :error_handler + + def dependencies + dependency_snapshot.job_dependencies + end + + # rubocop:disable Metrics/AbcSize + # rubocop:disable Metrics/PerceivedComplexity + # rubocop:disable Metrics/MethodLength + def check_and_update_pull_request(dependencies) + if dependencies.count != job.dependencies.count + # If the job dependencies mismatch the parsed dependencies, then + # we should close the PR as at least one thing we changed has been + # removed from the project. + close_pull_request(reason: :dependency_removed) + return + end + + # NOTE: Prevent security only updates from turning into latest version + # updates if the current version is no longer vulnerable. This happens + # when a security update is applied by the user directly and the existing + # pull request is rebased. + if dependencies.none? { |d| job.allowed_update?(d) } + lead_dependency = dependencies.first + if job.vulnerable?(lead_dependency) + Dependabot.logger.info( + "Dependency no longer allowed to update #{lead_dependency.name} #{lead_dependency.version}" + ) + else + Dependabot.logger.info("No longer vulnerable #{lead_dependency.name} #{lead_dependency.version}") + end + close_pull_request(reason: :up_to_date) + return + end + + # The first dependency is the "lead" dependency in a multi-dependency + # update - i.e., the one we're trying to update. + # + # Note: Gradle, Maven and Nuget dependency names can be case-insensitive + # and the dependency name in the security advisory often doesn't match + # what users have specified in their manifest. + lead_dep_name = job.dependencies.first.downcase + lead_dependency = dependencies.find do |dep| + dep.name.downcase == lead_dep_name + end + checker = update_checker_for(lead_dependency) + log_checking_for_update(lead_dependency) + + Dependabot.logger.info("Latest version is #{checker.latest_version}") + + return close_pull_request(reason: :up_to_date) if checker.up_to_date? + + requirements_to_unlock = requirements_to_unlock(checker) + log_requirements_for_update(requirements_to_unlock, checker) + + if requirements_to_unlock == :update_not_possible + return close_pull_request(reason: :update_no_longer_possible) + end + + updated_deps = checker.updated_dependencies( + requirements_to_unlock: requirements_to_unlock + ) + + dependency_change = Dependabot::DependencyChangeBuilder.create_from( + job: job, + dependency_files: dependency_snapshot.dependency_files, + updated_dependencies: updated_deps, + change_source: checker.dependency + ) + + # NOTE: Gradle, Maven and Nuget dependency names can be case-insensitive + # and the dependency name in the security advisory often doesn't match + # what users have specified in their manifest. + job_dependencies = job.dependencies.map(&:downcase) + if dependency_change.updated_dependencies.map(&:name).map(&:downcase) != job_dependencies + # The dependencies being updated have changed. Close the existing + # multi-dependency PR and try creating a new one. + close_pull_request(reason: :dependencies_changed) + create_pull_request(dependency_change) + elsif existing_pull_request(dependency_change.updated_dependencies) + # The existing PR is for this version. Update it. + update_pull_request(dependency_change) + else + # The existing PR is for a previous version. Supersede it. + create_pull_request(dependency_change) + end + rescue Dependabot::AllVersionsIgnored + Dependabot.logger.info("All updates for #{dependency.name} were ignored") + + # Report this error to the backend to create an update job error + raise + end + # rubocop:enable Metrics/AbcSize + # rubocop:enable Metrics/PerceivedComplexity + # rubocop:enable Metrics/MethodLength + + def requirements_to_unlock(checker) + if job.lockfile_only? || !checker.requirements_unlocked_or_can_be? + if checker.can_update?(requirements_to_unlock: :none) then :none + else + :update_not_possible + end + elsif checker.can_update?(requirements_to_unlock: :own) then :own + elsif checker.can_update?(requirements_to_unlock: :all) then :all + else + :update_not_possible + end + end + + def update_checker_for(dependency) + Dependabot::UpdateCheckers.for_package_manager(job.package_manager).new( + dependency: dependency, + dependency_files: dependency_snapshot.dependency_files, + repo_contents_path: job.repo_contents_path, + credentials: job.credentials, + ignored_versions: job.ignore_conditions_for(dependency), + security_advisories: job.security_advisories_for(dependency), + raise_on_ignored: true, + requirements_update_strategy: job.requirements_update_strategy, + options: job.experiments + ) + end + + def log_checking_for_update(dependency) + Dependabot.logger.info( + "Checking if #{dependency.name} #{dependency.version} needs updating" + ) + job.log_ignore_conditions_for(dependency) + end + + def log_up_to_date(dependency) + Dependabot.logger.info( + "No update needed for #{dependency.name} #{dependency.version}" + ) + end + + def log_requirements_for_update(requirements_to_unlock, checker) + Dependabot.logger.info("Requirements to unlock #{requirements_to_unlock}") + + return unless checker.respond_to?(:requirements_update_strategy) + + Dependabot.logger.info( + "Requirements update strategy #{checker.requirements_update_strategy}" + ) + end + + def existing_pull_request(updated_dependencies) + new_pr_set = Set.new( + updated_dependencies.map do |dep| + { + "dependency-name" => dep.name, + "dependency-version" => dep.version, + "dependency-removed" => dep.removed? ? true : nil + }.compact + end + ) + + job.existing_pull_requests.find { |pr| Set.new(pr) == new_pr_set } + end + + def create_pull_request(dependency_change) + Dependabot.logger.info("Submitting #{dependency_change.updated_dependencies.map(&:name).join(', ')} " \ + "pull request for creation") + + service.create_pull_request(dependency_change, dependency_snapshot.base_commit_sha) + end + + def update_pull_request(dependency_change) + Dependabot.logger.info("Submitting #{dependency_change.updated_dependencies.map(&:name).join(', ')} " \ + "pull request for update") + + service.update_pull_request(dependency_change, dependency_snapshot.base_commit_sha) + end + + def close_pull_request(reason:) + reason_string = reason.to_s.tr("_", " ") + Dependabot.logger.info("Telling backend to close pull request for " \ + "#{job.dependencies.join(', ')} - #{reason_string}") + service.close_pull_request(job.dependencies, reason) + end + end + end + end +end diff --git a/updater/lib/dependabot/updater/security_update_helpers.rb b/updater/lib/dependabot/updater/security_update_helpers.rb new file mode 100644 index 00000000000..05da542e00b --- /dev/null +++ b/updater/lib/dependabot/updater/security_update_helpers.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +# This module extracts all helpers required to perform additional update job +# error recording and logging for Security Updates since they are shared +# between a few operations. +module Dependabot + class Updater + module SecurityUpdateHelpers + def record_security_update_not_needed_error(checker) + Dependabot.logger.info( + "no security update needed as #{checker.dependency.name} " \ + "is no longer vulnerable" + ) + + service.record_update_job_error( + error_type: "security_update_not_needed", + error_details: { + "dependency-name": checker.dependency.name + } + ) + end + + def record_security_update_ignored(checker) + Dependabot.logger.info( + "Dependabot cannot update to the required version as all versions " \ + "were ignored for #{checker.dependency.name}" + ) + + service.record_update_job_error( + error_type: "all_versions_ignored", + error_details: { + "dependency-name": checker.dependency.name + } + ) + end + + def record_dependency_file_not_supported_error(checker) + Dependabot.logger.info( + "Dependabot can't update vulnerable dependencies for projects " \ + "without a lockfile or pinned version requirement as the currently " \ + "installed version of #{checker.dependency.name} isn't known." + ) + + service.record_update_job_error( + error_type: "dependency_file_not_supported", + error_details: { + "dependency-name": checker.dependency.name + } + ) + end + + def record_security_update_not_possible_error(checker) + latest_allowed_version = + (checker.lowest_resolvable_security_fix_version || + checker.dependency.version)&.to_s + lowest_non_vulnerable_version = + checker.lowest_security_fix_version&.to_s + conflicting_dependencies = checker.conflicting_dependencies + + Dependabot.logger.info( + security_update_not_possible_message(checker, latest_allowed_version, conflicting_dependencies) + ) + Dependabot.logger.info( + earliest_fixed_version_message(lowest_non_vulnerable_version) + ) + + service.record_update_job_error( + error_type: "security_update_not_possible", + error_details: { + "dependency-name": checker.dependency.name, + "latest-resolvable-version": latest_allowed_version, + "lowest-non-vulnerable-version": lowest_non_vulnerable_version, + "conflicting-dependencies": conflicting_dependencies + } + ) + end + + def record_security_update_not_found(checker) + Dependabot.logger.info( + "Dependabot can't find a published or compatible non-vulnerable " \ + "version for #{checker.dependency.name}. " \ + "The latest available version is #{checker.dependency.version}" + ) + + service.record_update_job_error( + error_type: "security_update_not_found", + error_details: { + "dependency-name": checker.dependency.name, + "dependency-version": checker.dependency.version + }, + dependency: checker.dependency + ) + end + + def record_pull_request_exists_for_latest_version(checker) + service.record_update_job_error( + error_type: "pull_request_exists_for_latest_version", + error_details: { + "dependency-name": checker.dependency.name, + "dependency-version": checker.latest_version&.to_s + }, + dependency: checker.dependency + ) + end + + def record_pull_request_exists_for_security_update(existing_pull_request) + updated_dependencies = existing_pull_request.map do |dep| + { + "dependency-name": dep.fetch("dependency-name"), + "dependency-version": dep.fetch("dependency-version", nil), + "dependency-removed": dep.fetch("dependency-removed", nil) + }.compact + end + + service.record_update_job_error( + error_type: "pull_request_exists_for_security_update", + error_details: { + "updated-dependencies": updated_dependencies + } + ) + end + + def earliest_fixed_version_message(lowest_non_vulnerable_version) + if lowest_non_vulnerable_version + "The earliest fixed version is #{lowest_non_vulnerable_version}." + else + "Dependabot could not find a non-vulnerable version" + end + end + + def security_update_not_possible_message(checker, latest_allowed_version, + conflicting_dependencies) + if conflicting_dependencies.any? + dep_messages = conflicting_dependencies.map do |dep| + " #{dep['explanation']}" + end.join("\n") + + dependencies_pluralized = + conflicting_dependencies.count > 1 ? "dependencies" : "dependency" + + "The latest possible version that can be installed is " \ + "#{latest_allowed_version} because of the following " \ + "conflicting #{dependencies_pluralized}:\n\n#{dep_messages}" + else + "The latest possible version of #{checker.dependency.name} that can " \ + "be installed is #{latest_allowed_version}" + end + end + end + end +end diff --git a/updater/spec/dependabot/updater/operations_spec.rb b/updater/spec/dependabot/updater/operations_spec.rb index 58b4c5e19d8..187219a4bcb 100644 --- a/updater/spec/dependabot/updater/operations_spec.rb +++ b/updater/spec/dependabot/updater/operations_spec.rb @@ -72,6 +72,17 @@ to be(Dependabot::Updater::Operations::CreateSecurityUpdatePullRequest) end + it "returns the RefreshSecurityUpdatePullRequest class when the Job is for an existing security update" do + job = instance_double(Dependabot::Job, + security_updates_only?: true, + updating_a_pull_request?: true, + dependencies: [anything], + is_a?: true) + + expect(described_class.class_for(job: job)). + to be(Dependabot::Updater::Operations::RefreshSecurityUpdatePullRequest) + end + it "raises an argument error with anything other than a Dependabot::Job" do expect { described_class.class_for(job: Object.new) }.to raise_error(ArgumentError) end