Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Manifest.txt
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,8 @@ lib/rubygems/compact_index_client/http_fetcher.rb
lib/rubygems/compact_index_client/parser.rb
lib/rubygems/compact_index_client/updater.rb
lib/rubygems/config_file.rb
lib/rubygems/cooldown.rb
lib/rubygems/cooldown_option.rb
lib/rubygems/core_ext/kernel_gem.rb
lib/rubygems/core_ext/kernel_require.rb
lib/rubygems/core_ext/kernel_warn.rb
Expand Down
79 changes: 77 additions & 2 deletions lib/rubygems/commands/outdated_command.rb
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
# frozen_string_literal: true

require_relative "../command"
require_relative "../cooldown"
require_relative "../cooldown_option"
require_relative "../local_remote_options"
require_relative "../spec_fetcher"
require_relative "../version_option"

class Gem::Commands::OutdatedCommand < Gem::Command
include Gem::LocalRemoteOptions
include Gem::VersionOption
include Gem::CooldownOption

def initialize
super "outdated", "Display all gems that need updates"

add_local_remote_options
add_platform_option
add_cooldown_option
end

def description # :nodoc:
Expand All @@ -26,8 +30,79 @@ def description # :nodoc:
end

def execute
Gem::Specification.outdated_and_latest_version.each do |spec, remote_version|
say "#{spec.name} (#{spec.version} < #{remote_version})"
@cooldown = Gem::Cooldown.from_options options

unless @cooldown.active?
Gem::Specification.outdated_and_latest_version.each do |spec, remote_version|
say "#{spec.name} (#{spec.version} < #{remote_version})"
end

return
end

execute_with_cooldown
end

private

##
# Like Gem::Specification.outdated_and_latest_version, but the newest
# version outside the cooldown period becomes the update candidate, and
# newer versions still within the period are annotated.

def execute_with_cooldown
fetcher = Gem::SpecFetcher.fetcher

Gem::Specification.latest_specs(true).each do |local_spec|
dependency = Gem::Dependency.new local_spec.name, ">= #{local_spec.version}"

# The :latest index carries only the newest version of each gem,
# which leaves nothing to fall back to when the cooldown excludes
# it, so search the full index instead.
remotes, = fetcher.search_for_dependency dependency,
type: dependency.prerelease? ? :complete : :released

selectable, embargoed = partition_by_cooldown remotes

candidate = selectable.max
candidate = nil unless candidate && local_spec.version < candidate

pending = embargoed.max
pending = nil unless pending && local_spec.version < pending &&
(candidate.nil? || candidate < pending)

next unless candidate || pending

pending = "#{pending} (cooldown #{@cooldown.days}d)" if pending
say "#{local_spec.name} (#{local_spec.version} < #{[candidate, pending].compact.join(", ")})"
end
end

##
# Splits [NameTuple, Gem::Source] pairs into versions outside and within
# the cooldown period. Tuples with an unknown publish time count as
# outside the period, so the cooldown fails open.

def partition_by_cooldown(spec_tuples)
selectable = []
embargoed = []

with_times = spec_tuples.map do |tup, source|
[tup, source, source.created_at(tup.name, tup.version, tup.platform)]
end

if !with_times.empty? && with_times.none? {|_, _, created_at| created_at }
Gem::Cooldown.warn_missing_created_at with_times.first[1]
end

with_times.each do |tup, _, created_at|
if @cooldown.skip?(created_at)
embargoed << tup.version
else
selectable << tup.version
end
end

[selectable, embargoed]
end
end
32 changes: 30 additions & 2 deletions lib/rubygems/commands/update_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

require_relative "../command"
require_relative "../command_manager"
require_relative "../cooldown"
require_relative "../dependency_installer"
require_relative "../install_update_options"
require_relative "../local_remote_options"
Expand Down Expand Up @@ -95,6 +96,8 @@ def check_update_arguments # :nodoc:
end

def execute
@cooldown = Gem::Cooldown.from_options options

if options[:system]
update_rubygems
return
Expand Down Expand Up @@ -140,7 +143,12 @@ def fetch_remote_gems(spec) # :nodoc:

fetcher = Gem::SpecFetcher.fetcher

spec_tuples, errors = fetcher.search_for_dependency dependency
# The default indexes carry only the newest version of each gem, which
# leaves nothing to fall back to when the cooldown excludes it, so
# search the full index instead.
type = dependency.prerelease? ? :complete : :released if @cooldown&.active?

spec_tuples, errors = fetcher.search_for_dependency dependency, type: type

error = errors.find {|e| e.respond_to? :exception }

Expand All @@ -167,12 +175,32 @@ def highest_installed_gems # :nodoc:
def highest_remote_name_tuple(spec) # :nodoc:
spec_tuples = fetch_remote_gems spec

highest_remote_gem = spec_tuples.max
highest_remote_gem = filter_cooldown_tuples(spec_tuples).max
return unless highest_remote_gem

highest_remote_gem.first
end

##
# Rejects [NameTuple, Gem::Source] pairs published within the cooldown
# period. Tuples with an unknown publish time are kept, so the cooldown
# fails open.

def filter_cooldown_tuples(spec_tuples) # :nodoc:
return spec_tuples unless @cooldown&.active?

with_times = spec_tuples.map do |tup, source|
[tup, source, source.created_at(tup.name, tup.version, tup.platform)]
end

if !with_times.empty? && with_times.none? {|_, _, created_at| created_at }
Gem::Cooldown.warn_missing_created_at with_times.first[1]
end

with_times.reject {|_, _, created_at| @cooldown.skip?(created_at) }.
map {|tup, source, _| [tup, source] }
end

def install_rubygems(spec) # :nodoc:
args = update_rubygems_arguments
version = spec.version
Expand Down
13 changes: 12 additions & 1 deletion lib/rubygems/config_file.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
#
# +:backtrace+:: See #backtrace
# +:bulk_threshold+:: See #bulk_threshold
# +:cooldown+:: See #cooldown
# +:verbose+:: See #verbose
# +:update_sources+:: See #update_sources
# +:concurrent_downloads+:: See #concurrent_downloads
Expand Down Expand Up @@ -55,6 +56,7 @@ class Gem::ConfigFile

DEFAULT_BACKTRACE = true
DEFAULT_BULK_THRESHOLD = 1000
DEFAULT_COOLDOWN = 0
DEFAULT_VERBOSITY = true
DEFAULT_UPDATE_SOURCES = true
DEFAULT_CONCURRENT_DOWNLOADS = 8
Expand Down Expand Up @@ -116,6 +118,13 @@ class Gem::ConfigFile

attr_accessor :bulk_threshold

##
# Number of days a newly published gem version must wait before it is
# considered for installation or update (the cooldown period). 0
# disables the cooldown.

attr_accessor :cooldown

##
# Verbose level of output:
# * false -- No output
Expand Down Expand Up @@ -211,6 +220,7 @@ def initialize(args)

@backtrace = DEFAULT_BACKTRACE
@bulk_threshold = DEFAULT_BULK_THRESHOLD
@cooldown = DEFAULT_COOLDOWN
@verbose = DEFAULT_VERBOSITY
@update_sources = DEFAULT_UPDATE_SOURCES
@concurrent_downloads = DEFAULT_CONCURRENT_DOWNLOADS
Expand Down Expand Up @@ -239,7 +249,7 @@ def initialize(args)

@hash.transform_keys! do |k|
# gemhome and gempath are not working with symbol keys
if %w[backtrace bulk_threshold verbose update_sources cert_expiration_length_days
if %w[backtrace bulk_threshold cooldown verbose update_sources cert_expiration_length_days
concurrent_downloads install_extension_in_lib ipv4_fallback_enabled
global_gem_cache use_psych sources
disable_default_gem_server ssl_verify_mode ssl_ca_cert ssl_client_cert].include?(k)
Expand All @@ -252,6 +262,7 @@ def initialize(args)
# HACK: these override command-line args, which is bad
@backtrace = @hash[:backtrace] if @hash.key? :backtrace
@bulk_threshold = @hash[:bulk_threshold] if @hash.key? :bulk_threshold
@cooldown = @hash[:cooldown] if @hash.key? :cooldown
@verbose = @hash[:verbose] if @hash.key? :verbose
@update_sources = @hash[:update_sources] if @hash.key? :update_sources
@concurrent_downloads = @hash[:concurrent_downloads] if @hash.key? :concurrent_downloads
Expand Down
68 changes: 68 additions & 0 deletions lib/rubygems/cooldown.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# frozen_string_literal: true

require_relative "user_interaction"

##
# Applies a cooldown period to remote gem versions as a supply chain attack
# mitigation. When a cooldown of N days is configured, gem versions
# published within the last N days are not considered for installation or
# update. Versions whose publish time is unknown are never excluded, so
# sources that do not provide publish times keep working.
#
# The cooldown period comes from the <tt>--cooldown DAYS</tt> option when
# given, falling back to the <tt>:cooldown:</tt> setting in the gemrc file.
# A value of 0 disables the cooldown.

class Gem::Cooldown
##
# The cooldown period in days.

attr_reader :days

##
# Creates a Cooldown from the command line +options+, preferring the
# --cooldown option over the :cooldown: gemrc setting.

def self.from_options(options)
new(options[:cooldown] || Gem.configuration.cooldown)
end

def initialize(days, now: Time.now)
@days = days.to_i
@now = now
end

##
# True when a cooldown period is configured.

def active?
@days > 0
end

##
# True when a gem version published at +created_at+ must not be
# considered. Versions with an unknown publish time (+nil+) are kept.

def skip?(created_at)
return false unless active?
return false unless created_at

(@now - created_at) < @days * 86_400
end

##
# Warns once per process that +source+ did not provide publish times, so
# the cooldown cannot be applied to gems from it.

def self.warn_missing_created_at(source)
return if @warned
@warned = true

Gem::DefaultUserInteraction.ui.alert_warning \
"#{source.uri} does not provide gem publish times, the cooldown period does not apply to gems from this source"
end

def self.reset_warned_missing_created_at # :nodoc:
@warned = nil
end
end
21 changes: 21 additions & 0 deletions lib/rubygems/cooldown_option.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# frozen_string_literal: true

require_relative "cooldown"

##
# Mixin methods for the cooldown option for Gem::Commands.

module Gem::CooldownOption
##
# Add the --cooldown option to the option parser.

def add_cooldown_option(group = nil)
args = [group, "--cooldown DAYS", Integer,
"Do not use gem versions published within",
"the last DAYS days (0 disables the cooldown)"].compact

add_option(*args) do |value, options|
options[:cooldown] = value
end
end
end
3 changes: 3 additions & 0 deletions lib/rubygems/dependency_installer.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require_relative "../rubygems"
require_relative "cooldown"
require_relative "dependency_list"
require_relative "package"
require_relative "installer"
Expand Down Expand Up @@ -90,6 +91,7 @@ def initialize(options = {})
@prog_mode = options[:prog_mode]
@build_extension = options[:build_extension]
@install_plugin = options[:install_plugin]
@cooldown = Gem::Cooldown.from_options options

# Indicates that we should not try to update any deps unless
# we absolutely must.
Expand Down Expand Up @@ -210,6 +212,7 @@ def resolve_dependencies(dep_or_name, version) # :nodoc:
request_set.development_shallow = @dev_shallow
request_set.soft_missing = @force
request_set.prerelease = @prerelease
request_set.cooldown = @cooldown

installer_set = Gem::Resolver::InstallerSet.new @domain
installer_set.ignore_installed = (@minimal_deps == false) || @only_install_dir
Expand Down
4 changes: 4 additions & 0 deletions lib/rubygems/install_update_options.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@
#++

require_relative "../rubygems"
require_relative "cooldown_option"
require_relative "security_option"

##
# Mixin methods for install and update options for Gem::Commands

module Gem::InstallUpdateOptions
include Gem::CooldownOption
include Gem::SecurityOption

##
Expand Down Expand Up @@ -204,6 +206,8 @@ def add_install_update_options
"Defaults to true") do |v, _o|
options[:install_plugin] = v
end

add_cooldown_option :"Install/Update"
end

##
Expand Down
Loading