package-audit 0.6.2 → 0.7.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
2
  SHA256:
3
- metadata.gz: cca016948e4ab3d4643c8e7c7b6bca064873a0b390709d6318d899ee03464c36
4
- data.tar.gz: '037543298033670ef8cd62ceb31a021389e55b0baf634117777018933d89b6b3'
3
+ metadata.gz: 117d0d8171512241e50a454b4d20a7282406719ad0a64f0805588152e24afd35
4
+ data.tar.gz: 11c35e745e5a07ba2321cd8c301136e45e5daf3a1a03ab07d63cb5de7b860099
5
5
  SHA512:
6
- metadata.gz: 494ad5afd212b57e596fd599b8b57f86453c3cb5f988d4ae88a572c685a83f8ba4c39c750353e962453d98ba3336d30ecf1da729ffad4c26cfce418c049e56eb
7
- data.tar.gz: 6088d06e5a5ccbf73d9ecd3e3fbcd130030dbc47ca861a910f56ba21850ae467eda5f7a4b0064200d18bb1d7fe461f7db561f64f471b0c747a25d6bc18ec9115
6
+ metadata.gz: f1a2add78503fbbba1af45a7e311a69b9cc5e1e17de4bff0e3707267a988ec65697b8a6cf1aab996be67ca6629939555dcfc1728ba9fd16afb524ef8b7083f8a
7
+ data.tar.gz: 4ff3cdd346772bc543ea0aa5b57bdb1eb6c983a1a2fdc43c78b29f9b029e3615b91c44b0caf6df3a68cd0ee0c6da64acfc6083b2ac023464f74e9fad226be41f
@@ -12,6 +12,8 @@ module Package
12
12
  end
13
13
 
14
14
  def format # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
15
+ return @curr if @target.nil? # No latest version available
16
+
15
17
  version_parts = @curr.split('.').map(&:to_i)
16
18
  latest_version_parts = @target.split('.').map(&:to_i)
17
19
  curr_tokens = @curr.split('.')
@@ -14,6 +14,8 @@ module Package
14
14
  end
15
15
 
16
16
  def format
17
+ return 'unknown' if @date.nil? # Handle private packages
18
+
17
19
  seconds_since_date = (Time.now - Time.parse(@date)).to_i
18
20
 
19
21
  if seconds_since_date >= Const::Time::SECONDS_ELAPSED_TO_BE_OUTDATED
@@ -55,15 +55,16 @@ module Package
55
55
  private
56
56
 
57
57
  def fetch_from_package_json
58
- package_json = JSON.parse(File.read("#{@dir}/#{Const::File::PACKAGE_JSON}"), symbolize_names: true)
59
- default_deps = package_json[:dependencies] || {}
60
- dev_deps = package_json[:devDependencies] || {}
58
+ package_json = JSON.parse(File.read("#{@dir}/#{Const::File::PACKAGE_JSON}"))
59
+ default_deps = package_json['dependencies'] || {}
60
+ dev_deps = package_json['devDependencies'] || {}
61
+ resolutions = package_json['resolutions'] || {}
61
62
 
62
63
  # Filter out local dependencies before processing
63
64
  default_deps = filter_local_dependencies(default_deps)
64
65
  dev_deps = filter_local_dependencies(dev_deps)
65
66
 
66
- [default_deps, dev_deps]
67
+ [default_deps, dev_deps, resolutions]
67
68
  end
68
69
 
69
70
  def filter_local_dependencies(dependencies)
@@ -79,9 +80,10 @@ module Package
79
80
  end
80
81
 
81
82
  def fetch_from_lock_file
82
- default_deps, dev_deps = fetch_from_package_json
83
+ default_deps, dev_deps, resolutions = fetch_from_package_json
83
84
  if File.exist?("#{@dir}/#{Const::File::YARN_LOCK}")
84
- YarnLockParser.new("#{@dir}/#{Const::File::YARN_LOCK}").fetch(default_deps || {}, dev_deps || {})
85
+ YarnLockParser.new("#{@dir}/#{Const::File::YARN_LOCK}").fetch(default_deps || {}, dev_deps || {},
86
+ resolutions || {})
85
87
  else
86
88
  []
87
89
  end
@@ -7,42 +7,27 @@ module Package
7
7
  module Npm
8
8
  class NpmMetaData
9
9
  REGISTRY_URL = 'https://registry.npmjs.org'
10
+ BATCH_SIZE = 10 # Process 10 packages at a time
11
+ MAX_RETRIES = 3 # Maximum number of retries per request
12
+ INITIAL_RETRY_DELAY = 1 # Initial retry delay in seconds
13
+ TIMEOUT = 10 # Timeout in seconds
10
14
 
11
15
  def initialize(packages)
12
16
  @packages = packages
13
17
  end
14
18
 
15
- def fetch # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
16
- threads = @packages.map do |package|
17
- Thread.new do
18
- response = Net::HTTP.get_response(URI.parse("#{REGISTRY_URL}/#{package.name}"))
19
- raise "Unable to fetch meta data for #{package.name} from #{REGISTRY_URL} (#{response.class})" unless
20
- response.is_a?(Net::HTTPSuccess)
21
-
22
- json_package = JSON.parse(response.body, symbolize_names: true)
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
30
- rescue StandardError => e
31
- Thread.current[:exception] = e
32
- end
33
- end
34
-
19
+ def fetch # rubocop:disable Metrics/MethodLength
35
20
  network_errors = []
36
- threads.each do |thread|
37
- thread.join
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]
21
+
22
+ @packages.each_slice(BATCH_SIZE) do |batch|
23
+ threads = batch.map do |package|
24
+ Thread.new do
25
+ fetch_package_metadata(package, network_errors)
26
+ end
45
27
  end
28
+
29
+ threads.each(&:join)
30
+ sleep(0.1) # Small delay between batches to avoid overwhelming the server
46
31
  end
47
32
 
48
33
  unless network_errors.empty?
@@ -55,10 +40,83 @@ module Package
55
40
 
56
41
  private
57
42
 
43
+ def fetch_package_metadata(package, network_errors, retry_count = 0)
44
+ response = make_request_with_retry(package.name, retry_count)
45
+ return if response.nil?
46
+
47
+ json_package = JSON.parse(response.body, symbolize_names: true)
48
+ update_meta_data(package, json_package)
49
+ rescue StandardError => e
50
+ handle_error(package, e, network_errors)
51
+ end
52
+
53
+ def make_request_with_retry(package_name, retry_count)
54
+ response = make_request(package_name)
55
+ return nil if response.is_a?(Net::HTTPNotFound) # Skip 404s - likely private packages
56
+
57
+ raise "Unable to fetch meta data for #{package_name} from #{REGISTRY_URL} (#{response.class})" unless
58
+ response.is_a?(Net::HTTPSuccess)
59
+
60
+ response
61
+ rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH
62
+ return nil if retry_count >= MAX_RETRIES
63
+
64
+ retry_after_delay(retry_count)
65
+ retry_count += 1
66
+ retry
67
+ end
68
+
69
+ def handle_error(package, error, network_errors)
70
+ # Don't warn about 404s for private packages
71
+ return if error.is_a?(RuntimeError) && error.message.include?('(Net::HTTPNotFound)')
72
+
73
+ warn "Warning: Error while fetching metadata for #{package.name}: #{error.message}"
74
+ network_errors << error
75
+ end
76
+
77
+ def make_request(package_name)
78
+ uri = URI(REGISTRY_URL)
79
+ http = Net::HTTP.new(uri.host, uri.port)
80
+ http.use_ssl = true
81
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
82
+ http.read_timeout = TIMEOUT
83
+ http.open_timeout = TIMEOUT
84
+
85
+ path = "/#{package_name}"
86
+ http.get(path)
87
+ end
88
+
89
+ def retry_after_delay(retry_count)
90
+ delay = INITIAL_RETRY_DELAY * (2**retry_count) # Exponential backoff
91
+ sleep(delay)
92
+ end
93
+
58
94
  def update_meta_data(package, json_data)
59
- latest_version = json_data[:'dist-tags'][:latest]
60
- version_date = json_data[:time][package.version.to_sym]
61
- latest_version_date = json_data[:time][latest_version.to_sym]
95
+ # No early return - let's try to get metadata even if version is unknown
96
+
97
+ latest_version = find_latest_version(json_data)
98
+ return unless latest_version
99
+
100
+ dates = find_version_dates(json_data, package.version, latest_version)
101
+ return unless dates
102
+
103
+ update_package_metadata(package, latest_version, dates)
104
+ end
105
+
106
+ def find_latest_version(json_data)
107
+ json_data[:'dist-tags']&.[](:latest)
108
+ end
109
+
110
+ def find_version_dates(json_data, version, latest_version)
111
+ version_date = json_data[:time]&.[](version.to_sym)
112
+ latest_version_date = json_data[:time]&.[](latest_version.to_sym)
113
+ return unless version_date || latest_version_date # Return if both dates are missing
114
+
115
+ [version_date || latest_version_date, latest_version_date || version_date]
116
+ end
117
+
118
+ def update_package_metadata(package, latest_version, dates)
119
+ version_date, latest_version_date = dates
62
120
  package.update version_date: Time.parse(version_date).strftime('%Y-%m-%d'),
63
121
  latest_version: latest_version,
64
122
  latest_version_date: Time.parse(latest_version_date).strftime('%Y-%m-%d')
@@ -32,17 +32,32 @@ module Package
32
32
 
33
33
  private
34
34
 
35
- def update_meta_data(json) # rubocop:disable Metrics/AbcSize
36
- parent_name = json[:data][:resolution][:path].split('>').first
35
+ def update_meta_data(json)
36
+ resolution_path = json.dig(:data, :resolution, :path)
37
+ return unless resolution_path # Skip if no resolution path (private package)
38
+
39
+ parent_name = resolution_path.split('>').first
37
40
  advisory = json[:data][:advisory]
38
- name = advisory[:module_name]
39
- version = advisory[:findings][0][:version]
41
+ package_info = extract_package_info(advisory)
42
+ update_vulnerability_info(package_info, parent_name, advisory[:severity])
43
+ end
44
+
45
+ def extract_package_info(advisory)
46
+ {
47
+ name: advisory[:module_name],
48
+ version: advisory[:findings][0][:version]
49
+ }
50
+ end
51
+
52
+ def update_vulnerability_info(package_info, parent_name, severity)
53
+ name = package_info[:name]
54
+ version = package_info[:version]
40
55
  full_name = "#{name}@#{version}"
41
- vulnerability = advisory[:severity] || Enum::VulnerabilityType::UNKNOWN
56
+ vulnerability = severity || Enum::VulnerabilityType::UNKNOWN
42
57
 
43
- @vuln_hash[full_name] = Package.new(name, version, 'node') unless @vuln_hash.key? full_name
58
+ @vuln_hash[full_name] ||= Package.new(name, version, 'node')
44
59
  @vuln_hash[full_name].update vulnerabilities: @vuln_hash[full_name].vulnerabilities + [vulnerability]
45
- @vuln_hash[full_name].update groups: @pkg_hash[parent_name].groups.map(&:to_s)
60
+ @vuln_hash[full_name].update groups: @pkg_hash[parent_name]&.groups&.map(&:to_s) || []
46
61
  end
47
62
  end
48
63
  end
@@ -9,45 +9,127 @@ module Package
9
9
  @yarn_lock_path = yarn_lock_path
10
10
  end
11
11
 
12
- def fetch(default_deps, dev_deps) # rubocop:disable Metrics/MethodLength
13
- pkgs = []
14
- default_deps.merge(dev_deps).each do |dep_name, expected_version|
15
- pkg_block = fetch_package_block(dep_name, expected_version)
16
- version = fetch_package_version(dep_name, pkg_block)
17
- pks = Package.new(dep_name.to_s, version, 'node')
18
- pks.update groups: if dev_deps.key?(dep_name)
19
- [Enum::Group::DEV]
20
- else
21
- [Enum::Group::DEFAULT, Enum::Group::DEV]
22
- end
23
- pkgs << pks
12
+ def fetch(default_deps, dev_deps, resolutions = {})
13
+ default_deps.merge(dev_deps).map do |dep_name, expected_version|
14
+ process_package(dep_name, expected_version, dev_deps, resolutions)
24
15
  end
25
- pkgs
26
16
  end
27
17
 
28
18
  private
29
19
 
30
- def fetch_package_block(dep_name, expected_version)
31
- regex = regex_pattern_for_package(dep_name, expected_version)
32
- blocks = @yarn_lock_file.match(regex)
33
- if blocks.nil? || blocks[0].nil?
34
- raise NoMatchingPatternError, "Unable to find \"#{dep_name}\" in #{@yarn_lock_path}"
20
+ def process_package(dep_name, expected_version, dev_deps, resolutions)
21
+ version_to_check = get_version_to_check(dep_name, expected_version, resolutions)
22
+ pkg_block = fetch_package_block(dep_name, version_to_check)
23
+ version = fetch_package_version(dep_name, pkg_block)
24
+ create_package(dep_name, version, dev_deps)
25
+ end
26
+
27
+ def get_version_to_check(dep_name, expected_version, resolutions)
28
+ version_to_check = resolutions[dep_name] || expected_version
29
+ return version_to_check unless version_to_check.start_with?('patch:')
30
+
31
+ patch_version = version_to_check.match(/patch:.*?@npm%3A([\d.-]+)#/)&.captures&.[](0)
32
+ patch_version || version_to_check
33
+ end
34
+
35
+ def create_package(dep_name, version, dev_deps)
36
+ pks = Package.new(dep_name.to_s, version, 'node')
37
+ pks.update groups: package_groups(dep_name, dev_deps)
38
+ pks
39
+ end
40
+
41
+ def package_groups(dep_name, dev_deps)
42
+ if dev_deps.key?(dep_name)
43
+ [Enum::Group::DEV]
44
+ else
45
+ [Enum::Group::DEFAULT, Enum::Group::DEV]
35
46
  end
47
+ end
48
+
49
+ def fetch_package_block(dep_name, expected_version)
50
+ blocks = find_package_blocks(dep_name)
51
+ raise NoMatchingPatternError, "Unable to find \"#{dep_name}\" in #{@yarn_lock_path}" if blocks.empty?
52
+
53
+ find_matching_block(blocks, dep_name, expected_version)
54
+ end
55
+
56
+ def find_package_blocks(dep_name)
57
+ block_pattern = build_block_pattern(dep_name)
58
+ @yarn_lock_file.scan(block_pattern)
59
+ end
60
+
61
+ def build_block_pattern(dep_name)
62
+ /
63
+ ^["']? # Start of line with optional quote
64
+ (?:[^"\n]+,\s*)* # Any previous entries in a comma-separated list
65
+ (?:patch:)? # Optional patch prefix
66
+ #{Regexp.escape(dep_name)}@[^:\n]+ # Our package name and version
67
+ (?:[^"\n]*,\s*[^"\n]+)* # Any following entries
68
+ ["']?:.*? # End quote and colon, followed by the rest
69
+ (?:resolution:.*?)? # Optional resolution field
70
+ (?=\n["']|\n\s*\n|\z) # Until next entry or end of file
71
+ /mx
72
+ end
36
73
 
37
- blocks[0] || ''
74
+ def find_matching_block(blocks, dep_name, expected_version)
75
+ version_pattern = build_version_pattern(dep_name, expected_version)
76
+ blocks.find { |block| block.match?(version_pattern) } || blocks.first
77
+ end
78
+
79
+ def build_version_pattern(dep_name, expected_version)
80
+ /
81
+ (?:patch:)?#{Regexp.escape(dep_name)}@
82
+ (?:npm:)?#{Regexp.escape(expected_version)}["']?(?:,|:)
83
+ /x
38
84
  end
39
85
 
40
86
  def fetch_package_version(dep_name, pkg_block)
41
- version = pkg_block.match(/version"?\s*"(.*?)"/)&.captures&.[](0)
87
+ # Try different version formats:
88
+ # 1. version: "1.2.3" - quoted version
89
+ # 2. version: 1.2.3 - unquoted version
90
+ # 3. "pkg@1.2.3": - version in package spec
91
+ # 4. "pkg@npm:1.2.3": - version with npm prefix
92
+ # Try to find version in this order:
93
+ # 1. resolution field (for overrides)
94
+ # 2. version field (both quoted and unquoted)
95
+ # 3. package spec
96
+ version = extract_version_from_block(dep_name, pkg_block)
97
+
42
98
  if version.nil?
43
99
  raise NoMatchingPatternError,
44
100
  "Unable to find the version of \"#{dep_name}\" in #{@yarn_lock_path}"
45
101
  end
46
102
 
47
- version || '0.0.0.0'
103
+ version || 'unknown'
104
+ end
105
+
106
+ def extract_version_from_block(dep_name, pkg_block)
107
+ find_resolution_version(dep_name, pkg_block) ||
108
+ find_version_field(pkg_block) ||
109
+ find_spec_version(dep_name, pkg_block)
110
+ end
111
+
112
+ def find_resolution_version(dep_name, pkg_block)
113
+ pattern = /
114
+ resolution:.*?["']#{Regexp.escape(dep_name)}@
115
+ (?:npm:)?(?:patch:#{Regexp.escape(dep_name)}@npm%3A)?
116
+ ([\d.-]+(?:-(?:beta|rc|dev)\.\d+(?:\.\d+)?)?)
117
+ (?:&hash=[a-f0-9]+)?["']
118
+ /x
119
+ pkg_block.match(pattern)&.captures&.[](0)
120
+ end
121
+
122
+ def find_version_field(pkg_block)
123
+ pattern = /version["']?\s*["']?([\d.-]+(?:-(?:beta|rc|dev)\.\d+(?:\.\d+)?)?)(?:&hash=[a-f0-9]+)?["']?(?:\s|$)/
124
+ pkg_block.match(pattern)&.captures&.[](0)
125
+ end
126
+
127
+ def find_spec_version(dep_name, pkg_block)
128
+ pattern = %r{^.*?#{Regexp.escape(dep_name)}@(?:npm:|https://[^#]+#)?(?:patch:#{Regexp.escape(dep_name)}@npm%3A)?(?:v)?([\d.-]+(?:-(?:beta|rc|dev)\.\d+(?:\.\d+)?)?)(?:&hash=[a-f0-9]+)?["']?(?:,|\s*:)}m
129
+ pkg_block.match(pattern)&.captures&.[](0)
48
130
  end
49
131
 
50
- def regex_pattern_for_package(dep_name, version)
132
+ def regex_pattern_for_package(dep_name, _version)
51
133
  # assume the package name is prefixed by a space, a quote or be the first thing on the line
52
134
  # there can be multiple comma-separated versions on the same line with or without quotes
53
135
  # Here are some examples of strings that would be matched:
@@ -55,7 +137,45 @@ module Package
55
137
  # - lodash@^4.17.15, lodash@^4.17.20:
56
138
  # - "@adobe/css-tools@^4.0.1":
57
139
  # - "@babel/runtime@^7.23.1", "@babel/runtime@^7.9.2":
58
- /(?:^|[ "])#{Regexp.escape(dep_name)}@#{Regexp.escape(version)}.*?:.*?(\n\n|\z)/m
140
+ # For resolutions (exact versions):
141
+ # - "@apollo/client@3.12.5":
142
+ # For both regular dependencies and resolutions
143
+ # The package might appear in different formats:
144
+ # 1. As a dependency spec: "@apollo/client@^3.14.0"
145
+ # 2. As a resolved version: "@apollo/client@3.12.5"
146
+ # 3. As part of a multi-version spec: "@apollo/client@^3.14.0, @apollo/client@^3.12.5"
147
+ # 4. With npm prefix: "@apollo/client@npm:3.12.5"
148
+ # 5. In resolution field: "resolution: \"@apollo/client@npm:3.12.5\""
149
+ escaped_name = Regexp.escape(dep_name)
150
+ # Look for any entry that starts with our package name and includes the version
151
+ # We match the entire block and let fetch_package_version handle version extraction
152
+ # The pattern matches:
153
+ # 1. Package name at start of line or after quote
154
+ # 2. Everything up to the next double newline or end of file
155
+ # 3. Handles both compact and expanded formats with dependencies
156
+ # Match both old and new yarn.lock formats:
157
+ # Old: pkg@^1.0.0:
158
+ # New: "pkg@^1.0.0":
159
+ # The pattern matches the entire block including any indented lines
160
+ # We look for:
161
+ # 1. Start of line
162
+ # 2. Optional quote
163
+ # 3. Package name
164
+ # 4. @ symbol
165
+ # 5. Version spec (anything up to : or ")
166
+ # 6. Optional quote and colon
167
+ # 7. Rest of the block until next entry or end of file
168
+ # The pattern needs to match:
169
+ # 1. Basic format: pkg@^1.0.0:
170
+ # 2. Quoted format: "pkg@^1.0.0":
171
+ # 3. Multiple specs: "pkg@1.0.0", "pkg@npm:1.0.0":
172
+ # 4. Scoped packages: "@scope/pkg@1.0.0":
173
+ # Match any of:
174
+ # 1. Basic: pkg@^1.0.0:
175
+ # 2. Quoted: "pkg@^1.0.0":
176
+ # 3. Multiple: "pkg@1.0.0", "pkg@npm:1.0.0":
177
+ # 4. Scoped: "@scope/pkg@1.0.0":
178
+ /^(?:["']?#{escaped_name}@[^,\n:"]+(?:,\s*["']#{escaped_name}@[^,\n:"]+)*["']?:.*?)(?=\n["']|\n\s*\n|\z)/m
59
179
  end
60
180
  end
61
181
  end
@@ -1,5 +1,5 @@
1
1
  module Package
2
2
  module Audit
3
- VERSION = '0.6.2'
3
+ VERSION = '0.7.0'
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: package-audit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.2
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vadim Kononov
@@ -111,7 +111,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
111
111
  - !ruby/object:Gem::Version
112
112
  version: '0'
113
113
  requirements: []
114
- rubygems_version: 3.6.9
114
+ rubygems_version: 3.7.2
115
115
  specification_version: 4
116
116
  summary: A helper tool to find outdated, deprecated and vulnerable dependencies.
117
117
  test_files: []