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