myprecious 0.0.5 → 0.2.0

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