package-audit 0.5.1 → 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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/lib/package/audit/cli.rb +11 -9
  3. data/lib/package/audit/const/cmd.rb +2 -2
  4. data/lib/package/audit/enum/format.rb +14 -0
  5. data/lib/package/audit/enum/option.rb +1 -1
  6. data/lib/package/audit/enum/technology.rb +1 -1
  7. data/lib/package/audit/models/package.rb +2 -2
  8. data/lib/package/audit/npm/node_collection.rb +17 -0
  9. data/lib/package/audit/npm/npm_meta_data.rb +24 -2
  10. data/lib/package/audit/npm/vulnerability_finder.rb +7 -1
  11. data/lib/package/audit/ruby/bundler_specs.rb +16 -1
  12. data/lib/package/audit/services/command_parser.rb +56 -15
  13. data/lib/package/audit/services/config_cleaner.rb +221 -0
  14. data/lib/package/audit/services/package_filter.rb +24 -4
  15. data/lib/package/audit/services/package_finder.rb +1 -1
  16. data/lib/package/audit/services/package_printer.rb +65 -56
  17. data/lib/package/audit/technology/validator.rb +7 -14
  18. data/lib/package/audit/util/risk_legend.rb +49 -0
  19. data/lib/package/audit/util/spinner.rb +1 -1
  20. data/lib/package/audit/util/summary_printer.rb +58 -45
  21. data/lib/package/audit/version.rb +1 -1
  22. metadata +12 -52
  23. data/sig/package/audit/cli.rbs +0 -33
  24. data/sig/package/audit/const/cmd.rbs +0 -14
  25. data/sig/package/audit/const/fields.rbs +0 -11
  26. data/sig/package/audit/const/file.rbs +0 -14
  27. data/sig/package/audit/const/time.rbs +0 -11
  28. data/sig/package/audit/const/yaml.rbs +0 -13
  29. data/sig/package/audit/enum/group.rbs +0 -15
  30. data/sig/package/audit/enum/option.rbs +0 -14
  31. data/sig/package/audit/enum/report.rbs +0 -12
  32. data/sig/package/audit/enum/risk_explanation.rbs +0 -12
  33. data/sig/package/audit/enum/risk_type.rbs +0 -12
  34. data/sig/package/audit/enum/technology.rbs +0 -12
  35. data/sig/package/audit/enum/vulnerability_type.rbs +0 -15
  36. data/sig/package/audit/formatter/base.rbs +0 -9
  37. data/sig/package/audit/formatter/risk_printer.rbs +0 -13
  38. data/sig/package/audit/formatter/version_date.rbs +0 -13
  39. data/sig/package/audit/formatter/version_printer.rbs +0 -14
  40. data/sig/package/audit/formatter/vulnerability.rbs +0 -13
  41. data/sig/package/audit/models/package.rbs +0 -47
  42. data/sig/package/audit/models/risk.rbs +0 -12
  43. data/sig/package/audit/npm/node_collection.rbs +0 -28
  44. data/sig/package/audit/npm/npm_meta_data.rbs +0 -19
  45. data/sig/package/audit/npm/vulnerability_finder.rbs +0 -21
  46. data/sig/package/audit/npm/yarn_lock_parser.rbs +0 -22
  47. data/sig/package/audit/ruby/bundler_specs.rbs +0 -11
  48. data/sig/package/audit/ruby/gem_collection.rbs +0 -22
  49. data/sig/package/audit/ruby/gem_meta_data.rbs +0 -23
  50. data/sig/package/audit/ruby/vulnerability_finder.rbs +0 -18
  51. data/sig/package/audit/services/command_parser.rbs +0 -31
  52. data/sig/package/audit/services/duplicate_package_merger.rbs +0 -11
  53. data/sig/package/audit/services/package_filter.rbs +0 -19
  54. data/sig/package/audit/services/package_finder.rbs +0 -26
  55. data/sig/package/audit/services/package_printer.rbs +0 -24
  56. data/sig/package/audit/services/risk_calculator.rbs +0 -21
  57. data/sig/package/audit/technology/detector.rbs +0 -19
  58. data/sig/package/audit/technology/validator.rbs +0 -19
  59. data/sig/package/audit/util/bash_color.rbs +0 -21
  60. data/sig/package/audit/util/spinner.rbs +0 -24
  61. data/sig/package/audit/util/summary_printer.rbs +0 -19
  62. data/sig/package/audit/version.rbs +0 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3987dbcffb0bef510d5897ad47ec79aa5ba65572c62d1a78003496a44264ca7e
4
- data.tar.gz: f2608608cee05dde5a409e9dc6a4c885b208ee7d52cc7d6e745e0d3ccf37d7b7
3
+ metadata.gz: c4f817af73ba4616da54a934f0f6c4120d921e6026226c20a02c592c6b55e064
4
+ data.tar.gz: 818282a8f489ba8afd7b9675b78db96c3e2b9b18731415085ff44c25eaf07a77
5
5
  SHA512:
6
- metadata.gz: baa304f965258c639f7e4bee858da18ddd74bfb83926a66d95ce43367ac5bfcf363e4847dd56c98ed649cda9d7143cbb44bb2015d1c7dc263b73f8942538011e
7
- data.tar.gz: a5adfb16e863dacea34dc1d1ca8e4962e76ab984f766d86073f2a5bcfceea9923d2c048ae11487e8a0644d9420025a6dba61f57dede9b6faaf74cd82954f5f52
6
+ metadata.gz: 46267de0e991aaf0e1fdeb4f8c778cc2a59abf49f7d7a0849ae9731c9fb633ae8c958ac1865bf17aa98d50cd359e8e30d16975031f4dfd4fc1a7c2aacf6868fc
7
+ data.tar.gz: 65c6acb5e9e224f6736781f1db26eee0ec7772f220c87b8e8da1f2cecc872cfb667f78336314ad4b670000f849c60051087cdc9cda1b9e69ad11d896b717c471
@@ -1,7 +1,9 @@
1
1
  require_relative 'const/file'
2
2
  require_relative 'const/time'
3
+ require_relative 'enum/format'
3
4
  require_relative 'enum/option'
4
5
  require_relative 'services/command_parser'
6
+ require_relative 'util//risk_legend'
5
7
  require_relative 'version'
6
8
 
7
9
  require 'json'
@@ -10,7 +12,7 @@ require 'thor'
10
12
  module Package
11
13
  module Audit
12
14
  class CLI < Thor
13
- default_task :report
15
+ default_task :default
14
16
 
15
17
  class_option Enum::Option::CONFIG,
16
18
  aliases: '-c', banner: 'FILE',
@@ -24,18 +26,18 @@ module Package
24
26
  class_option Enum::Option::INCLUDE_IGNORED,
25
27
  type: :boolean, default: false,
26
28
  desc: 'Include packages ignored by a configuration file'
27
- class_option Enum::Option::CSV,
28
- type: :boolean, default: false,
29
- desc: 'Output reports using comma separated values (CSV)'
29
+ class_option Enum::Option::FORMAT,
30
+ aliases: '-f', banner: Enum::Format.all.join('|'), type: :string,
31
+ desc: 'Output reports using a different format (e.g. CSV or Markdown)'
30
32
  class_option Enum::Option::CSV_EXCLUDE_HEADERS,
31
33
  type: :boolean, default: false,
32
- desc: "Hide headers when using the --#{Enum::Option::CSV} option"
34
+ desc: "Hide headers when using the #{Enum::Format::CSV} format"
33
35
 
34
36
  map '-v' => :version
35
37
  map '--version' => :version
36
38
 
37
- desc 'report [DIR]', 'Show a report of potentially deprecated, outdated or vulnerable packages'
38
- def report(dir = Dir.pwd)
39
+ desc '[DIR]', 'Show a report of potentially deprecated, outdated or vulnerable packages'
40
+ def default(dir = Dir.pwd)
39
41
  within_rescue_block { exit CommandParser.new(dir, options, Enum::Report::ALL).run }
40
42
  end
41
43
 
@@ -57,7 +59,7 @@ module Package
57
59
 
58
60
  desc 'risk', 'Print information on how risk is calculated'
59
61
  def risk
60
- Util::SummaryPrinter.risk
62
+ Util::RiskLegend.print
61
63
  end
62
64
 
63
65
  desc 'version', 'Print the currently installed version of the package-audit gem'
@@ -70,7 +72,7 @@ module Package
70
72
  end
71
73
 
72
74
  def method_missing(command, *args)
73
- invoke :report, [command], args
75
+ invoke :default, [command], args
74
76
  end
75
77
 
76
78
  def respond_to_missing?
@@ -3,13 +3,13 @@ module Package
3
3
  module Const
4
4
  module Cmd
5
5
  BUNDLE_AUDIT = 'bundle-audit check --update'
6
- BUNDLE_AUDIT_JSON = 'bundle-audit check --update --quiet --format json %s'
6
+ BUNDLE_AUDIT_JSON = 'bundle-audit check --update --quiet --format json "%s" 2>/dev/null'
7
7
 
8
8
  NPM_AUDIT = 'npm audit'
9
9
  NPM_AUDIT_JSON = 'npm audit --json'
10
10
 
11
11
  YARN_AUDIT = 'yarn audit'
12
- YARN_AUDIT_JSON = 'yarn audit --json --cwd %s'
12
+ YARN_AUDIT_JSON = 'yarn audit --json --cwd "%s"'
13
13
  end
14
14
  end
15
15
  end
@@ -0,0 +1,14 @@
1
+ module Package
2
+ module Audit
3
+ module Enum
4
+ module Format
5
+ CSV = 'csv'
6
+ MARKDOWN = 'md'
7
+
8
+ def self.all
9
+ constants.map { |key| const_get(key) }.sort
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -3,7 +3,7 @@ module Package
3
3
  module Enum
4
4
  module Option
5
5
  CONFIG = 'config'
6
- CSV = 'csv'
6
+ FORMAT = 'format'
7
7
  CSV_EXCLUDE_HEADERS = 'exclude-headers'
8
8
  GROUP = 'group'
9
9
  INCLUDE_IGNORED = 'include-ignored'
@@ -6,7 +6,7 @@ module Package
6
6
  RUBY = 'ruby'
7
7
 
8
8
  def self.all
9
- constants.map { |key| Enum::Technology.const_get(key) }.sort
9
+ constants.map { |key| const_get(key) }.sort
10
10
  end
11
11
  end
12
12
  end
@@ -25,7 +25,7 @@ module Package
25
25
  end
26
26
 
27
27
  def update(**attr)
28
- attr.each { |key, value| instance_variable_set("@#{key}", value) }
28
+ attr.each { |key, value| instance_variable_set(:"@#{key}", value) }
29
29
  end
30
30
 
31
31
  def risk
@@ -41,7 +41,7 @@ module Package
41
41
  end
42
42
 
43
43
  def group_list
44
- @groups.join('|')
44
+ @groups.join(' ')
45
45
  end
46
46
 
47
47
  def vulnerabilities_grouped
@@ -58,9 +58,26 @@ module Package
58
58
  package_json = JSON.parse(File.read("#{@dir}/#{Const::File::PACKAGE_JSON}"), symbolize_names: true)
59
59
  default_deps = package_json[:dependencies] || {}
60
60
  dev_deps = package_json[:devDependencies] || {}
61
+
62
+ # Filter out local dependencies before processing
63
+ default_deps = filter_local_dependencies(default_deps)
64
+ dev_deps = filter_local_dependencies(dev_deps)
65
+
61
66
  [default_deps, dev_deps]
62
67
  end
63
68
 
69
+ def filter_local_dependencies(dependencies)
70
+ dependencies.reject { |_name, version| local_dependency?(version) }
71
+ end
72
+
73
+ def local_dependency?(version)
74
+ # Check for local file paths
75
+ version.to_s.start_with?('file:', 'link:', './', '../') ||
76
+ version.to_s.include?('file:') ||
77
+ # Check for git repositories with local paths
78
+ (version.to_s.start_with?('git+') && version.to_s.include?('file:'))
79
+ end
80
+
64
81
  def fetch_from_lock_file
65
82
  default_deps, dev_deps = fetch_from_package_json
66
83
  if File.exist?("#{@dir}/#{Const::File::YARN_LOCK}")
@@ -1,5 +1,6 @@
1
1
  require 'json'
2
2
  require 'net/http'
3
+ require 'socket'
3
4
 
4
5
  module Package
5
6
  module Audit
@@ -11,7 +12,7 @@ module Package
11
12
  @packages = packages
12
13
  end
13
14
 
14
- def fetch # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
15
+ def fetch # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
15
16
  threads = @packages.map do |package|
16
17
  Thread.new do
17
18
  response = Net::HTTP.get_response(URI.parse("#{REGISTRY_URL}/#{package.name}"))
@@ -20,14 +21,35 @@ module Package
20
21
 
21
22
  json_package = JSON.parse(response.body, symbolize_names: true)
22
23
  update_meta_data(package, json_package)
24
+ rescue Net::TimeoutError, Net::OpenTimeout, Net::ReadTimeout => e
25
+ warn "Warning: Network timeout while fetching metadata for #{package.name}: #{e.message}"
26
+ Thread.current[:exception] = e
27
+ rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH => e
28
+ warn "Warning: Network error while fetching metadata for #{package.name}: #{e.message}"
29
+ Thread.current[:exception] = e
23
30
  rescue StandardError => e
24
31
  Thread.current[:exception] = e
25
32
  end
26
33
  end
34
+
35
+ network_errors = []
27
36
  threads.each do |thread|
28
37
  thread.join
29
- raise thread[:exception] if thread[:exception]
38
+ next unless thread[:exception]
39
+
40
+ case thread[:exception]
41
+ when Net::TimeoutError, Net::OpenTimeout, Net::ReadTimeout, SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH # rubocop:disable Layout/LineLength
42
+ network_errors << thread[:exception]
43
+ else
44
+ raise thread[:exception]
45
+ end
46
+ end
47
+
48
+ unless network_errors.empty?
49
+ warn "Warning: #{network_errors.size} network error(s) occurred while fetching package metadata."
50
+ warn 'Some packages may not show complete version information.'
30
51
  end
52
+
31
53
  @packages
32
54
  end
33
55
 
@@ -1,3 +1,5 @@
1
+ require 'open3'
2
+
1
3
  require_relative '../const/cmd'
2
4
  require_relative '../enum/vulnerability_type'
3
5
 
@@ -14,7 +16,11 @@ module Package
14
16
  end
15
17
 
16
18
  def run
17
- json_string_lines = `#{format(Const::Cmd::YARN_AUDIT_JSON, @dir)}`
19
+ # Suppress Node.js url.parse deprecation warnings from yarn audit command
20
+ command = format(Const::Cmd::YARN_AUDIT_JSON, @dir)
21
+ env_vars = { 'NODE_NO_WARNINGS' => '1' }
22
+
23
+ json_string_lines, = Open3.capture3(env_vars, command)
18
24
  array = json_string_lines.scan(AUDIT_ADVISORY_REGEX)
19
25
 
20
26
  vulnerability_json_array = JSON.parse("[#{array.join(',')}]", symbolize_names: true)
@@ -9,15 +9,17 @@ module Package
9
9
  module Ruby
10
10
  class BundlerSpecs
11
11
  def self.all(dir)
12
- Bundler.with_unbundled_env do
12
+ specs = Bundler.with_unbundled_env do
13
13
  ENV['BUNDLE_GEMFILE'] = "#{dir}/Gemfile"
14
14
  Bundler.ui.silence { Bundler.definition.resolve }
15
15
  end
16
+ filter_local_dependencies(specs)
16
17
  end
17
18
 
18
19
  def self.gemfile(dir)
19
20
  current_dependencies = Bundler.with_unbundled_env do
20
21
  ENV['BUNDLE_GEMFILE'] = "#{dir}/Gemfile"
22
+ Bundler.ui.level = 'error'
21
23
  Bundler.reset!
22
24
  Bundler.ui.silence do
23
25
  Bundler.load.dependencies.to_h { |dep| [dep.name, dep] }
@@ -29,6 +31,19 @@ module Package
29
31
  end
30
32
  gemfile_specs
31
33
  end
34
+
35
+ def self.filter_local_dependencies(specs)
36
+ specs.reject { |spec| local_dependency?(spec) }
37
+ end
38
+
39
+ def self.local_dependency?(spec)
40
+ # Check if the gem has a local source (path or git with local path)
41
+ source = spec.source
42
+ return true if source.is_a?(Bundler::Source::Path)
43
+ return true if source.is_a?(Bundler::Source::Git) && source.uri.start_with?('file:', './', '../')
44
+
45
+ false
46
+ end
32
47
  end
33
48
  end
34
49
  end
@@ -6,6 +6,7 @@ require_relative '../technology/detector'
6
6
  require_relative '../technology/validator'
7
7
  require_relative '../util/spinner'
8
8
  require_relative '../util/summary_printer'
9
+ require_relative 'config_cleaner'
9
10
  require_relative 'package_finder'
10
11
  require_relative 'package_printer'
11
12
 
@@ -13,20 +14,36 @@ require 'yaml'
13
14
 
14
15
  module Package
15
16
  module Audit
16
- class CommandParser
17
+ class CommandParser # rubocop:disable Metrics/ClassLength
17
18
  def initialize(dir, options, report)
18
19
  @dir = dir
19
20
  @options = options
20
21
  @report = report
21
- @config = parse_config_file
22
+ @config = parse_config_file!
22
23
  @groups = @options[Enum::Option::GROUP]
23
- @technologies = parse_technologies
24
- @spinner = Util::Spinner.new('Evaluating packages and their dependencies...')
24
+ @technologies = parse_technologies!
25
+ validate_format!
26
+ @spinner = Util::Spinner.new("Evaluating packages and their dependencies for #{human_readable_technologies}...")
25
27
  end
26
28
 
27
- def run # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
29
+ def run
30
+ if File.file? @dir.to_s
31
+ raise "\"#{@dir}\" is a file instead of directory"
32
+ elsif !File.directory? @dir.to_s
33
+ raise "\"#{@dir}\" is not a valid directory"
34
+ elsif @technologies.empty?
35
+ raise 'No supported technologies found in this directory'
36
+ else
37
+ process_technologies
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def process_technologies # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
28
44
  mutex = Mutex.new
29
45
  cumulative_pkgs = []
46
+ all_packages_for_config = []
30
47
  thread_index = 0
31
48
 
32
49
  @spinner.start
@@ -34,11 +51,14 @@ module Package
34
51
  Thread.new do
35
52
  all_pkgs, ignored_pkgs = PackageFinder.new(@config, @dir, @report, @groups).run(technology)
36
53
  ignored_pkgs = [] if @options[Enum::Option::INCLUDE_IGNORED]
37
- cumulative_pkgs += all_pkgs || []
54
+ active_pkgs = (all_pkgs || []) - (ignored_pkgs || [])
55
+ cumulative_pkgs += active_pkgs
56
+ mutex.synchronize { all_packages_for_config += all_pkgs || [] }
57
+
38
58
  sleep 0.1 while technology_index != thread_index # print each technology in order
39
59
  mutex.synchronize do
40
60
  @spinner.stop
41
- print_results(technology, (all_pkgs || []) - (ignored_pkgs || []), ignored_pkgs || [])
61
+ print_results(technology, active_pkgs, ignored_pkgs || [])
42
62
  thread_index += 1
43
63
  @spinner.start
44
64
  end
@@ -50,22 +70,24 @@ module Package
50
70
  thread.join
51
71
  raise thread[:exception] if thread[:exception]
52
72
  end
73
+
74
+ @spinner.stop # Stop spinner before cleaning config to ensure clean output
75
+ clean_config(all_packages_for_config)
76
+
53
77
  cumulative_pkgs.any? ? 1 : 0
54
78
  ensure
55
79
  @spinner.stop
56
80
  end
57
81
 
58
- private
59
-
60
82
  def print_results(technology, pkgs, ignored_pkgs)
61
83
  PackagePrinter.new(@options, pkgs).print(Const::Fields::DEFAULT)
62
- print_summary(technology, pkgs, ignored_pkgs) unless @options[Enum::Option::CSV]
63
- print_disclaimer(technology) unless @options[Enum::Option::CSV] || pkgs.empty?
84
+ print_summary(technology, pkgs, ignored_pkgs) unless @options[Enum::Option::FORMAT] == Enum::Format::CSV
85
+ print_disclaimer(technology) unless @options[Enum::Option::FORMAT] || pkgs.empty?
64
86
  end
65
87
 
66
88
  def print_summary(technology, pkgs, ignored_pkgs)
67
89
  if @report == Enum::Report::ALL
68
- Util::SummaryPrinter.statistics(technology, @report, pkgs, ignored_pkgs)
90
+ Util::SummaryPrinter.statistics(@options[Enum::Option::FORMAT], technology, @report, pkgs, ignored_pkgs)
69
91
  else
70
92
  Util::SummaryPrinter.total(technology, @report, pkgs, ignored_pkgs)
71
93
  end
@@ -91,7 +113,7 @@ module Package
91
113
  end
92
114
  end
93
115
 
94
- def parse_config_file
116
+ def parse_config_file!
95
117
  if @options[Enum::Option::CONFIG].nil?
96
118
  YAML.load_file("#{@dir}/#{Const::File::CONFIG}") if File.exist? "#{@dir}/#{Const::File::CONFIG}"
97
119
  elsif File.exist? @options[Enum::Option::CONFIG]
@@ -101,10 +123,29 @@ module Package
101
123
  end
102
124
  end
103
125
 
104
- def parse_technologies
126
+ def validate_format!
127
+ format = @options[Enum::Option::FORMAT]
128
+ raise ArgumentError, "Invalid format: #{format}, should be one of [#{Enum::Format.all.join('|')}]" unless
129
+ @options[Enum::Option::FORMAT].nil? || Enum::Format.all.include?(format)
130
+ end
131
+
132
+ def parse_technologies!
105
133
  technology_validator = Technology::Validator.new(@dir)
106
134
  @options[Enum::Option::TECHNOLOGY]&.each { |technology| technology_validator.validate! technology }
107
- @options[Enum::Option::TECHNOLOGY] || Technology::Detector.new(@dir).detect
135
+ (@options[Enum::Option::TECHNOLOGY] || Technology::Detector.new(@dir).detect).sort
136
+ end
137
+
138
+ def clean_config(all_packages)
139
+ ConfigCleaner.new(@dir, @config, all_packages, @options).run
140
+ end
141
+
142
+ def human_readable_technologies
143
+ array = @technologies.map(&:capitalize)
144
+ return '' if array.nil?
145
+ return array.join if array.size <= 1
146
+ return array.join(' and ') if array.size == 2
147
+
148
+ "#{array[0..-2].join(', ')}, and #{array.last}"
108
149
  end
109
150
  end
110
151
  end
@@ -0,0 +1,221 @@
1
+ require_relative '../const/file'
2
+ require_relative '../const/yaml'
3
+ require_relative '../enum/option'
4
+ require_relative '../enum/technology'
5
+
6
+ require 'yaml'
7
+ require 'json'
8
+
9
+ module Package
10
+ module Audit
11
+ class ConfigCleaner # rubocop:disable Metrics/ClassLength
12
+ def initialize(dir, config, all_packages, options)
13
+ @dir = dir
14
+ @config = config
15
+ @all_packages = all_packages
16
+ @options = options
17
+ @config_file_path = determine_config_file_path
18
+ @removed_packages = []
19
+ end
20
+
21
+ def run
22
+ return unless @config && File.exist?(@config_file_path)
23
+
24
+ cleaned_config = clean_config
25
+
26
+ return unless config_changed?(cleaned_config)
27
+
28
+ write_config_file(cleaned_config)
29
+ print_summary unless @options[Enum::Option::FORMAT]
30
+ end
31
+
32
+ attr_reader :removed_packages
33
+
34
+ private
35
+
36
+ def determine_config_file_path
37
+ if @options[Enum::Option::CONFIG].nil?
38
+ "#{@dir}/#{Const::File::CONFIG}"
39
+ else
40
+ @options[Enum::Option::CONFIG]
41
+ end
42
+ end
43
+
44
+ def clean_config
45
+ return {} unless @config
46
+
47
+ cleaned = {}
48
+
49
+ if @config[Const::YAML::TECHNOLOGY]
50
+ cleaned[Const::YAML::TECHNOLOGY] = {}
51
+
52
+ # Sort technologies alphabetically to ensure consistent ordering
53
+ @config[Const::YAML::TECHNOLOGY].sort.each do |technology, packages|
54
+ cleaned_packages = clean_packages_for_technology(technology, packages)
55
+ cleaned[Const::YAML::TECHNOLOGY][technology] = cleaned_packages unless cleaned_packages.empty?
56
+ end
57
+
58
+ # Remove the technology key if no technologies have any packages
59
+ cleaned.delete(Const::YAML::TECHNOLOGY) if cleaned[Const::YAML::TECHNOLOGY].empty?
60
+ end
61
+
62
+ cleaned
63
+ end
64
+
65
+ def clean_packages_for_technology(technology, packages)
66
+ return {} unless packages.is_a?(Hash)
67
+
68
+ current_packages = current_packages_for_technology(technology)
69
+ cleaned_packages = {}
70
+
71
+ packages.each do |package_name, package_config|
72
+ next unless package_config.is_a?(Hash)
73
+
74
+ if should_keep_package?(package_name, package_config, current_packages)
75
+ cleaned_packages[package_name] = sort_package_config(package_config)
76
+ else
77
+ track_removed_package(technology, package_name, package_config)
78
+ end
79
+ end
80
+
81
+ # Sort package names alphabetically
82
+ cleaned_packages.sort.to_h
83
+ end
84
+
85
+ def current_packages_for_technology(technology)
86
+ @all_packages.select { |pkg| pkg.technology == technology }
87
+ .to_h { |pkg| [pkg.name, pkg.version] }
88
+ end
89
+
90
+ def should_keep_package?(package_name, package_config, current_packages)
91
+ config_version = package_config[Const::YAML::VERSION]
92
+ current_version = current_packages[package_name]
93
+
94
+ # Keep the package if it exists and the version matches
95
+ current_version && config_version == current_version
96
+ end
97
+
98
+ def sort_package_config(package_config)
99
+ sorted_config = {}
100
+
101
+ # Add version first if it exists
102
+ if package_config[Const::YAML::VERSION]
103
+ sorted_config[Const::YAML::VERSION] =
104
+ package_config[Const::YAML::VERSION]
105
+ end
106
+
107
+ # Add other keys in alphabetical order
108
+ other_keys = (package_config.keys - [Const::YAML::VERSION]).sort
109
+ other_keys.each do |key|
110
+ sorted_config[key] = package_config[key]
111
+ end
112
+
113
+ sorted_config
114
+ end
115
+
116
+ def track_removed_package(technology, package_name, package_config)
117
+ @removed_packages << {
118
+ technology: technology,
119
+ name: package_name,
120
+ version: package_config[Const::YAML::VERSION],
121
+ reason: determine_removal_reason(package_name, package_config)
122
+ }
123
+ end
124
+
125
+ def determine_removal_reason(package_name, package_config)
126
+ technology = find_technology_for_package(package_name)
127
+ return 'unknown reason' unless technology
128
+
129
+ current_packages = current_packages_for_technology(technology)
130
+ config_version = package_config[Const::YAML::VERSION]
131
+ current_version = current_packages[package_name]
132
+
133
+ determine_reason_based_on_versions(package_name, technology, config_version, current_version)
134
+ end
135
+
136
+ def find_technology_for_package(package_name)
137
+ @config[Const::YAML::TECHNOLOGY].each do |tech, packages|
138
+ return tech if packages.key?(package_name)
139
+ end
140
+ nil
141
+ end
142
+
143
+ def determine_reason_based_on_versions(package_name, technology, config_version, current_version)
144
+ if current_version.nil?
145
+ determine_reason_for_missing_package(package_name, technology)
146
+ elsif config_version != current_version
147
+ "version changed from #{config_version} to #{current_version}"
148
+ else
149
+ 'unknown reason'
150
+ end
151
+ end
152
+
153
+ def determine_reason_for_missing_package(package_name, technology)
154
+ if package_exists_in_project_files?(package_name, technology)
155
+ 'package version has changed'
156
+ else
157
+ 'package no longer exists'
158
+ end
159
+ end
160
+
161
+ def package_exists_in_project_files?(package_name, technology)
162
+ case technology
163
+ when Enum::Technology::RUBY
164
+ package_exists_in_gemfile?(package_name)
165
+ when Enum::Technology::NODE
166
+ package_exists_in_package_json?(package_name)
167
+ else
168
+ false
169
+ end
170
+ end
171
+
172
+ def package_exists_in_gemfile?(package_name)
173
+ gemfile_path = "#{@dir}/#{Const::File::GEMFILE}"
174
+ return false unless File.exist?(gemfile_path)
175
+
176
+ gemfile_content = File.read(gemfile_path)
177
+ # Check for gem declarations with single or double quotes
178
+ gemfile_content.match?(/^\s*gem\s+['"]#{Regexp.escape(package_name)}['"]/)
179
+ end
180
+
181
+ def package_exists_in_package_json?(package_name)
182
+ package_json_path = "#{@dir}/#{Const::File::PACKAGE_JSON}"
183
+ return false unless File.exist?(package_json_path)
184
+
185
+ begin
186
+ package_json = JSON.parse(File.read(package_json_path))
187
+ dependencies = package_json['dependencies'] || {}
188
+ dev_dependencies = package_json['devDependencies'] || {}
189
+
190
+ dependencies.key?(package_name) || dev_dependencies.key?(package_name)
191
+ rescue JSON::ParserError
192
+ false
193
+ end
194
+ end
195
+
196
+ def config_changed?(cleaned_config)
197
+ # Compare YAML representations to detect key reordering
198
+ cleaned_config.to_yaml != @config.to_yaml
199
+ end
200
+
201
+ def write_config_file(cleaned_config)
202
+ File.write(@config_file_path, cleaned_config.to_yaml)
203
+ end
204
+
205
+ def print_summary
206
+ return if @removed_packages.empty?
207
+
208
+ puts
209
+ puts "Cleaned up #{@removed_packages.count} package(s) from #{File.basename(@config_file_path)}:"
210
+
211
+ # Sort by technology then by name for consistent output
212
+ @removed_packages.sort_by { |pkg| [pkg[:technology], pkg[:name]] }.each do |removed_package|
213
+ package_info = "#{removed_package[:name]}@#{removed_package[:version]}"
214
+ tech_info = "(#{removed_package[:technology]})"
215
+ reason = removed_package[:reason]
216
+ puts " - #{package_info} #{tech_info}: #{reason}"
217
+ end
218
+ end
219
+ end
220
+ end
221
+ end
@@ -9,7 +9,8 @@ require 'yaml'
9
9
  module Package
10
10
  module Audit
11
11
  class PackageFilter
12
- def initialize(config)
12
+ def initialize(report, config)
13
+ @report = report
13
14
  @config = config
14
15
  end
15
16
 
@@ -30,9 +31,28 @@ module Package
30
31
  end
31
32
 
32
33
  def ignore_package?(pkg, yaml)
33
- (!pkg.deprecated? || yaml&.dig(Const::YAML::DEPRECATED) == false) &&
34
- (!pkg.outdated? || yaml&.dig(Const::YAML::OUTDATED) == false) &&
35
- (!pkg.vulnerable? || yaml&.dig(Const::YAML::VULNERABLE) == false)
34
+ case @report
35
+ when Enum::Report::DEPRECATED
36
+ ignore_deprecated?(pkg, yaml)
37
+ when Enum::Report::OUTDATED
38
+ ignore_outdated?(pkg, yaml)
39
+ when Enum::Report::VULNERABLE
40
+ ignore_vulnerable?(pkg, yaml)
41
+ else
42
+ ignore_deprecated?(pkg, yaml) && ignore_outdated?(pkg, yaml) && ignore_vulnerable?(pkg, yaml)
43
+ end
44
+ end
45
+
46
+ def ignore_deprecated?(pkg, yaml)
47
+ !pkg.deprecated? || yaml&.dig(Const::YAML::DEPRECATED) == false
48
+ end
49
+
50
+ def ignore_outdated?(pkg, yaml)
51
+ !pkg.outdated? || yaml&.dig(Const::YAML::OUTDATED) == false
52
+ end
53
+
54
+ def ignore_vulnerable?(pkg, yaml)
55
+ !pkg.vulnerable? || yaml&.dig(Const::YAML::VULNERABLE) == false
36
56
  end
37
57
  end
38
58
  end