bundler-audit-ng 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.document +3 -0
- data/.gitignore +11 -0
- data/.gitmodules +3 -0
- data/.rspec +1 -0
- data/.travis.yml +13 -0
- data/.yardopts +1 -0
- data/COPYING.txt +674 -0
- data/ChangeLog.md +129 -0
- data/Gemfile +13 -0
- data/README.md +168 -0
- data/Rakefile +57 -0
- data/bin/bundle-audit +10 -0
- data/bin/bundler-audit +3 -0
- data/bundler-audit.gemspec +67 -0
- data/data/ruby-advisory-db.ts +1 -0
- data/gemspec.yml +14 -0
- data/lib/bundler/audit.rb +19 -0
- data/lib/bundler/audit/advisory.rb +177 -0
- data/lib/bundler/audit/cli.rb +155 -0
- data/lib/bundler/audit/database.rb +248 -0
- data/lib/bundler/audit/scanner.rb +213 -0
- data/lib/bundler/audit/task.rb +31 -0
- data/lib/bundler/audit/version.rb +23 -0
- data/spec/advisory_spec.rb +282 -0
- data/spec/audit_spec.rb +8 -0
- data/spec/bundle/insecure_sources/Gemfile +4 -0
- data/spec/bundle/secure/Gemfile +3 -0
- data/spec/bundle/unpatched_gems/Gemfile +3 -0
- data/spec/cli_spec.rb +99 -0
- data/spec/database_spec.rb +138 -0
- data/spec/fixtures/not_a_hash.yml +2 -0
- data/spec/integration_spec.rb +103 -0
- data/spec/scanner_spec.rb +75 -0
- data/spec/spec_helper.rb +62 -0
- metadata +115 -0
@@ -0,0 +1,213 @@
|
|
1
|
+
require 'bundler'
|
2
|
+
require 'bundler/audit/database'
|
3
|
+
require 'bundler/lockfile_parser'
|
4
|
+
|
5
|
+
require 'ipaddr'
|
6
|
+
require 'resolv'
|
7
|
+
require 'set'
|
8
|
+
require 'uri'
|
9
|
+
|
10
|
+
module Bundler
|
11
|
+
module Audit
|
12
|
+
class Scanner
|
13
|
+
|
14
|
+
# Represents a plain-text source
|
15
|
+
InsecureSource = Struct.new(:source)
|
16
|
+
|
17
|
+
# Represents a gem that is covered by an Advisory
|
18
|
+
UnpatchedGem = Struct.new(:gem, :advisory)
|
19
|
+
|
20
|
+
# The advisory database
|
21
|
+
#
|
22
|
+
# @return [Database]
|
23
|
+
attr_reader :database
|
24
|
+
|
25
|
+
# Project root directory
|
26
|
+
attr_reader :root
|
27
|
+
|
28
|
+
# The parsed `Gemfile.lock` from the project
|
29
|
+
#
|
30
|
+
# @return [Bundler::LockfileParser]
|
31
|
+
attr_reader :lockfile
|
32
|
+
|
33
|
+
#
|
34
|
+
# Initializes a scanner.
|
35
|
+
#
|
36
|
+
# @param [String] root
|
37
|
+
# The path to the project root.
|
38
|
+
#
|
39
|
+
# @param [String] gemfile_lock
|
40
|
+
# Alternative name for the `Gemfile.lock` file.
|
41
|
+
#
|
42
|
+
def initialize(root=Dir.pwd,gemfile_lock='Gemfile.lock')
|
43
|
+
@root = File.expand_path(root)
|
44
|
+
@database = Database.new
|
45
|
+
@lockfile = LockfileParser.new(
|
46
|
+
File.read(File.join(@root,gemfile_lock))
|
47
|
+
)
|
48
|
+
end
|
49
|
+
|
50
|
+
#
|
51
|
+
# Scans the project for issues.
|
52
|
+
#
|
53
|
+
# @param [Hash] options
|
54
|
+
# Additional options.
|
55
|
+
#
|
56
|
+
# @option options [Array<String>] :ignore
|
57
|
+
# The advisories to ignore.
|
58
|
+
#
|
59
|
+
# @yield [result]
|
60
|
+
# The given block will be passed the results of the scan.
|
61
|
+
#
|
62
|
+
# @yieldparam [InsecureSource, UnpatchedGem] result
|
63
|
+
# A result from the scan.
|
64
|
+
#
|
65
|
+
# @return [Enumerator]
|
66
|
+
# If no block is given, an Enumerator will be returned.
|
67
|
+
#
|
68
|
+
def scan(options={},&block)
|
69
|
+
return enum_for(__method__,options) unless block
|
70
|
+
|
71
|
+
ignore = Set[]
|
72
|
+
ignore += options[:ignore] if options[:ignore]
|
73
|
+
|
74
|
+
scan_sources(options,&block)
|
75
|
+
scan_specs(options,&block)
|
76
|
+
|
77
|
+
return self
|
78
|
+
end
|
79
|
+
|
80
|
+
#
|
81
|
+
# Scans the gem sources in the lockfile.
|
82
|
+
#
|
83
|
+
# @param [Hash] options
|
84
|
+
# Additional options.
|
85
|
+
#
|
86
|
+
# @yield [result]
|
87
|
+
# The given block will be passed the results of the scan.
|
88
|
+
#
|
89
|
+
# @yieldparam [InsecureSource] result
|
90
|
+
# A result from the scan.
|
91
|
+
#
|
92
|
+
# @return [Enumerator]
|
93
|
+
# If no block is given, an Enumerator will be returned.
|
94
|
+
#
|
95
|
+
# @api semipublic
|
96
|
+
#
|
97
|
+
# @since 0.4.0
|
98
|
+
#
|
99
|
+
def scan_sources(options={})
|
100
|
+
return enum_for(__method__,options) unless block_given?
|
101
|
+
|
102
|
+
@lockfile.sources.map do |source|
|
103
|
+
case source
|
104
|
+
when Source::Git
|
105
|
+
case source.uri
|
106
|
+
when /^git:/, /^http:/
|
107
|
+
unless internal_source?(source.uri)
|
108
|
+
yield InsecureSource.new(source.uri)
|
109
|
+
end
|
110
|
+
end
|
111
|
+
when Source::Rubygems
|
112
|
+
source.remotes.each do |uri|
|
113
|
+
if (uri.scheme == 'http' && !internal_source?(uri))
|
114
|
+
yield InsecureSource.new(uri.to_s)
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
#
|
122
|
+
# Scans the gem sources in the lockfile.
|
123
|
+
#
|
124
|
+
# @param [Hash] options
|
125
|
+
# Additional options.
|
126
|
+
#
|
127
|
+
# @option options [Array<String>] :ignore
|
128
|
+
# The advisories to ignore.
|
129
|
+
#
|
130
|
+
# @yield [result]
|
131
|
+
# The given block will be passed the results of the scan.
|
132
|
+
#
|
133
|
+
# @yieldparam [UnpatchedGem] result
|
134
|
+
# A result from the scan.
|
135
|
+
#
|
136
|
+
# @return [Enumerator]
|
137
|
+
# If no block is given, an Enumerator will be returned.
|
138
|
+
#
|
139
|
+
# @api semipublic
|
140
|
+
#
|
141
|
+
# @since 0.4.0
|
142
|
+
#
|
143
|
+
def scan_specs(options={})
|
144
|
+
return enum_for(__method__,options) unless block_given?
|
145
|
+
|
146
|
+
ignore = Set[]
|
147
|
+
ignore += options[:ignore] if options[:ignore]
|
148
|
+
|
149
|
+
@lockfile.specs.each do |gem|
|
150
|
+
@database.check_gem(gem) do |advisory|
|
151
|
+
is_ignored = ignore.intersect?(advisory.identifiers.to_set)
|
152
|
+
next if is_ignored
|
153
|
+
|
154
|
+
yield UnpatchedGem.new(gem,advisory)
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
private
|
160
|
+
|
161
|
+
#
|
162
|
+
# Determines whether a source is internal.
|
163
|
+
#
|
164
|
+
# @param [URI, String] uri
|
165
|
+
# The URI.
|
166
|
+
#
|
167
|
+
# @return [Boolean]
|
168
|
+
#
|
169
|
+
def internal_source?(uri)
|
170
|
+
uri = URI(uri)
|
171
|
+
|
172
|
+
internal_host?(uri.host) if uri.host
|
173
|
+
end
|
174
|
+
|
175
|
+
#
|
176
|
+
# Determines whether a host is internal.
|
177
|
+
#
|
178
|
+
# @param [String] host
|
179
|
+
# The hostname.
|
180
|
+
#
|
181
|
+
# @return [Boolean]
|
182
|
+
#
|
183
|
+
def internal_host?(host)
|
184
|
+
Resolv.getaddresses(host).all? { |ip| internal_ip?(ip) }
|
185
|
+
rescue URI::Error
|
186
|
+
false
|
187
|
+
end
|
188
|
+
|
189
|
+
# List of internal IP address ranges.
|
190
|
+
#
|
191
|
+
# @see https://tools.ietf.org/html/rfc1918#section-3
|
192
|
+
# @see https://tools.ietf.org/html/rfc4193#section-8
|
193
|
+
INTERNAL_SUBNETS = %w[
|
194
|
+
10.0.0.0/8
|
195
|
+
172.16.0.0/12
|
196
|
+
192.168.0.0/16
|
197
|
+
fc00::/7
|
198
|
+
].map(&IPAddr.method(:new))
|
199
|
+
|
200
|
+
#
|
201
|
+
# Determines whether an IP is internal.
|
202
|
+
#
|
203
|
+
# @param [String] ip
|
204
|
+
# The IPv4/IPv6 address.
|
205
|
+
#
|
206
|
+
# @return [Boolean]
|
207
|
+
#
|
208
|
+
def internal_ip?(ip)
|
209
|
+
INTERNAL_SUBNETS.any? { |subnet| subnet.include?(ip) }
|
210
|
+
end
|
211
|
+
end
|
212
|
+
end
|
213
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
require 'rake/tasklib'
|
2
|
+
|
3
|
+
module Bundler
|
4
|
+
module Audit
|
5
|
+
class Task < Rake::TaskLib
|
6
|
+
#
|
7
|
+
# Initializes the task.
|
8
|
+
#
|
9
|
+
def initialize
|
10
|
+
define
|
11
|
+
end
|
12
|
+
|
13
|
+
protected
|
14
|
+
|
15
|
+
#
|
16
|
+
# Defines the `bundle:audit` task.
|
17
|
+
#
|
18
|
+
def define
|
19
|
+
namespace :bundle do
|
20
|
+
desc 'Updates the ruby-advisory-db then runs bundle-audit'
|
21
|
+
task :audit do
|
22
|
+
require 'bundler/audit/cli'
|
23
|
+
%w(update check).each do |command|
|
24
|
+
Bundler::Audit::CLI.start [command]
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
#
|
2
|
+
# Copyright (c) 2013-2019 Hal Brodigan (postmodern.mod3 at gmail.com)
|
3
|
+
#
|
4
|
+
# bundler-audit is free software: you can redistribute it and/or modify
|
5
|
+
# it under the terms of the GNU General Public License as published by
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
7
|
+
# (at your option) any later version.
|
8
|
+
#
|
9
|
+
# bundler-audit is distributed in the hope that it will be useful,
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
12
|
+
# GNU General Public License for more details.
|
13
|
+
#
|
14
|
+
# You should have received a copy of the GNU General Public License
|
15
|
+
# along with bundler-audit. If not, see <http://www.gnu.org/licenses/>.
|
16
|
+
#
|
17
|
+
|
18
|
+
module Bundler
|
19
|
+
module Audit
|
20
|
+
# bundler-audit version
|
21
|
+
VERSION = '0.6.1'
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,282 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'bundler/audit/database'
|
3
|
+
require 'bundler/audit/advisory'
|
4
|
+
|
5
|
+
describe Bundler::Audit::Advisory do
|
6
|
+
let(:root) { Bundler::Audit::Database::VENDORED_PATH }
|
7
|
+
let(:gem) { 'actionpack' }
|
8
|
+
let(:id) { 'OSVDB-84243' }
|
9
|
+
let(:path) { File.join(root,'gems',gem,"#{id}.yml") }
|
10
|
+
let(:an_unaffected_version) do
|
11
|
+
Bundler::Audit::Advisory.load(path).unaffected_versions.map { |version_rule|
|
12
|
+
# For all the rules, get the individual constraints out and see if we
|
13
|
+
# can find a suitable one...
|
14
|
+
version_rule.requirements.select { |(constraint, gem_version)|
|
15
|
+
# We only want constraints where the version number specified is
|
16
|
+
# one of the unaffected version. I.E. we don't want ">", "<", or if
|
17
|
+
# such a thing exists, "!=" constraints.
|
18
|
+
['~>', '>=', '=', '<='].include?(constraint)
|
19
|
+
}.map { |(constraint, gem_version)|
|
20
|
+
# Fetch just the version component, which is a Gem::Version,
|
21
|
+
# and extract the string representation of the version.
|
22
|
+
gem_version.version
|
23
|
+
}
|
24
|
+
}.flatten.first
|
25
|
+
end
|
26
|
+
|
27
|
+
subject { described_class.load(path) }
|
28
|
+
|
29
|
+
describe "load" do
|
30
|
+
let(:data) { YAML.load_file(path) }
|
31
|
+
|
32
|
+
describe '#id' do
|
33
|
+
subject { super().id }
|
34
|
+
it { is_expected.to eq(id) }
|
35
|
+
end
|
36
|
+
|
37
|
+
describe '#url' do
|
38
|
+
subject { super().url }
|
39
|
+
it { is_expected.to eq(data['url']) }
|
40
|
+
end
|
41
|
+
|
42
|
+
describe '#title' do
|
43
|
+
subject { super().title }
|
44
|
+
it { is_expected.to eq(data['title']) }
|
45
|
+
end
|
46
|
+
|
47
|
+
describe '#date' do
|
48
|
+
subject { super().date }
|
49
|
+
it { is_expected.to eq(data['date']) }
|
50
|
+
end
|
51
|
+
|
52
|
+
describe '#cvss_v2' do
|
53
|
+
subject { super().cvss_v2 }
|
54
|
+
it { is_expected.to eq(data['cvss_v2']) }
|
55
|
+
end
|
56
|
+
|
57
|
+
describe '#description' do
|
58
|
+
subject { super().description }
|
59
|
+
it { is_expected.to eq(data['description']) }
|
60
|
+
end
|
61
|
+
|
62
|
+
context "YAML data not representing a hash" do
|
63
|
+
it "should raise an exception" do
|
64
|
+
path = File.expand_path('../fixtures/not_a_hash.yml', __FILE__)
|
65
|
+
expect {
|
66
|
+
Advisory.load(path)
|
67
|
+
}.to raise_exception("advisory data in #{path.dump} was not a Hash")
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
describe "#patched_versions" do
|
72
|
+
subject { described_class.load(path).patched_versions }
|
73
|
+
|
74
|
+
it "should all be Gem::Requirement objects" do
|
75
|
+
expect(subject.all? { |version|
|
76
|
+
expect(version).to be_kind_of(Gem::Requirement)
|
77
|
+
}).to be_truthy
|
78
|
+
end
|
79
|
+
|
80
|
+
it "should parse the versions" do
|
81
|
+
expect(subject.map(&:to_s)).to eq(data['patched_versions'])
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
describe "#cve_id" do
|
87
|
+
let(:cve) { "2015-1234" }
|
88
|
+
|
89
|
+
subject do
|
90
|
+
described_class.new.tap do |advisory|
|
91
|
+
advisory.cve = cve
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
it "should prepend CVE- to the CVE id" do
|
96
|
+
expect(subject.cve_id).to be == "CVE-#{cve}"
|
97
|
+
end
|
98
|
+
|
99
|
+
context "when cve is nil" do
|
100
|
+
subject { described_class.new }
|
101
|
+
|
102
|
+
it { expect(subject.cve_id).to be_nil }
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
describe "#osvdb_id" do
|
107
|
+
let(:osvdb) { "123456" }
|
108
|
+
|
109
|
+
subject do
|
110
|
+
described_class.new.tap do |advisory|
|
111
|
+
advisory.osvdb = osvdb
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
it "should prepend OSVDB- to the OSVDB id" do
|
116
|
+
expect(subject.osvdb_id).to be == "OSVDB-#{osvdb}"
|
117
|
+
end
|
118
|
+
|
119
|
+
context "when cve is nil" do
|
120
|
+
subject { described_class.new }
|
121
|
+
|
122
|
+
it { expect(subject.osvdb_id).to be_nil }
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
describe "#ghsa_id" do
|
127
|
+
let(:ghsa) { "xfhh-rx56-rxcr" }
|
128
|
+
|
129
|
+
subject do
|
130
|
+
described_class.new.tap do |advisory|
|
131
|
+
advisory.ghsa = ghsa
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
it "should prepend GHSA- to the GHSA id" do
|
136
|
+
expect(subject.ghsa_id).to be == "GHSA-#{ghsa}"
|
137
|
+
end
|
138
|
+
|
139
|
+
context "when ghsa is nil" do
|
140
|
+
subject { described_class.new }
|
141
|
+
|
142
|
+
it { expect(subject.ghsa_id).to be_nil }
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
describe "#identifiers" do
|
147
|
+
it "should include all identifiers if defined" do
|
148
|
+
advisory = described_class.new.tap do |advisory|
|
149
|
+
advisory.cve = "2018-1234"
|
150
|
+
advisory.osvdb = "2019-2345"
|
151
|
+
advisory.ghsa = "2020-3456"
|
152
|
+
end
|
153
|
+
|
154
|
+
expect(advisory.identifiers).to eq([
|
155
|
+
"CVE-2018-1234",
|
156
|
+
"OSVDB-2019-2345",
|
157
|
+
"GHSA-2020-3456"
|
158
|
+
])
|
159
|
+
end
|
160
|
+
|
161
|
+
it "should exclude nil identifiers" do
|
162
|
+
advisory = described_class.new
|
163
|
+
expect(advisory.identifiers).to eq([])
|
164
|
+
|
165
|
+
advisory = described_class.new.tap do |advisory|
|
166
|
+
advisory.cve = "2018-1234"
|
167
|
+
end
|
168
|
+
expect(advisory.identifiers).to eq(["CVE-2018-1234"])
|
169
|
+
|
170
|
+
advisory = described_class.new.tap do |advisory|
|
171
|
+
advisory.ghsa = "2020-3456"
|
172
|
+
end
|
173
|
+
expect(advisory.identifiers).to eq(["GHSA-2020-3456"])
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
177
|
+
describe "#criticality" do
|
178
|
+
context "when cvss_v2 is between 0.0 and 3.3" do
|
179
|
+
subject do
|
180
|
+
described_class.new.tap do |advisory|
|
181
|
+
advisory.cvss_v2 = 3.3
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
it { expect(subject.criticality).to eq(:low) }
|
186
|
+
end
|
187
|
+
|
188
|
+
context "when cvss_v2 is between 3.3 and 6.6" do
|
189
|
+
subject do
|
190
|
+
described_class.new.tap do |advisory|
|
191
|
+
advisory.cvss_v2 = 6.6
|
192
|
+
end
|
193
|
+
end
|
194
|
+
|
195
|
+
it { expect(subject.criticality).to eq(:medium) }
|
196
|
+
end
|
197
|
+
|
198
|
+
context "when cvss_v2 is between 6.6 and 10.0" do
|
199
|
+
subject do
|
200
|
+
described_class.new.tap do |advisory|
|
201
|
+
advisory.cvss_v2 = 10.0
|
202
|
+
end
|
203
|
+
end
|
204
|
+
|
205
|
+
it { expect(subject.criticality).to eq(:high) }
|
206
|
+
end
|
207
|
+
end
|
208
|
+
|
209
|
+
describe "#unaffected?" do
|
210
|
+
context "when passed a version that matches one unaffected version" do
|
211
|
+
let(:version) { Gem::Version.new(an_unaffected_version) }
|
212
|
+
|
213
|
+
it "should return true" do
|
214
|
+
expect(subject.unaffected?(version)).to be_truthy
|
215
|
+
end
|
216
|
+
end
|
217
|
+
|
218
|
+
context "when passed a version that matches no unaffected version" do
|
219
|
+
let(:version) { Gem::Version.new('3.0.9') }
|
220
|
+
|
221
|
+
it "should return false" do
|
222
|
+
expect(subject.unaffected?(version)).to be_falsey
|
223
|
+
end
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
describe "#patched?" do
|
228
|
+
context "when passed a version that matches one patched version" do
|
229
|
+
let(:version) { Gem::Version.new('3.1.11') }
|
230
|
+
|
231
|
+
it "should return true" do
|
232
|
+
expect(subject.patched?(version)).to be_truthy
|
233
|
+
end
|
234
|
+
end
|
235
|
+
|
236
|
+
context "when passed a version that matches no patched version" do
|
237
|
+
let(:version) { Gem::Version.new('2.9.0') }
|
238
|
+
|
239
|
+
it "should return false" do
|
240
|
+
expect(subject.patched?(version)).to be_falsey
|
241
|
+
end
|
242
|
+
end
|
243
|
+
end
|
244
|
+
|
245
|
+
describe "#vulnerable?" do
|
246
|
+
context "when passed a version that matches one patched version" do
|
247
|
+
let(:version) { Gem::Version.new('3.1.11') }
|
248
|
+
|
249
|
+
it "should return false" do
|
250
|
+
expect(subject.vulnerable?(version)).to be_falsey
|
251
|
+
end
|
252
|
+
end
|
253
|
+
|
254
|
+
context "when passed a version that matches no patched version" do
|
255
|
+
let(:version) { Gem::Version.new('2.9.0') }
|
256
|
+
|
257
|
+
it "should return true" do
|
258
|
+
expect(subject.vulnerable?(version)).to be_truthy
|
259
|
+
end
|
260
|
+
|
261
|
+
context "when unaffected_versions is not empty" do
|
262
|
+
subject { described_class.load(path) }
|
263
|
+
|
264
|
+
context "when passed a version that matches one unaffected version" do
|
265
|
+
let(:version) { Gem::Version.new(an_unaffected_version) }
|
266
|
+
|
267
|
+
it "should return false" do
|
268
|
+
expect(subject.vulnerable?(version)).to be_falsey
|
269
|
+
end
|
270
|
+
end
|
271
|
+
|
272
|
+
context "when passed a version that matches no unaffected version" do
|
273
|
+
let(:version) { Gem::Version.new('1.2.3') }
|
274
|
+
|
275
|
+
it "should return true" do
|
276
|
+
expect(subject.vulnerable?(version)).to be_truthy
|
277
|
+
end
|
278
|
+
end
|
279
|
+
end
|
280
|
+
end
|
281
|
+
end
|
282
|
+
end
|