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.
@@ -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