rubion 0.3.3 → 0.3.5
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 +4 -4
- data/lib/rubion/reporter.rb +35 -5
- data/lib/rubion/scanner.rb +201 -144
- data/lib/rubion/version.rb +1 -1
- data/lib/rubion.rb +10 -3
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2650a4a9cb6749a2d6e7a3c74f2917130aff97b5dc2c47dd8cf221ea5e1f5f23
|
|
4
|
+
data.tar.gz: 3b5f971c826e2e4a7e687fbd7565f78afe4977d0c6cc09e41ec8e0f83fde3069
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 13fc340260cd647ed4515419e2befa74e10fa00aa85ffa8c4d8a8c48822ad394b09d03c4ddc33f68f21d63052ed735354e743b2b219dd5c35b67e5d3748606c4
|
|
7
|
+
data.tar.gz: 5d807f967aba90df47307498229ea1f7e6b8d3dcb884cf18e4bd0d485a7cf0d31dc3d24539faed14e883f0b4c6114f384245d386dcb571951b86193078856d0e
|
data/lib/rubion/reporter.rb
CHANGED
|
@@ -74,16 +74,27 @@ module Rubion
|
|
|
74
74
|
return
|
|
75
75
|
end
|
|
76
76
|
|
|
77
|
-
#
|
|
77
|
+
# Filter to only direct dependencies if flag is set
|
|
78
78
|
versions = @result.gem_versions.dup
|
|
79
|
+
versions = versions.select { |gem| gem[:direct] } if @exclude_dependencies
|
|
80
|
+
|
|
81
|
+
if versions.empty?
|
|
82
|
+
puts " ✅ No direct dependencies found!\n\n"
|
|
83
|
+
return
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Sort if sort_by is specified
|
|
79
87
|
versions = sort_versions(versions, :gem) if @sort_by
|
|
80
88
|
|
|
81
89
|
table = Terminal::Table.new do |t|
|
|
82
90
|
t.headings = ['Name', 'Current', 'Date', 'Latest', 'Date', 'Behind By(Time)', 'Behind By(Versions)']
|
|
83
91
|
|
|
84
92
|
versions.each do |gem|
|
|
93
|
+
# Make direct dependencies bold
|
|
94
|
+
gem_name = gem[:direct] ? bold(gem[:gem]) : gem[:gem]
|
|
95
|
+
|
|
85
96
|
t.add_row [
|
|
86
|
-
|
|
97
|
+
gem_name,
|
|
87
98
|
gem[:current],
|
|
88
99
|
gem[:current_date] || 'N/A',
|
|
89
100
|
gem[:latest],
|
|
@@ -131,16 +142,27 @@ module Rubion
|
|
|
131
142
|
return
|
|
132
143
|
end
|
|
133
144
|
|
|
134
|
-
#
|
|
145
|
+
# Filter to only direct dependencies if flag is set
|
|
135
146
|
versions = @result.package_versions.dup
|
|
147
|
+
versions = versions.select { |pkg| pkg[:direct] } if @exclude_dependencies
|
|
148
|
+
|
|
149
|
+
if versions.empty?
|
|
150
|
+
puts " ✅ No direct dependencies found!\n\n"
|
|
151
|
+
return
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Sort if sort_by is specified
|
|
136
155
|
versions = sort_versions(versions, :package) if @sort_by
|
|
137
156
|
|
|
138
157
|
table = Terminal::Table.new do |t|
|
|
139
158
|
t.headings = ['Name', 'Current', 'Date', 'Latest', 'Date', 'Behind By(Time)', 'Behind By(Versions)']
|
|
140
159
|
|
|
141
160
|
versions.each do |pkg|
|
|
161
|
+
# Make direct dependencies bold
|
|
162
|
+
package_name = pkg[:direct] ? bold(pkg[:package]) : pkg[:package]
|
|
163
|
+
|
|
142
164
|
t.add_row [
|
|
143
|
-
|
|
165
|
+
package_name,
|
|
144
166
|
pkg[:current],
|
|
145
167
|
pkg[:current_date] || 'N/A',
|
|
146
168
|
pkg[:latest],
|
|
@@ -206,6 +228,11 @@ module Rubion
|
|
|
206
228
|
"#{text[0..(length - 3)]}..."
|
|
207
229
|
end
|
|
208
230
|
|
|
231
|
+
# Make text bold using ANSI escape codes
|
|
232
|
+
def bold(text)
|
|
233
|
+
"\033[1m#{text}\033[0m"
|
|
234
|
+
end
|
|
235
|
+
|
|
209
236
|
def version_difference(current, latest)
|
|
210
237
|
# Simple version difference calculation
|
|
211
238
|
current_parts = current.split('.').map(&:to_i)
|
|
@@ -248,7 +275,10 @@ module Rubion
|
|
|
248
275
|
sorted = versions.sort_by do |item|
|
|
249
276
|
case normalized_column
|
|
250
277
|
when 'name'
|
|
251
|
-
|
|
278
|
+
# Remove ANSI codes for sorting
|
|
279
|
+
name = item[name_key_sym].to_s
|
|
280
|
+
name = name.gsub(/\033\[[0-9;]*m/, '') # Remove ANSI escape codes
|
|
281
|
+
name.downcase
|
|
252
282
|
when 'current'
|
|
253
283
|
parse_version_for_sort(item[:current])
|
|
254
284
|
when 'date'
|
data/lib/rubion/scanner.rb
CHANGED
|
@@ -6,7 +6,6 @@ require_relative 'reporter'
|
|
|
6
6
|
require 'net/http'
|
|
7
7
|
require 'uri'
|
|
8
8
|
require 'date'
|
|
9
|
-
require 'thread'
|
|
10
9
|
|
|
11
10
|
module Rubion
|
|
12
11
|
class Scanner
|
|
@@ -26,37 +25,41 @@ module Rubion
|
|
|
26
25
|
@result = ScanResult.new
|
|
27
26
|
@package_manager = package_manager
|
|
28
27
|
@package_manager_detected = false
|
|
28
|
+
@direct_gems = nil
|
|
29
|
+
@direct_packages = nil
|
|
29
30
|
end
|
|
30
31
|
|
|
31
32
|
def scan
|
|
32
33
|
puts "🔍 Scanning project at: #{@project_path}\n\n"
|
|
33
|
-
|
|
34
|
+
|
|
34
35
|
scan_ruby_gems
|
|
35
36
|
scan_npm_packages
|
|
36
|
-
|
|
37
|
+
|
|
37
38
|
@result
|
|
38
39
|
end
|
|
39
40
|
|
|
40
|
-
def scan_incremental(options = { gems: true, packages: true, sort_by:
|
|
41
|
+
def scan_incremental(options = { gems: true, packages: true, sort_by: 'Behind By(Time)', sort_desc: true,
|
|
42
|
+
exclude_dependencies: false })
|
|
41
43
|
puts "🔍 Scanning project at: #{@project_path}\n\n"
|
|
42
|
-
|
|
44
|
+
|
|
43
45
|
# Scan and display Ruby gems first (if enabled)
|
|
44
46
|
if options[:gems]
|
|
45
47
|
scan_ruby_gems
|
|
46
|
-
|
|
48
|
+
|
|
47
49
|
# Print gem results immediately
|
|
48
50
|
puts "\n"
|
|
49
|
-
reporter = Reporter.new(@result, sort_by: options[:sort_by], sort_desc: options[:sort_desc]
|
|
51
|
+
reporter = Reporter.new(@result, sort_by: options[:sort_by], sort_desc: options[:sort_desc],
|
|
52
|
+
exclude_dependencies: options[:exclude_dependencies])
|
|
50
53
|
reporter.print_gem_vulnerabilities
|
|
51
54
|
reporter.print_gem_versions
|
|
52
55
|
end
|
|
53
|
-
|
|
56
|
+
|
|
54
57
|
# Then scan NPM packages (if enabled)
|
|
55
58
|
if options[:packages]
|
|
56
|
-
puts "\n" if options[:gems]
|
|
59
|
+
puts "\n" if options[:gems] # Add spacing if gems were scanned
|
|
57
60
|
scan_npm_packages
|
|
58
61
|
end
|
|
59
|
-
|
|
62
|
+
|
|
60
63
|
@result
|
|
61
64
|
end
|
|
62
65
|
|
|
@@ -64,10 +67,10 @@ module Rubion
|
|
|
64
67
|
|
|
65
68
|
def scan_ruby_gems
|
|
66
69
|
return unless File.exist?(File.join(@project_path, 'Gemfile.lock'))
|
|
67
|
-
|
|
70
|
+
|
|
68
71
|
# Check for vulnerabilities using bundler-audit
|
|
69
72
|
check_gem_vulnerabilities
|
|
70
|
-
|
|
73
|
+
|
|
71
74
|
# Check for outdated versions using bundle outdated (will show progress)
|
|
72
75
|
check_gem_versions
|
|
73
76
|
end
|
|
@@ -75,86 +78,86 @@ module Rubion
|
|
|
75
78
|
def scan_npm_packages
|
|
76
79
|
package_json = File.join(@project_path, 'package.json')
|
|
77
80
|
return unless File.exist?(package_json)
|
|
78
|
-
|
|
81
|
+
|
|
79
82
|
# Detect package manager if not already set
|
|
80
83
|
unless @package_manager_detected
|
|
81
|
-
@package_manager
|
|
84
|
+
@package_manager ||= detect_package_manager
|
|
82
85
|
@package_manager_detected = true
|
|
83
86
|
end
|
|
84
|
-
|
|
87
|
+
|
|
85
88
|
unless @package_manager
|
|
86
|
-
puts
|
|
89
|
+
puts ' ⚠️ Neither npm nor yarn is available. Skipping package scanning.'
|
|
87
90
|
return
|
|
88
91
|
end
|
|
89
|
-
|
|
92
|
+
|
|
90
93
|
# Check for vulnerabilities using package manager audit
|
|
91
94
|
check_npm_vulnerabilities
|
|
92
|
-
|
|
95
|
+
|
|
93
96
|
# Check for outdated versions using package manager outdated (will show progress)
|
|
94
97
|
check_npm_versions
|
|
95
98
|
end
|
|
96
99
|
|
|
97
100
|
def check_gem_vulnerabilities
|
|
98
101
|
# Try to use bundler-audit if available
|
|
99
|
-
stdout, stderr, status = Open3.capture3(
|
|
100
|
-
|
|
102
|
+
stdout, stderr, status = Open3.capture3('bundle-audit check 2>&1', chdir: @project_path)
|
|
103
|
+
|
|
101
104
|
# bundle-audit returns exit code 1 when vulnerabilities are found, 0 when none found
|
|
102
105
|
# Always parse if there's output (vulnerabilities found) or if it succeeded (no vulnerabilities)
|
|
103
|
-
if stdout.include?(
|
|
106
|
+
if stdout.include?('vulnerabilities found') || stdout.include?('Name:') || status.success?
|
|
104
107
|
parse_bundler_audit_output(stdout)
|
|
105
108
|
else
|
|
106
109
|
# No vulnerabilities found or bundler-audit not available
|
|
107
110
|
@result.gem_vulnerabilities = []
|
|
108
111
|
end
|
|
109
|
-
rescue => e
|
|
112
|
+
rescue StandardError => e
|
|
110
113
|
puts " ⚠️ Could not run bundle-audit (#{e.message}). Skipping gem vulnerability check."
|
|
111
114
|
@result.gem_vulnerabilities = []
|
|
112
115
|
end
|
|
113
116
|
|
|
114
117
|
def check_gem_versions
|
|
115
|
-
stdout, stderr, status = Open3.capture3(
|
|
116
|
-
|
|
118
|
+
stdout, stderr, status = Open3.capture3('bundle outdated --parseable', chdir: @project_path)
|
|
119
|
+
|
|
117
120
|
if status.success? || !stdout.empty?
|
|
118
121
|
parse_bundle_outdated_output(stdout)
|
|
119
122
|
else
|
|
120
123
|
# No outdated gems found
|
|
121
124
|
@result.gem_versions = []
|
|
122
125
|
end
|
|
123
|
-
rescue => e
|
|
126
|
+
rescue StandardError => e
|
|
124
127
|
puts " ⚠️ Could not run bundle outdated (#{e.message}). Skipping gem version check."
|
|
125
128
|
@result.gem_versions = []
|
|
126
129
|
end
|
|
127
130
|
|
|
128
131
|
def check_npm_vulnerabilities
|
|
129
132
|
return unless @package_manager
|
|
130
|
-
|
|
133
|
+
|
|
131
134
|
command = "#{@package_manager} audit --json 2>&1"
|
|
132
135
|
stdout, stderr, status = Open3.capture3(command, chdir: @project_path)
|
|
133
|
-
|
|
136
|
+
|
|
134
137
|
begin
|
|
135
138
|
data = JSON.parse(stdout)
|
|
136
139
|
parse_npm_audit_output(data)
|
|
137
140
|
rescue JSON::ParserError
|
|
138
141
|
@result.package_vulnerabilities = []
|
|
139
142
|
end
|
|
140
|
-
rescue => e
|
|
143
|
+
rescue StandardError => e
|
|
141
144
|
puts " ⚠️ Could not run #{@package_manager} audit (#{e.message}). Skipping package vulnerability check."
|
|
142
145
|
@result.package_vulnerabilities = []
|
|
143
146
|
end
|
|
144
147
|
|
|
145
148
|
def check_npm_versions
|
|
146
149
|
return unless @package_manager
|
|
147
|
-
|
|
150
|
+
|
|
148
151
|
command = "#{@package_manager} outdated --json 2>&1"
|
|
149
152
|
stdout, stderr, status = Open3.capture3(command, chdir: @project_path)
|
|
150
|
-
|
|
153
|
+
|
|
151
154
|
begin
|
|
152
155
|
data = JSON.parse(stdout) unless stdout.empty?
|
|
153
156
|
parse_npm_outdated_output(data || {})
|
|
154
157
|
rescue JSON::ParserError
|
|
155
158
|
@result.package_versions = []
|
|
156
159
|
end
|
|
157
|
-
rescue => e
|
|
160
|
+
rescue StandardError => e
|
|
158
161
|
puts " ⚠️ Could not run #{@package_manager} outdated (#{e.message}). Skipping package version check."
|
|
159
162
|
@result.package_versions = []
|
|
160
163
|
end
|
|
@@ -164,86 +167,86 @@ module Rubion
|
|
|
164
167
|
def parse_bundler_audit_output(output)
|
|
165
168
|
vulnerabilities = []
|
|
166
169
|
current_gem = nil
|
|
167
|
-
|
|
170
|
+
|
|
168
171
|
output.each_line do |line|
|
|
169
172
|
line = line.strip
|
|
170
173
|
next if line.empty?
|
|
171
|
-
|
|
174
|
+
|
|
172
175
|
if line =~ /^Name: (.+)/
|
|
173
|
-
current_gem = { gem:
|
|
176
|
+
current_gem = { gem: ::Regexp.last_match(1).strip }
|
|
174
177
|
elsif line =~ /^Version: (.+)/ && current_gem
|
|
175
|
-
current_gem[:version] =
|
|
178
|
+
current_gem[:version] = ::Regexp.last_match(1).strip
|
|
176
179
|
elsif line =~ /^CVE: (.+)/ && current_gem
|
|
177
|
-
current_gem[:advisory] =
|
|
180
|
+
current_gem[:advisory] = ::Regexp.last_match(1).strip
|
|
178
181
|
elsif line =~ /^Advisory: (.+)/ && current_gem
|
|
179
182
|
# Fallback for older bundle-audit versions
|
|
180
|
-
current_gem[:advisory] =
|
|
183
|
+
current_gem[:advisory] = ::Regexp.last_match(1).strip
|
|
181
184
|
elsif line =~ /^Criticality: (.+)/ && current_gem
|
|
182
|
-
current_gem[:severity] =
|
|
185
|
+
current_gem[:severity] = ::Regexp.last_match(1).strip
|
|
183
186
|
elsif line =~ /^Title: (.+)/ && current_gem
|
|
184
|
-
current_gem[:title] =
|
|
187
|
+
current_gem[:title] = ::Regexp.last_match(1).strip
|
|
185
188
|
# Only add if we have at least name, version, and title
|
|
186
|
-
if current_gem[:gem] && current_gem[:version] && current_gem[:title]
|
|
187
|
-
vulnerabilities << current_gem
|
|
188
|
-
end
|
|
189
|
+
vulnerabilities << current_gem if current_gem[:gem] && current_gem[:version] && current_gem[:title]
|
|
189
190
|
current_gem = nil
|
|
190
191
|
end
|
|
191
192
|
end
|
|
192
|
-
|
|
193
|
+
|
|
193
194
|
# Handle case where vulnerability block ends without Title (use CVE as title)
|
|
194
195
|
if current_gem && current_gem[:gem] && current_gem[:version]
|
|
195
|
-
current_gem[:title] ||= current_gem[:advisory] ||
|
|
196
|
+
current_gem[:title] ||= current_gem[:advisory] || 'Vulnerability detected'
|
|
196
197
|
vulnerabilities << current_gem
|
|
197
198
|
end
|
|
198
|
-
|
|
199
|
+
|
|
199
200
|
@result.gem_vulnerabilities = vulnerabilities
|
|
200
201
|
end
|
|
201
202
|
|
|
202
203
|
def parse_bundle_outdated_output(output)
|
|
203
204
|
versions = []
|
|
204
205
|
lines_to_process = []
|
|
205
|
-
|
|
206
|
+
|
|
206
207
|
# First pass: collect all lines to process
|
|
207
208
|
output.each_line do |line|
|
|
208
209
|
next if line.strip.empty?
|
|
209
|
-
|
|
210
|
+
|
|
210
211
|
# Parse format: gem_name (newest version, installed version, requested version)
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
212
|
+
next unless line =~ /^(.+?)\s+\(newest\s+(.+?),\s+installed\s+(.+?)(?:,|\))/
|
|
213
|
+
|
|
214
|
+
lines_to_process << {
|
|
215
|
+
gem_name: ::Regexp.last_match(1).strip,
|
|
216
|
+
current_version: ::Regexp.last_match(3).strip,
|
|
217
|
+
latest_version: ::Regexp.last_match(2).strip
|
|
218
|
+
}
|
|
218
219
|
end
|
|
219
|
-
|
|
220
|
+
|
|
220
221
|
total = lines_to_process.size
|
|
221
|
-
|
|
222
|
+
|
|
222
223
|
# Process in parallel with threads (limit to 10 concurrent requests)
|
|
223
224
|
mutex = Mutex.new
|
|
224
225
|
thread_pool = []
|
|
225
226
|
max_threads = 10
|
|
226
|
-
|
|
227
|
+
|
|
227
228
|
lines_to_process.each_with_index do |line_data, index|
|
|
228
229
|
# Wait if we have too many threads
|
|
229
|
-
if thread_pool.size >= max_threads
|
|
230
|
-
|
|
231
|
-
end
|
|
232
|
-
|
|
230
|
+
thread_pool.shift.join if thread_pool.size >= max_threads
|
|
231
|
+
|
|
233
232
|
thread = Thread.new do
|
|
234
233
|
# Fetch all version info once per gem (includes dates and version list)
|
|
235
234
|
gem_data = fetch_gem_all_versions(line_data[:gem_name])
|
|
236
|
-
|
|
235
|
+
|
|
237
236
|
# Extract dates for current and latest versions
|
|
238
237
|
current_date = gem_data[:versions][line_data[:current_version]] || 'N/A'
|
|
239
238
|
latest_date = gem_data[:versions][line_data[:latest_version]] || 'N/A'
|
|
240
|
-
|
|
239
|
+
|
|
241
240
|
# Calculate time difference
|
|
242
241
|
time_diff = calculate_time_difference(current_date, latest_date)
|
|
243
|
-
|
|
242
|
+
|
|
244
243
|
# Count versions between current and latest
|
|
245
|
-
version_count = count_versions_from_list(gem_data[:version_list], line_data[:current_version],
|
|
246
|
-
|
|
244
|
+
version_count = count_versions_from_list(gem_data[:version_list], line_data[:current_version],
|
|
245
|
+
line_data[:latest_version])
|
|
246
|
+
|
|
247
|
+
# Check if this is a direct dependency
|
|
248
|
+
direct_dependency = is_direct_gem?(line_data[:gem_name])
|
|
249
|
+
|
|
247
250
|
result = {
|
|
248
251
|
gem: line_data[:gem_name],
|
|
249
252
|
current: line_data[:current_version],
|
|
@@ -252,38 +255,39 @@ module Rubion
|
|
|
252
255
|
latest_date: latest_date,
|
|
253
256
|
time_diff: time_diff,
|
|
254
257
|
version_count: version_count,
|
|
258
|
+
direct: direct_dependency,
|
|
255
259
|
index: index
|
|
256
260
|
}
|
|
257
|
-
|
|
261
|
+
|
|
258
262
|
mutex.synchronize do
|
|
259
263
|
versions << result
|
|
260
264
|
print "\r📦 Checking Ruby gems... #{versions.size}/#{total}"
|
|
261
265
|
$stdout.flush
|
|
262
266
|
end
|
|
263
267
|
end
|
|
264
|
-
|
|
268
|
+
|
|
265
269
|
thread_pool << thread
|
|
266
270
|
end
|
|
267
|
-
|
|
271
|
+
|
|
268
272
|
# Wait for all threads to complete
|
|
269
273
|
thread_pool.each(&:join)
|
|
270
|
-
|
|
274
|
+
|
|
271
275
|
# Sort by original index to maintain order
|
|
272
276
|
versions.sort_by! { |v| v[:index] }
|
|
273
277
|
versions.each { |v| v.delete(:index) }
|
|
274
|
-
|
|
278
|
+
|
|
275
279
|
puts "\r📦 Checking Ruby gems... #{total}/#{total} ✓" if total > 0
|
|
276
|
-
|
|
280
|
+
|
|
277
281
|
@result.gem_versions = versions
|
|
278
282
|
end
|
|
279
283
|
|
|
280
284
|
def parse_npm_audit_output(data)
|
|
281
285
|
vulnerabilities = []
|
|
282
|
-
|
|
286
|
+
|
|
283
287
|
if data['vulnerabilities'] && data['vulnerabilities'].is_a?(Hash)
|
|
284
288
|
data['vulnerabilities'].each do |name, info|
|
|
285
289
|
next unless info.is_a?(Hash)
|
|
286
|
-
|
|
290
|
+
|
|
287
291
|
# Extract title from via array
|
|
288
292
|
title = 'Vulnerability detected'
|
|
289
293
|
if info['via'].is_a?(Array) && info['via'].first.is_a?(Hash)
|
|
@@ -291,7 +295,7 @@ module Rubion
|
|
|
291
295
|
elsif info['via'].is_a?(String)
|
|
292
296
|
title = info['via']
|
|
293
297
|
end
|
|
294
|
-
|
|
298
|
+
|
|
295
299
|
vulnerabilities << {
|
|
296
300
|
package: name,
|
|
297
301
|
version: info['range'] || info['version'] || 'unknown',
|
|
@@ -300,57 +304,59 @@ module Rubion
|
|
|
300
304
|
}
|
|
301
305
|
end
|
|
302
306
|
end
|
|
303
|
-
|
|
307
|
+
|
|
304
308
|
@result.package_vulnerabilities = vulnerabilities
|
|
305
|
-
rescue => e
|
|
309
|
+
rescue StandardError => e
|
|
306
310
|
puts " ⚠️ Error parsing npm audit data: #{e.message}"
|
|
307
311
|
@result.package_vulnerabilities = []
|
|
308
312
|
end
|
|
309
313
|
|
|
310
314
|
def parse_npm_outdated_output(data)
|
|
311
315
|
versions = []
|
|
312
|
-
|
|
316
|
+
|
|
313
317
|
if data.is_a?(Hash)
|
|
314
318
|
packages_to_process = []
|
|
315
|
-
|
|
319
|
+
|
|
316
320
|
# First pass: collect all packages to process
|
|
317
321
|
data.each do |name, info|
|
|
318
322
|
next unless info.is_a?(Hash)
|
|
319
|
-
|
|
323
|
+
|
|
320
324
|
packages_to_process << {
|
|
321
325
|
name: name,
|
|
322
326
|
current_version: info['current'] || 'unknown',
|
|
323
327
|
latest_version: info['latest'] || 'unknown'
|
|
324
328
|
}
|
|
325
329
|
end
|
|
326
|
-
|
|
330
|
+
|
|
327
331
|
total = packages_to_process.size
|
|
328
|
-
|
|
332
|
+
|
|
329
333
|
# Process in parallel with threads (limit to 10 concurrent requests)
|
|
330
334
|
mutex = Mutex.new
|
|
331
335
|
thread_pool = []
|
|
332
336
|
max_threads = 10
|
|
333
|
-
|
|
337
|
+
|
|
334
338
|
packages_to_process.each_with_index do |pkg_data, index|
|
|
335
339
|
# Wait if we have too many threads
|
|
336
|
-
if thread_pool.size >= max_threads
|
|
337
|
-
|
|
338
|
-
end
|
|
339
|
-
|
|
340
|
+
thread_pool.shift.join if thread_pool.size >= max_threads
|
|
341
|
+
|
|
340
342
|
thread = Thread.new do
|
|
341
343
|
# Fetch all version info once per package (includes dates and version list)
|
|
342
344
|
pkg_data_full = fetch_npm_all_versions(pkg_data[:name])
|
|
343
|
-
|
|
345
|
+
|
|
344
346
|
# Extract dates for current and latest versions
|
|
345
347
|
current_date = pkg_data_full[:versions][pkg_data[:current_version]] || 'N/A'
|
|
346
348
|
latest_date = pkg_data_full[:versions][pkg_data[:latest_version]] || 'N/A'
|
|
347
|
-
|
|
349
|
+
|
|
348
350
|
# Calculate time difference
|
|
349
351
|
time_diff = calculate_time_difference(current_date, latest_date)
|
|
350
|
-
|
|
352
|
+
|
|
351
353
|
# Count versions between current and latest
|
|
352
|
-
version_count = count_versions_from_list(pkg_data_full[:version_list], pkg_data[:current_version],
|
|
353
|
-
|
|
354
|
+
version_count = count_versions_from_list(pkg_data_full[:version_list], pkg_data[:current_version],
|
|
355
|
+
pkg_data[:latest_version])
|
|
356
|
+
|
|
357
|
+
# Check if this is a direct dependency
|
|
358
|
+
direct_dependency = is_direct_package?(pkg_data[:name])
|
|
359
|
+
|
|
354
360
|
result = {
|
|
355
361
|
package: pkg_data[:name],
|
|
356
362
|
current: pkg_data[:current_version],
|
|
@@ -359,31 +365,32 @@ module Rubion
|
|
|
359
365
|
latest_date: latest_date,
|
|
360
366
|
time_diff: time_diff,
|
|
361
367
|
version_count: version_count,
|
|
368
|
+
direct: direct_dependency,
|
|
362
369
|
index: index
|
|
363
370
|
}
|
|
364
|
-
|
|
371
|
+
|
|
365
372
|
mutex.synchronize do
|
|
366
373
|
versions << result
|
|
367
374
|
print "\r📦 Checking NPM packages... #{versions.size}/#{total}"
|
|
368
375
|
$stdout.flush
|
|
369
376
|
end
|
|
370
377
|
end
|
|
371
|
-
|
|
378
|
+
|
|
372
379
|
thread_pool << thread
|
|
373
380
|
end
|
|
374
|
-
|
|
381
|
+
|
|
375
382
|
# Wait for all threads to complete
|
|
376
383
|
thread_pool.each(&:join)
|
|
377
|
-
|
|
384
|
+
|
|
378
385
|
# Sort by original index to maintain order
|
|
379
386
|
versions.sort_by! { |v| v[:index] }
|
|
380
387
|
versions.each { |v| v.delete(:index) }
|
|
381
|
-
|
|
388
|
+
|
|
382
389
|
puts "\r📦 Checking NPM packages... #{total}/#{total} ✓" if total > 0
|
|
383
390
|
end
|
|
384
|
-
|
|
391
|
+
|
|
385
392
|
@result.package_versions = versions
|
|
386
|
-
rescue => e
|
|
393
|
+
rescue StandardError => e
|
|
387
394
|
puts " ⚠️ Error parsing npm outdated data: #{e.message}"
|
|
388
395
|
@result.package_versions = []
|
|
389
396
|
end
|
|
@@ -463,27 +470,27 @@ module Rubion
|
|
|
463
470
|
# Fetch all gem version info (dates and version list) from RubyGems API in one call
|
|
464
471
|
def fetch_gem_all_versions(gem_name)
|
|
465
472
|
return { versions: {}, version_list: [] } if gem_name.nil?
|
|
466
|
-
|
|
473
|
+
|
|
467
474
|
uri = URI("https://rubygems.org/api/v1/versions/#{gem_name}.json")
|
|
468
|
-
|
|
475
|
+
|
|
469
476
|
# Set timeout to avoid hanging
|
|
470
477
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
471
478
|
http.use_ssl = true
|
|
472
|
-
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
|
479
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_NONE # Skip SSL verification
|
|
473
480
|
http.open_timeout = 2
|
|
474
481
|
http.read_timeout = 3
|
|
475
|
-
|
|
482
|
+
|
|
476
483
|
request = Net::HTTP::Get.new(uri.request_uri)
|
|
477
484
|
response = http.request(request)
|
|
478
|
-
|
|
485
|
+
|
|
479
486
|
return { versions: {}, version_list: [] } unless response.is_a?(Net::HTTPSuccess)
|
|
480
|
-
|
|
487
|
+
|
|
481
488
|
all_versions = JSON.parse(response.body)
|
|
482
|
-
|
|
489
|
+
|
|
483
490
|
# Build hash of version => date
|
|
484
491
|
version_dates = {}
|
|
485
492
|
version_list = []
|
|
486
|
-
|
|
493
|
+
|
|
487
494
|
all_versions.each do |v|
|
|
488
495
|
version_num = v['number']
|
|
489
496
|
if v['created_at']
|
|
@@ -494,9 +501,9 @@ module Rubion
|
|
|
494
501
|
end
|
|
495
502
|
version_list << version_num
|
|
496
503
|
end
|
|
497
|
-
|
|
504
|
+
|
|
498
505
|
{ versions: version_dates, version_list: version_list }
|
|
499
|
-
rescue => e
|
|
506
|
+
rescue StandardError => e
|
|
500
507
|
puts " Debug: Error fetching versions for #{gem_name}: #{e.message}" if ENV['DEBUG']
|
|
501
508
|
{ versions: {}, version_list: [] }
|
|
502
509
|
end
|
|
@@ -505,13 +512,13 @@ module Rubion
|
|
|
505
512
|
def count_versions_from_list(version_list, current_version, latest_version)
|
|
506
513
|
return 'N/A' if current_version == 'unknown' || latest_version == 'unknown' || version_list.empty?
|
|
507
514
|
return 0 if current_version == latest_version
|
|
508
|
-
|
|
515
|
+
|
|
509
516
|
# Find indices of current and latest versions
|
|
510
517
|
current_idx = version_list.index(current_version)
|
|
511
518
|
latest_idx = version_list.index(latest_version)
|
|
512
|
-
|
|
519
|
+
|
|
513
520
|
return 'N/A' if current_idx.nil? || latest_idx.nil?
|
|
514
|
-
|
|
521
|
+
|
|
515
522
|
# Count versions between (exclusive of current, inclusive of latest)
|
|
516
523
|
(latest_idx - current_idx).abs
|
|
517
524
|
end
|
|
@@ -519,33 +526,33 @@ module Rubion
|
|
|
519
526
|
# Calculate time difference between two dates
|
|
520
527
|
def calculate_time_difference(date1_str, date2_str)
|
|
521
528
|
return 'N/A' if date1_str == 'N/A' || date2_str == 'N/A'
|
|
522
|
-
|
|
529
|
+
|
|
523
530
|
begin
|
|
524
531
|
# Parse dates - convert "8/13/2025" format to Date object
|
|
525
532
|
# Split by / and create Date object
|
|
526
533
|
parts1 = date1_str.split('/').map(&:to_i)
|
|
527
534
|
parts2 = date2_str.split('/').map(&:to_i)
|
|
528
|
-
|
|
535
|
+
|
|
529
536
|
date1 = Date.new(parts1[2], parts1[0], parts1[1])
|
|
530
537
|
date2 = Date.new(parts2[2], parts2[0], parts2[1])
|
|
531
|
-
|
|
538
|
+
|
|
532
539
|
# Always use the later date minus the earlier date
|
|
533
540
|
diff = (date2 - date1).to_i.abs
|
|
534
|
-
|
|
541
|
+
|
|
535
542
|
if diff < 30
|
|
536
|
-
"#{diff} day#{diff != 1
|
|
543
|
+
"#{diff} day#{'s' if diff != 1}"
|
|
537
544
|
elsif diff < 365
|
|
538
545
|
months = (diff / 30.0).round
|
|
539
|
-
"#{months} month#{months != 1
|
|
546
|
+
"#{months} month#{'s' if months != 1}"
|
|
540
547
|
else
|
|
541
548
|
years = (diff / 365.0).round(1)
|
|
542
549
|
if years == years.to_i
|
|
543
|
-
"#{years.to_i} year#{years.to_i != 1
|
|
550
|
+
"#{years.to_i} year#{'s' if years.to_i != 1}"
|
|
544
551
|
else
|
|
545
552
|
"#{years} years"
|
|
546
553
|
end
|
|
547
554
|
end
|
|
548
|
-
rescue => e
|
|
555
|
+
rescue StandardError => e
|
|
549
556
|
puts " Debug: Error calculating time diff: #{e.message}" if ENV['DEBUG']
|
|
550
557
|
'N/A'
|
|
551
558
|
end
|
|
@@ -555,7 +562,7 @@ module Rubion
|
|
|
555
562
|
def detect_package_manager
|
|
556
563
|
npm_available = check_command_available('npm')
|
|
557
564
|
yarn_available = check_command_available('yarn')
|
|
558
|
-
|
|
565
|
+
|
|
559
566
|
if npm_available && yarn_available
|
|
560
567
|
# Both available - prompt user
|
|
561
568
|
prompt_package_manager_choice
|
|
@@ -567,28 +574,81 @@ module Rubion
|
|
|
567
574
|
nil
|
|
568
575
|
end
|
|
569
576
|
end
|
|
570
|
-
|
|
577
|
+
|
|
571
578
|
# Check if a command is available in the system
|
|
572
579
|
def check_command_available(command)
|
|
573
580
|
_, _, status = Open3.capture3("which #{command} 2>&1")
|
|
574
581
|
status.success?
|
|
575
|
-
rescue
|
|
582
|
+
rescue StandardError
|
|
576
583
|
false
|
|
577
584
|
end
|
|
578
|
-
|
|
585
|
+
|
|
586
|
+
# Parse Gemfile to get direct dependencies
|
|
587
|
+
def parse_gemfile
|
|
588
|
+
return @direct_gems if @direct_gems
|
|
589
|
+
|
|
590
|
+
gemfile_path = File.join(@project_path, 'Gemfile')
|
|
591
|
+
return [] unless File.exist?(gemfile_path)
|
|
592
|
+
|
|
593
|
+
direct_gems = []
|
|
594
|
+
content = File.read(gemfile_path)
|
|
595
|
+
|
|
596
|
+
# Match gem declarations: gem 'name', gem "name", gem('name'), gem("name")
|
|
597
|
+
# Also handle version constraints and options
|
|
598
|
+
content.scan(/^\s*gem\s+['"]([^'"]+)['"]/) do |match|
|
|
599
|
+
gem_name = match[0]
|
|
600
|
+
direct_gems << gem_name unless gem_name.nil?
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
@direct_gems = direct_gems.uniq
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
# Check if a gem is a direct dependency
|
|
607
|
+
def is_direct_gem?(gem_name)
|
|
608
|
+
parse_gemfile.include?(gem_name)
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
# Parse package.json to get direct dependencies
|
|
612
|
+
def parse_package_json
|
|
613
|
+
return @direct_packages if @direct_packages
|
|
614
|
+
|
|
615
|
+
package_json_path = File.join(@project_path, 'package.json')
|
|
616
|
+
return [] unless File.exist?(package_json_path)
|
|
617
|
+
|
|
618
|
+
begin
|
|
619
|
+
content = File.read(package_json_path)
|
|
620
|
+
data = JSON.parse(content)
|
|
621
|
+
|
|
622
|
+
# Get dependencies from dependencies, devDependencies, peerDependencies, optionalDependencies
|
|
623
|
+
direct_packages = []
|
|
624
|
+
%w[dependencies devDependencies peerDependencies optionalDependencies].each do |key|
|
|
625
|
+
direct_packages.concat(data[key].keys) if data[key].is_a?(Hash)
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
@direct_packages = direct_packages.uniq
|
|
629
|
+
rescue JSON::ParserError, Errno::ENOENT
|
|
630
|
+
@direct_packages = []
|
|
631
|
+
end
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
# Check if a package is a direct dependency
|
|
635
|
+
def is_direct_package?(package_name)
|
|
636
|
+
parse_package_json.include?(package_name)
|
|
637
|
+
end
|
|
638
|
+
|
|
579
639
|
# Prompt user to choose between npm and yarn when both are available
|
|
580
640
|
def prompt_package_manager_choice
|
|
581
641
|
puts "\n Both npm and yarn are available. Which would you like to use?"
|
|
582
|
-
print " Enter '
|
|
583
|
-
|
|
642
|
+
print " Enter 'n' for npm or 'y' for yarn (default: npm): "
|
|
643
|
+
|
|
584
644
|
choice = $stdin.gets.chomp.strip.downcase
|
|
585
|
-
|
|
586
|
-
if choice.empty? || choice == 'npm'
|
|
645
|
+
|
|
646
|
+
if choice.empty? || choice == 'n' || choice == 'npm'
|
|
587
647
|
'npm'
|
|
588
|
-
elsif
|
|
648
|
+
elsif %w[y yarn].include?(choice)
|
|
589
649
|
'yarn'
|
|
590
650
|
else
|
|
591
|
-
puts
|
|
651
|
+
puts ' ⚠️ Invalid choice. Using npm as default.'
|
|
592
652
|
'npm'
|
|
593
653
|
end
|
|
594
654
|
end
|
|
@@ -604,7 +664,7 @@ module Rubion
|
|
|
604
664
|
# Set timeout to avoid hanging
|
|
605
665
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
606
666
|
http.use_ssl = true
|
|
607
|
-
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
|
667
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_NONE # Skip SSL verification
|
|
608
668
|
http.open_timeout = 2
|
|
609
669
|
http.read_timeout = 3
|
|
610
670
|
|
|
@@ -614,31 +674,28 @@ module Rubion
|
|
|
614
674
|
return { versions: {}, version_list: [] } unless response.is_a?(Net::HTTPSuccess)
|
|
615
675
|
|
|
616
676
|
data = JSON.parse(response.body)
|
|
617
|
-
|
|
677
|
+
|
|
618
678
|
return { versions: {}, version_list: [] } unless data['time'] && data['versions']
|
|
619
|
-
|
|
679
|
+
|
|
620
680
|
# Build hash of version => date and sorted version list
|
|
621
681
|
version_dates = {}
|
|
622
682
|
version_times = data['time'].select { |k, v| k != 'created' && k != 'modified' && data['versions'][k] }
|
|
623
|
-
|
|
683
|
+
|
|
624
684
|
# Sort versions by release date
|
|
625
685
|
sorted_versions = version_times.sort_by { |k, v| DateTime.parse(v) }.map(&:first)
|
|
626
|
-
|
|
686
|
+
|
|
627
687
|
# Build date hash
|
|
628
688
|
version_times.each do |version, date_str|
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
version_dates[version] = 'N/A'
|
|
634
|
-
end
|
|
689
|
+
date = DateTime.parse(date_str)
|
|
690
|
+
version_dates[version] = date.strftime('%-m/%-d/%Y')
|
|
691
|
+
rescue StandardError
|
|
692
|
+
version_dates[version] = 'N/A'
|
|
635
693
|
end
|
|
636
|
-
|
|
694
|
+
|
|
637
695
|
{ versions: version_dates, version_list: sorted_versions }
|
|
638
|
-
rescue => e
|
|
696
|
+
rescue StandardError => e
|
|
639
697
|
puts " Debug: Error fetching versions for #{package_name}: #{e.message}" if ENV['DEBUG']
|
|
640
698
|
{ versions: {}, version_list: [] }
|
|
641
699
|
end
|
|
642
700
|
end
|
|
643
701
|
end
|
|
644
|
-
|
data/lib/rubion/version.rb
CHANGED
data/lib/rubion.rb
CHANGED
|
@@ -29,7 +29,7 @@ module Rubion
|
|
|
29
29
|
|
|
30
30
|
def self.parse_scan_options(args)
|
|
31
31
|
# Default to sorting by "Behind By(Time)" in descending order
|
|
32
|
-
options = { gems: true, packages: true, sort_by: "Behind By(Time)", sort_desc: true }
|
|
32
|
+
options = { gems: true, packages: true, sort_by: "Behind By(Time)", sort_desc: true, exclude_dependencies: false }
|
|
33
33
|
|
|
34
34
|
# Check for --gems-only or --packages-only flags
|
|
35
35
|
if args.include?('--gems-only') || args.include?('-g')
|
|
@@ -53,10 +53,13 @@ module Rubion
|
|
|
53
53
|
# Parse --asc or --ascending for ascending order (descending is default)
|
|
54
54
|
options[:sort_desc] = false if args.include?('--asc') || args.include?('--ascending')
|
|
55
55
|
|
|
56
|
+
# Parse --exclude-dependencies flag
|
|
57
|
+
options[:exclude_dependencies] = true if args.include?('--exclude-dependencies')
|
|
58
|
+
|
|
56
59
|
options
|
|
57
60
|
end
|
|
58
61
|
|
|
59
|
-
def self.scan(options = { gems: true, packages: true, sort_by: "Behind By(Time)", sort_desc: true })
|
|
62
|
+
def self.scan(options = { gems: true, packages: true, sort_by: "Behind By(Time)", sort_desc: true, exclude_dependencies: false })
|
|
60
63
|
project_path = Dir.pwd
|
|
61
64
|
|
|
62
65
|
scanner = Scanner.new(project_path: project_path)
|
|
@@ -68,7 +71,7 @@ module Rubion
|
|
|
68
71
|
# Actually, scan_incremental handles gem printing, but package printing
|
|
69
72
|
# happens here, so we need a reporter for packages
|
|
70
73
|
if options[:packages]
|
|
71
|
-
reporter = Reporter.new(result, sort_by: options[:sort_by], sort_desc: options[:sort_desc])
|
|
74
|
+
reporter = Reporter.new(result, sort_by: options[:sort_by], sort_desc: options[:sort_desc], exclude_dependencies: options[:exclude_dependencies])
|
|
72
75
|
reporter.print_package_vulnerabilities
|
|
73
76
|
reporter.print_package_versions
|
|
74
77
|
end
|
|
@@ -91,6 +94,7 @@ module Rubion
|
|
|
91
94
|
--sort-by COLUMN, -s COLUMN Sort results by column (Name, Current, Date, Latest, Behind By(Time), Behind By(Versions))
|
|
92
95
|
(default: "Behind By(Time)" in descending order)
|
|
93
96
|
--asc, --ascending Sort in ascending order (use with --sort-by)
|
|
97
|
+
--exclude-dependencies Show only direct dependencies (from Gemfile/package.json)
|
|
94
98
|
|
|
95
99
|
DESCRIPTION:
|
|
96
100
|
Rubion scans your project for:
|
|
@@ -128,6 +132,9 @@ module Rubion
|
|
|
128
132
|
# Sort by name in ascending order
|
|
129
133
|
rubion scan --sort-by Name --asc
|
|
130
134
|
|
|
135
|
+
# Show only direct dependencies
|
|
136
|
+
rubion scan --exclude-dependencies
|
|
137
|
+
|
|
131
138
|
# Get help
|
|
132
139
|
rubion help
|
|
133
140
|
|