bun/lib/dependabot/bun/update_checker/vulnerability_auditor.rb
# typed: strict
# frozen_string_literal: true

require "sorbet-runtime"
require "stringio"

require "dependabot/dependency"
require "dependabot/errors"
require "dependabot/logger"
require "dependabot/bun/file_parser"
require "dependabot/bun/helpers"
require "dependabot/bun/native_helpers"
require "dependabot/bun/update_checker"
require "dependabot/bun/update_checker/dependency_files_builder"
require "dependabot/shared_helpers"

module Dependabot
  module Bun
    class UpdateChecker < Dependabot::UpdateCheckers::Base
      class VulnerabilityAuditor
        extend T::Sig

        sig do
          params(
            dependency_files: T::Array[Dependabot::DependencyFile],
            credentials: T::Array[Dependabot::Credential]
          )
            .void
        end
        def initialize(dependency_files:, credentials:)
          @dependency_files = dependency_files
          @credentials = credentials
        end

        # rubocop:disable Metrics/MethodLength
        # Finds any dependencies in the `package-lock.json` or `npm-shrinkwrap.json` that have
        # a subdependency on the given dependency that is locked to a vuln version range.
        #
        # NOTE: yarn is currently not supported.
        #
        # @param dependency [Dependabot::Dependency] the dependency to check
        # @param security_advisories [Array<Dependabot::SecurityAdvisory>] advisories for the dependency
        # @return [Hash<String, [String, Array<Hash<String, String>>]>] the audit results
        #   * :dependency_name [String] the name of the dependency
        #   * :fix_available [Boolean] whether a fix is available
        #   * :current_version [String] the version of the dependency
        #   * :target_version [String] the version of the dependency after the fix
        #   * :fix_updates [Array<Hash<String, String>>] a list of dependencies to update in order to fix
        #     * :dependency_name [String] the name of the blocking dependency
        #     * :current_version [String] the current version of the blocking dependency
        #     * :target_version [String] the target version of the blocking dependency
        #     * :top_level_ancestors [Array<String>] the names of top-level dependencies with a transitive
        #       dependency on the blocking dependency
        #   * :top_level_ancestors [Array<String>] the names of all top-level dependencies with a transitive
        #     dependency on the dependency
        #   * :explanation [String] an explanation for why the project failed the vulnerability auditor run
        sig do
          params(
            dependency: Dependabot::Dependency,
            security_advisories: T::Array[Dependabot::SecurityAdvisory]
          )
            .returns(T::Hash[String, T.untyped])
        end
        def audit(dependency:, security_advisories:)
          Dependabot.logger.info("VulnerabilityAuditor: starting audit")

          fix_unavailable = {
            "dependency_name" => dependency.name,
            "fix_available" => false,
            "fix_updates" => [],
            "top_level_ancestors" => []
          }

          SharedHelpers.in_a_temporary_directory do
            dependency_files_builder = DependencyFilesBuilder.new(
              dependency: dependency,
              dependency_files: dependency_files,
              credentials: credentials
            )
            dependency_files_builder.write_temporary_dependency_files

            vuln_versions = security_advisories.map do |a|
              {
                dependency_name: a.dependency_name,
                affected_versions: a.vulnerable_version_strings
              }
            end

            audit_result = T.cast(
              SharedHelpers.run_helper_subprocess(
                command: NativeHelpers.helper_path,
                function: "npm:vulnerabilityAuditor",
                args: [Dir.pwd, vuln_versions]
              ),
              T::Hash[String, T.untyped]
            )

            validation_result = validate_audit_result(audit_result, security_advisories)
            if validation_result != :viable
              Dependabot.logger.info("VulnerabilityAuditor: audit result not viable: #{validation_result}")
              fix_unavailable["explanation"] = explain_fix_unavailable(validation_result, dependency)
              return fix_unavailable
            end

            Dependabot.logger.info("VulnerabilityAuditor: audit result viable")
            audit_result
          end
        rescue SharedHelpers::HelperSubprocessFailed => e
          log_helper_subprocess_failure(dependency, e)
          T.must(fix_unavailable)
        end
        # rubocop:enable Metrics/MethodLength

        private

        sig { returns(T::Array[Dependabot::DependencyFile]) }
        attr_reader :dependency_files

        sig { returns(T::Array[Dependabot::Credential]) }
        attr_reader :credentials

        sig { params(validation_result: Symbol, dependency: Dependabot::Dependency).returns(String) }
        def explain_fix_unavailable(validation_result, dependency)
          case validation_result
          when :fix_unavailable, :dependency_still_vulnerable, :downgrades_dependencies
            "No patched version available for #{dependency.name}"
          when :fix_incomplete
            "The lockfile might be out of sync?"
          else
            raise "Unexpected validation result: #{validation_result}"
          end
        end

        sig do
          params(
            audit_result: T::Hash[String, T.untyped],
            security_advisories: T::Array[Dependabot::SecurityAdvisory]
          ).returns(Symbol)
        end
        def validate_audit_result(audit_result, security_advisories)
          return :fix_unavailable unless audit_result["fix_available"]
          return :dependency_still_vulnerable if dependency_still_vulnerable?(audit_result, security_advisories)
          return :downgrades_dependencies if downgrades_dependencies?(audit_result)
          return :fix_incomplete if fix_incomplete?(audit_result)

          :viable
        end

        sig do
          params(
            audit_result: T::Hash[String, T.untyped],
            security_advisories: T::Array[Dependabot::SecurityAdvisory]
          )
            .returns(T::Boolean)
        end
        def dependency_still_vulnerable?(audit_result, security_advisories)
          # vulnerable dependency is removed if the target version is nil
          return false unless audit_result["target_version"]

          version = Version.new(audit_result["target_version"])
          security_advisories.any? { |a| a.vulnerable?(version) }
        end

        sig { params(audit_result: T::Hash[String, T.untyped]).returns(T::Boolean) }
        def downgrades_dependencies?(audit_result)
          return true if downgrades_version?(audit_result["current_version"], audit_result["target_version"])

          audit_result["fix_updates"].any? do |update|
            downgrades_version?(update["current_version"], update["target_version"])
          end
        end

        sig do
          params(
            current_version: T.nilable(T.any(String, Integer, Gem::Version)),
            target_version: T.nilable(T.any(String, Integer, Gem::Version))
          )
            .returns(T::Boolean)
        end
        def downgrades_version?(current_version, target_version)
          return false unless target_version

          current = Version.new(current_version)
          target = Version.new(target_version)
          current > target
        end

        sig { params(audit_result: T::Hash[String, T.untyped]).returns(T::Boolean) }
        def fix_incomplete?(audit_result)
          audit_result["fix_updates"].any? { |update| !update.key?("target_version") } ||
            audit_result["fix_updates"].empty?
        end

        sig do
          params(
            dependency: Dependabot::Dependency,
            error: Dependabot::SharedHelpers::HelperSubprocessFailed
          ).void
        end
        def log_helper_subprocess_failure(dependency, error)
          # See `Dependabot::SharedHelpers.run_helper_subprocess` for details on error context
          context = error.error_context

          builder = ::StringIO.new
          builder << "VulnerabilityAuditor: "
          builder << "#{context[:function]} " if context[:function]
          builder << "failed"
          builder << " after #{context[:time_taken].truncate(2)}s" if context[:time_taken]
          builder << " while auditing #{dependency.name}: "
          builder << error.message
          builder << "\n" << context[:trace]

          msg = builder.string
          Dependabot.logger.info(msg) # TODO: is this the right log level?
        end
      end
    end
  end
end
