rubion 0.3.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 +7 -0
- data/Gemfile +11 -0
- data/LICENSE +22 -0
- data/README.md +273 -0
- data/bin/rubion +7 -0
- data/lib/rubion/reporter.rb +246 -0
- data/lib/rubion/scanner.rb +583 -0
- data/lib/rubion/version.rb +6 -0
- data/lib/rubion.rb +117 -0
- data/rubion.gemspec +36 -0
- metadata +112 -0
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'open3'
|
|
5
|
+
require_relative 'reporter'
|
|
6
|
+
require 'net/http'
|
|
7
|
+
require 'uri'
|
|
8
|
+
require 'date'
|
|
9
|
+
require 'thread'
|
|
10
|
+
|
|
11
|
+
module Rubion
|
|
12
|
+
class Scanner
|
|
13
|
+
class ScanResult
|
|
14
|
+
attr_accessor :gem_vulnerabilities, :gem_versions, :package_vulnerabilities, :package_versions
|
|
15
|
+
|
|
16
|
+
def initialize
|
|
17
|
+
@gem_vulnerabilities = []
|
|
18
|
+
@gem_versions = []
|
|
19
|
+
@package_vulnerabilities = []
|
|
20
|
+
@package_versions = []
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(project_path: Dir.pwd)
|
|
25
|
+
@project_path = project_path
|
|
26
|
+
@result = ScanResult.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def scan
|
|
30
|
+
puts "🔍 Scanning project at: #{@project_path}\n\n"
|
|
31
|
+
|
|
32
|
+
scan_ruby_gems
|
|
33
|
+
scan_npm_packages
|
|
34
|
+
|
|
35
|
+
@result
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def scan_incremental(options = { gems: true, packages: true })
|
|
39
|
+
puts "🔍 Scanning project at: #{@project_path}\n\n"
|
|
40
|
+
|
|
41
|
+
# Scan and display Ruby gems first (if enabled)
|
|
42
|
+
if options[:gems]
|
|
43
|
+
scan_ruby_gems
|
|
44
|
+
|
|
45
|
+
# Print gem results immediately
|
|
46
|
+
puts "\n"
|
|
47
|
+
reporter = Reporter.new(@result)
|
|
48
|
+
reporter.print_gem_vulnerabilities
|
|
49
|
+
reporter.print_gem_versions
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Then scan NPM packages (if enabled)
|
|
53
|
+
if options[:packages]
|
|
54
|
+
puts "\n" if options[:gems] # Add spacing if gems were scanned
|
|
55
|
+
scan_npm_packages
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
@result
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def scan_ruby_gems
|
|
64
|
+
return unless File.exist?(File.join(@project_path, 'Gemfile.lock'))
|
|
65
|
+
|
|
66
|
+
# Check for vulnerabilities using bundler-audit
|
|
67
|
+
check_gem_vulnerabilities
|
|
68
|
+
|
|
69
|
+
# Check for outdated versions using bundle outdated (will show progress)
|
|
70
|
+
check_gem_versions
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def scan_npm_packages
|
|
74
|
+
package_json = File.join(@project_path, 'package.json')
|
|
75
|
+
return unless File.exist?(package_json)
|
|
76
|
+
|
|
77
|
+
# Check for vulnerabilities using npm audit
|
|
78
|
+
check_npm_vulnerabilities
|
|
79
|
+
|
|
80
|
+
# Check for outdated versions using npm outdated (will show progress)
|
|
81
|
+
check_npm_versions
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def check_gem_vulnerabilities
|
|
85
|
+
# Try to use bundler-audit if available
|
|
86
|
+
stdout, stderr, status = Open3.capture3("bundle-audit check 2>&1", chdir: @project_path)
|
|
87
|
+
|
|
88
|
+
# bundle-audit returns exit code 1 when vulnerabilities are found, 0 when none found
|
|
89
|
+
# Always parse if there's output (vulnerabilities found) or if it succeeded (no vulnerabilities)
|
|
90
|
+
if stdout.include?("vulnerabilities found") || stdout.include?("Name:") || status.success?
|
|
91
|
+
parse_bundler_audit_output(stdout)
|
|
92
|
+
else
|
|
93
|
+
# No vulnerabilities found or bundler-audit not available
|
|
94
|
+
@result.gem_vulnerabilities = []
|
|
95
|
+
end
|
|
96
|
+
rescue => e
|
|
97
|
+
puts " ⚠️ Could not run bundle-audit (#{e.message}). Skipping gem vulnerability check."
|
|
98
|
+
@result.gem_vulnerabilities = []
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def check_gem_versions
|
|
102
|
+
stdout, stderr, status = Open3.capture3("bundle outdated --parseable", chdir: @project_path)
|
|
103
|
+
|
|
104
|
+
if status.success? || !stdout.empty?
|
|
105
|
+
parse_bundle_outdated_output(stdout)
|
|
106
|
+
else
|
|
107
|
+
# No outdated gems found
|
|
108
|
+
@result.gem_versions = []
|
|
109
|
+
end
|
|
110
|
+
rescue => e
|
|
111
|
+
puts " ⚠️ Could not run bundle outdated (#{e.message}). Skipping gem version check."
|
|
112
|
+
@result.gem_versions = []
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def check_npm_vulnerabilities
|
|
116
|
+
stdout, stderr, status = Open3.capture3("npm audit --json 2>&1", chdir: @project_path)
|
|
117
|
+
|
|
118
|
+
begin
|
|
119
|
+
data = JSON.parse(stdout)
|
|
120
|
+
parse_npm_audit_output(data)
|
|
121
|
+
rescue JSON::ParserError
|
|
122
|
+
@result.package_vulnerabilities = []
|
|
123
|
+
end
|
|
124
|
+
rescue => e
|
|
125
|
+
puts " ⚠️ Could not run npm audit (#{e.message}). Skipping package vulnerability check."
|
|
126
|
+
@result.package_vulnerabilities = []
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def check_npm_versions
|
|
130
|
+
stdout, stderr, status = Open3.capture3("npm outdated --json 2>&1", chdir: @project_path)
|
|
131
|
+
|
|
132
|
+
begin
|
|
133
|
+
data = JSON.parse(stdout) unless stdout.empty?
|
|
134
|
+
parse_npm_outdated_output(data || {})
|
|
135
|
+
rescue JSON::ParserError
|
|
136
|
+
@result.package_versions = []
|
|
137
|
+
end
|
|
138
|
+
rescue => e
|
|
139
|
+
puts " ⚠️ Could not run npm outdated (#{e.message}). Skipping package version check."
|
|
140
|
+
@result.package_versions = []
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Parsers
|
|
144
|
+
|
|
145
|
+
def parse_bundler_audit_output(output)
|
|
146
|
+
vulnerabilities = []
|
|
147
|
+
current_gem = nil
|
|
148
|
+
|
|
149
|
+
output.each_line do |line|
|
|
150
|
+
line = line.strip
|
|
151
|
+
next if line.empty?
|
|
152
|
+
|
|
153
|
+
if line =~ /^Name: (.+)/
|
|
154
|
+
current_gem = { gem: $1.strip }
|
|
155
|
+
elsif line =~ /^Version: (.+)/ && current_gem
|
|
156
|
+
current_gem[:version] = $1.strip
|
|
157
|
+
elsif line =~ /^CVE: (.+)/ && current_gem
|
|
158
|
+
current_gem[:advisory] = $1.strip
|
|
159
|
+
elsif line =~ /^Advisory: (.+)/ && current_gem
|
|
160
|
+
# Fallback for older bundle-audit versions
|
|
161
|
+
current_gem[:advisory] = $1.strip
|
|
162
|
+
elsif line =~ /^Criticality: (.+)/ && current_gem
|
|
163
|
+
current_gem[:severity] = $1.strip
|
|
164
|
+
elsif line =~ /^Title: (.+)/ && current_gem
|
|
165
|
+
current_gem[:title] = $1.strip
|
|
166
|
+
# Only add if we have at least name, version, and title
|
|
167
|
+
if current_gem[:gem] && current_gem[:version] && current_gem[:title]
|
|
168
|
+
vulnerabilities << current_gem
|
|
169
|
+
end
|
|
170
|
+
current_gem = nil
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Handle case where vulnerability block ends without Title (use CVE as title)
|
|
175
|
+
if current_gem && current_gem[:gem] && current_gem[:version]
|
|
176
|
+
current_gem[:title] ||= current_gem[:advisory] || "Vulnerability detected"
|
|
177
|
+
vulnerabilities << current_gem
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
@result.gem_vulnerabilities = vulnerabilities
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def parse_bundle_outdated_output(output)
|
|
184
|
+
versions = []
|
|
185
|
+
lines_to_process = []
|
|
186
|
+
|
|
187
|
+
# First pass: collect all lines to process
|
|
188
|
+
output.each_line do |line|
|
|
189
|
+
next if line.strip.empty?
|
|
190
|
+
|
|
191
|
+
# Parse format: gem_name (newest version, installed version, requested version)
|
|
192
|
+
if line =~ /^(.+?)\s+\(newest\s+(.+?),\s+installed\s+(.+?)(?:,|\))/
|
|
193
|
+
lines_to_process << {
|
|
194
|
+
gem_name: $1.strip,
|
|
195
|
+
current_version: $3.strip,
|
|
196
|
+
latest_version: $2.strip
|
|
197
|
+
}
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
total = lines_to_process.size
|
|
202
|
+
|
|
203
|
+
# Process in parallel with threads (limit to 10 concurrent requests)
|
|
204
|
+
mutex = Mutex.new
|
|
205
|
+
thread_pool = []
|
|
206
|
+
max_threads = 10
|
|
207
|
+
|
|
208
|
+
lines_to_process.each_with_index do |line_data, index|
|
|
209
|
+
# Wait if we have too many threads
|
|
210
|
+
if thread_pool.size >= max_threads
|
|
211
|
+
thread_pool.shift.join
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
thread = Thread.new do
|
|
215
|
+
# Fetch all version info once per gem (includes dates and version list)
|
|
216
|
+
gem_data = fetch_gem_all_versions(line_data[:gem_name])
|
|
217
|
+
|
|
218
|
+
# Extract dates for current and latest versions
|
|
219
|
+
current_date = gem_data[:versions][line_data[:current_version]] || 'N/A'
|
|
220
|
+
latest_date = gem_data[:versions][line_data[:latest_version]] || 'N/A'
|
|
221
|
+
|
|
222
|
+
# Calculate time difference
|
|
223
|
+
time_diff = calculate_time_difference(current_date, latest_date)
|
|
224
|
+
|
|
225
|
+
# Count versions between current and latest
|
|
226
|
+
version_count = count_versions_from_list(gem_data[:version_list], line_data[:current_version], line_data[:latest_version])
|
|
227
|
+
|
|
228
|
+
result = {
|
|
229
|
+
gem: line_data[:gem_name],
|
|
230
|
+
current: line_data[:current_version],
|
|
231
|
+
current_date: current_date,
|
|
232
|
+
latest: line_data[:latest_version],
|
|
233
|
+
latest_date: latest_date,
|
|
234
|
+
time_diff: time_diff,
|
|
235
|
+
version_count: version_count,
|
|
236
|
+
index: index
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
mutex.synchronize do
|
|
240
|
+
versions << result
|
|
241
|
+
print "\r📦 Checking Ruby gems... #{versions.size}/#{total}"
|
|
242
|
+
$stdout.flush
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
thread_pool << thread
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Wait for all threads to complete
|
|
250
|
+
thread_pool.each(&:join)
|
|
251
|
+
|
|
252
|
+
# Sort by original index to maintain order
|
|
253
|
+
versions.sort_by! { |v| v[:index] }
|
|
254
|
+
versions.each { |v| v.delete(:index) }
|
|
255
|
+
|
|
256
|
+
puts "\r📦 Checking Ruby gems... #{total}/#{total} ✓" if total > 0
|
|
257
|
+
|
|
258
|
+
@result.gem_versions = versions
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def parse_npm_audit_output(data)
|
|
262
|
+
vulnerabilities = []
|
|
263
|
+
|
|
264
|
+
if data['vulnerabilities'] && data['vulnerabilities'].is_a?(Hash)
|
|
265
|
+
data['vulnerabilities'].each do |name, info|
|
|
266
|
+
next unless info.is_a?(Hash)
|
|
267
|
+
|
|
268
|
+
# Extract title from via array
|
|
269
|
+
title = 'Vulnerability detected'
|
|
270
|
+
if info['via'].is_a?(Array) && info['via'].first.is_a?(Hash)
|
|
271
|
+
title = info['via'].first['title'] || title
|
|
272
|
+
elsif info['via'].is_a?(String)
|
|
273
|
+
title = info['via']
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
vulnerabilities << {
|
|
277
|
+
package: name,
|
|
278
|
+
version: info['range'] || info['version'] || 'unknown',
|
|
279
|
+
severity: info['severity'] || 'unknown',
|
|
280
|
+
title: title
|
|
281
|
+
}
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
@result.package_vulnerabilities = vulnerabilities
|
|
286
|
+
rescue => e
|
|
287
|
+
puts " ⚠️ Error parsing npm audit data: #{e.message}"
|
|
288
|
+
@result.package_vulnerabilities = []
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def parse_npm_outdated_output(data)
|
|
292
|
+
versions = []
|
|
293
|
+
|
|
294
|
+
if data.is_a?(Hash)
|
|
295
|
+
packages_to_process = []
|
|
296
|
+
|
|
297
|
+
# First pass: collect all packages to process
|
|
298
|
+
data.each do |name, info|
|
|
299
|
+
next unless info.is_a?(Hash)
|
|
300
|
+
|
|
301
|
+
packages_to_process << {
|
|
302
|
+
name: name,
|
|
303
|
+
current_version: info['current'] || 'unknown',
|
|
304
|
+
latest_version: info['latest'] || 'unknown'
|
|
305
|
+
}
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
total = packages_to_process.size
|
|
309
|
+
|
|
310
|
+
# Process in parallel with threads (limit to 10 concurrent requests)
|
|
311
|
+
mutex = Mutex.new
|
|
312
|
+
thread_pool = []
|
|
313
|
+
max_threads = 10
|
|
314
|
+
|
|
315
|
+
packages_to_process.each_with_index do |pkg_data, index|
|
|
316
|
+
# Wait if we have too many threads
|
|
317
|
+
if thread_pool.size >= max_threads
|
|
318
|
+
thread_pool.shift.join
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
thread = Thread.new do
|
|
322
|
+
# Fetch all version info once per package (includes dates and version list)
|
|
323
|
+
pkg_data_full = fetch_npm_all_versions(pkg_data[:name])
|
|
324
|
+
|
|
325
|
+
# Extract dates for current and latest versions
|
|
326
|
+
current_date = pkg_data_full[:versions][pkg_data[:current_version]] || 'N/A'
|
|
327
|
+
latest_date = pkg_data_full[:versions][pkg_data[:latest_version]] || 'N/A'
|
|
328
|
+
|
|
329
|
+
# Calculate time difference
|
|
330
|
+
time_diff = calculate_time_difference(current_date, latest_date)
|
|
331
|
+
|
|
332
|
+
# Count versions between current and latest
|
|
333
|
+
version_count = count_versions_from_list(pkg_data_full[:version_list], pkg_data[:current_version], pkg_data[:latest_version])
|
|
334
|
+
|
|
335
|
+
result = {
|
|
336
|
+
package: pkg_data[:name],
|
|
337
|
+
current: pkg_data[:current_version],
|
|
338
|
+
current_date: current_date,
|
|
339
|
+
latest: pkg_data[:latest_version],
|
|
340
|
+
latest_date: latest_date,
|
|
341
|
+
time_diff: time_diff,
|
|
342
|
+
version_count: version_count,
|
|
343
|
+
index: index
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
mutex.synchronize do
|
|
347
|
+
versions << result
|
|
348
|
+
print "\r📦 Checking NPM packages... #{versions.size}/#{total}"
|
|
349
|
+
$stdout.flush
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
thread_pool << thread
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Wait for all threads to complete
|
|
357
|
+
thread_pool.each(&:join)
|
|
358
|
+
|
|
359
|
+
# Sort by original index to maintain order
|
|
360
|
+
versions.sort_by! { |v| v[:index] }
|
|
361
|
+
versions.each { |v| v.delete(:index) }
|
|
362
|
+
|
|
363
|
+
puts "\r📦 Checking NPM packages... #{total}/#{total} ✓" if total > 0
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
@result.package_versions = versions
|
|
367
|
+
rescue => e
|
|
368
|
+
puts " ⚠️ Error parsing npm outdated data: #{e.message}"
|
|
369
|
+
@result.package_versions = []
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# Dummy data for demonstration (commented out - only show real data)
|
|
373
|
+
# Uncomment these methods if you need dummy data for testing
|
|
374
|
+
|
|
375
|
+
# def dummy_gem_vulnerabilities
|
|
376
|
+
# [
|
|
377
|
+
# {
|
|
378
|
+
# gem: 'rails',
|
|
379
|
+
# version: '7.2.2.2',
|
|
380
|
+
# severity: 'Critical',
|
|
381
|
+
# advisory: 'CVE-2024-12345',
|
|
382
|
+
# title: 'ActiveRecord attack'
|
|
383
|
+
# },
|
|
384
|
+
# {
|
|
385
|
+
# gem: 'rack',
|
|
386
|
+
# version: '3.1.18',
|
|
387
|
+
# severity: 'High',
|
|
388
|
+
# advisory: 'CVE-2024-54321',
|
|
389
|
+
# title: 'Man in middle attack'
|
|
390
|
+
# },
|
|
391
|
+
# {
|
|
392
|
+
# gem: 'nokogiri',
|
|
393
|
+
# version: '1.10.4',
|
|
394
|
+
# severity: 'Medium',
|
|
395
|
+
# advisory: 'CVE-2021-30560',
|
|
396
|
+
# title: 'Update bundled libxml2 to v2.9.12'
|
|
397
|
+
# }
|
|
398
|
+
# ]
|
|
399
|
+
# end
|
|
400
|
+
|
|
401
|
+
# def dummy_gem_versions
|
|
402
|
+
# [
|
|
403
|
+
# { gem: 'sidekiq', current: '7.3.0', current_date: '3/5/2024', latest: '8.1.0', latest_date: '11/11/2024' },
|
|
404
|
+
# { gem: 'fastimage', current: '2.2.7', current_date: '2/2/2025', latest: '2.3.2', latest_date: '9/9/2025' },
|
|
405
|
+
# { gem: 'puma', current: '4.3.8', current_date: '1/15/2024', latest: '6.4.0', latest_date: '10/20/2024' },
|
|
406
|
+
# { gem: 'devise', current: '4.7.3', current_date: '3/10/2024', latest: '4.9.3', latest_date: '8/15/2024' },
|
|
407
|
+
# { gem: 'rspec-rails', current: '4.0.2', current_date: '2/5/2024', latest: '6.1.0', latest_date: '12/1/2024' }
|
|
408
|
+
# ]
|
|
409
|
+
# end
|
|
410
|
+
|
|
411
|
+
# def dummy_npm_vulnerabilities
|
|
412
|
+
# [
|
|
413
|
+
# {
|
|
414
|
+
# package: 'moment',
|
|
415
|
+
# version: '1.2.3',
|
|
416
|
+
# severity: 'high',
|
|
417
|
+
# title: 'Wrong timezone date'
|
|
418
|
+
# },
|
|
419
|
+
# {
|
|
420
|
+
# package: 'axios',
|
|
421
|
+
# version: '0.21.1',
|
|
422
|
+
# severity: 'high',
|
|
423
|
+
# title: 'Server-Side Request Forgery in axios'
|
|
424
|
+
# },
|
|
425
|
+
# {
|
|
426
|
+
# package: 'minimist',
|
|
427
|
+
# version: '1.2.5',
|
|
428
|
+
# severity: 'critical',
|
|
429
|
+
# title: 'Prototype Pollution in minimist'
|
|
430
|
+
# }
|
|
431
|
+
# ]
|
|
432
|
+
# end
|
|
433
|
+
|
|
434
|
+
# def dummy_npm_versions
|
|
435
|
+
# [
|
|
436
|
+
# { package: 'jquery', current: '3.7.1', current_date: '4/5/2024', latest: '3.9.1', latest_date: '10/11/2025' },
|
|
437
|
+
# { package: 'vue', current: '2.6.12', current_date: '5/15/2024', latest: '3.4.3', latest_date: '11/20/2024' },
|
|
438
|
+
# { package: 'webpack', current: '4.46.0', current_date: '3/8/2024', latest: '5.89.0', latest_date: '9/25/2024' },
|
|
439
|
+
# { package: 'eslint', current: '7.32.0', current_date: '2/12/2024', latest: '8.56.0', latest_date: '12/5/2024' },
|
|
440
|
+
# { package: '@babel/core', current: '7.15.0', current_date: '4/20/2024', latest: '7.23.6', latest_date: '10/30/2024' }
|
|
441
|
+
# ]
|
|
442
|
+
# end
|
|
443
|
+
|
|
444
|
+
# Fetch all gem version info (dates and version list) from RubyGems API in one call
|
|
445
|
+
def fetch_gem_all_versions(gem_name)
|
|
446
|
+
return { versions: {}, version_list: [] } if gem_name.nil?
|
|
447
|
+
|
|
448
|
+
uri = URI("https://rubygems.org/api/v1/versions/#{gem_name}.json")
|
|
449
|
+
|
|
450
|
+
# Set timeout to avoid hanging
|
|
451
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
452
|
+
http.use_ssl = true
|
|
453
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_NONE # Skip SSL verification
|
|
454
|
+
http.open_timeout = 2
|
|
455
|
+
http.read_timeout = 3
|
|
456
|
+
|
|
457
|
+
request = Net::HTTP::Get.new(uri.request_uri)
|
|
458
|
+
response = http.request(request)
|
|
459
|
+
|
|
460
|
+
return { versions: {}, version_list: [] } unless response.is_a?(Net::HTTPSuccess)
|
|
461
|
+
|
|
462
|
+
all_versions = JSON.parse(response.body)
|
|
463
|
+
|
|
464
|
+
# Build hash of version => date
|
|
465
|
+
version_dates = {}
|
|
466
|
+
version_list = []
|
|
467
|
+
|
|
468
|
+
all_versions.each do |v|
|
|
469
|
+
version_num = v['number']
|
|
470
|
+
if v['created_at']
|
|
471
|
+
date = DateTime.parse(v['created_at'])
|
|
472
|
+
version_dates[version_num] = date.strftime('%-m/%-d/%Y')
|
|
473
|
+
else
|
|
474
|
+
version_dates[version_num] = 'N/A'
|
|
475
|
+
end
|
|
476
|
+
version_list << version_num
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
{ versions: version_dates, version_list: version_list }
|
|
480
|
+
rescue => e
|
|
481
|
+
puts " Debug: Error fetching versions for #{gem_name}: #{e.message}" if ENV['DEBUG']
|
|
482
|
+
{ versions: {}, version_list: [] }
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# Count versions between current and latest from a version list
|
|
486
|
+
def count_versions_from_list(version_list, current_version, latest_version)
|
|
487
|
+
return 'N/A' if current_version == 'unknown' || latest_version == 'unknown' || version_list.empty?
|
|
488
|
+
return 0 if current_version == latest_version
|
|
489
|
+
|
|
490
|
+
# Find indices of current and latest versions
|
|
491
|
+
current_idx = version_list.index(current_version)
|
|
492
|
+
latest_idx = version_list.index(latest_version)
|
|
493
|
+
|
|
494
|
+
return 'N/A' if current_idx.nil? || latest_idx.nil?
|
|
495
|
+
|
|
496
|
+
# Count versions between (exclusive of current, inclusive of latest)
|
|
497
|
+
(latest_idx - current_idx).abs
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
# Calculate time difference between two dates
|
|
501
|
+
def calculate_time_difference(date1_str, date2_str)
|
|
502
|
+
return 'N/A' if date1_str == 'N/A' || date2_str == 'N/A'
|
|
503
|
+
|
|
504
|
+
begin
|
|
505
|
+
# Parse dates - convert "8/13/2025" format to Date object
|
|
506
|
+
# Split by / and create Date object
|
|
507
|
+
parts1 = date1_str.split('/').map(&:to_i)
|
|
508
|
+
parts2 = date2_str.split('/').map(&:to_i)
|
|
509
|
+
|
|
510
|
+
date1 = Date.new(parts1[2], parts1[0], parts1[1])
|
|
511
|
+
date2 = Date.new(parts2[2], parts2[0], parts2[1])
|
|
512
|
+
|
|
513
|
+
# Always use the later date minus the earlier date
|
|
514
|
+
diff = (date2 - date1).to_i.abs
|
|
515
|
+
|
|
516
|
+
if diff < 30
|
|
517
|
+
"#{diff} day#{diff != 1 ? 's' : ''}"
|
|
518
|
+
elsif diff < 365
|
|
519
|
+
months = (diff / 30.0).round
|
|
520
|
+
"#{months} month#{months != 1 ? 's' : ''}"
|
|
521
|
+
else
|
|
522
|
+
years = (diff / 365.0).round(1)
|
|
523
|
+
if years == years.to_i
|
|
524
|
+
"#{years.to_i} year#{years.to_i != 1 ? 's' : ''}"
|
|
525
|
+
else
|
|
526
|
+
"#{years} years"
|
|
527
|
+
end
|
|
528
|
+
end
|
|
529
|
+
rescue => e
|
|
530
|
+
puts " Debug: Error calculating time diff: #{e.message}" if ENV['DEBUG']
|
|
531
|
+
'N/A'
|
|
532
|
+
end
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
# Fetch all NPM package version info (dates and version list) from NPM registry in one call
|
|
536
|
+
def fetch_npm_all_versions(package_name)
|
|
537
|
+
return { versions: {}, version_list: [] } if package_name.nil?
|
|
538
|
+
|
|
539
|
+
# Encode package name for URL (handles scoped packages like @babel/core)
|
|
540
|
+
encoded_name = URI.encode_www_form_component(package_name)
|
|
541
|
+
uri = URI("https://registry.npmjs.org/#{encoded_name}")
|
|
542
|
+
|
|
543
|
+
# Set timeout to avoid hanging
|
|
544
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
545
|
+
http.use_ssl = true
|
|
546
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_NONE # Skip SSL verification
|
|
547
|
+
http.open_timeout = 2
|
|
548
|
+
http.read_timeout = 3
|
|
549
|
+
|
|
550
|
+
request = Net::HTTP::Get.new(uri.request_uri)
|
|
551
|
+
response = http.request(request)
|
|
552
|
+
|
|
553
|
+
return { versions: {}, version_list: [] } unless response.is_a?(Net::HTTPSuccess)
|
|
554
|
+
|
|
555
|
+
data = JSON.parse(response.body)
|
|
556
|
+
|
|
557
|
+
return { versions: {}, version_list: [] } unless data['time'] && data['versions']
|
|
558
|
+
|
|
559
|
+
# Build hash of version => date and sorted version list
|
|
560
|
+
version_dates = {}
|
|
561
|
+
version_times = data['time'].select { |k, v| k != 'created' && k != 'modified' && data['versions'][k] }
|
|
562
|
+
|
|
563
|
+
# Sort versions by release date
|
|
564
|
+
sorted_versions = version_times.sort_by { |k, v| DateTime.parse(v) }.map(&:first)
|
|
565
|
+
|
|
566
|
+
# Build date hash
|
|
567
|
+
version_times.each do |version, date_str|
|
|
568
|
+
begin
|
|
569
|
+
date = DateTime.parse(date_str)
|
|
570
|
+
version_dates[version] = date.strftime('%-m/%-d/%Y')
|
|
571
|
+
rescue
|
|
572
|
+
version_dates[version] = 'N/A'
|
|
573
|
+
end
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
{ versions: version_dates, version_list: sorted_versions }
|
|
577
|
+
rescue => e
|
|
578
|
+
puts " Debug: Error fetching versions for #{package_name}: #{e.message}" if ENV['DEBUG']
|
|
579
|
+
{ versions: {}, version_list: [] }
|
|
580
|
+
end
|
|
581
|
+
end
|
|
582
|
+
end
|
|
583
|
+
|
data/lib/rubion.rb
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rubion/version"
|
|
4
|
+
require_relative "rubion/scanner"
|
|
5
|
+
require_relative "rubion/reporter"
|
|
6
|
+
|
|
7
|
+
module Rubion
|
|
8
|
+
class Error < StandardError; end
|
|
9
|
+
|
|
10
|
+
class CLI
|
|
11
|
+
def self.start(args)
|
|
12
|
+
command = args[0]
|
|
13
|
+
|
|
14
|
+
case command
|
|
15
|
+
when 'scan'
|
|
16
|
+
# Parse options
|
|
17
|
+
options = parse_scan_options(args[1..-1])
|
|
18
|
+
scan(options)
|
|
19
|
+
when 'version', '-v', '--version'
|
|
20
|
+
puts "Rubion version #{VERSION}"
|
|
21
|
+
when 'help', '-h', '--help', nil
|
|
22
|
+
print_help
|
|
23
|
+
else
|
|
24
|
+
puts "Unknown command: #{command}"
|
|
25
|
+
print_help
|
|
26
|
+
exit 1
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.parse_scan_options(args)
|
|
31
|
+
options = { gems: true, packages: true }
|
|
32
|
+
|
|
33
|
+
# Check for --gems-only or --packages-only flags
|
|
34
|
+
if args.include?('--gems-only') || args.include?('-g')
|
|
35
|
+
options[:gems] = true
|
|
36
|
+
options[:packages] = false
|
|
37
|
+
elsif args.include?('--packages-only') || args.include?('-p')
|
|
38
|
+
options[:gems] = false
|
|
39
|
+
options[:packages] = true
|
|
40
|
+
elsif args.include?('--gems') || args.include?('--packages')
|
|
41
|
+
# Legacy support for --gems and --packages
|
|
42
|
+
options[:gems] = args.include?('--gems')
|
|
43
|
+
options[:packages] = args.include?('--packages')
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
options
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.scan(options = { gems: true, packages: true })
|
|
50
|
+
project_path = Dir.pwd
|
|
51
|
+
|
|
52
|
+
scanner = Scanner.new(project_path: project_path)
|
|
53
|
+
result = scanner.scan_incremental(options)
|
|
54
|
+
|
|
55
|
+
# Results are already printed incrementally based on options
|
|
56
|
+
reporter = Reporter.new(result)
|
|
57
|
+
|
|
58
|
+
# Only print package results if packages were scanned
|
|
59
|
+
if options[:packages]
|
|
60
|
+
reporter.print_package_vulnerabilities
|
|
61
|
+
reporter.print_package_versions
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.print_help
|
|
66
|
+
puts <<~HELP
|
|
67
|
+
|
|
68
|
+
🔒 Rubion - Security & Version Scanner for Ruby and JavaScript projects
|
|
69
|
+
|
|
70
|
+
USAGE:
|
|
71
|
+
rubion scan [OPTIONS] Scan current project for vulnerabilities and outdated versions
|
|
72
|
+
rubion version Display Rubion version
|
|
73
|
+
rubion help Display this help message
|
|
74
|
+
|
|
75
|
+
SCAN OPTIONS:
|
|
76
|
+
--gems, --gem, -g Scan only Ruby gems (skip NPM packages)
|
|
77
|
+
--packages, --npm, -p Scan only NPM packages (skip Ruby gems)
|
|
78
|
+
--all, -a Scan both gems and packages (default)
|
|
79
|
+
|
|
80
|
+
DESCRIPTION:
|
|
81
|
+
Rubion scans your project for:
|
|
82
|
+
- Ruby gem vulnerabilities (using bundler-audit)
|
|
83
|
+
- Outdated Ruby gems (using bundle outdated)
|
|
84
|
+
- NPM package vulnerabilities (using npm audit)
|
|
85
|
+
- Outdated NPM packages (using npm outdated)
|
|
86
|
+
|
|
87
|
+
OUTPUT:
|
|
88
|
+
Results are displayed in organized tables with:
|
|
89
|
+
📛 Vulnerabilities with severity icons (🔴 Critical, 🟠 High, 🟡 Medium, 🟢 Low)
|
|
90
|
+
📦 Version information with release dates
|
|
91
|
+
⏱️ Time difference ("Behind By" column)
|
|
92
|
+
🔢 Version count between current and latest
|
|
93
|
+
|
|
94
|
+
EXAMPLES:
|
|
95
|
+
# Scan both gems and packages (default)
|
|
96
|
+
rubion scan
|
|
97
|
+
|
|
98
|
+
# Scan only Ruby gems
|
|
99
|
+
rubion scan --gems
|
|
100
|
+
|
|
101
|
+
# Scan only NPM packages
|
|
102
|
+
rubion scan --packages
|
|
103
|
+
|
|
104
|
+
# Get help
|
|
105
|
+
rubion help
|
|
106
|
+
|
|
107
|
+
REQUIREMENTS:
|
|
108
|
+
- Ruby 2.6+
|
|
109
|
+
- Bundler (for gem scanning)
|
|
110
|
+
- NPM (for package scanning, optional)
|
|
111
|
+
- bundler-audit (optional, install with: gem install bundler-audit)
|
|
112
|
+
|
|
113
|
+
HELP
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|