myprecious 0.0.5 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: 8ba0e57f613ce257c9cf87d941a5874be82e654a
4
- data.tar.gz: b0845a7432c5cf316565fb6a0566210afd7d6f6b
2
+ SHA256:
3
+ metadata.gz: d1d5adc30e0ccc2fb1c7a183402c7ac0015a9fbdde70e18267ad61d21bf4d790
4
+ data.tar.gz: '07394d3a1ff9a238220b9ae4d2be7328a9878ee08c47ec7179e9564af53e9e60'
5
5
  SHA512:
6
- metadata.gz: f6f83c6bcbaea074c36a531749cd439c488e8bc3556343f121341491291e3930bb41fb9f3ba984fbda638e4e1d223728f3697391815a210fad1fc0a02962be0e
7
- data.tar.gz: e178d2961d1ef24c558b763433b5b611abef6c396ac1041dee9cf7037f425e5d6309efc87e86fc83e5f8b40d5d748464961929c65dc55155af2cc35a8c6961c0
6
+ metadata.gz: 6f152193febe7482d2e9aeb1f3435b56c84f127a897cd0e3c3f8e35c5027a93d955bcb04b09c1e16f6d3a691bb92e85ff15047e31ba0b956daa5664ff6430287
7
+ data.tar.gz: ba2ea748b6a1c5f566f28375776639cb774449c885b11db306d02f709b7d6c86d7727acad771fd9eedaab610fa5be93914aa30b30607ada1d66691a9362c1f20
@@ -1,4 +1,8 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
+ require 'rubygems'
3
4
  require 'myprecious'
4
- puts MyPrecious.update
5
+
6
+ MyPrecious::Program.run(
7
+ on_error: (:exit_program! unless MyPrecious.tracing_errors?)
8
+ )
@@ -1,111 +1,635 @@
1
- require 'gems'
2
1
  require 'date'
2
+ require 'git'
3
+ require 'ostruct'
4
+ require 'pathname'
5
+ require 'rake/toolkit_program'
6
+ require 'uri'
3
7
 
4
- class MyPrecious
5
- def self.update
6
- gem_lines = {}
7
- gem_name_pos = 0
8
- gem_version_pos = 1
9
- gem_latest_pos = 2
10
- gem_date_pos = 3
11
- gem_age_pos = 4
12
- gem_license_pos = 5
13
- gem_change_pos = 6
14
- default_length = 7
15
- if File.file?('myprecious.md')
16
- File.open("myprecious.md", "r").each_with_index do |line, line_number|
17
- #puts line + " dds " + line_number.to_s
18
- word_array = line.split('|')
19
- if line_number == 0
20
- default_length = word_array.size
21
- word_array.each_with_index do |word, index|
22
- if word.strip == 'Gem'
23
- gem_name_pos = index
24
- elsif word.strip == 'Our Version'
25
- gem_version_pos = index
26
- elsif word.strip == 'Latest Version'
27
- gem_latest_pos = index
28
- elsif word.strip == 'Date available'
29
- gem_date_pos = index
30
- elsif word.strip == 'Age (in days)'
31
- gem_age_pos = index
32
- elsif word.strip == 'License Type'
33
- gem_change_pos = index
34
- elsif word.strip == 'Change Log'
35
- gem_change_pos = index
36
- end
8
+ # Declare the module here so it doesn't cause problems for files in the
9
+ # "myprecious" directory (or _does_ cause problems if they try to declare
10
+ # it a class)
11
+ module MyPrecious
12
+ DATA_DIR = Pathname('~/.local/lib/myprecious').expand_path
13
+ ONE_DAY = 60 * 60 * 24
14
+ end
15
+ require 'myprecious/data_caches'
16
+ require 'myprecious/cves'
17
+
18
+ module MyPrecious
19
+ extend Rake::DSL
20
+
21
+ Program = Rake::ToolkitProgram
22
+ Program.title = "myprecious Dependecy Reporting Tool"
23
+
24
+ def self.tracing_errors?
25
+ !ENV['TRACE_ERRORS'].to_s.empty?
26
+ end
27
+
28
+ def self.common_program_args(parser, args)
29
+ parser.on(
30
+ '-o', '--out FILE',
31
+ "Output file to generate",
32
+ ) {|fpath| args.output_file = Pathname(fpath)}
33
+
34
+ args.target = Pathname.pwd
35
+ parser.on(
36
+ '-C', '--dir PATH',
37
+ "Path to inspect",
38
+ ) do |fpath|
39
+ fpath = Pathname(fpath)
40
+ parser.invalid_args!("#{fpath} does not exist.") unless fpath.exist?
41
+ args.target = fpath
42
+ CVEs.config_dir = fpath
43
+ end
44
+
45
+ parser.on(
46
+ '--[no-]cache',
47
+ "Control caching of dependency information"
48
+ ) {|v| MyPrecious.caching_disabled = v}
49
+ end
50
+
51
+ # Declare the tasks exposed as subcommands in this block
52
+ Program.command_tasks do
53
+ desc "Generate report on Ruby gems"
54
+ task('ruby-gems').parse_args(into: OpenStruct.new) do |parser, args|
55
+ parser.expect_positional_cardinality(0)
56
+ common_program_args(parser, args)
57
+ end
58
+ task 'ruby-gems' do
59
+ require 'myprecious/ruby_gems'
60
+ args = Program.args
61
+ out_fpath = args.output_file || Reporting.default_output_fpath(args.target)
62
+
63
+ col_order = Reporting.read_column_order_from(out_fpath)
64
+
65
+ # Get all gems used via RubyGemInfo.each_gem_used, accumulating version requirements
66
+ gems = RubyGemInfo.accum_gem_lock_info(args.target)
67
+
68
+ out_fpath.open('w') do |outf|
69
+ # Header
70
+ outf.puts "Last updated: #{Time.now.rfc2822}; Use for directional purposes only, this data is not real time and might be slightly inaccurate"
71
+ outf.puts
72
+
73
+ Reporting.header_lines(
74
+ col_order,
75
+ RubyGemInfo.method(:col_title)
76
+ ).each {|l| outf.puts l}
77
+
78
+ # Iterate all gems in name order, pulling column values from the RubyGemInfo objects
79
+ gems.keys.sort_by {|n| n.downcase}.map {|name| gems[name]}.each do |gem|
80
+ Reporting.on_dependency(gem.name) do
81
+ outf.puts col_order.markdown_columns(MarkdownAdapter.new(gem))
37
82
  end
38
- elsif line_number > 1
39
- gem_name_index = word_array[gem_name_pos].strip
40
- #extract just the name of the gem from the first column
41
- #since that column contains a markdown-formatted hyperlink
42
- gem_name_index = gem_name_index[/\[(.*?)\]/,1]
43
- gem_lines[gem_name_index] = line_number
44
83
  end
84
+ end
85
+
86
+ end
87
+
88
+ desc "Generate report on Python packages"
89
+ task('python-packages').parse_args(into: OpenStruct.new) do |parser, args|
90
+ parser.expect_positional_cardinality(0)
91
+ common_program_args(parser, args)
92
+
93
+ parser.on(
94
+ '-r', '--requirements-file FILE',
95
+ "requirements.txt-style file to read"
96
+ ) do |file|
97
+ if args.requirements_file
98
+ invalid_args!("Only one requirements file may be specified.")
99
+ end
100
+ args.requirements_file = file
101
+ end
102
+ end
103
+ task 'python-packages' do
104
+ require 'myprecious/python_packages'
105
+ args = Program.args
106
+ out_fpath = args.output_file || Reporting.default_output_fpath(args.target)
107
+
108
+ col_order = Reporting.read_column_order_from(out_fpath)
109
+
110
+ req_file = args.requirements_file
111
+ req_file ||= PyPackageInfo.guess_req_file(args.target)
112
+ req_file = args.target.join(req_file) unless req_file.nil?
113
+ if req_file.nil? || !req_file.exist?
114
+ invalid_args!("Unable to guess requirement file name; specify with '-r FILE' option.")
115
+ end
116
+ pkgs = PyPackageInfo::Reader.new(req_file).each_installed_package
117
+
118
+ out_fpath.open('w') do |outf|
119
+ # Header
120
+ outf.puts "Last updated: #{Time.now.rfc2822}; Use for directional purposes only, this data is not real time and might be slightly inaccurate"
121
+ outf.puts
122
+
123
+ Reporting.header_lines(
124
+ col_order,
125
+ PyPackageInfo.method(:col_title)
126
+ ).each {|l| outf.puts l}
45
127
 
128
+ pkgs = pkgs.to_a
129
+ pkgs.sort_by! {|pkg| pkg.name.downcase}
130
+ pkgs.each do |pkg|
131
+ Reporting.on_dependency(pkg.name) do
132
+ outf.puts col_order.markdown_columns(MarkdownAdapter.new(pkg))
133
+ end
134
+ end
46
135
  end
47
- #puts gem_lines
48
- else
49
- File.open('myprecious.md', 'w') { |write_file|
50
- write_file.puts "Gem | Our Version | Latest Version | Date available | Age (in days) | License Type | Change Log"
51
- write_file.puts "--- | --- | --- | --- | --- | --- | ---"
52
- }
53
136
  end
54
-
55
- file = File.new("Gemfile", "r")
56
-
57
- final_write = File.readlines('myprecious.md')
58
-
59
- while (line = file.gets)
60
- gem_line = line.strip
61
- if (gem_line.include? 'gem') && !gem_line.start_with?('#') && !gem_line.start_with?('source')
137
+
138
+ desc "Clear all myprecious data caches"
139
+ task('clear-caches').parse_args(into: OpenStruct.new) do |parser, args|
140
+ args.prompt = true
141
+ parser.on('--[no-]confirm', "Control confirmation prompt") {|v| args.prompt = v}
142
+
143
+ # This option exists to force users to specify the full "--no-confirm"
144
+ parser.on('--no-confir', "Warn about incomplete --no-confirm") do
145
+ parser.invalid_args!("Incomplete --no-confirm flag")
146
+ end
147
+ end
148
+ task 'clear-caches' do
149
+ # Load all "myprecious/*.rb" files so we know where all the caches are
150
+ Dir[File.expand_path('myprecious/*.rb', __dir__)].each {|f| require f}
151
+
152
+ next if Program.args.prompt && !yes_no('Delete all cached data (y/n)?')
153
+ MyPrecious.data_caches.each do |cache|
62
154
  begin
63
- name = gem_line.split(' ')[1].split(',')[0].tr(" '\"", "")
64
- a = require name
65
- if a
66
- current_version = Gem::Specification.find_all_by_name(name).max.version
67
-
68
- gems_info = Gems.versions name
69
- gems_latest_info = Gems.info name
70
- latest_build = ''
71
- gems_info.each do |gem_info|
72
- if gem_info["number"].to_s == gems_latest_info["version"].to_s
73
- latest_build = Date.parse gem_info["built_at"]
74
- end
75
- if gem_info["number"].to_s == current_version.to_s
76
- current_build = Date.parse gem_info["built_at"]
77
-
78
- days_complete = latest_build - current_build
79
- if gem_lines[name].nil?
80
- array_to_write = Array.new(default_length) { |i| "" }
81
- else
82
- array_to_write = final_write[gem_lines[name]].split('|')
83
- end
84
- array_to_write[gem_name_pos] = "[" + name + "]" + "(" + gems_latest_info["homepage_uri"].to_s + ")"
85
- array_to_write[gem_version_pos] = current_version.to_s
86
- array_to_write[gem_latest_pos] = gems_latest_info["version"].to_s
87
- array_to_write[gem_date_pos] = (latest_build).to_s
88
- array_to_write[gem_age_pos] = days_complete.to_i.to_s
89
- if !gem_info["licenses"].nil?
90
- array_to_write[gem_license_pos] = gem_info["licenses"][0]
91
- else
92
- array_to_write[gem_license_pos] = "N/A"
93
- end
94
- array_to_write[gem_change_pos] = gems_latest_info["changelog_uri"].to_s + "\n"
95
- if !gem_lines[name].nil?
96
- final_write[gem_lines[name]] = array_to_write.join("|")
97
- else
98
- final_write << array_to_write.join("|")
99
- end
155
+ rm_r cache
156
+ rescue Errno::ENOENT
157
+ # No problem, we wanted to delete this anyway
158
+ end
159
+ end
160
+ end
161
+ end
162
+
163
+ ##
164
+ # Prompt user for a yes/no answer
165
+ #
166
+ # It doesn't matter if they've redirected STDIN/STDOUT -- this grabs the TTY
167
+ # directly.
168
+ #
169
+ def self.yes_no(prompt)
170
+ Pathname('/dev/tty').open('r+') do |term|
171
+ loop do
172
+ term.write("#{prompt} ")
173
+ case term.gets[0..-2]
174
+ when 'y', 'Y', 'yes', 'Yes', 'YES'
175
+ return true
176
+ when 'n', 'N', 'no', 'No', 'NO'
177
+ return false
178
+ end
179
+ end
180
+ end
181
+ end
182
+
183
+ ##
184
+ # Tool for getting information about the Git repository associated with a
185
+ # directory
186
+ #
187
+ class GitInfoExtractor
188
+ URL_PATTERN = /\/([^\/]+)\.git$/
189
+
190
+ def initialize(dir)
191
+ super()
192
+ @dir = dir
193
+ end
194
+ attr_reader :dir
195
+
196
+ def git_info
197
+ @git_info ||= Git.open(self.dir)
198
+ end
199
+
200
+ def origin_remote
201
+ git_info.remotes.find {|r| r.name == 'origin'}
202
+ end
203
+
204
+ def repo_name
205
+ @repo_name ||= (URL_PATTERN =~ origin_remote.url) && $1
206
+ end
207
+ end
208
+
209
+ ##
210
+ # Common behavior in dependency report generation
211
+ #
212
+ # The methods here are not specific to any language or dependency management
213
+ # framework. They do work with ColumnOrder and an expected set of attributes
214
+ # on the dependency information objects.
215
+ #
216
+ module Reporting
217
+ ##
218
+ # Compute the default output filepath for a directory
219
+ #
220
+ def default_output_fpath(dir)
221
+ dir / (GitInfoExtractor.new(dir).repo_name + "-dependency-tracking.md")
222
+ end
223
+ module_function :default_output_fpath
224
+
225
+ ##
226
+ # Read the column order from the file at the given path
227
+ #
228
+ # If +fpath+ indicates a file that does not exist, return the default
229
+ # ColumnOrder.
230
+ #
231
+ def read_column_order_from(fpath)
232
+ result = ColumnOrder.new
233
+ begin
234
+ prev_line = nil
235
+ fpath.open {|inf| inf.each_line do |line|
236
+ if prev_line && /^-+(?:\|-+)*$/ =~ line.gsub(' ', '')
237
+ result.read_order_from_headers(prev_line)
238
+ break
239
+ end
240
+ prev_line = line
241
+ end}
242
+ rescue Errno::ENOENT
243
+ # No problem
244
+ end
245
+ return result
246
+ end
247
+ module_function :read_column_order_from
248
+
249
+ ##
250
+ # Generate header lines for the output Markdown table
251
+ #
252
+ # Returns an Array of strings, currently two lines giving header text and
253
+ # divider row.
254
+ #
255
+ def header_lines(order, titlizer)
256
+ col_titles = order.map {|c| titlizer.call(c)}
257
+
258
+ # Check that all attributes in #order round-trip through the title name
259
+ order.zip(col_titles) do |attr, title|
260
+ unless title.kind_of?(Symbol) || ColumnOrder.col_from_text_name(title) == attr
261
+ raise "'#{attr}' does not round-trip (rendered as #{result.inspect})"
262
+ end
263
+ end
264
+
265
+ return [
266
+ col_titles.join(" | "),
267
+ (["---"] * col_titles.length).join(" | "),
268
+ ]
269
+ end
270
+ module_function :header_lines
271
+ # TODO: Mark dependencies with colors a la https://stackoverflow.com/a/41247934
272
+
273
+ ##
274
+ # Converts an attribute name to the column title for generic attributes
275
+ #
276
+ # Dependency info classes (like RubyGemInfo) can delegate common column
277
+ # title generation to this function.
278
+ #
279
+ def common_col_title(attr)
280
+ case attr
281
+ when :current_version then 'Our Version'
282
+ when :age then 'Age (days)'
283
+ when :latest_version then 'Latest Version'
284
+ when :latest_released then 'Date Available'
285
+ when :recommended_version then 'Recommended Version'
286
+ when :license then 'License Type'
287
+ when :changelog then 'Change Log'
288
+ when :obsolescence then 'How Bad'
289
+ when :cves then 'CVEs'
290
+ else
291
+ warn("'#{attr}' column does not have a mapped name")
292
+ attr
293
+ end
294
+ end
295
+ module_function :common_col_title
296
+
297
+ ##
298
+ # Determine obsolescence level from days
299
+ #
300
+ # Returns one of +nil+, +:mild+, +:moderate+, or +:severe+.
301
+ #
302
+ # +at_least_moderate:+ allows putting a floor of +:moderate+ obsolescence
303
+ # on the result.
304
+ #
305
+ def obsolescence_by_age(days, at_least_moderate: false)
306
+ return case
307
+ when days < 270
308
+ at_least_moderate ? :moderate : nil
309
+ when days < 500
310
+ at_least_moderate ? :moderate : :mild
311
+ when days < 730
312
+ :moderate
313
+ else
314
+ :severe
315
+ end
316
+ end
317
+ module_function :obsolescence_by_age
318
+
319
+ ##
320
+ # Wrap output from processing of an individual dependency with intro and
321
+ # outro messages
322
+ #
323
+ def on_dependency(name)
324
+ progress_out = $stdout
325
+ progress_out.puts("--- Reporting on #{name}...")
326
+ yield
327
+ progress_out.puts(" (done)")
328
+ end
329
+ module_function :on_dependency
330
+ end
331
+
332
+ ##
333
+ # Order of columns in a Markdown table
334
+ #
335
+ # Contains the default column ordering when constructed. Columns are
336
+ # identified by the Symbol commonly used as an attribute on a dependency
337
+ # info object (e.g. RubyGemInfo instance). Objects of this class behave
338
+ # to some extent like frozen Array instances.
339
+ #
340
+ class ColumnOrder
341
+ DEFAULT = %i[name current_version age latest_version latest_released recommended_version cves license changelog].freeze
342
+ COLUMN_FROM_TEXT_NAME = {
343
+ 'gem' => :name,
344
+ 'package' => :name,
345
+ 'module' => :name,
346
+ 'our version' => :current_version,
347
+ 'how bad' => :obsolescence,
348
+ 'latest version' => :latest_version,
349
+ 'date available' => :latest_released,
350
+ 'age (days)' => :age,
351
+ 'license type' => :license,
352
+ /change ?log/ => :changelog,
353
+ 'recommended version' => :recommended_version,
354
+ 'cves' => :cves,
355
+ }
356
+
357
+ def initialize
358
+ super
359
+ @order = DEFAULT
360
+ end
361
+
362
+ ##
363
+ # Get the +n+-th column attribute Symbol
364
+ #
365
+ def [](n)
366
+ @order[n]
367
+ end
368
+
369
+ def length
370
+ @order.length
371
+ end
372
+
373
+ def each(&blk)
374
+ @order.each(&blk)
375
+ end
376
+ include Enumerable
377
+
378
+ ##
379
+ # Update the column order to match those in the given line
380
+ #
381
+ # Columns not included in the line are appended in the order they
382
+ # appear in the default order.
383
+ #
384
+ def read_order_from_headers(headers_line)
385
+ headers = headers_line.split('|').map {|h| h.strip.squeeze(' ')}
386
+ @order = headers.map {|h| self.class.col_from_text_name(h)}.compact
387
+
388
+ # Add in any missing columns at the end
389
+ @order.concat(DEFAULT - @order)
390
+
391
+ return @order.dup
392
+ end
393
+
394
+ ##
395
+ # Render a line to include in a Markdown table for the given dependency
396
+ #
397
+ # The dependency must know how to respond to (Ruby) messages (i.e.
398
+ # have attributes) for all columns currently included in the order as
399
+ # represented by this instance.
400
+ #
401
+ def markdown_columns(dependency)
402
+ map {|attr| dependency.send(attr)}.join(" | ")
403
+ end
404
+
405
+ ##
406
+ # Given a text name, derive the equivalent column attribute
407
+ #
408
+ def self.col_from_text_name(n)
409
+ n = n.downcase
410
+ entry = COLUMN_FROM_TEXT_NAME.find {|k, v| k === n}
411
+ return entry && entry[1]
412
+ end
413
+ end
414
+
415
+ ##
416
+ # Extension of String that can accomodate some additional commentary
417
+ #
418
+ # The +update_info+ attribute is used to pass information about changes
419
+ # to licensing between the current and recommended version of a dependency,
420
+ # and may be +nil+.
421
+ #
422
+ class LicenseDescription < String
423
+ attr_accessor :update_info
424
+ end
425
+
426
+ ##
427
+ # Dependency info wrapper to generate nice Markdown columns
428
+ #
429
+ # This wrapper takes basic data from the underlying dependency info object
430
+ # and returns enhanced Markdown for selected columns (e.g. +name+).
431
+ #
432
+ class MarkdownAdapter
433
+ QS_VALUE_UNSAFE = /#{URI::UNSAFE}|[&=]/
434
+
435
+ def initialize(dep)
436
+ super()
437
+ @dependency = dep
438
+ end
439
+ attr_reader :dependency
440
+
441
+ def self.embellish(attr_name, &blk)
442
+ define_method(attr_name) do
443
+ value = begin
444
+ dependency.send(attr_name)
445
+ rescue NoMethodError
446
+ raise
447
+ rescue StandardError
448
+ return "(error)"
449
+ end
450
+
451
+ begin
452
+ instance_exec(value, &blk)
453
+ rescue StandardError => ex
454
+ err_key = [ex.backtrace[0], ex.to_s]
455
+ unless (@errors ||= Set.new).include?(err_key)
456
+ @errors << err_key
457
+ if MyPrecious.tracing_errors?
458
+ $stderr.puts("Traceback (most recent call last):")
459
+ ex.backtrace[1..-1].reverse.each_with_index do |loc, i|
460
+ $stderr.puts("#{(i + 1).to_s.rjust(8)}: #{loc}")
100
461
  end
462
+ $stderr.puts("#{ex.backtrace[0]}: #{ex} (#{ex.class} while computing #{attr_name})")
463
+ else
464
+ $stderr.puts("#{ex} (while computing #{attr_name})")
101
465
  end
102
466
  end
103
- rescue Exception => e
104
- puts e
467
+ value
468
+ end
469
+ end
470
+ end
471
+
472
+ ##
473
+ # Generate Markdown linking the +name+ to the homepage for the dependency
474
+ #
475
+ embellish(:name) do |base_val|
476
+ cswatch = begin
477
+ color_swatch + ' '
478
+ rescue StandardError
479
+ ''
480
+ end
481
+ "#{cswatch}[#{base_val}](#{dependency.homepage_uri})"
482
+ rescue Gems::NotFound
483
+ base_val
484
+ end
485
+
486
+ ##
487
+ # Decorate the latest version as a link to the release history
488
+ #
489
+ embellish(:latest_version) do |base_val|
490
+ release_history_url = begin
491
+ dependency.release_history_url
492
+ rescue StandardError
493
+ nil
494
+ end
495
+
496
+ if release_history_url
497
+ "[#{base_val}](#{release_history_url})"
498
+ else
499
+ base_val
500
+ end
501
+ end
502
+
503
+ ##
504
+ # Include information about temporal difference between current and
505
+ # recommended versions
506
+ #
507
+ embellish(:recommended_version) do |recced_ver|
508
+ if recced_ver.kind_of?(String)
509
+ recced_ver = Gem::Version.new(recced_ver)
510
+ end
511
+ next recced_ver if dependency.current_version.nil? || dependency.current_version >= recced_ver
512
+
513
+ span_comment = begin
514
+ if days_newer = dependency.days_between_current_and_recommended
515
+ " -- #{days_newer} days newer"
516
+ else
517
+ ""
518
+ end
519
+ end
520
+
521
+ cve_url = URI('https://nvd.nist.gov/products/cpe/search/results')
522
+ cve_url.query = URI.encode_www_form(
523
+ keyword: "cpe:2.3:a:*:#{dependency.name.downcase}:#{recced_ver}",
524
+ )
525
+
526
+ "**#{recced_ver}**#{span_comment} ([current CVEs](#{cve_url}))"
527
+ end
528
+
529
+ ##
530
+ # Include update info in the license column
531
+ #
532
+ embellish(:license) do |base_val|
533
+ begin
534
+ update_info = base_val.update_info
535
+ rescue NoMethodError
536
+ base_val
537
+ else
538
+ update_info.to_s.empty? ? base_val : "#{base_val}<br/>(#{update_info})"
539
+ end
540
+ end
541
+
542
+ ##
543
+ # Render short links for http: or https: changelog URLs
544
+ #
545
+ embellish(:changelog) do |base_val|
546
+ begin
547
+ uri = URI.parse(base_val)
548
+ if ['http', 'https'].include?(uri.scheme)
549
+ next "[on #{uri.hostname}](#{base_val})"
105
550
  end
551
+ rescue StandardError
106
552
  end
553
+
554
+ base_val
555
+ end
556
+
557
+ ##
558
+ # Render links to NIST's NVD
559
+ #
560
+ NVD_CVE_URL_TEMPLATE = "https://nvd.nist.gov/vuln/detail/%s"
561
+ embellish(:cves) do |base_val|
562
+ base_val.map do |cve|
563
+ link_text_parts = [cve]
564
+ addnl_info_parts = []
565
+ begin
566
+ addnl_info_parts << cve.vendors.to_a.join(',') unless cve.vendors.empty?
567
+ rescue StandardError
568
+ end
569
+ begin
570
+ addnl_info_parts << cve.score.to_s if cve.score
571
+ rescue StandardError
572
+ end
573
+ unless addnl_info_parts.empty?
574
+ link_text_parts << "(#{addnl_info_parts.join(' - ')})"
575
+ end
576
+ "[#{link_text_parts.join(' ')}](#{NVD_CVE_URL_TEMPLATE % cve})"
577
+ end.join(', ')
578
+ end
579
+
580
+ def obsolescence
581
+ color_swatch
582
+ rescue StandardError
583
+ ''
584
+ end
585
+
586
+ ##
587
+ # Get a CSS-style hex color code corresponding to the obsolescence of the dependency
588
+ #
589
+ def color
590
+ red = "fb0e0e"
591
+
592
+ if (dependency.cves.map(&:score).compact.max || 0) >= 7
593
+ return red
594
+ end
595
+
596
+ case dependency.obsolescence
597
+ when :mild then "dde418"
598
+ when :moderate then "f9b733"
599
+ when :severe then red
600
+ else "4dda1b"
601
+ end
602
+ end
603
+
604
+ ##
605
+ # Markdown for an obsolescence color swatch
606
+ #
607
+ # Sourced from: https://stackoverflow.com/a/41247934
608
+ #
609
+ def color_swatch
610
+ "![##{color}](https://placehold.it/15/#{color}/000000?text=+)"
611
+ end
612
+
613
+ ##
614
+ # Delegate other attribute queries to the base dependency object
615
+ #
616
+ # Errors are caught and rendered as "(error)"
617
+ #
618
+ def method_missing(meth, *args, &blk)
619
+ dependency.send(meth, *args, &blk)
620
+ rescue NoMethodError
621
+ raise
622
+ rescue StandardError
623
+ "(error)"
624
+ end
625
+ end
626
+
627
+ module URIModuleMethods
628
+ def try_parse(s)
629
+ parse(s)
630
+ rescue URI::InvalidURIError
631
+ nil
107
632
  end
108
- File.open('myprecious.md', 'w') { |f| f.write(final_write.join) }
109
- file.close
110
633
  end
634
+ URI.extend(URIModuleMethods)
111
635
  end