gem_guard 0.1.6 → 0.1.10
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/README.md +316 -33
- data/SECURITY.md +46 -2
- data/lib/gem_guard/analyzer.rb +4 -1
- data/lib/gem_guard/cli.rb +250 -9
- data/lib/gem_guard/config.rb +193 -0
- data/lib/gem_guard/parser.rb +3 -1
- data/lib/gem_guard/typosquat_checker.rb +157 -0
- data/lib/gem_guard/version.rb +1 -1
- data/lib/gem_guard/vulnerability_fetcher.rb +25 -1
- data/lib/gem_guard.rb +2 -0
- data/templates/circleci-config.yml +107 -0
- data/templates/github-actions.yml +85 -0
- data/templates/gitlab-ci.yml +112 -0
- data/test_nokogiri.lock +13 -0
- metadata +8 -3
- data/gem_guard-0.1.0.gem +0 -0
data/lib/gem_guard/cli.rb
CHANGED
@@ -1,25 +1,65 @@
|
|
1
1
|
require "thor"
|
2
|
+
require "stringio"
|
2
3
|
|
3
4
|
module GemGuard
|
4
5
|
class CLI < Thor
|
6
|
+
# Exit codes for CI/CD integration
|
7
|
+
EXIT_SUCCESS = 0
|
8
|
+
EXIT_VULNERABILITIES_FOUND = 1
|
9
|
+
EXIT_ERROR = 2
|
10
|
+
|
5
11
|
desc "scan", "Scan dependencies for known vulnerabilities"
|
6
|
-
option :format, type: :string,
|
7
|
-
option :lockfile, type: :string,
|
12
|
+
option :format, type: :string, desc: "Output format (table, json)"
|
13
|
+
option :lockfile, type: :string, desc: "Path to Gemfile.lock"
|
14
|
+
option :config, type: :string, default: ".gemguard.yml", desc: "Path to config file"
|
15
|
+
option :fail_on_vulnerabilities, type: :boolean, desc: "Exit with code 1 if vulnerabilities found"
|
16
|
+
option :severity_threshold, type: :string, desc: "Minimum severity level (low, medium, high, critical)"
|
17
|
+
option :output, type: :string, desc: "Output file path"
|
8
18
|
def scan
|
9
|
-
|
19
|
+
config = Config.new(options[:config])
|
20
|
+
|
21
|
+
# Override config with CLI options
|
22
|
+
lockfile_path = options[:lockfile] || config.lockfile_path
|
23
|
+
format = options[:format] || config.output_format
|
24
|
+
fail_on_vulns = options[:fail_on_vulnerabilities].nil? ? config.fail_on_vulnerabilities? : options[:fail_on_vulnerabilities]
|
25
|
+
severity_threshold = options[:severity_threshold] || config.severity_threshold
|
26
|
+
output_file = options[:output] || config.output_file
|
10
27
|
|
11
28
|
unless File.exist?(lockfile_path)
|
12
29
|
puts "Error: #{lockfile_path} not found"
|
13
|
-
exit
|
30
|
+
exit EXIT_ERROR
|
14
31
|
end
|
15
32
|
|
16
|
-
|
17
|
-
|
18
|
-
|
33
|
+
begin
|
34
|
+
dependencies = Parser.new.parse(lockfile_path)
|
35
|
+
vulnerabilities = VulnerabilityFetcher.new.fetch_for(dependencies)
|
36
|
+
|
37
|
+
# Filter vulnerabilities based on config
|
38
|
+
filtered_vulnerabilities = filter_vulnerabilities(vulnerabilities, config)
|
39
|
+
|
40
|
+
analysis = Analyzer.new.analyze(dependencies, filtered_vulnerabilities)
|
19
41
|
|
20
|
-
|
42
|
+
# Filter analysis based on severity threshold
|
43
|
+
filtered_analysis = filter_analysis_by_severity(analysis, severity_threshold, config)
|
21
44
|
|
22
|
-
|
45
|
+
if output_file
|
46
|
+
output_content = capture_report_output(filtered_analysis, format)
|
47
|
+
File.write(output_file, output_content)
|
48
|
+
puts "Report written to #{output_file}"
|
49
|
+
else
|
50
|
+
Reporter.new.report(filtered_analysis, format: format)
|
51
|
+
end
|
52
|
+
|
53
|
+
# Exit with appropriate code for CI/CD
|
54
|
+
if filtered_analysis.has_vulnerabilities? && fail_on_vulns
|
55
|
+
exit EXIT_VULNERABILITIES_FOUND
|
56
|
+
else
|
57
|
+
exit EXIT_SUCCESS
|
58
|
+
end
|
59
|
+
rescue => e
|
60
|
+
puts "Error: #{e.message}"
|
61
|
+
exit EXIT_ERROR
|
62
|
+
end
|
23
63
|
end
|
24
64
|
|
25
65
|
desc "sbom", "Generate Software Bill of Materials (SBOM)"
|
@@ -58,9 +98,210 @@ module GemGuard
|
|
58
98
|
end
|
59
99
|
end
|
60
100
|
|
101
|
+
desc "typosquat", "Check for potential typosquat dependencies"
|
102
|
+
option :lockfile, type: :string, default: "Gemfile.lock", desc: "Path to Gemfile.lock"
|
103
|
+
option :format, type: :string, default: "table", desc: "Output format (table, json)"
|
104
|
+
option :output, type: :string, desc: "Output file path"
|
105
|
+
option :config, type: :string, desc: "Config file path"
|
106
|
+
def typosquat
|
107
|
+
config = Config.new(options[:config] || ".gemguard.yml")
|
108
|
+
|
109
|
+
lockfile_path = options[:lockfile] || config.lockfile_path
|
110
|
+
format = options[:format] || config.output_format
|
111
|
+
output_file = options[:output] || config.output_file
|
112
|
+
|
113
|
+
unless File.exist?(lockfile_path)
|
114
|
+
puts "Error: #{lockfile_path} not found"
|
115
|
+
exit EXIT_ERROR
|
116
|
+
end
|
117
|
+
|
118
|
+
begin
|
119
|
+
dependencies = Parser.new.parse(lockfile_path)
|
120
|
+
checker = TyposquatChecker.new
|
121
|
+
suspicious_gems = checker.check_dependencies(dependencies)
|
122
|
+
|
123
|
+
if output_file
|
124
|
+
output_content = format_typosquat_output(suspicious_gems, format)
|
125
|
+
File.write(output_file, output_content)
|
126
|
+
puts "Typosquat report written to #{output_file}"
|
127
|
+
else
|
128
|
+
display_typosquat_results(suspicious_gems, format)
|
129
|
+
end
|
130
|
+
|
131
|
+
# Exit with appropriate code
|
132
|
+
if suspicious_gems.any? { |sg| sg[:risk_level] == "critical" || sg[:risk_level] == "high" }
|
133
|
+
exit EXIT_VULNERABILITIES_FOUND
|
134
|
+
else
|
135
|
+
exit EXIT_SUCCESS
|
136
|
+
end
|
137
|
+
rescue => e
|
138
|
+
puts "Error: #{e.message}"
|
139
|
+
exit EXIT_ERROR
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
desc "config", "Manage configuration"
|
144
|
+
option :init, type: :boolean, desc: "Initialize default config file"
|
145
|
+
option :show, type: :boolean, desc: "Show current configuration"
|
146
|
+
option :path, type: :string, default: ".gemguard.yml", desc: "Config file path"
|
147
|
+
def config
|
148
|
+
config_file = Config.new(options[:path])
|
149
|
+
|
150
|
+
if options[:init]
|
151
|
+
if File.exist?(options[:path])
|
152
|
+
puts "Config file #{options[:path]} already exists"
|
153
|
+
exit EXIT_ERROR
|
154
|
+
end
|
155
|
+
|
156
|
+
# Create default config file
|
157
|
+
default_config = {
|
158
|
+
"lockfile" => "Gemfile.lock",
|
159
|
+
"format" => "table",
|
160
|
+
"fail_on_vulnerabilities" => true,
|
161
|
+
"severity_threshold" => "low",
|
162
|
+
"ignore_vulnerabilities" => [],
|
163
|
+
"ignore_gems" => [],
|
164
|
+
"output_file" => nil,
|
165
|
+
"project_name" => config_file.send(:detect_project_name),
|
166
|
+
"sbom" => {
|
167
|
+
"format" => "spdx",
|
168
|
+
"include_dev_dependencies" => false
|
169
|
+
},
|
170
|
+
"scan" => {
|
171
|
+
"sources" => ["osv", "ruby_advisory_db"],
|
172
|
+
"timeout" => 30
|
173
|
+
}
|
174
|
+
}
|
175
|
+
|
176
|
+
File.write(options[:path], YAML.dump(default_config))
|
177
|
+
puts "Created #{options[:path]} with default configuration"
|
178
|
+
elsif options[:show]
|
179
|
+
if config_file.exists?
|
180
|
+
puts File.read(options[:path])
|
181
|
+
else
|
182
|
+
puts "No config file found at #{options[:path]}"
|
183
|
+
puts "Run 'gem_guard config --init' to create one"
|
184
|
+
end
|
185
|
+
else
|
186
|
+
puts "Usage: gem_guard config [--init|--show] [--path PATH]"
|
187
|
+
puts " --init Create a new .gemguard.yml config file"
|
188
|
+
puts " --show Display current configuration"
|
189
|
+
puts " --path Specify config file path (default: .gemguard.yml)"
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
61
193
|
desc "version", "Show gem_guard version"
|
62
194
|
def version
|
63
195
|
puts GemGuard::VERSION
|
64
196
|
end
|
197
|
+
|
198
|
+
private
|
199
|
+
|
200
|
+
def filter_vulnerabilities(vulnerabilities, config)
|
201
|
+
vulnerabilities.reject do |vuln|
|
202
|
+
config.should_ignore_vulnerability?(vuln.id)
|
203
|
+
end
|
204
|
+
end
|
205
|
+
|
206
|
+
def filter_analysis_by_severity(analysis, severity_threshold, config)
|
207
|
+
return analysis unless severity_threshold
|
208
|
+
|
209
|
+
filtered_vulnerable_deps = analysis.vulnerable_dependencies.select do |vuln_dep|
|
210
|
+
config.meets_severity_threshold?(extract_severity_level(vuln_dep.vulnerability.severity))
|
211
|
+
end
|
212
|
+
|
213
|
+
# Create new analysis with filtered vulnerabilities
|
214
|
+
GemGuard::Analysis.new(filtered_vulnerable_deps)
|
215
|
+
end
|
216
|
+
|
217
|
+
def extract_severity_level(severity_string)
|
218
|
+
return "unknown" if severity_string.nil? || severity_string.empty?
|
219
|
+
|
220
|
+
# Extract severity from CVSS string or direct severity
|
221
|
+
case severity_string.downcase
|
222
|
+
when /critical/
|
223
|
+
"critical"
|
224
|
+
when /high/
|
225
|
+
"high"
|
226
|
+
when /medium/
|
227
|
+
"medium"
|
228
|
+
when /low/
|
229
|
+
"low"
|
230
|
+
else
|
231
|
+
"unknown"
|
232
|
+
end
|
233
|
+
end
|
234
|
+
|
235
|
+
def capture_report_output(analysis, format)
|
236
|
+
output = StringIO.new
|
237
|
+
old_stdout = $stdout
|
238
|
+
$stdout = output
|
239
|
+
|
240
|
+
Reporter.new.report(analysis, format: format)
|
241
|
+
|
242
|
+
$stdout = old_stdout
|
243
|
+
output.string
|
244
|
+
end
|
245
|
+
|
246
|
+
def display_typosquat_results(suspicious_gems, format)
|
247
|
+
if suspicious_gems.empty?
|
248
|
+
puts "No potential typosquat dependencies found."
|
249
|
+
return
|
250
|
+
end
|
251
|
+
|
252
|
+
case format.downcase
|
253
|
+
when "json"
|
254
|
+
puts JSON.pretty_generate(suspicious_gems)
|
255
|
+
else
|
256
|
+
display_typosquat_table(suspicious_gems)
|
257
|
+
end
|
258
|
+
end
|
259
|
+
|
260
|
+
def display_typosquat_table(suspicious_gems)
|
261
|
+
puts "\n🚨 Potential Typosquat Dependencies Found:"
|
262
|
+
puts "=" * 80
|
263
|
+
|
264
|
+
suspicious_gems.each do |gem_info|
|
265
|
+
risk_emoji = case gem_info[:risk_level]
|
266
|
+
when "critical" then "🔴"
|
267
|
+
when "high" then "🟠"
|
268
|
+
when "medium" then "🟡"
|
269
|
+
else "🟢"
|
270
|
+
end
|
271
|
+
|
272
|
+
puts "\n#{risk_emoji} #{gem_info[:gem_name]} (#{gem_info[:version]})"
|
273
|
+
puts " Suspected target: #{gem_info[:suspected_target]}"
|
274
|
+
puts " Similarity: #{(gem_info[:similarity_score] * 100).round(1)}%"
|
275
|
+
puts " Risk level: #{gem_info[:risk_level].upcase}"
|
276
|
+
puts " Target downloads: #{number_with_commas(gem_info[:target_downloads])}"
|
277
|
+
end
|
278
|
+
|
279
|
+
puts "\n" + "=" * 80
|
280
|
+
puts "💡 Review these dependencies carefully. Consider:"
|
281
|
+
puts " • Verifying the gem's legitimacy on rubygems.org"
|
282
|
+
puts " • Checking the gem's source code repository"
|
283
|
+
puts " • Looking for official documentation or endorsements"
|
284
|
+
puts " • Comparing with the suspected target gem"
|
285
|
+
end
|
286
|
+
|
287
|
+
def format_typosquat_output(suspicious_gems, format)
|
288
|
+
case format.downcase
|
289
|
+
when "json"
|
290
|
+
JSON.pretty_generate(suspicious_gems)
|
291
|
+
else
|
292
|
+
output = StringIO.new
|
293
|
+
old_stdout = $stdout
|
294
|
+
$stdout = output
|
295
|
+
|
296
|
+
display_typosquat_table(suspicious_gems)
|
297
|
+
|
298
|
+
$stdout = old_stdout
|
299
|
+
output.string
|
300
|
+
end
|
301
|
+
end
|
302
|
+
|
303
|
+
def number_with_commas(number)
|
304
|
+
number.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
|
305
|
+
end
|
65
306
|
end
|
66
307
|
end
|
@@ -0,0 +1,193 @@
|
|
1
|
+
require "yaml"
|
2
|
+
|
3
|
+
module GemGuard
|
4
|
+
class Config
|
5
|
+
DEFAULT_CONFIG = {
|
6
|
+
"lockfile" => "Gemfile.lock",
|
7
|
+
"format" => "table",
|
8
|
+
"fail_on_vulnerabilities" => true,
|
9
|
+
"severity_threshold" => "low",
|
10
|
+
"ignore_vulnerabilities" => [],
|
11
|
+
"ignore_gems" => [],
|
12
|
+
"output_file" => nil,
|
13
|
+
"project_name" => nil,
|
14
|
+
"sbom" => {
|
15
|
+
"format" => "spdx",
|
16
|
+
"include_dev_dependencies" => false
|
17
|
+
},
|
18
|
+
"scan" => {
|
19
|
+
"sources" => ["osv", "ruby_advisory_db"],
|
20
|
+
"timeout" => 30
|
21
|
+
}
|
22
|
+
}.freeze
|
23
|
+
|
24
|
+
SEVERITY_LEVELS = %w[low medium high critical].freeze
|
25
|
+
|
26
|
+
def initialize(config_path = ".gemguard.yml")
|
27
|
+
@config_path = config_path
|
28
|
+
@config = load_config
|
29
|
+
end
|
30
|
+
|
31
|
+
def get(key)
|
32
|
+
keys = key.split(".")
|
33
|
+
value = @config
|
34
|
+
|
35
|
+
keys.each do |k|
|
36
|
+
value = value[k] if value.is_a?(Hash)
|
37
|
+
end
|
38
|
+
|
39
|
+
value
|
40
|
+
end
|
41
|
+
|
42
|
+
def set(key, value)
|
43
|
+
keys = key.split(".")
|
44
|
+
target = @config
|
45
|
+
|
46
|
+
keys[0..-2].each do |k|
|
47
|
+
target[k] ||= {}
|
48
|
+
target = target[k]
|
49
|
+
end
|
50
|
+
|
51
|
+
target[keys.last] = value
|
52
|
+
end
|
53
|
+
|
54
|
+
def save
|
55
|
+
File.write(@config_path, YAML.dump(@config))
|
56
|
+
end
|
57
|
+
|
58
|
+
def exists?
|
59
|
+
File.exist?(@config_path)
|
60
|
+
end
|
61
|
+
|
62
|
+
def lockfile_path
|
63
|
+
get("lockfile")
|
64
|
+
end
|
65
|
+
|
66
|
+
def output_format
|
67
|
+
get("format")
|
68
|
+
end
|
69
|
+
|
70
|
+
def fail_on_vulnerabilities?
|
71
|
+
get("fail_on_vulnerabilities")
|
72
|
+
end
|
73
|
+
|
74
|
+
def severity_threshold
|
75
|
+
get("severity_threshold")
|
76
|
+
end
|
77
|
+
|
78
|
+
def ignored_vulnerabilities
|
79
|
+
get("ignore_vulnerabilities") || []
|
80
|
+
end
|
81
|
+
|
82
|
+
def ignored_gems
|
83
|
+
get("ignore_gems") || []
|
84
|
+
end
|
85
|
+
|
86
|
+
def output_file
|
87
|
+
get("output_file")
|
88
|
+
end
|
89
|
+
|
90
|
+
def project_name
|
91
|
+
get("project_name") || detect_project_name
|
92
|
+
end
|
93
|
+
|
94
|
+
def sbom_format
|
95
|
+
get("sbom.format")
|
96
|
+
end
|
97
|
+
|
98
|
+
def include_dev_dependencies?
|
99
|
+
get("sbom.include_dev_dependencies")
|
100
|
+
end
|
101
|
+
|
102
|
+
def vulnerability_sources
|
103
|
+
get("scan.sources")
|
104
|
+
end
|
105
|
+
|
106
|
+
def scan_timeout
|
107
|
+
get("scan.timeout")
|
108
|
+
end
|
109
|
+
|
110
|
+
def should_ignore_vulnerability?(vulnerability_id)
|
111
|
+
ignored_vulnerabilities.include?(vulnerability_id)
|
112
|
+
end
|
113
|
+
|
114
|
+
def should_ignore_gem?(gem_name)
|
115
|
+
ignored_gems.include?(gem_name)
|
116
|
+
end
|
117
|
+
|
118
|
+
def meets_severity_threshold?(severity)
|
119
|
+
return true if severity.nil? || severity.empty?
|
120
|
+
|
121
|
+
severity_index = SEVERITY_LEVELS.index(severity.downcase)
|
122
|
+
threshold_index = SEVERITY_LEVELS.index(severity_threshold.downcase)
|
123
|
+
|
124
|
+
return true if severity_index.nil? || threshold_index.nil?
|
125
|
+
|
126
|
+
severity_index >= threshold_index
|
127
|
+
end
|
128
|
+
|
129
|
+
private
|
130
|
+
|
131
|
+
def load_config
|
132
|
+
if File.exist?(@config_path)
|
133
|
+
user_config = YAML.load_file(@config_path) || {}
|
134
|
+
deep_merge(deep_dup(DEFAULT_CONFIG), user_config)
|
135
|
+
else
|
136
|
+
deep_dup(DEFAULT_CONFIG)
|
137
|
+
end
|
138
|
+
rescue Psych::SyntaxError => e
|
139
|
+
puts "Warning: Invalid YAML in #{@config_path}: #{e.message}"
|
140
|
+
puts "Using default configuration."
|
141
|
+
deep_dup(DEFAULT_CONFIG)
|
142
|
+
end
|
143
|
+
|
144
|
+
def deep_dup(obj)
|
145
|
+
case obj
|
146
|
+
when Hash
|
147
|
+
obj.each_with_object({}) { |(key, value), hash| hash[key] = deep_dup(value) }
|
148
|
+
when Array
|
149
|
+
obj.map { |item| deep_dup(item) }
|
150
|
+
else
|
151
|
+
begin
|
152
|
+
obj.dup
|
153
|
+
rescue
|
154
|
+
obj
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
def deep_merge(hash1, hash2)
|
160
|
+
result = hash1.dup
|
161
|
+
|
162
|
+
hash2.each do |key, value|
|
163
|
+
result[key] = if result[key].is_a?(Hash) && value.is_a?(Hash)
|
164
|
+
deep_merge(result[key], value)
|
165
|
+
else
|
166
|
+
value
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
result
|
171
|
+
end
|
172
|
+
|
173
|
+
def detect_project_name
|
174
|
+
# Try to detect project name from various sources
|
175
|
+
if File.exist?("Gemfile")
|
176
|
+
gemfile_content = File.read("Gemfile")
|
177
|
+
if gemfile_content =~ /gem\s+['"]([^'"]+)['"]/
|
178
|
+
return $1
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
if File.exist?("*.gemspec")
|
183
|
+
gemspec_files = Dir.glob("*.gemspec")
|
184
|
+
unless gemspec_files.empty?
|
185
|
+
return File.basename(gemspec_files.first, ".gemspec")
|
186
|
+
end
|
187
|
+
end
|
188
|
+
|
189
|
+
# Fallback to directory name
|
190
|
+
File.basename(Dir.pwd)
|
191
|
+
end
|
192
|
+
end
|
193
|
+
end
|
data/lib/gem_guard/parser.rb
CHANGED
@@ -0,0 +1,157 @@
|
|
1
|
+
require "net/http"
|
2
|
+
require "json"
|
3
|
+
require "uri"
|
4
|
+
|
5
|
+
module GemGuard
|
6
|
+
class TyposquatChecker
|
7
|
+
POPULAR_GEMS_CACHE_TTL = 3600 # 1 hour
|
8
|
+
SIMILARITY_THRESHOLD = 0.8
|
9
|
+
MIN_POPULAR_GEM_DOWNLOADS = 1_000_000
|
10
|
+
|
11
|
+
def initialize
|
12
|
+
@popular_gems_cache = nil
|
13
|
+
@cache_timestamp = nil
|
14
|
+
end
|
15
|
+
|
16
|
+
def check_dependencies(dependencies)
|
17
|
+
suspicious_gems = []
|
18
|
+
popular_gems = fetch_popular_gems
|
19
|
+
|
20
|
+
dependencies.each do |dependency|
|
21
|
+
suspicious_match = find_suspicious_match(dependency.name, popular_gems)
|
22
|
+
if suspicious_match
|
23
|
+
suspicious_gems << {
|
24
|
+
gem_name: dependency.name,
|
25
|
+
version: dependency.version,
|
26
|
+
suspected_target: suspicious_match[:name],
|
27
|
+
similarity_score: suspicious_match[:similarity],
|
28
|
+
target_downloads: suspicious_match[:downloads],
|
29
|
+
risk_level: calculate_risk_level(suspicious_match[:similarity])
|
30
|
+
}
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
suspicious_gems
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def fetch_popular_gems
|
40
|
+
return @popular_gems_cache if cache_valid?
|
41
|
+
|
42
|
+
begin
|
43
|
+
# Use fallback popular gems list for now since RubyGems API structure is complex
|
44
|
+
# In a production environment, you might want to use a different data source
|
45
|
+
# or scrape the RubyGems.org popular page
|
46
|
+
@popular_gems_cache = fallback_popular_gems
|
47
|
+
@cache_timestamp = Time.now
|
48
|
+
@popular_gems_cache
|
49
|
+
rescue
|
50
|
+
# Silently fall back to hardcoded list - no need to warn user
|
51
|
+
fallback_popular_gems
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def cache_valid?
|
56
|
+
@popular_gems_cache && @cache_timestamp &&
|
57
|
+
(Time.now - @cache_timestamp) < POPULAR_GEMS_CACHE_TTL
|
58
|
+
end
|
59
|
+
|
60
|
+
def find_suspicious_match(gem_name, popular_gems)
|
61
|
+
return nil if popular_gems.any? { |pg| pg[:name] == gem_name }
|
62
|
+
|
63
|
+
best_match = nil
|
64
|
+
highest_similarity = 0
|
65
|
+
|
66
|
+
popular_gems.each do |popular_gem|
|
67
|
+
similarity = calculate_similarity(gem_name, popular_gem[:name])
|
68
|
+
|
69
|
+
if similarity >= SIMILARITY_THRESHOLD && similarity > highest_similarity
|
70
|
+
highest_similarity = similarity
|
71
|
+
best_match = {
|
72
|
+
name: popular_gem[:name],
|
73
|
+
similarity: similarity,
|
74
|
+
downloads: popular_gem[:downloads]
|
75
|
+
}
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
best_match
|
80
|
+
end
|
81
|
+
|
82
|
+
def calculate_similarity(str1, str2)
|
83
|
+
return 0.0 if str1.nil? || str2.nil? || str1.empty? || str2.empty?
|
84
|
+
return 1.0 if str1 == str2
|
85
|
+
|
86
|
+
# Use Levenshtein distance for similarity calculation
|
87
|
+
levenshtein_similarity(str1.downcase, str2.downcase)
|
88
|
+
end
|
89
|
+
|
90
|
+
def levenshtein_similarity(str1, str2)
|
91
|
+
distance = levenshtein_distance(str1, str2)
|
92
|
+
max_length = [str1.length, str2.length].max
|
93
|
+
return 1.0 if max_length == 0
|
94
|
+
|
95
|
+
1.0 - (distance.to_f / max_length)
|
96
|
+
end
|
97
|
+
|
98
|
+
def levenshtein_distance(str1, str2)
|
99
|
+
matrix = Array.new(str1.length + 1) { Array.new(str2.length + 1) }
|
100
|
+
|
101
|
+
(0..str1.length).each { |i| matrix[i][0] = i }
|
102
|
+
(0..str2.length).each { |j| matrix[0][j] = j }
|
103
|
+
|
104
|
+
(1..str1.length).each do |i|
|
105
|
+
(1..str2.length).each do |j|
|
106
|
+
cost = (str1[i - 1] == str2[j - 1]) ? 0 : 1
|
107
|
+
matrix[i][j] = [
|
108
|
+
matrix[i - 1][j] + 1, # deletion
|
109
|
+
matrix[i][j - 1] + 1, # insertion
|
110
|
+
matrix[i - 1][j - 1] + cost # substitution
|
111
|
+
].min
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
matrix[str1.length][str2.length]
|
116
|
+
end
|
117
|
+
|
118
|
+
def calculate_risk_level(similarity)
|
119
|
+
case similarity
|
120
|
+
when 0.95..1.0
|
121
|
+
"critical"
|
122
|
+
when 0.9..0.95
|
123
|
+
"high"
|
124
|
+
when 0.85..0.9
|
125
|
+
"medium"
|
126
|
+
else
|
127
|
+
"low"
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
def fallback_popular_gems
|
132
|
+
# Hardcoded list of very popular Ruby gems as fallback
|
133
|
+
[
|
134
|
+
{name: "rails", downloads: 100_000_000},
|
135
|
+
{name: "bundler", downloads: 90_000_000},
|
136
|
+
{name: "rake", downloads: 80_000_000},
|
137
|
+
{name: "json", downloads: 70_000_000},
|
138
|
+
{name: "minitest", downloads: 60_000_000},
|
139
|
+
{name: "thread_safe", downloads: 50_000_000},
|
140
|
+
{name: "tzinfo", downloads: 45_000_000},
|
141
|
+
{name: "concurrent-ruby", downloads: 40_000_000},
|
142
|
+
{name: "i18n", downloads: 35_000_000},
|
143
|
+
{name: "activesupport", downloads: 30_000_000},
|
144
|
+
{name: "activerecord", downloads: 25_000_000},
|
145
|
+
{name: "actionpack", downloads: 20_000_000},
|
146
|
+
{name: "actionview", downloads: 18_000_000},
|
147
|
+
{name: "activemodel", downloads: 15_000_000},
|
148
|
+
{name: "rspec", downloads: 12_000_000},
|
149
|
+
{name: "puma", downloads: 10_000_000},
|
150
|
+
{name: "nokogiri", downloads: 8_000_000},
|
151
|
+
{name: "thor", downloads: 7_000_000},
|
152
|
+
{name: "sass", downloads: 6_000_000},
|
153
|
+
{name: "devise", downloads: 5_000_000}
|
154
|
+
]
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
data/lib/gem_guard/version.rb
CHANGED
@@ -15,7 +15,31 @@ module GemGuard
|
|
15
15
|
vulnerabilities.concat(fetch_ruby_advisory_vulnerabilities(dependency))
|
16
16
|
end
|
17
17
|
|
18
|
-
vulnerabilities
|
18
|
+
# Deduplicate vulnerabilities by ID and gem name, merging affected/fixed versions
|
19
|
+
deduplicated = {}
|
20
|
+
vulnerabilities.each do |vuln|
|
21
|
+
key = [vuln.id, vuln.gem_name]
|
22
|
+
if deduplicated[key]
|
23
|
+
# Merge affected and fixed versions from duplicate entries
|
24
|
+
existing = deduplicated[key]
|
25
|
+
merged_affected = (existing.affected_versions + vuln.affected_versions).uniq
|
26
|
+
merged_fixed = (existing.fixed_versions + vuln.fixed_versions).uniq
|
27
|
+
|
28
|
+
deduplicated[key] = Vulnerability.new(
|
29
|
+
id: existing.id,
|
30
|
+
gem_name: existing.gem_name,
|
31
|
+
affected_versions: merged_affected,
|
32
|
+
fixed_versions: merged_fixed,
|
33
|
+
severity: existing.severity,
|
34
|
+
summary: existing.summary,
|
35
|
+
details: existing.details
|
36
|
+
)
|
37
|
+
else
|
38
|
+
deduplicated[key] = vuln
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
deduplicated.values
|
19
43
|
end
|
20
44
|
|
21
45
|
private
|
data/lib/gem_guard.rb
CHANGED
@@ -4,6 +4,8 @@ require_relative "gem_guard/vulnerability_fetcher"
|
|
4
4
|
require_relative "gem_guard/analyzer"
|
5
5
|
require_relative "gem_guard/reporter"
|
6
6
|
require_relative "gem_guard/sbom_generator"
|
7
|
+
require_relative "gem_guard/typosquat_checker"
|
8
|
+
require_relative "gem_guard/config"
|
7
9
|
require_relative "gem_guard/cli"
|
8
10
|
|
9
11
|
module GemGuard
|