diff --git a/lib/bundler/audit/advisory.rb b/lib/bundler/audit/advisory.rb index e467b821..5b33a31e 100644 --- a/lib/bundler/audit/advisory.rb +++ b/lib/bundler/audit/advisory.rb @@ -36,7 +36,9 @@ class Advisory < Struct.new(:path, :osvdb, :ghsa, :unaffected_versions, - :patched_versions) + :patched_versions, + :gem, + :engine) # # Loads the advisory from a YAML file. @@ -83,7 +85,9 @@ def self.load(path) data['osvdb'], data['ghsa'], parse_versions[data['unaffected_versions']], - parse_versions[data['patched_versions']] + parse_versions[data['patched_versions']], + data['gem'], + data['engine'] ) end diff --git a/lib/bundler/audit/cli/formats/json.rb b/lib/bundler/audit/cli/formats/json.rb index e6250313..f3fb5749 100644 --- a/lib/bundler/audit/cli/formats/json.rb +++ b/lib/bundler/audit/cli/formats/json.rb @@ -37,7 +37,7 @@ module JSON # The output stream. # def print_report(report,output=$stdout) - hash = report.to_h + hash = prepare_data(report) if output.tty? output.puts(::JSON.pretty_generate(hash)) @@ -56,6 +56,37 @@ def criticality_label(advisory) else "unknown" end end + + private + + # + # Prepares the data from the report into a hash before it is formatted as JSON. + # + # @param [Report] report + # The results from the {Scanner}. + # + # @return [Hash] + # + def prepare_data(report) + hash = report.to_h + hash[:results].each do |result| + prepare_result(result) + end + hash + end + + # + # Prepares a result hash before it is formatted as JSON. + # + # @param [Hash] result + # A result + # + def prepare_result(result) + if (advisory = result[:advisory]) + advisory.delete(:gem) + advisory.delete(:engine) + end + end end Formats.register :json, JSON diff --git a/lib/bundler/audit/cli/formats/junit.rb b/lib/bundler/audit/cli/formats/junit.rb index 075975ba..a68a2962 100644 --- a/lib/bundler/audit/cli/formats/junit.rb +++ b/lib/bundler/audit/cli/formats/junit.rb @@ -78,17 +78,17 @@ def print_xml_testcase(result) %{ }, %{ } ) - when Results::UnpatchedGem + else say_xml( - %{ }, + %{ }, %{ }, - %{ Name: #{xml(result.gem.name)}}, - %{ Version: #{xml(result.gem.version)}}, + %{ Name: #{xml(result.vulnerable_name)}}, + %{ Version: #{xml(result.vulnerable_version)}}, %{ Advisory: #{xml(advisory_ref(result.advisory))}}, %{ Criticality: #{xml(advisory_criticality(result.advisory))}}, %{ URL: #{xml(result.advisory.url)}}, %{ Title: #{xml(result.advisory.title)}}, - %{ Solution: #{xml(advisory_solution(result.advisory))}}, + %{ Solution: #{xml(advisory_solution(result))}}, %{ }, %{ } ) @@ -96,14 +96,15 @@ def print_xml_testcase(result) end def bundle_title(result) - "#{advisory_criticality(result.advisory).upcase} #{result.gem.name}(#{result.gem.version}) #{result.advisory.title}" + "#{advisory_criticality(result.advisory).upcase} #{result.vulnerable_name}(#{result.vulnerable_version}) #{result.advisory.title}" end - def advisory_solution(advisory) + def advisory_solution(result) + advisory = result.advisory unless advisory.patched_versions.empty? "update to #{advisory.patched_versions.map { |v| "'#{v}'" }.join(', ')}" else - "remove or disable this gem until a patch is available!" + "remove or disable this #{result.short_type} until a patch is available!" end end diff --git a/lib/bundler/audit/cli/formats/text.rb b/lib/bundler/audit/cli/formats/text.rb index c603f31d..09e0cac9 100644 --- a/lib/bundler/audit/cli/formats/text.rb +++ b/lib/bundler/audit/cli/formats/text.rb @@ -42,8 +42,8 @@ def print_report(report,output=$stdout) case result when Results::InsecureSource print_warning "Insecure Source URI found: #{result.source}" - when Results::UnpatchedGem - print_advisory result.gem, result.advisory + else + print_advisory result end end @@ -62,13 +62,14 @@ def print_warning(message) say message, :yellow end - def print_advisory(gem, advisory) + def print_advisory(result) say "Name: ", :red - say gem.name + say result.vulnerable_name say "Version: ", :red - say gem.version + say result.vulnerable_version + advisory = result.advisory if advisory.cve say "CVE: ", :red say advisory.cve_id @@ -108,7 +109,7 @@ def print_advisory(gem, advisory) say advisory.patched_versions.map { |v| "'#{v}'" }.join(', ') else say "Solution: ", :red - say "remove or disable this gem until a patch is available!", [:red, :bold] + say "remove or disable this #{result.short_type} until a patch is available!", [:red, :bold] end say diff --git a/lib/bundler/audit/database.rb b/lib/bundler/audit/database.rb index 437515c9..ebfa5dc3 100644 --- a/lib/bundler/audit/database.rb +++ b/lib/bundler/audit/database.rb @@ -271,24 +271,27 @@ def advisories(&block) end # - # Enumerates over advisories for the given gem. + # Enumerates over advisories for the given gem or engine. # # @param [String] name - # The gem name to lookup. + # The gem or engine name to lookup. + # + # @param [String] dir + # The 'gems' or 'rubies' subdirectory. Use 'gems' for gem advisories or 'rubies' for engine advisories. # # @yield [advisory] - # If a block is given, each advisory for the given gem will be yielded. + # If a block is given, each advisory for the given gem or engine will be yielded. # # @yieldparam [Advisory] advisory - # An advisory for the given gem. + # An advisory for the given gem or engine. # # @return [Enumerator] # If no block is given, an Enumerator will be returned. # - def advisories_for(name) + def advisories_for(name, dir='gems') return enum_for(__method__,name) unless block_given? - each_advisory_path_for(name) do |path| + each_advisory_path_for(name, dir) do |path| yield Advisory.load(path) end end @@ -319,6 +322,32 @@ def check_gem(gem) end end + # + # Verifies whether the ruby version is impacted by any advisories. + # + # @param [Bundler::RubyVersion] ruby_version + # The ruby version to verify. + # + # @yield [advisory] + # If a block is given, it will be passed advisories that impact + # the ruby version. + # + # @yieldparam [Advisory] advisory + # An advisory that impacts the ruby version. + # + # @return [Enumerator] + # If no block is given, an Enumerator will be returned. + # + def check_ruby(ruby_version) + return enum_for(__method__,ruby_version) unless block_given? + + advisories_for(ruby_version.engine, 'rubies') do |advisory| + if advisory.vulnerable?(ruby_version.engine_gem_version) + yield advisory + end + end + end + # # The number of advisories within the database. # @@ -361,14 +390,17 @@ def inspect # A path to an advisory `.yml` file. # def each_advisory_path(&block) - Dir.glob(File.join(@path,'gems','*','*.yml'),&block) + Dir.glob(File.join(@path,'{gems,rubies}','*','*.yml'),&block) end # - # Enumerates over the advisories for the given gem. + # Enumerates over the advisories for the given gem or engine. # # @param [String] name - # The gem of the gem. + # The name of the gem or engine. + # + # @param [String] dir + # The 'gems' or 'rubies' subdirectory. Use 'gems' for gem advisories or 'rubies' for engine advisories. # # @yield [path] # The given block will be passed each advisory path. @@ -376,8 +408,8 @@ def each_advisory_path(&block) # @yieldparam [String] path # A path to an advisory `.yml` file. # - def each_advisory_path_for(name,&block) - Dir.glob(File.join(@path,'gems',name,'*.yml'),&block) + def each_advisory_path_for(name, dir='gems', &block) + Dir.glob(File.join(@path,dir,name,'*.yml'),&block) end end diff --git a/lib/bundler/audit/report.rb b/lib/bundler/audit/report.rb index 51847f5f..ea759744 100644 --- a/lib/bundler/audit/report.rb +++ b/lib/bundler/audit/report.rb @@ -37,6 +37,11 @@ class Report # @return [Array] attr_reader :unpatched_gems + # The unpatched engines results. + # + # @return [Array] + attr_reader :unpatched_engines + # # Initializes the report. # @@ -49,6 +54,7 @@ def initialize(results=[]) @results = [] @insecure_sources = [] @unpatched_gems = [] + @unpatched_engines = [] results.each { |result| self << result } end @@ -58,7 +64,7 @@ def initialize(results=[]) # # @yield [result] # - # @yieldparam [Results::InsecureSource, Results::UnpatchedGem] result + # @yieldparam [Results::InsecureSource, Results::UnpatchedGem, Results::UnpatchedEngine] result # # @return [Enumerator] # @@ -69,7 +75,7 @@ def each(&block) # # Appends a result to the report. # - # @param [InsecureSource, UnpatchedGem] result + # @param [InsecureSource, UnpatchedGem, UnpatchedEngine] result # def <<(result) @results << result @@ -79,6 +85,8 @@ def <<(result) @insecure_sources << result when Results::UnpatchedGem @unpatched_gems << result + when Results::UnpatchedEngine + @unpatched_engines << result end return self @@ -103,14 +111,14 @@ def vulnerable? def each_advisory return enum_for(__method__) unless block_given? - @unpatched_gems.each { |result| yield result.advisory } + (@unpatched_gems + @unpatched_engines).each { |result| yield result.advisory } end # # @return [Array] # def advisories - @unpatched_gems.map(&:advisory) + each_advisory.to_a end # diff --git a/lib/bundler/audit/results.rb b/lib/bundler/audit/results.rb index 31b717f4..cbdbf87e 100644 --- a/lib/bundler/audit/results.rb +++ b/lib/bundler/audit/results.rb @@ -17,3 +17,4 @@ require 'bundler/audit/results/insecure_source' require 'bundler/audit/results/unpatched_gem' +require 'bundler/audit/results/unpatched_engine' diff --git a/lib/bundler/audit/results/unpatched_engine.rb b/lib/bundler/audit/results/unpatched_engine.rb new file mode 100644 index 00000000..3ddb5c9f --- /dev/null +++ b/lib/bundler/audit/results/unpatched_engine.rb @@ -0,0 +1,126 @@ +# +# Copyright (c) 2013-2024 Hal Brodigan (postmodern.mod3 at gmail.com) +# +# bundler-audit is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# bundler-audit is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with bundler-audit. If not, see . +# + +require 'bundler/audit/results/result' + +require 'uri' + +module Bundler + module Audit + module Results + # + # Represents a ruby engine that has known vulnerabilities and needs to be + # upgraded. + # + class UnpatchedEngine < Result + + SHORT_TYPE = 'engine'.freeze + + # The vulnerable ruby engine. + # + # @return [Bundler::RubyVersion] + attr_reader :ruby_version + + # The advisory documenting the vulnerability. + # + # @return [Advisory] + attr_reader :advisory + + # + # Initializes the unpatched engine result. + # + # @param [Bundler::RubyVersion] ruby_version + # The vulnerable ruby engine. + # + # @param [Advisory] advisory + # The advisory documenting the vulnerability. + # + def initialize(ruby_version,advisory) + @ruby_version = ruby_version + @advisory = advisory + end + + # + # The name of the vulnerable engine. + # + # @return [String] + # + def vulnerable_name + @ruby_version.engine + end + + # + # The version of the vulnerable engine. + # + # @return [String] + # + def vulnerable_version + @ruby_version.engine_gem_version.version + end + + # + # A short human-friendly type to output in warnings, returns 'engine'. + # + # @return [String] + # + def short_type + SHORT_TYPE + end + + # + # Compares the unpatched engine to another result. + # + # @param [Result] other + # + # @return [Boolean] + # + def ==(other) + self.class == other.class && ( + @ruby_version == other.ruby_version && + @advisory == other.advisory + ) + end + + # + # Converts the unpatched engine result into a String. + # + # @return [String] + # + def to_s + @advisory.id + end + + # + # Converts the unpatched engine to a Hash. + # + # @return [Hash{Symbol => Object}] + # + def to_h + { + type: :unpatched_engine, + engine: { + name: vulnerable_name, + version: vulnerable_version + }, + advisory: @advisory.to_h + } + end + + end + end + end +end diff --git a/lib/bundler/audit/results/unpatched_gem.rb b/lib/bundler/audit/results/unpatched_gem.rb index 24472b6d..1ff0dca9 100644 --- a/lib/bundler/audit/results/unpatched_gem.rb +++ b/lib/bundler/audit/results/unpatched_gem.rb @@ -28,6 +28,8 @@ module Results # class UnpatchedGem < Result + SHORT_TYPE = 'gem'.freeze + # The specification of the vulnerable gem. # # @return [Gem::Specification] @@ -52,6 +54,33 @@ def initialize(gem,advisory) @advisory = advisory end + # + # The name of the vulnerable gem. + # + # @return [String] + # + def vulnerable_name + @gem.name + end + + # + # The version of the vulnerable gem. + # + # @return [String] + # + def vulnerable_version + @gem.version + end + + # + # A short human-friendly type to output in warnings, returns 'gem'. + # + # @return [String] + # + def short_type + SHORT_TYPE + end + # # Compares the unpatched gem to another result. # @@ -85,8 +114,8 @@ def to_h { type: :unpatched_gem, gem: { - name: @gem.name, - version: @gem.version + name: vulnerable_name, + version: vulnerable_version }, advisory: @advisory.to_h } diff --git a/lib/bundler/audit/scanner.rb b/lib/bundler/audit/scanner.rb index 5db2c7cc..9e279262 100644 --- a/lib/bundler/audit/scanner.rb +++ b/lib/bundler/audit/scanner.rb @@ -21,6 +21,7 @@ require 'bundler/audit/report' require 'bundler/audit/results/insecure_source' require 'bundler/audit/results/unpatched_gem' +require 'bundler/audit/results/unpatched_engine' require 'bundler/lockfile_parser' require 'ipaddr' @@ -55,6 +56,12 @@ class Scanner # @return [Hash] attr_reader :config + # The ruby version from the lockfile if the 'RUBY VERSION' metadata is present in the file, and the installed + # bundler version >= 1.12.0.pre.1. Otherwise returns nil. + # + # @return [Bundler::RubyVersion, nil] + attr_reader :ruby_version + # # Initializes a scanner. # @@ -85,6 +92,7 @@ def initialize(root=Dir.pwd,gemfile_lock='Gemfile.lock',database=Database.new,co end @lockfile = LockfileParser.new(File.read(gemfile_lock_path)) + @ruby_version = build_ruby_version config_dot_file_full_path = File.absolute_path(config_dot_file, @root) @@ -148,6 +156,7 @@ def scan(options={},&block) scan_sources(options,&block) scan_specs(options,&block) + scan_ruby(options,&block) return self end @@ -218,11 +227,7 @@ def scan_sources(options={}) def scan_specs(options={}) return enum_for(__method__,options) unless block_given? - ignore = if options[:ignore] - Set.new(options[:ignore]) - else - config.ignore - end + ignore = advisories_to_ignore(options) @lockfile.specs.each do |gem| @database.check_gem(gem) do |advisory| @@ -234,8 +239,102 @@ def scan_specs(options={}) end end + # + # Scans the ruby version in the lockfile. + # + # @param [Hash] options + # Additional options. + # + # @option options [Array] :ignore + # The advisories to ignore. + # + # @yield [result] + # The given block will be passed the results of the scan. + # + # @yieldparam [Results::UnpatchedEngine] result + # A result from the scan. + # + # @return [Enumerator] + # If no block is given, an Enumerator will be returned. + # + # @api semipublic + # + # @since 0.9.4 + # + def scan_ruby(options={}) + return enum_for(__method__,options) unless block_given? + + return unless @ruby_version + + ignore = advisories_to_ignore(options) + + @database.check_ruby(@ruby_version) do |advisory| + is_ignored = ignore.intersect?(advisory.identifiers.to_set) + next if is_ignored + + yield Results::UnpatchedEngine.new(@ruby_version,advisory) + end + end + private + # + # The collection of advisories to ignore. + # + # @param [Hash] options + # Additional options. + # + # @option options [Array] :ignore + # The advisories to ignore. + # + # @return [Set] + # + def advisories_to_ignore(options) + if options[:ignore] + Set.new(options[:ignore]) + else + config.ignore + end + end + + # + # Builds a Bundler::RubyVersion representing the ruby version stored in the lockfile. If the ruby version + # is not in the lockfile, then nil is returned. + # + # @return [Bundler::RubyVersion, nil] + # + def build_ruby_version + return unless supports_ruby_version_in_lockfile? + + string = @lockfile.ruby_version + return unless string + + if Bundler::RubyVersion.respond_to?(:from_string) + # Bundler::RubyVersion.from_string added in bundler 1.13.0.pre.1 + Bundler::RubyVersion.from_string(string) + else + # pattern copied from Bundler::RubyVersion::PATTERN + pattern = / + ruby\s + (\d+\.\d+\.\d+(?:\.\S+)?) # ruby version + (?:p(-?\d+))? # optional patchlevel + (?:\s\((\S+)\s(.+)\))? # optional engine info + /xo + match_data = string.match(pattern) + Bundler::RubyVersion.new(match_data[1], match_data[2], match_data[3], match_data[4]) if match_data + end + end + + # + # Determines if installed bundler has support for storing the ruby version in the lock file. Support was added in + # bundler 1.12.0.pre.1. + # + # @return [Boolean] + # + def supports_ruby_version_in_lockfile? + @lockfile.respond_to?(:ruby_version) + end + # # Determines whether a source is internal. # diff --git a/spec/advisory_spec.rb b/spec/advisory_spec.rb index 6edd7281..606eb9e1 100644 --- a/spec/advisory_spec.rb +++ b/spec/advisory_spec.rb @@ -96,6 +96,24 @@ it { is_expected.to eq(data['description']) } end + describe '#gem' do + subject { super().gem } + it { is_expected.to eq(data['gem']) } + end + + describe '#engine' do + subject { super().engine } + + context 'when advisory for gem' do + it { is_expected.to be_nil } + end + + context 'when advisory for engine' do + let(:id) { 'CVE-2020-99999' } + it { is_expected.to eq(data['engine']) } + end + end + context "YAML data not representing a hash" do let(:path) do File.expand_path('../fixtures/advisory/not_a_hash.yml', __FILE__) diff --git a/spec/bundle/secure/Gemfile b/spec/bundle/secure/Gemfile index ef1beece..cc457efc 100644 --- a/spec/bundle/secure/Gemfile +++ b/spec/bundle/secure/Gemfile @@ -1,4 +1,6 @@ source 'https://rubygems.org' +ruby '3.4.10' + gem 'rails', '~> 5.2' gem 'rails-html-sanitizer', '~> 1.4.4' diff --git a/spec/bundle/secure/Gemfile.lock b/spec/bundle/secure/Gemfile.lock index 1174ad32..36566b9d 100644 --- a/spec/bundle/secure/Gemfile.lock +++ b/spec/bundle/secure/Gemfile.lock @@ -119,5 +119,8 @@ DEPENDENCIES rails (~> 5.2) rails-html-sanitizer (~> 1.4.4) +RUBY VERSION + ruby 3.4.10p104 + BUNDLED WITH 2.3.6 diff --git a/spec/bundle/unpatched_gems_with_dot_configuration/.bundler-audit.yml b/spec/bundle/unpatched_gems_with_dot_configuration/.bundler-audit.yml index 4a5067e7..757748cc 100644 --- a/spec/bundle/unpatched_gems_with_dot_configuration/.bundler-audit.yml +++ b/spec/bundle/unpatched_gems_with_dot_configuration/.bundler-audit.yml @@ -1,3 +1,4 @@ --- ignore: - OSVDB-89025 +- CVE-2018-8779 diff --git a/spec/bundle/unpatched_gems_with_dot_configuration/Gemfile b/spec/bundle/unpatched_gems_with_dot_configuration/Gemfile index 40f61778..2af93f0d 100644 --- a/spec/bundle/unpatched_gems_with_dot_configuration/Gemfile +++ b/spec/bundle/unpatched_gems_with_dot_configuration/Gemfile @@ -1,3 +1,5 @@ source 'https://rubygems.org' +ruby '2.3.0' + gem 'activerecord', '3.2.10' diff --git a/spec/bundle/unpatched_gems_with_dot_configuration/Gemfile.lock b/spec/bundle/unpatched_gems_with_dot_configuration/Gemfile.lock index 8765d288..d8bda3e2 100644 --- a/spec/bundle/unpatched_gems_with_dot_configuration/Gemfile.lock +++ b/spec/bundle/unpatched_gems_with_dot_configuration/Gemfile.lock @@ -27,5 +27,8 @@ PLATFORMS DEPENDENCIES activerecord (= 3.2.10) +RUBY VERSION + ruby 2.3.0p0 + BUNDLED WITH 2.2.0 diff --git a/spec/bundle/unpatched_ruby/Gemfile b/spec/bundle/unpatched_ruby/Gemfile new file mode 100644 index 00000000..91421fc5 --- /dev/null +++ b/spec/bundle/unpatched_ruby/Gemfile @@ -0,0 +1,5 @@ +source 'https://rubygems.org' + +ruby '2.3.0' + +gem 'rake' diff --git a/spec/bundle/unpatched_ruby/Gemfile.lock b/spec/bundle/unpatched_ruby/Gemfile.lock new file mode 100644 index 00000000..ba65b295 --- /dev/null +++ b/spec/bundle/unpatched_ruby/Gemfile.lock @@ -0,0 +1,17 @@ +GEM + remote: https://rubygems.org/ + specs: + rake (13.4.2) + +PLATFORMS + arm64-darwin-23 + ruby + +DEPENDENCIES + rake + +RUBY VERSION + ruby 2.3.0p0 + +BUNDLED WITH + 2.6.2 diff --git a/spec/cli/formats/json_spec.rb b/spec/cli/formats/json_spec.rb index b05a0184..cfff6695 100644 --- a/spec/cli/formats/json_spec.rb +++ b/spec/cli/formats/json_spec.rb @@ -100,6 +100,52 @@ expect(output_json[:results][0][:advisory][:criticality]).to be == advisory.criticality.to_s.downcase expect(output_json[:results][0][:advisory][:unaffected_versions]).to be == advisory.unaffected_versions.map(&:to_s) expect(output_json[:results][0][:advisory][:patched_versions]).to be == advisory.patched_versions.map(&:to_s) + expect(output_json[:results][0][:advisory]).not_to have_key(:gem) + expect(output_json[:results][0][:advisory]).not_to have_key(:engine) + end + end + + context "when the report contains UnpatchedEngines" do + let(:ruby_version) do + Bundler::RubyVersion.new('2.3.0', '0', nil, nil) + end + + let(:advisory) do + Bundler::Audit::Advisory.load(Fixtures.join('advisory','CVE-2018-8779.yml')) + end + let(:unpatched_engine) do + Bundler::Audit::Results::UnpatchedEngine.new(ruby_version,advisory) + end + + let(:report) do + super().tap do |report| + report << unpatched_engine + end + end + + it 'must output the UnpatchedEngine as JSON in the "results" Array' do + expect(output_json[:results]).to be_kind_of(Array) + expect(output_json[:results][0]).to be_kind_of(Hash) + expect(output_json[:results][0][:type]).to be == 'unpatched_engine' + expect(output_json[:results][0][:engine]).to be_kind_of(Hash) + expect(output_json[:results][0][:engine][:name]).to be == ruby_version.engine + expect(output_json[:results][0][:engine][:version]).to be == ruby_version.engine_gem_version.version + expect(output_json[:results][0][:advisory]).to be_kind_of(Hash) + expect(output_json[:results][0][:advisory][:path]).to be == advisory.path + expect(output_json[:results][0][:advisory][:id]).to be == advisory.id + expect(output_json[:results][0][:advisory][:url]).to be == advisory.url + expect(output_json[:results][0][:advisory][:title]).to be == advisory.title + expect(output_json[:results][0][:advisory][:date]).to be == advisory.date.to_s + expect(output_json[:results][0][:advisory][:description]).to be == advisory.description + expect(output_json[:results][0][:advisory][:cvss_v2]).to be == advisory.cvss_v2 + expect(output_json[:results][0][:advisory][:cve]).to be == advisory.cve + expect(output_json[:results][0][:advisory][:osvdb]).to be == advisory.osvdb + expect(output_json[:results][0][:advisory][:ghsa]).to be == advisory.ghsa + expect(output_json[:results][0][:advisory][:criticality]).to be == advisory.criticality.to_s.downcase + expect(output_json[:results][0][:advisory][:unaffected_versions]).to be == advisory.unaffected_versions.map(&:to_s) + expect(output_json[:results][0][:advisory][:patched_versions]).to be == advisory.patched_versions.map(&:to_s) + expect(output_json[:results][0][:advisory]).not_to have_key(:gem) + expect(output_json[:results][0][:advisory]).not_to have_key(:engine) end end end diff --git a/spec/cli/formats/junit_spec.rb b/spec/cli/formats/junit_spec.rb index 8f6f52f3..3c1fbdf6 100644 --- a/spec/cli/formats/junit_spec.rb +++ b/spec/cli/formats/junit_spec.rb @@ -330,6 +330,37 @@ expect(output).to match(/failures="[1-9][0-9]*"/) end end + + context "when the report contains UnpatchedEngines" do + let(:ruby_version) do + Bundler::RubyVersion.new('2.3.0', '0', nil, nil) + end + + let(:advisory) do + Bundler::Audit::Advisory.load(Fixtures.join('advisory','CVE-2018-8779.yml')) + end + let(:unpatched_engine) do + Bundler::Audit::Results::UnpatchedEngine.new(ruby_version,advisory) + end + + let(:report) do + super().tap do |report| + report << unpatched_engine + end + end + + context "when Advisory#patched_versions is empty" do + let(:advisory) do + super().tap do |advisory| + advisory.patched_versions = [] + end + end + + it 'must print "Solution: remove or disable this engine until a patch is available!"' do + expect(output).to include("Solution: remove or disable this engine until a patch is available!") + end + end + end end context "when no vulnerabilities were found" do diff --git a/spec/cli/formats/text_spec.rb b/spec/cli/formats/text_spec.rb index f774e8ed..7cbd7ce4 100644 --- a/spec/cli/formats/text_spec.rb +++ b/spec/cli/formats/text_spec.rb @@ -319,6 +319,41 @@ expect(output_lines).to include("Vulnerabilities found!") end end + + context "when the report contains UnpatchedEngine results" do + let(:ruby_version) do + Bundler::RubyVersion.new('2.3.0', '0', nil, nil) + end + + let(:advisory) do + Bundler::Audit::Advisory.load(Fixtures.join('advisory','CVE-2018-8779.yml')) + end + let(:unpatched_engine) do + Bundler::Audit::Results::UnpatchedEngine.new(ruby_version,advisory) + end + + let(:report) do + super().tap do |report| + report << unpatched_engine + end + end + + context "when Advisory#patched_versions is empty" do + let(:advisory) do + super().tap do |advisory| + advisory.patched_versions = [] + end + end + + it 'must print "Solution: remove or disable this engine until a patch is available!"' do + expect(output_lines).to include("Solution: remove or disable this engine until a patch is available!") + end + + it 'must print "Vulnerabilities found!"' do + expect(output_lines).to include("Vulnerabilities found!") + end + end + end end context "when no vulnerabilities were found" do diff --git a/spec/database_spec.rb b/spec/database_spec.rb index 97d89ce1..8acfb4f3 100644 --- a/spec/database_spec.rb +++ b/spec/database_spec.rb @@ -1,10 +1,11 @@ require 'spec_helper' require 'bundler/audit/database' +require 'securerandom' require 'tmpdir' describe Bundler::Audit::Database do let(:vendored_advisories) do - Dir[File.join(Fixtures::Database::PATH, 'gems/*/*.yml')].sort + Dir[File.join(Fixtures::Database::PATH, '{gems,rubies}/*/*.yml')].sort end describe ".path" do @@ -367,6 +368,64 @@ end end + describe "#check_ruby" do + let(:ruby_version_string) { 'ruby 2.3.0p0' } + let(:ruby_version) { Bundler::RubyVersion.from_string(ruby_version_string) } + + context "when given a block" do + it "should yield every advisory for ruby version" do + advisories = [] + + subject.check_ruby(ruby_version) do |advisory| + advisories << advisory + end + + expect(advisories).not_to be_empty + expect(advisories.all? { |advisory| + advisory.kind_of?(Bundler::Audit::Advisory) && advisory.engine == 'ruby' + }).to be_truthy + end + end + + context "when given no block" do + it "should return an Enumerator" do + expect(subject.check_ruby(ruby_version)).to be_kind_of(Enumerable) + end + end + + context "when engine is not ruby" do + let(:ruby_version_string) { 'ruby 1.9.3 (jruby 1.7.0)' } + + it "locates relevant advisories" do + advisories = [] + + subject.check_ruby(ruby_version) do |advisory| + advisories << advisory + end + + expect(advisories).not_to be_empty + expect(advisories.all? { |advisory| + advisory.kind_of?(Bundler::Audit::Advisory) && advisory.engine == 'jruby' + }).to be_truthy + end + end + + context "when engine has no advisory db directory" do + let(:engine) { "testengine#{SecureRandom.alphanumeric(5)}" } + let(:ruby_version_string) { "ruby 4.0.0 (#{engine} 1.0.0)" } + + it "handles gracefully" do + advisories = [] + + subject.check_ruby(ruby_version) do |advisory| + advisories << advisory + end + + expect(advisories).to be_empty + end + end + end + describe "#size" do it { expect(subject.size).to eq vendored_advisories.count } end diff --git a/spec/fixtures/advisory/CVE-2018-8779.yml b/spec/fixtures/advisory/CVE-2018-8779.yml new file mode 100644 index 00000000..c092fc7e --- /dev/null +++ b/spec/fixtures/advisory/CVE-2018-8779.yml @@ -0,0 +1,50 @@ +--- +engine: ruby +cve: 2018-8779 +url: https://www.ruby-lang.org/en/news/2018/03/28/poisoned-nul-byte-unixsocket-cve-2018-8779/ +title: Unintentional socket creation by poisoned NUL byte in UNIXServer and UNIXSocket +date: 2018-03-28 +description: | + There is a unintentional socket creation vulnerability in `UNIXServer.open` + method of socket library bundled with Ruby. And there is also a unintentional + socket access vulnerability in `UNIXSocket.open` method. + + `UNIXServer.open` accepts the path of the socket to be created at the first + parameter. If the path contains NUL (`\0`) bytes, this method recognize that + the path is completed before the NUL bytes. So, if a script accepts an external + input as the argument of this method, the attacker can make the socket file in + the unintentional path. And, `UNIXSocket.open` also accepts the path of the + socket to be created at the first parameter without checking NUL bytes like + `UNIXServer.open`. So, if a script accepts an external input as the argument of + this method, the attacker can accepts the socket file in the unintentional + path. + + All users running an affected release should upgrade immediately. +cvss_v2: 5.0 +cvss_v3: 7.5 +patched_versions: + - "~> 2.2.10" + - "~> 2.3.7" + - "~> 2.4.4" + - "~> 2.5.1" + - "> 2.6.0-preview1" +related: + url: + - https://nvd.nist.gov/vuln/detail/CVE-2018-8779 + - https://www.ruby-lang.org/en/news/2018/03/28/poisoned-nul-byte-unixsocket-cve-2018-8779 + - https://www.ruby-lang.org/en/news/2018/03/28/ruby-2-2-10-released + - https://www.ruby-lang.org/en/news/2018/03/28/ruby-2-3-7-released + - https://www.ruby-lang.org/en/news/2018/03/28/ruby-2-4-4-released + - https://www.ruby-lang.org/en/news/2018/03/28/ruby-2-5-1-released + - https://www.ruby-lang.org/en/news/2018/05/31/ruby-2-6-0-preview2-released + - https://ubuntu.com/security/notices/USN-3626-1 + - https://lists.debian.org/debian-lts-announce/2018/04/msg00023.html + - https://lists.debian.org/debian-lts-announce/2018/04/msg00024.html + - https://lists.debian.org/debian-lts-announce/2018/07/msg00012.html + - https://www.debian.org/security/2018/dsa-4259 + - https://access.redhat.com/errata/RHSA-2018:3729 + - https://access.redhat.com/errata/RHSA-2018:3730 + - https://access.redhat.com/errata/RHSA-2018:3731 + - https://access.redhat.com/errata/RHSA-2019:2028 + - http://lists.opensuse.org/opensuse-security-announce/2019-07/msg00036.html + - https://github.com/advisories/GHSA-mwq4-948j-88c5 diff --git a/spec/fixtures/advisory/CVE-2020-99999.yml b/spec/fixtures/advisory/CVE-2020-99999.yml new file mode 100644 index 00000000..59c62a21 --- /dev/null +++ b/spec/fixtures/advisory/CVE-2020-99999.yml @@ -0,0 +1,22 @@ +--- +engine: testengine +cve: 2020-99999 +ghsa: xxxx-yyyy-zzzz +url: https://example.com/ +title: Test advisory +date: 2020-12-31 + +description: | + This is a test advisory. + +cvss_v2: 10.0 +cvss_v3: 9.8 +cvss_v4: 9.9 + +unaffected_versions: + - "< 0.1.0" + +patched_versions: + - "~> 0.1.42" + - "~> 0.2.42" + - ">= 1.0.0" diff --git a/spec/report_spec.rb b/spec/report_spec.rb index 6f3b115b..931231e9 100644 --- a/spec/report_spec.rb +++ b/spec/report_spec.rb @@ -13,13 +13,19 @@ spec.version = '0.0.0' end end - let(:advisory) { double('Bundler::Audit::Advisory', id: 'CVE-3000-1234') } + let(:gem_advisory) { double('Bundler::Audit::Advisory', id: 'CVE-3000-1234') } let(:unpatched_gem) do - Bundler::Audit::Results::UnpatchedGem.new(gem,advisory) + Bundler::Audit::Results::UnpatchedGem.new(gem,gem_advisory) + end + + let(:ruby_version) { Bundler::RubyVersion.new('2.3.0', '0', nil, nil) } + let(:engine_advisory) { double('Bundler::Audit::Advisory', id: 'CVE-2018-8779') } + let(:unpatched_engine) do + Bundler::Audit::Results::UnpatchedEngine.new(ruby_version,engine_advisory) end let(:results) do - [insecure_source, unpatched_gem] + [insecure_source, unpatched_gem, unpatched_engine] end subject { described_class.new(results) } @@ -68,6 +74,20 @@ expect(subject.unpatched_gems.last).to be(result) end end + + context "when given a Result::UnpatchedEngine" do + let(:result) { unpatched_engine } + + before { subject << result } + + it "should add the result to the report" do + expect(subject.results.last).to be(result) + end + + it "should also add the result to #unpatched_engines" do + expect(subject.unpatched_engines.last).to be(result) + end + end end describe "#each" do @@ -95,4 +115,32 @@ it { expect(subject.vulnerable?).to be true } end end + + describe "#each_advisory" do + it "includes gem advisories" do + advisories = [] + subject.each_advisory do |advisory| + advisories << advisory + end + expect(advisories).to include(gem_advisory) + end + + it "includes engine advisories" do + advisories = [] + subject.each_advisory do |advisory| + advisories << advisory + end + expect(advisories).to include(engine_advisory) + end + end + + describe "#advisories" do + it "includes gem advisories" do + expect(subject.advisories).to include(gem_advisory) + end + + it "includes engine advisories" do + expect(subject.advisories).to include(engine_advisory) + end + end end diff --git a/spec/results/unpatched_engine_spec.rb b/spec/results/unpatched_engine_spec.rb new file mode 100644 index 00000000..0347a0e3 --- /dev/null +++ b/spec/results/unpatched_engine_spec.rb @@ -0,0 +1,102 @@ +require 'spec_helper' +require 'bundler/audit/results/unpatched_engine' + +describe Bundler::Audit::Results::UnpatchedEngine do + let(:ruby_version) do + Bundler::RubyVersion.new('2.3.0', '0', nil, nil) + end + + let(:advisory) do + double('Bundler::Audit::Advisory', id: 'CVE-3000-1234') + end + + subject { described_class.new(ruby_version,advisory) } + + describe "#initialize" do + it "must set the ruby_version attribute" do + expect(subject.ruby_version).to be(ruby_version) + end + + it "must set the advisory attribute" do + expect(subject.advisory).to be(advisory) + end + end + + describe "#==" do + context "when the other class is different" do + let(:other) { Object.new } + + it "should return false" do + expect(subject).to_not be == other + end + end + + context "when the other ruby version is different" do + let(:other_ruby_version) do + Bundler::RubyVersion.new('2.3.1', '0', nil, nil) + end + + let(:other) { described_class.new(other_ruby_version,advisory) } + + it "must return false" do + expect(subject).to_not be == other + end + end + + context "when the other advisory is different" do + let(:other_advisory) do + double('Bundler::Audit::Advisory', id: 'CVE-3000-9876') + end + + let(:other) { described_class.new(ruby_version,other_advisory) } + + it "must return false" do + expect(subject).to_not be == other + end + end + + context "when the ruby version and advisory are the same" do + let(:other) { described_class.new(ruby_version,advisory) } + + it "must return true" do + expect(subject).to be == other + end + end + end + + describe "#to_h" do + subject { super().to_h } + + let(:advisory_hash) { {id: advisory.id} } + + before { expect(advisory).to receive(:to_h).and_return(advisory_hash) } + + it "must include type: :unpatched_engine" do + expect(subject[:type]).to be :unpatched_engine + end + + it "must include a :engine key containing a Hash" do + expect(subject[:engine]).to be_kind_of(Hash) + end + + context ":engine" do + it "must contain a :name key of the engine name" do + expect(subject[:engine][:name]).to be == ruby_version.engine + end + + it "must contain a :version key of the engine version" do + expect(subject[:engine][:version]).to be == ruby_version.engine_gem_version.version + end + end + + it "must include a :advisory key containing a Hash of the advisory" do + expect(subject[:advisory]).to be == advisory_hash + end + end + + describe "#to_s" do + it "should return the advisory ID" do + expect(subject.to_s).to be == advisory.id + end + end +end diff --git a/spec/scanner_spec.rb b/spec/scanner_spec.rb index 66f7d6f7..5f2ad519 100644 --- a/spec/scanner_spec.rb +++ b/spec/scanner_spec.rb @@ -90,7 +90,7 @@ it "must load the configuration from the config file" do expect(subject.config).to be_kind_of(Configuration) - expect(subject.config.ignore).to include('OSVDB-89025') + expect(subject.config.ignore).to include('OSVDB-89025', 'CVE-2018-8779') end end @@ -104,7 +104,7 @@ it "must load the configuration from the absolute path" do expect(subject.config).to be_kind_of(Configuration) - expect(subject.config.ignore).to include('OSVDB-89025') + expect(subject.config.ignore).to include('OSVDB-89025', 'CVE-2018-8779') end end @@ -115,7 +115,7 @@ it "must load the configuration from the relative path" do expect(subject.config).to be_kind_of(Configuration) - expect(subject.config.ignore).to include('OSVDB-89025') + expect(subject.config.ignore).to include('OSVDB-89025', 'CVE-2018-8779') end end end @@ -130,6 +130,46 @@ expect(subject.config.ignore).to be_empty end end + + context "when bundler does not support ruby version in lockfile (bundler < 1.12.0.pre.1)" do + before do + allow_any_instance_of(Scanner).to receive(:supports_ruby_version_in_lockfile?).and_return(false) + end + + it "must set ruby_version to nil" do + expect(subject.ruby_version).to be_nil + end + end + + context "when bundler supports ruby version in lockfile (bundler >= 1.12.0.pre.1)" do + context "when lockfile does not contain ruby version" do + it "must set ruby_version to nil" do + expect(subject.ruby_version).to be_nil + end + end + + context "when lockfile contains ruby version" do + let(:bundle) { 'unpatched_ruby' } + + context "when RubyVersion.from_string not available (bundler < 1.13.0.pre.1)" do + before do + expect(Bundler::RubyVersion).to receive(:respond_to?).with(:from_string).and_return(false) + end + + it "sets ruby_version to a RubyVersion" do + expect(subject.ruby_version).to be_an_instance_of(Bundler::RubyVersion) + .and have_attributes(engine: 'ruby', engine_gem_version: Gem::Version.new('2.3.0')) + end + end + + context "when RubyVersion.from_string available (bundler >= 1.13.0.pre.1)" do + it "sets ruby_version to a RubyVersion" do + expect(subject.ruby_version).to be_an_instance_of(Bundler::RubyVersion) + .and have_attributes(engine: 'ruby', engine_gem_version: Gem::Version.new('2.3.0')) + end + end + end + end end describe "#scan" do @@ -171,6 +211,33 @@ end end + context "when auditing a bundle with unpatched ruby version" do + let(:bundle) { 'unpatched_ruby' } + + context "with defaults" do + subject { super().scan.to_a } + + it "should match unpatched ruby version to its advisories" do + expect(subject).not_to be_empty + expect(subject.all? { |result| + advisory = result.advisory + ruby_version = result.ruby_version + advisory.vulnerable?(ruby_version.engine_gem_version) && advisory.engine == ruby_version.engine + }).to be_truthy + end + end + + context "when the :ignore option is given" do + subject { super().scan(ignore: ['CVE-2018-8779']) } + + it "should ignore the specified advisories" do + ids = subject.map { |result| result.advisory.id } + + expect(ids).not_to include('CVE-2018-8779') + end + end + end + context "when auditing a bundle with insecure sources" do let(:bundle) { 'insecure_sources' } @@ -202,7 +269,7 @@ it "should ignore the specified advisories" do ids = subject.map { |result| result.advisory.id } - expect(ids).not_to include('OSVDB-89025') + expect(ids).not_to include('OSVDB-89025', 'CVE-2018-8779') end context "when config path is absolute" do @@ -213,7 +280,7 @@ it "should read the config just fine" do ids = subject.map { |result| result.advisory.id } - expect(ids).not_to include('OSVDB-89025') + expect(ids).not_to include('OSVDB-89025', 'CVE-2018-8779') end end @@ -225,7 +292,7 @@ it "should read the config just fine" do ids = subject.map { |result| result.advisory.id } - expect(ids).not_to include('OSVDB-89025') + expect(ids).not_to include('OSVDB-89025', 'CVE-2018-8779') end end end