package-audit 0.8.1 → 0.9.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: d613e9a49c48ae22ef6e156b7f6a6ba1f2ebe8172e30589cc2eabbb5530bfdc1
4
- data.tar.gz: aad9e813b4e6d2b73f15b19626fbc70631312d8965823a27d44cceef4c5115aa
3
+ metadata.gz: 6a9f8b791f93ef77f2eecb40a1d0bacaebf40c94a06e938ef7528f26281ac30b
4
+ data.tar.gz: e9a24f0114518ba97deaa1687ca5c786fee0ba54dd868cbaec48c4d6d183be7b
5
5
  SHA512:
6
- metadata.gz: e6b3406928eb792637f7fbf5de5745a83ae1ce7181febc24d95882c6e38281002bcb953b3f727e18cf983606285f4df428aa36c203ed874a9d919c47c53dad9f
7
- data.tar.gz: c6abc41931c2c26b91443474262353b35a0c64b0d2372258fec255f4f9569c127c3e48a172a219ec1fbd5a71fb7d4e3f15a6bbd6e2f717bb8b65ae0da74ec31f
6
+ metadata.gz: e106be4632777be3433e572bb60b34a9b8fa4f7e038f4be86b1d9744ffcaf2e709804bcd4ffffd2b001ee82887ea70cbb7234d2df37a4033224f155ac84b4b88
7
+ data.tar.gz: 400eb639d4f16d4671bc10b1e0b76ad7e49f7f3991239c8f3c5a7dbee5138fcc46d3ae819bcb06021d45a3d46e4ca2cff8e755760dda387976b2fd67d8c8aa03
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2023 Vadim Kononov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,215 @@
1
+ # Package::Audit
2
+
3
+ [![Gem Version](https://img.shields.io/gem/v/package-audit.svg?label=Gem&logo=rubygems&logoColor=white)](https://rubygems.org/gems/package-audit)
4
+ [![Ruby](https://img.shields.io/badge/Ruby-2.6%20to%204.0-CC342D?logo=ruby&logoColor=white)](https://github.com/vkononov/package-audit/blob/main/.github/workflows/test.yml)
5
+ [![Test Matrix](https://img.shields.io/github/actions/workflow/status/vkononov/package-audit/test.yml?branch=main&label=Test%20Matrix&logo=github)](https://github.com/vkononov/package-audit/actions/workflows/test.yml)
6
+ [![Lint](https://img.shields.io/github/actions/workflow/status/vkononov/package-audit/lint.yml?branch=main&label=Lint&logo=github)](https://github.com/vkononov/package-audit/actions/workflows/lint.yml)
7
+ [![License](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/license/MIT)
8
+
9
+ A useful tool for patch management and prioritization, `package-audit` produces a list of dependencies that are outdated, deprecated or have security vulnerabilities.
10
+
11
+ Supports **Ruby 2.6 → latest**, with the full Ruby matrix tested daily in CI.
12
+
13
+ [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/yellow_img.png)](https://www.buymeacoffee.com/vkononov)
14
+
15
+ ## Supported Technologies
16
+
17
+ * Ruby
18
+ * Node (using npm or Yarn)
19
+
20
+ ## Report Example
21
+
22
+ ![Example Output](docs/example.png)
23
+
24
+ ### Understanding the Flags Column
25
+
26
+ The Flags column shows which risk types apply to each package:
27
+
28
+ - `⦗V··⦘` - Vulnerable (has security vulnerabilities)
29
+ - `⦗·O·⦘` - Outdated (newer version available)
30
+ - `⦗··D⦘` - Deprecated (no updates in 2+ years)
31
+ - `⦗VO·⦘` - Both vulnerable and outdated
32
+ - `⦗VOD⦘` - All three risk types apply
33
+
34
+ The footer uses the same notation (⦗V⦘ulnerable, ⦗O⦘utdated, ⦗D⦘eprecated) as a legend.
35
+
36
+ ## Continuous Integration
37
+
38
+ This gem provides a return code of `0` to indicate success and `1` to indicate failure. It is specifically designed for seamless integration into continuous integration pipelines.
39
+
40
+ ## Installation
41
+
42
+ Add this line to your application's Gemfile:
43
+
44
+ ```ruby
45
+ gem 'package-audit', require: false
46
+ ```
47
+
48
+ And then execute:
49
+
50
+ ```bash
51
+ bundle
52
+ ```
53
+
54
+ Or install it yourself as:
55
+
56
+ ```bash
57
+ gem install package-audit
58
+ ```
59
+
60
+ ## Usage
61
+
62
+ ### Basic Report
63
+
64
+ Run a full audit (`DIR` defaults to current directory):
65
+
66
+ ```bash
67
+ package-audit [DIR]
68
+ ```
69
+
70
+ ### Filtering by Risk Type
71
+
72
+ Show only specific risk types:
73
+
74
+ ```bash
75
+ # Show only deprecated packages
76
+ package-audit [DIR] --deprecated
77
+
78
+ # Show only outdated packages
79
+ package-audit [DIR] --outdated
80
+
81
+ # Show only vulnerable packages
82
+ package-audit [DIR] --vulnerable
83
+
84
+ # Show packages that are deprecated and/or vulnerable
85
+ package-audit [DIR] --deprecated --vulnerable
86
+ ```
87
+
88
+ ### Excluding Risk Types
89
+
90
+ Exclude specific risk types from the report:
91
+
92
+ ```bash
93
+ # Show everything except deprecated packages
94
+ package-audit [DIR] --skip-deprecated
95
+
96
+ # Show everything except outdated packages
97
+ package-audit [DIR] --skip-outdated
98
+
99
+ # Show everything except vulnerable packages
100
+ package-audit [DIR] --skip-vulnerable
101
+
102
+ # Show only vulnerable packages (exclude deprecated and outdated)
103
+ package-audit [DIR] --skip-deprecated --skip-outdated
104
+ ```
105
+
106
+ **Important:** Packages with multiple risk types are handled intelligently. For example, a package that is both outdated and vulnerable will still appear when using `--skip-outdated` because it has a vulnerability.
107
+
108
+ ### Scoping Options
109
+
110
+ Filter by technology or group:
111
+
112
+ ```bash
113
+ # Specific technologies
114
+ package-audit [DIR] -t node -t ruby
115
+ package-audit [DIR] --technology node --technology ruby
116
+
117
+ # Specific groups
118
+ package-audit [DIR] -g staging -g production
119
+ package-audit [DIR] --group staging --group production
120
+ ```
121
+
122
+ **Note:** `Package::Audit` will attempt to automatically detect the technologies used by the project.
123
+
124
+ ### Configuration
125
+
126
+ Use a custom configuration file (see [Configuration File](#configuration-file) for details):
127
+
128
+ ```bash
129
+ package-audit [DIR] --config .package-audit.yml
130
+ ```
131
+
132
+ Include packages normally ignored by configuration:
133
+
134
+ ```bash
135
+ package-audit [DIR] --include-ignored
136
+ ```
137
+
138
+ ### Output Formats
139
+
140
+ ```bash
141
+ # CSV format (-f is an alias for --format)
142
+ package-audit [DIR] --format csv
143
+
144
+ # Markdown format
145
+ package-audit [DIR] --format md
146
+
147
+ # CSV without the header row
148
+ package-audit [DIR] --format csv --exclude-headers
149
+ ```
150
+
151
+ ### Other Commands
152
+
153
+ ```bash
154
+ # Show how risk is calculated
155
+ package-audit risk
156
+
157
+ # Show version (-v and --version also work)
158
+ package-audit version
159
+ ```
160
+
161
+ ### Getting Help
162
+
163
+ ```bash
164
+ package-audit help
165
+ package-audit help [COMMAND]
166
+ ```
167
+
168
+ ## Configuration File
169
+
170
+ The `package-audit` gem automatically searches for `.package-audit.yml` in the current directory or in the specified `DIR` if available. However, you have the option to override the default configuration file location by using the `--config` (or `-c`) flag.
171
+
172
+ #### Below is an example of a configuration file:
173
+
174
+ ```YAML
175
+ technology:
176
+ node:
177
+ nth-check:
178
+ version: 1.0.2
179
+ vulnerable: false
180
+ ruby:
181
+ devise-async:
182
+ version: 1.0.0
183
+ deprecated: false
184
+ puma:
185
+ version: 6.3.0
186
+ deprecated: false
187
+ selenium-webdriver:
188
+ version: 4.1.0
189
+ outdated: false
190
+ ```
191
+
192
+ #### This configuration file allows you to specify the following exclusions:
193
+
194
+
195
+ * Ignore all security vulnerabilities associated with `nth-check@1.0.2`.
196
+ * Suppress messages regarding potential deprecations for `device-async@1.0.0` and `puma@6.3.0`.
197
+ * Disable warnings about newer available versions of `selenium-webdriver@4.1.0`
198
+
199
+ **Note:** If the installed package version differs from the expected package version specified in the configuration file, the exclusion settings will not apply to that particular package.
200
+
201
+ **Note:** If a package is reported for multiple reasons (e.g. vulnerable and outdated), it will still be reported unless the exclusion criteria match every reason for being on the report.
202
+
203
+ > By design, wildcard (`*`) version exclusions are not supported to prevent developers from inadvertently overlooking crucial messages when packages are updated.
204
+
205
+ ## Contributing
206
+
207
+ 1. Fork it
208
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
209
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
210
+ 4. Push to the branch (`git push origin my-new-feature`)
211
+ 5. Create new Pull Request
212
+
213
+ ## License
214
+
215
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/license/MIT).
data/docs/example.png ADDED
Binary file
@@ -6,10 +6,6 @@ module Package
6
6
  BUNDLE_AUDIT_JSON = 'bundle-audit check --update --quiet --format json "%s" 2>/dev/null'
7
7
 
8
8
  NPM_AUDIT = 'npm audit'
9
- NPM_AUDIT_JSON = 'npm audit --json'
10
-
11
- YARN_AUDIT = 'yarn audit'
12
- YARN_AUDIT_JSON = 'yarn audit --json --cwd "%s"'
13
9
  end
14
10
  end
15
11
  end
@@ -1,6 +1,7 @@
1
1
  require_relative '../const/file'
2
2
  require_relative '../services/duplicate_package_merger'
3
3
  require_relative 'npm_meta_data'
4
+ require_relative 'package_lock_parser'
4
5
  require_relative 'vulnerability_finder'
5
6
  require_relative 'yarn_lock_parser'
6
7
 
@@ -73,22 +74,27 @@ module Package
73
74
  dependencies.reject { |_name, version| local_dependency?(version) }
74
75
  end
75
76
 
77
+ # Packages that live in the project itself (paths, workspaces, portals)
78
+ # are not published to the registry and cannot be audited against it.
76
79
  def local_dependency?(version)
77
- # Check for local file paths
78
- version.to_s.start_with?('file:', 'link:', './', '../') ||
79
- version.to_s.include?('file:') ||
80
- # Check for git repositories with local paths
81
- (version.to_s.start_with?('git+') && version.to_s.include?('file:'))
80
+ version.to_s.start_with?('file:', 'link:', 'workspace:', 'portal:', './', '../') ||
81
+ version.to_s.include?('file:')
82
82
  end
83
83
 
84
84
  def fetch_from_lock_file
85
85
  default_deps, dev_deps, resolutions = fetch_from_package_json
86
- if File.exist?("#{@dir}/#{Const::File::YARN_LOCK}")
87
- YarnLockParser.new("#{@dir}/#{Const::File::YARN_LOCK}").fetch(default_deps || {}, dev_deps || {},
88
- resolutions || {})
89
- else
90
- []
91
- end
86
+ parser = lock_file_parser
87
+ return [] if parser.nil?
88
+
89
+ parser.fetch(default_deps || {}, dev_deps || {}, resolutions || {})
90
+ end
91
+
92
+ def lock_file_parser
93
+ yarn_lock = "#{@dir}/#{Const::File::YARN_LOCK}"
94
+ return YarnLockParser.new(yarn_lock) if File.exist?(yarn_lock)
95
+
96
+ package_lock = "#{@dir}/#{Const::File::PACKAGE_LOCK_JSON}"
97
+ PackageLockParser.new(package_lock) if File.exist?(package_lock)
92
98
  end
93
99
  end
94
100
  end
@@ -0,0 +1,80 @@
1
+ require 'json'
2
+
3
+ require_relative '../enum/group'
4
+ require_relative '../models/package'
5
+
6
+ # Ruby 2.7 added this. A missing lockfile entry raises it, same as the yarn parser.
7
+ Object.const_set(:NoMatchingPatternError, Class.new(StandardError)) unless defined?(NoMatchingPatternError)
8
+
9
+ module Package
10
+ module Audit
11
+ module Npm
12
+ # Reads package-lock.json (lockfileVersion 1, 2 and 3) and resolves the
13
+ # installed version of each direct dependency.
14
+ class PackageLockParser
15
+ NODE_MODULES = 'node_modules/'
16
+
17
+ def initialize(package_lock_path)
18
+ @package_lock_path = package_lock_path
19
+ @lock = JSON.parse(File.read(package_lock_path))
20
+ end
21
+
22
+ def fetch(default_deps, dev_deps, _resolutions = {})
23
+ default_deps.merge(dev_deps).map do |dep_name, _expected_version|
24
+ version = direct_version(dep_name)
25
+ raise NoMatchingPatternError, "Unable to find \"#{dep_name}\" in #{@package_lock_path}" if version.nil?
26
+
27
+ pkg = Package.new(dep_name.to_s, version, 'node')
28
+ pkg.update groups: package_groups(dep_name, dev_deps)
29
+ pkg
30
+ end
31
+ end
32
+
33
+ # Every installed package, including transitive ones, as [name, version] pairs.
34
+ # Entries outside node_modules are workspace sources, not registry packages.
35
+ def all_packages
36
+ if @lock.key?('packages')
37
+ @lock['packages'].map do |path, entry|
38
+ next if entry['version'].nil? || !path.include?(NODE_MODULES)
39
+
40
+ [name_from_path(path), entry['version']]
41
+ end.compact
42
+ else
43
+ walk_v1(@lock['dependencies'] || {})
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def direct_version(dep_name)
50
+ if @lock.key?('packages')
51
+ @lock.dig('packages', "#{NODE_MODULES}#{dep_name}", 'version')
52
+ else
53
+ @lock.dig('dependencies', dep_name, 'version')
54
+ end
55
+ end
56
+
57
+ def package_groups(dep_name, dev_deps)
58
+ if dev_deps.key?(dep_name)
59
+ [Enum::Group::DEV]
60
+ else
61
+ [Enum::Group::DEFAULT, Enum::Group::DEV]
62
+ end
63
+ end
64
+
65
+ # "node_modules/a/node_modules/@scope/b" => "@scope/b"
66
+ def name_from_path(path)
67
+ path.split(NODE_MODULES).last
68
+ end
69
+
70
+ def walk_v1(dependencies, packages = [])
71
+ dependencies.each do |name, entry|
72
+ packages << [name, entry['version']] unless entry['version'].nil?
73
+ walk_v1(entry['dependencies'] || {}, packages)
74
+ end
75
+ packages
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,138 @@
1
+ module Package
2
+ module Audit
3
+ module Npm
4
+ # Evaluates npm semver ranges (as used in registry advisories) against an
5
+ # installed version. Supports comparator sets joined by "||", hyphen
6
+ # ranges, caret and tilde ranges, and x-ranges. A range that cannot be
7
+ # understood is treated as matching, so an advisory is never silently
8
+ # dropped because of unusual syntax.
9
+ class SemverRange
10
+ WILDCARDS = %w[* x X].freeze
11
+
12
+ def initialize(range)
13
+ @range = range.to_s.strip
14
+ end
15
+
16
+ def satisfied_by?(version)
17
+ return true if @range.empty?
18
+
19
+ installed = parse_version(version)
20
+ return false if installed.nil?
21
+
22
+ @range.split('||').any? { |set| comparator_set_matches?(set.strip, installed) }
23
+ rescue ArgumentError
24
+ true
25
+ end
26
+
27
+ private
28
+
29
+ def comparator_set_matches?(set, installed)
30
+ return true if set.empty? || WILDCARDS.include?(set)
31
+
32
+ Gem::Requirement.new(*requirements_for(set)).satisfied_by?(installed)
33
+ end
34
+
35
+ def requirements_for(set)
36
+ return hyphen_requirements(*set.split(/\s+-\s+/, 2)) if set.match?(/\s-\s/)
37
+
38
+ set.split(/\s+/).flat_map { |comparator| comparator_requirements(comparator) }
39
+ end
40
+
41
+ def comparator_requirements(comparator)
42
+ case comparator
43
+ when /\A\^(.+)\z/ then caret_requirements(Regexp.last_match(1))
44
+ when /\A~>?(.+)\z/ then tilde_requirements(Regexp.last_match(1))
45
+ when /\A(>=|<=|>|<|=)\s*(.+)\z/ then operator_requirements(Regexp.last_match(1), Regexp.last_match(2))
46
+ else x_range_requirements(comparator)
47
+ end
48
+ end
49
+
50
+ def operator_requirements(operator, partial)
51
+ parts = version_parts(partial)
52
+ return [] if parts.empty?
53
+ return ["#{operator} #{normalize(partial)}"] if parts.length == 3 || partial.include?('-')
54
+
55
+ partial_operator_requirements(operator, fill(parts), bump(parts))
56
+ end
57
+
58
+ # ">=1.2" means >=1.2.0, ">1.2" means >=1.3.0, "<=1.2" means <1.3.0, "=1.2" means 1.2.x
59
+ def partial_operator_requirements(operator, lower, upper)
60
+ case operator
61
+ when '>' then [">= #{upper}"]
62
+ when '<=' then ["< #{upper}"]
63
+ when '=' then [">= #{lower}", "< #{upper}"]
64
+ else ["#{operator} #{lower}"]
65
+ end
66
+ end
67
+
68
+ def hyphen_requirements(low, high)
69
+ requirements = []
70
+ requirements << ">= #{normalize(low)}" unless version_parts(low).empty?
71
+ high_parts = version_parts(high)
72
+ return requirements if high_parts.empty?
73
+
74
+ requirements << (high_parts.length == 3 ? "<= #{normalize(high)}" : "< #{bump(high_parts)}")
75
+ end
76
+
77
+ def caret_requirements(partial)
78
+ parts = version_parts(partial)
79
+ return [] if parts.empty?
80
+
81
+ # Bump the leftmost non-zero part, or the last given part when all are zero.
82
+ index = parts.index(&:positive?) || (parts.length - 1)
83
+ [">= #{normalize(partial)}", "< #{bump(parts.first(index + 1))}"]
84
+ end
85
+
86
+ def tilde_requirements(partial)
87
+ parts = version_parts(partial)
88
+ return [] if parts.empty?
89
+
90
+ upper = parts.length == 1 ? "#{parts[0] + 1}.0.0" : "#{parts[0]}.#{parts[1] + 1}.0"
91
+ [">= #{normalize(partial)}", "< #{upper}"]
92
+ end
93
+
94
+ def x_range_requirements(partial)
95
+ parts = version_parts(partial)
96
+ return [] if parts.empty?
97
+ return ["= #{normalize(partial)}"] if parts.length == 3 && !partial.match?(/[xX*]/)
98
+
99
+ [">= #{fill(parts)}", "< #{bump(parts)}"]
100
+ end
101
+
102
+ # Numeric leading parts of a version, stopping at the first wildcard.
103
+ # "1.2.x" => [1, 2], "1.2.3-beta" => [1, 2, 3], "x" => []
104
+ def version_parts(partial)
105
+ partial.to_s.sub(/\A[v=]+/, '').sub(/[-+].*\z/, '').split('.').take_while do |part|
106
+ part.match?(/\A\d+\z/)
107
+ end.map(&:to_i)
108
+ end
109
+
110
+ def fill(parts)
111
+ (parts + [0, 0, 0]).first(3).join('.')
112
+ end
113
+
114
+ def bump(parts)
115
+ bumped = parts.dup
116
+ bumped[-1] += 1
117
+ fill(bumped)
118
+ end
119
+
120
+ # Gem::Version understands "1.2.3-beta.1" (as a prerelease) but not build
121
+ # metadata, a leading "v", or missing patch numbers.
122
+ def normalize(version)
123
+ cleaned = version.to_s.sub(/\A[v=]+/, '').sub(/\+.*\z/, '')
124
+ numeric, prerelease = cleaned.split('-', 2)
125
+ numeric = numeric.to_s
126
+ numeric = fill(numeric.split('.').map(&:to_i)) if numeric.match?(/\A\d+(\.\d+){0,2}\z/)
127
+ prerelease ? "#{numeric}-#{prerelease}" : numeric
128
+ end
129
+
130
+ def parse_version(version)
131
+ Gem::Version.new(normalize(version))
132
+ rescue ArgumentError
133
+ nil
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
@@ -1,13 +1,20 @@
1
- require 'open3'
1
+ require 'json'
2
+ require 'net/http'
3
+ require 'openssl'
2
4
 
3
- require_relative '../const/cmd'
5
+ require_relative '../const/file'
4
6
  require_relative '../enum/vulnerability_type'
7
+ require_relative 'package_lock_parser'
8
+ require_relative 'semver_range'
5
9
 
6
10
  module Package
7
11
  module Audit
8
12
  module Npm
9
- class VulnerabilityFinder
10
- AUDIT_ADVISORY_REGEX = /^{"type":"auditAdvisory".*$/
13
+ class VulnerabilityFinder # rubocop:disable Metrics/ClassLength
14
+ BULK_ADVISORY_URL = 'https://registry.npmjs.org/-/npm/v1/security/advisories/bulk'
15
+ BATCH_SIZE = 100
16
+ TIMEOUT = 30
17
+ VERSION_LINE = /^\s+"?version"?\s+"([^"]+)"/
11
18
 
12
19
  def initialize(dir, pkgs)
13
20
  @dir = dir
@@ -16,48 +23,140 @@ module Package
16
23
  end
17
24
 
18
25
  def run
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' }
26
+ versions_by_name = collect_versions_by_name
27
+ return [] if versions_by_name.empty?
22
28
 
23
- json_string_lines, = Open3.capture3(env_vars, command)
24
- array = json_string_lines.scan(AUDIT_ADVISORY_REGEX)
25
-
26
- vulnerability_json_array = JSON.parse("[#{array.join(',')}]", symbolize_names: true)
27
- vulnerability_json_array.each do |vulnerability_json|
28
- update_meta_data(vulnerability_json)
29
+ versions_by_name.each_slice(BATCH_SIZE) do |batch|
30
+ batch_versions = batch.to_h
31
+ fetch_advisories(batch_versions).each do |name, advisories|
32
+ package_name = name.to_s
33
+ apply_advisories(package_name, Array(advisories), batch_versions[package_name] || [])
34
+ end
29
35
  end
36
+
30
37
  @vuln_hash.values
31
38
  end
32
39
 
33
40
  private
34
41
 
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)
42
+ def collect_versions_by_name
43
+ versions_by_name = {}
44
+
45
+ (lockfile_packages + @pkg_hash.values.map { |pkg| [pkg.name, pkg.version] }).each do |name, version|
46
+ next if version.to_s.empty? || version == 'unknown'
47
+
48
+ versions = versions_by_name[name] ||= []
49
+ versions << version unless versions.include?(version)
50
+ end
51
+
52
+ versions_by_name
53
+ end
54
+
55
+ def lockfile_packages
56
+ yarn_lock = "#{@dir}/#{Const::File::YARN_LOCK}"
57
+ return yarn_lockfile_packages(yarn_lock) if File.exist?(yarn_lock)
58
+
59
+ package_lock = "#{@dir}/#{Const::File::PACKAGE_LOCK_JSON}"
60
+ return PackageLockParser.new(package_lock).all_packages if File.exist?(package_lock)
38
61
 
39
- parent_name = resolution_path.split('>').first
40
- advisory = json[:data][:advisory]
41
- package_info = extract_package_info(advisory)
42
- update_vulnerability_info(package_info, parent_name, advisory[:severity])
62
+ []
63
+ end
64
+
65
+ def yarn_lockfile_packages(path)
66
+ packages = []
67
+ current_names = []
68
+ File.foreach(path) do |line|
69
+ current_names, packages = process_lockfile_line(line, current_names, packages)
70
+ end
71
+ packages
72
+ end
73
+
74
+ def process_lockfile_line(line, current_names, packages)
75
+ if lockfile_key_line?(line)
76
+ [package_names_from_lock_key(line.strip.chomp(':')), packages]
77
+ elsif (match = line.match(VERSION_LINE))
78
+ match_version = match[1]
79
+ current_names.each { |name| packages << [name, match_version] }
80
+ [[], packages]
81
+ else
82
+ [current_names, packages]
83
+ end
84
+ end
85
+
86
+ def lockfile_key_line?(line)
87
+ line.match?(/^\S/) && line.include?('@') && line.strip.end_with?(':')
88
+ end
89
+
90
+ def package_names_from_lock_key(key)
91
+ key.split(',').map do |entry|
92
+ package_name_from_spec(entry.strip.gsub(/\A["']|["']\z/, ''))
93
+ end.uniq
94
+ end
95
+
96
+ def package_name_from_spec(spec)
97
+ if spec.start_with?('@')
98
+ "@#{spec.split('@', 3)[1]}"
99
+ else
100
+ spec.split('@', 2).first
101
+ end
102
+ end
103
+
104
+ def fetch_advisories(versions_by_name)
105
+ uri = URI(BULK_ADVISORY_URL)
106
+ response = make_request(uri, versions_by_name.to_json)
107
+ unless response.is_a?(Net::HTTPSuccess)
108
+ warn "Warning: Unable to fetch npm advisories (#{response.code} #{response.message})."
109
+ return {}
110
+ end
111
+
112
+ JSON.parse(response.body)
113
+ rescue StandardError => e
114
+ warn "Warning: Error while fetching npm advisories: #{e.message}"
115
+ {}
116
+ end
117
+
118
+ def make_request(uri, body)
119
+ request = Net::HTTP::Post.new(uri)
120
+ request['Content-Type'] = 'application/json'
121
+ request['Accept'] = 'application/json'
122
+ request.body = body
123
+ create_http_client(uri).request(request)
124
+ end
125
+
126
+ def create_http_client(uri)
127
+ Net::HTTP.new(uri.host, uri.port).tap do |http|
128
+ http.use_ssl = true
129
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
130
+ http.cert_store = OpenSSL::X509::Store.new
131
+ http.cert_store.set_default_paths
132
+ http.read_timeout = TIMEOUT
133
+ http.open_timeout = TIMEOUT
134
+ end
135
+ end
136
+
137
+ def apply_advisories(name, advisories, versions)
138
+ advisories.each do |advisory|
139
+ Array(versions).each do |version|
140
+ next unless version_matches?(version, advisory['vulnerable_versions'])
141
+
142
+ update_vulnerability_info(name, version, advisory['severity'])
143
+ end
144
+ end
43
145
  end
44
146
 
45
- def extract_package_info(advisory)
46
- {
47
- name: advisory[:module_name],
48
- version: advisory[:findings][0][:version]
49
- }
147
+ def version_matches?(version, vulnerable_versions)
148
+ SemverRange.new(vulnerable_versions).satisfied_by?(version)
50
149
  end
51
150
 
52
- def update_vulnerability_info(package_info, parent_name, severity)
53
- name = package_info[:name]
54
- version = package_info[:version]
151
+ def update_vulnerability_info(name, version, severity)
55
152
  full_name = "#{name}@#{version}"
56
153
  vulnerability = severity || Enum::VulnerabilityType::UNKNOWN
57
154
 
58
155
  @vuln_hash[full_name] ||= Package.new(name, version, 'node')
59
- @vuln_hash[full_name].update vulnerabilities: @vuln_hash[full_name].vulnerabilities + [vulnerability]
60
- @vuln_hash[full_name].update groups: @pkg_hash[parent_name]&.groups&.map(&:to_s) || []
156
+ @vuln_hash[full_name].update(
157
+ vulnerabilities: @vuln_hash[full_name].vulnerabilities + [vulnerability],
158
+ groups: @pkg_hash[name]&.groups&.map(&:to_s) || []
159
+ )
61
160
  end
62
161
  end
63
162
  end
@@ -226,8 +226,8 @@ module Package
226
226
 
227
227
  def parse_version_dates(response_body)
228
228
  versions = JSON.parse(response_body)
229
- versions.each_with_object({}) do |version_info, dates|
230
- dates[version_info['number']] = version_info['created_at']
229
+ versions.to_h do |version_info|
230
+ [version_info['number'], version_info['created_at']]
231
231
  end
232
232
  end
233
233
 
@@ -140,7 +140,7 @@ module Package
140
140
  when Enum::Technology::RUBY
141
141
  Const::Cmd::BUNDLE_AUDIT
142
142
  when Enum::Technology::NODE
143
- Const::Cmd::YARN_AUDIT
143
+ Const::Cmd::NPM_AUDIT
144
144
  else
145
145
  raise ArgumentError, "Unexpected technology \"#{technology}\" found in #{__method__}"
146
146
  end
@@ -1,5 +1,5 @@
1
1
  module Package
2
2
  module Audit
3
- VERSION = '0.8.1'
3
+ VERSION = '0.9.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.8.1
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vadim Kononov
@@ -37,8 +37,9 @@ dependencies:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
39
  version: '1.2'
40
- description: A useful tool for patch management and prioritization, package-audit
41
- produces a list of dependencies that are outdated, deprecated or have security vulnerabilities.
40
+ description: Audits project dependencies across supported package managers and lists
41
+ those that are outdated, deprecated or have known security vulnerabilities, for
42
+ patch prioritization.
42
43
  email:
43
44
  - vadim@konoson.com
44
45
  executables:
@@ -46,6 +47,9 @@ executables:
46
47
  extensions: []
47
48
  extra_rdoc_files: []
48
49
  files:
50
+ - LICENSE.txt
51
+ - README.md
52
+ - docs/example.png
49
53
  - exe/package-audit
50
54
  - lib/package/audit/cli.rb
51
55
  - lib/package/audit/const/cmd.rb
@@ -70,6 +74,8 @@ files:
70
74
  - lib/package/audit/models/risk.rb
71
75
  - lib/package/audit/npm/node_collection.rb
72
76
  - lib/package/audit/npm/npm_meta_data.rb
77
+ - lib/package/audit/npm/package_lock_parser.rb
78
+ - lib/package/audit/npm/semver_range.rb
73
79
  - lib/package/audit/npm/vulnerability_finder.rb
74
80
  - lib/package/audit/npm/yarn_lock_parser.rb
75
81
  - lib/package/audit/ruby/bundler_specs.rb
@@ -111,7 +117,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
111
117
  - !ruby/object:Gem::Version
112
118
  version: '0'
113
119
  requirements: []
114
- rubygems_version: 3.7.2
120
+ rubygems_version: 4.0.10
115
121
  specification_version: 4
116
- summary: A helper tool to find outdated, deprecated and vulnerable dependencies.
122
+ summary: CLI that reports outdated, deprecated and vulnerable dependencies in a project
117
123
  test_files: []