typosquatting 0.1.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/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE +21 -0
- data/README.md +218 -0
- data/Rakefile +8 -0
- data/exe/typosquatting +6 -0
- data/lib/typosquatting/algorithms/addition.rb +20 -0
- data/lib/typosquatting/algorithms/base.rb +34 -0
- data/lib/typosquatting/algorithms/delimiter.rb +48 -0
- data/lib/typosquatting/algorithms/homoglyph.rb +61 -0
- data/lib/typosquatting/algorithms/misspelling.rb +78 -0
- data/lib/typosquatting/algorithms/numeral.rb +45 -0
- data/lib/typosquatting/algorithms/omission.rb +16 -0
- data/lib/typosquatting/algorithms/plural.rb +74 -0
- data/lib/typosquatting/algorithms/repetition.rb +16 -0
- data/lib/typosquatting/algorithms/replacement.rb +59 -0
- data/lib/typosquatting/algorithms/transposition.rb +17 -0
- data/lib/typosquatting/algorithms/vowel_swap.rb +27 -0
- data/lib/typosquatting/algorithms/word_order.rb +25 -0
- data/lib/typosquatting/cli.rb +380 -0
- data/lib/typosquatting/confusion.rb +70 -0
- data/lib/typosquatting/ecosystems/base.rb +65 -0
- data/lib/typosquatting/ecosystems/cargo.rb +45 -0
- data/lib/typosquatting/ecosystems/composer.rb +64 -0
- data/lib/typosquatting/ecosystems/golang.rb +56 -0
- data/lib/typosquatting/ecosystems/hex.rb +42 -0
- data/lib/typosquatting/ecosystems/maven.rb +64 -0
- data/lib/typosquatting/ecosystems/npm.rb +66 -0
- data/lib/typosquatting/ecosystems/nuget.rb +41 -0
- data/lib/typosquatting/ecosystems/pub.rb +43 -0
- data/lib/typosquatting/ecosystems/pypi.rb +38 -0
- data/lib/typosquatting/ecosystems/rubygems.rb +42 -0
- data/lib/typosquatting/generator.rb +58 -0
- data/lib/typosquatting/lookup.rb +138 -0
- data/lib/typosquatting/sbom.rb +98 -0
- data/lib/typosquatting/version.rb +5 -0
- data/lib/typosquatting.rb +103 -0
- data/sig/typosquatting.rbs +4 -0
- metadata +114 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typosquatting
|
|
4
|
+
module Algorithms
|
|
5
|
+
class Replacement < Base
|
|
6
|
+
KEYBOARD_ADJACENT = {
|
|
7
|
+
"q" => %w[w a s],
|
|
8
|
+
"w" => %w[q e a s d],
|
|
9
|
+
"e" => %w[w r s d f],
|
|
10
|
+
"r" => %w[e t d f g],
|
|
11
|
+
"t" => %w[r y f g h],
|
|
12
|
+
"y" => %w[t u g h j],
|
|
13
|
+
"u" => %w[y i h j k],
|
|
14
|
+
"i" => %w[u o j k l],
|
|
15
|
+
"o" => %w[i p k l],
|
|
16
|
+
"p" => %w[o l],
|
|
17
|
+
"a" => %w[q w s z],
|
|
18
|
+
"s" => %w[q w e a d z x],
|
|
19
|
+
"d" => %w[w e r s f x c],
|
|
20
|
+
"f" => %w[e r t d g c v],
|
|
21
|
+
"g" => %w[r t y f h v b],
|
|
22
|
+
"h" => %w[t y u g j b n],
|
|
23
|
+
"j" => %w[y u i h k n m],
|
|
24
|
+
"k" => %w[u i o j l m],
|
|
25
|
+
"l" => %w[i o p k],
|
|
26
|
+
"z" => %w[a s x],
|
|
27
|
+
"x" => %w[s d z c],
|
|
28
|
+
"c" => %w[d f x v],
|
|
29
|
+
"v" => %w[f g c b],
|
|
30
|
+
"b" => %w[g h v n],
|
|
31
|
+
"n" => %w[h j b m],
|
|
32
|
+
"m" => %w[j k n],
|
|
33
|
+
"1" => %w[2 q],
|
|
34
|
+
"2" => %w[1 3 q w],
|
|
35
|
+
"3" => %w[2 4 w e],
|
|
36
|
+
"4" => %w[3 5 e r],
|
|
37
|
+
"5" => %w[4 6 r t],
|
|
38
|
+
"6" => %w[5 7 t y],
|
|
39
|
+
"7" => %w[6 8 y u],
|
|
40
|
+
"8" => %w[7 9 u i],
|
|
41
|
+
"9" => %w[8 0 i o],
|
|
42
|
+
"0" => %w[9 o p]
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
def generate(package_name)
|
|
46
|
+
variants = []
|
|
47
|
+
package_name.each_char.with_index do |char, i|
|
|
48
|
+
adjacent = KEYBOARD_ADJACENT[char.downcase] || []
|
|
49
|
+
adjacent.each do |replacement|
|
|
50
|
+
replacement = replacement.upcase if char == char.upcase && char =~ /[a-z]/i
|
|
51
|
+
variant = package_name[0...i] + replacement + package_name[(i + 1)..]
|
|
52
|
+
variants << variant
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
variants.uniq
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typosquatting
|
|
4
|
+
module Algorithms
|
|
5
|
+
class Transposition < Base
|
|
6
|
+
def generate(package_name)
|
|
7
|
+
variants = []
|
|
8
|
+
(package_name.length - 1).times do |i|
|
|
9
|
+
chars = package_name.chars
|
|
10
|
+
chars[i], chars[i + 1] = chars[i + 1], chars[i]
|
|
11
|
+
variants << chars.join
|
|
12
|
+
end
|
|
13
|
+
variants.uniq
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typosquatting
|
|
4
|
+
module Algorithms
|
|
5
|
+
class VowelSwap < Base
|
|
6
|
+
VOWELS = %w[a e i o u y].freeze
|
|
7
|
+
|
|
8
|
+
def generate(package_name)
|
|
9
|
+
variants = []
|
|
10
|
+
|
|
11
|
+
package_name.each_char.with_index do |char, i|
|
|
12
|
+
next unless VOWELS.include?(char.downcase)
|
|
13
|
+
|
|
14
|
+
VOWELS.each do |vowel|
|
|
15
|
+
next if vowel == char.downcase
|
|
16
|
+
|
|
17
|
+
replacement = char == char.upcase ? vowel.upcase : vowel
|
|
18
|
+
variant = package_name[0...i] + replacement + package_name[(i + 1)..]
|
|
19
|
+
variants << variant
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
variants.uniq
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typosquatting
|
|
4
|
+
module Algorithms
|
|
5
|
+
class WordOrder < Base
|
|
6
|
+
DELIMITERS = %w[- _ .].freeze
|
|
7
|
+
|
|
8
|
+
def generate(package_name)
|
|
9
|
+
variants = []
|
|
10
|
+
|
|
11
|
+
DELIMITERS.each do |delim|
|
|
12
|
+
parts = package_name.split(delim)
|
|
13
|
+
next if parts.length < 2
|
|
14
|
+
|
|
15
|
+
(0...parts.length).to_a.permutation.each do |perm|
|
|
16
|
+
reordered = perm.map { |i| parts[i] }.join(delim)
|
|
17
|
+
variants << reordered unless reordered == package_name
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
variants.uniq
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Typosquatting
|
|
7
|
+
class CLI
|
|
8
|
+
def self.run(args = ARGV)
|
|
9
|
+
new.run(args)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def run(args)
|
|
13
|
+
command = args.shift
|
|
14
|
+
case command
|
|
15
|
+
when "generate"
|
|
16
|
+
generate(args)
|
|
17
|
+
when "check"
|
|
18
|
+
check(args)
|
|
19
|
+
when "confusion"
|
|
20
|
+
confusion(args)
|
|
21
|
+
when "sbom"
|
|
22
|
+
sbom(args)
|
|
23
|
+
when "ecosystems"
|
|
24
|
+
ecosystems
|
|
25
|
+
when "algorithms"
|
|
26
|
+
algorithms
|
|
27
|
+
when "version", "-v", "--version"
|
|
28
|
+
version
|
|
29
|
+
when "help", "-h", "--help", nil
|
|
30
|
+
help
|
|
31
|
+
else
|
|
32
|
+
$stderr.puts "Unknown command: #{command}"
|
|
33
|
+
help
|
|
34
|
+
exit 1
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def generate(args)
|
|
39
|
+
options = { format: "text", verbose: false }
|
|
40
|
+
parser = OptionParser.new do |opts|
|
|
41
|
+
opts.banner = "Usage: typosquatting generate PACKAGE -e ECOSYSTEM [options]"
|
|
42
|
+
opts.on("-e", "--ecosystem ECOSYSTEM", "Package ecosystem (required)") { |v| options[:ecosystem] = v }
|
|
43
|
+
opts.on("-f", "--format FORMAT", "Output format (text, json, csv)") { |v| options[:format] = v }
|
|
44
|
+
opts.on("-v", "--verbose", "Show algorithm for each variant") { options[:verbose] = true }
|
|
45
|
+
opts.on("-a", "--algorithms LIST", "Comma-separated list of algorithms to use") { |v| options[:algorithms] = v }
|
|
46
|
+
end
|
|
47
|
+
parser.parse!(args)
|
|
48
|
+
|
|
49
|
+
package = args.shift
|
|
50
|
+
unless package && options[:ecosystem]
|
|
51
|
+
$stderr.puts "Error: Package name and ecosystem required"
|
|
52
|
+
$stderr.puts parser
|
|
53
|
+
exit 1
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
ecosystem = Ecosystems::Base.get(options[:ecosystem])
|
|
57
|
+
algorithms = select_algorithms(options[:algorithms])
|
|
58
|
+
generator = Generator.new(ecosystem: ecosystem, algorithms: algorithms)
|
|
59
|
+
variants = generator.generate(package)
|
|
60
|
+
|
|
61
|
+
output_variants(variants, options)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def check(args)
|
|
65
|
+
options = { format: "text", verbose: false, existing_only: false, dry_run: false }
|
|
66
|
+
parser = OptionParser.new do |opts|
|
|
67
|
+
opts.banner = "Usage: typosquatting check PACKAGE -e ECOSYSTEM [options]"
|
|
68
|
+
opts.on("-e", "--ecosystem ECOSYSTEM", "Package ecosystem (required)") { |v| options[:ecosystem] = v }
|
|
69
|
+
opts.on("-f", "--format FORMAT", "Output format (text, json, csv)") { |v| options[:format] = v }
|
|
70
|
+
opts.on("-v", "--verbose", "Show algorithm and registry details") { options[:verbose] = true }
|
|
71
|
+
opts.on("-a", "--algorithms LIST", "Comma-separated list of algorithms to use") { |v| options[:algorithms] = v }
|
|
72
|
+
opts.on("--existing-only", "Only show packages that exist") { options[:existing_only] = true }
|
|
73
|
+
opts.on("--dry-run", "Show variants without making API calls") { options[:dry_run] = true }
|
|
74
|
+
end
|
|
75
|
+
parser.parse!(args)
|
|
76
|
+
|
|
77
|
+
package = args.shift
|
|
78
|
+
unless package && options[:ecosystem]
|
|
79
|
+
$stderr.puts "Error: Package name and ecosystem required"
|
|
80
|
+
$stderr.puts parser
|
|
81
|
+
exit 1
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
ecosystem = Ecosystems::Base.get(options[:ecosystem])
|
|
85
|
+
algorithms = select_algorithms(options[:algorithms])
|
|
86
|
+
generator = Generator.new(ecosystem: ecosystem, algorithms: algorithms)
|
|
87
|
+
variants = generator.generate(package)
|
|
88
|
+
|
|
89
|
+
if options[:dry_run]
|
|
90
|
+
puts "Would check #{variants.length} variants:"
|
|
91
|
+
variants.each { |v| puts " #{v.name}" }
|
|
92
|
+
return
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
lookup = Lookup.new(ecosystem: ecosystem)
|
|
96
|
+
results = check_variants(variants, lookup)
|
|
97
|
+
results = results.select { |r| r[:result].exists? } if options[:existing_only]
|
|
98
|
+
|
|
99
|
+
output_check_results(results, options)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def confusion(args)
|
|
103
|
+
options = { format: "text" }
|
|
104
|
+
parser = OptionParser.new do |opts|
|
|
105
|
+
opts.banner = "Usage: typosquatting confusion PACKAGE -e ECOSYSTEM [options]"
|
|
106
|
+
opts.on("-e", "--ecosystem ECOSYSTEM", "Package ecosystem (required)") { |v| options[:ecosystem] = v }
|
|
107
|
+
opts.on("-f", "--format FORMAT", "Output format (text, json)") { |v| options[:format] = v }
|
|
108
|
+
opts.on("--file FILE", "Read package names from file") { |v| options[:file] = v }
|
|
109
|
+
end
|
|
110
|
+
parser.parse!(args)
|
|
111
|
+
|
|
112
|
+
unless options[:ecosystem]
|
|
113
|
+
$stderr.puts "Error: Ecosystem required"
|
|
114
|
+
$stderr.puts parser
|
|
115
|
+
exit 1
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
packages = if options[:file]
|
|
119
|
+
File.readlines(options[:file]).map(&:strip).reject(&:empty?)
|
|
120
|
+
else
|
|
121
|
+
package = args.shift
|
|
122
|
+
unless package
|
|
123
|
+
$stderr.puts "Error: Package name or --file required"
|
|
124
|
+
exit 1
|
|
125
|
+
end
|
|
126
|
+
[package]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
confusion_checker = Confusion.new(ecosystem: options[:ecosystem])
|
|
130
|
+
results = packages.map do |pkg|
|
|
131
|
+
$stderr.puts "Checking #{pkg}..." if packages.length > 1
|
|
132
|
+
confusion_checker.check(pkg)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
output_confusion_results(results, options)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def sbom(args)
|
|
139
|
+
options = { format: "text", dry_run: false }
|
|
140
|
+
parser = OptionParser.new do |opts|
|
|
141
|
+
opts.banner = "Usage: typosquatting sbom FILE [options]"
|
|
142
|
+
opts.on("-f", "--format FORMAT", "Output format (text, json)") { |v| options[:format] = v }
|
|
143
|
+
opts.on("--dry-run", "Show packages without making API calls") { options[:dry_run] = true }
|
|
144
|
+
end
|
|
145
|
+
parser.parse!(args)
|
|
146
|
+
|
|
147
|
+
file = args.shift
|
|
148
|
+
unless file
|
|
149
|
+
$stderr.puts "Error: SBOM file required"
|
|
150
|
+
$stderr.puts parser
|
|
151
|
+
exit 1
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
unless File.exist?(file)
|
|
155
|
+
$stderr.puts "Error: File not found: #{file}"
|
|
156
|
+
exit 1
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
checker = SBOMChecker.new(file)
|
|
160
|
+
|
|
161
|
+
if options[:dry_run]
|
|
162
|
+
puts "Packages in SBOM:"
|
|
163
|
+
checker.sbom.packages.each do |pkg|
|
|
164
|
+
purl = checker.extract_purl(pkg)
|
|
165
|
+
puts " #{pkg[:name]} (#{purl || "no purl"})"
|
|
166
|
+
end
|
|
167
|
+
puts ""
|
|
168
|
+
puts "#{checker.sbom.packages.length} packages found"
|
|
169
|
+
return
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
results = checker.check
|
|
173
|
+
|
|
174
|
+
output_sbom_results(results, options)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def ecosystems
|
|
178
|
+
puts "Supported ecosystems:"
|
|
179
|
+
puts ""
|
|
180
|
+
puts " pypi - Python Package Index"
|
|
181
|
+
puts " npm - Node Package Manager"
|
|
182
|
+
puts " gem - RubyGems"
|
|
183
|
+
puts " cargo - Rust packages"
|
|
184
|
+
puts " golang - Go modules"
|
|
185
|
+
puts " maven - Java/JVM packages"
|
|
186
|
+
puts " nuget - .NET packages"
|
|
187
|
+
puts " composer - PHP packages"
|
|
188
|
+
puts " hex - Erlang/Elixir packages"
|
|
189
|
+
puts " pub - Dart packages"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def algorithms
|
|
193
|
+
puts "Typosquatting algorithms:"
|
|
194
|
+
puts ""
|
|
195
|
+
Algorithms::Base.all.each do |algo|
|
|
196
|
+
puts " #{algo.name}"
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def version
|
|
201
|
+
puts "typosquatting #{VERSION}"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def help
|
|
205
|
+
puts "typosquatting - Detect potential typosquatting packages"
|
|
206
|
+
puts ""
|
|
207
|
+
puts "Usage: typosquatting COMMAND [options]"
|
|
208
|
+
puts ""
|
|
209
|
+
puts "Commands:"
|
|
210
|
+
puts " generate PACKAGE -e ECOSYSTEM Generate typosquat variants"
|
|
211
|
+
puts " check PACKAGE -e ECOSYSTEM Check which variants exist"
|
|
212
|
+
puts " confusion PACKAGE -e ECOSYSTEM Check for dependency confusion"
|
|
213
|
+
puts " sbom FILE Check SBOM for potential typosquats"
|
|
214
|
+
puts " ecosystems List supported ecosystems"
|
|
215
|
+
puts " algorithms List typosquatting algorithms"
|
|
216
|
+
puts " version Show version"
|
|
217
|
+
puts " help Show this help"
|
|
218
|
+
puts ""
|
|
219
|
+
puts "Examples:"
|
|
220
|
+
puts " typosquatting generate requests -e pypi"
|
|
221
|
+
puts " typosquatting check requests -e pypi --existing-only"
|
|
222
|
+
puts " typosquatting confusion my-package -e maven"
|
|
223
|
+
puts " typosquatting sbom bom.json"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def output_variants(variants, options)
|
|
227
|
+
case options[:format]
|
|
228
|
+
when "json"
|
|
229
|
+
data = variants.map { |v| options[:verbose] ? v.to_h : v.name }
|
|
230
|
+
puts JSON.pretty_generate(data)
|
|
231
|
+
when "csv"
|
|
232
|
+
if options[:verbose]
|
|
233
|
+
puts "name,algorithm"
|
|
234
|
+
variants.each { |v| puts "#{v.name},#{v.algorithm}" }
|
|
235
|
+
else
|
|
236
|
+
variants.each { |v| puts v.name }
|
|
237
|
+
end
|
|
238
|
+
else
|
|
239
|
+
if options[:verbose]
|
|
240
|
+
variants.each { |v| puts "#{v.name} (#{v.algorithm})" }
|
|
241
|
+
else
|
|
242
|
+
variants.each { |v| puts v.name }
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
$stderr.puts ""
|
|
247
|
+
$stderr.puts "Generated #{variants.length} variants"
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def output_check_results(results, options)
|
|
251
|
+
case options[:format]
|
|
252
|
+
when "json"
|
|
253
|
+
data = results.map do |r|
|
|
254
|
+
{
|
|
255
|
+
name: r[:variant].name,
|
|
256
|
+
algorithm: r[:variant].algorithm,
|
|
257
|
+
exists: r[:result].exists?,
|
|
258
|
+
registries: r[:result].registries
|
|
259
|
+
}
|
|
260
|
+
end
|
|
261
|
+
puts JSON.pretty_generate(data)
|
|
262
|
+
when "csv"
|
|
263
|
+
puts "name,algorithm,exists,registries"
|
|
264
|
+
results.each do |r|
|
|
265
|
+
puts "#{r[:variant].name},#{r[:variant].algorithm},#{r[:result].exists?},\"#{r[:result].registries.join("; ")}\""
|
|
266
|
+
end
|
|
267
|
+
else
|
|
268
|
+
results.each do |r|
|
|
269
|
+
status = r[:result].exists? ? "EXISTS" : "available"
|
|
270
|
+
if options[:verbose]
|
|
271
|
+
puts "#{r[:variant].name} (#{r[:variant].algorithm}) - #{status}"
|
|
272
|
+
puts " registries: #{r[:result].registries.join(", ")}" if r[:result].exists?
|
|
273
|
+
else
|
|
274
|
+
puts "#{r[:variant].name} - #{status}"
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
puts ""
|
|
279
|
+
existing = results.count { |r| r[:result].exists? }
|
|
280
|
+
puts "Checked #{results.length} variants, #{existing} exist"
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def output_confusion_results(results, options)
|
|
285
|
+
case options[:format]
|
|
286
|
+
when "json"
|
|
287
|
+
data = results.map(&:to_h)
|
|
288
|
+
puts JSON.pretty_generate(data)
|
|
289
|
+
else
|
|
290
|
+
results.each do |result|
|
|
291
|
+
puts ""
|
|
292
|
+
puts "Package: #{result.name}"
|
|
293
|
+
puts "PURL: #{result.purl}"
|
|
294
|
+
|
|
295
|
+
if result.registry_status.empty?
|
|
296
|
+
puts " No registries found for this ecosystem"
|
|
297
|
+
else
|
|
298
|
+
result.registry_status.each do |registry, exists|
|
|
299
|
+
status = exists ? "EXISTS" : "available"
|
|
300
|
+
puts " #{registry}: #{status}"
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
if result.confusion_risk?
|
|
305
|
+
puts ""
|
|
306
|
+
puts "WARNING: Dependency confusion risk detected!"
|
|
307
|
+
puts "Package exists on: #{result.present_registries.join(", ")}"
|
|
308
|
+
puts "Package missing from: #{result.absent_registries.join(", ")}"
|
|
309
|
+
elsif result.exists_anywhere
|
|
310
|
+
puts ""
|
|
311
|
+
puts "Package exists on all registries"
|
|
312
|
+
else
|
|
313
|
+
puts ""
|
|
314
|
+
puts "Package does not exist on any registry"
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def check_variants(variants, lookup)
|
|
321
|
+
$stderr.puts "Checking #{variants.length} variants..." if $stderr.tty?
|
|
322
|
+
|
|
323
|
+
names = variants.map(&:name)
|
|
324
|
+
api_results = lookup.check_many(names, concurrency: 10)
|
|
325
|
+
|
|
326
|
+
variants.zip(api_results).map do |variant, result|
|
|
327
|
+
{ variant: variant, result: result }
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def select_algorithms(algorithm_list)
|
|
332
|
+
return nil unless algorithm_list
|
|
333
|
+
|
|
334
|
+
names = algorithm_list.split(",").map(&:strip)
|
|
335
|
+
all_algorithms = Algorithms::Base.all
|
|
336
|
+
selected = []
|
|
337
|
+
|
|
338
|
+
names.each do |name|
|
|
339
|
+
algo = all_algorithms.find { |a| a.name == name }
|
|
340
|
+
if algo
|
|
341
|
+
selected << algo
|
|
342
|
+
else
|
|
343
|
+
$stderr.puts "Warning: Unknown algorithm '#{name}', skipping"
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
selected.empty? ? nil : selected
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def output_sbom_results(results, options)
|
|
351
|
+
case options[:format]
|
|
352
|
+
when "json"
|
|
353
|
+
data = results.map(&:to_h)
|
|
354
|
+
puts JSON.pretty_generate(data)
|
|
355
|
+
else
|
|
356
|
+
if results.empty?
|
|
357
|
+
puts "No potential typosquats found in SBOM"
|
|
358
|
+
return
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
puts "Potential typosquats found:"
|
|
362
|
+
puts ""
|
|
363
|
+
|
|
364
|
+
results.each do |result|
|
|
365
|
+
puts "#{result.name} (#{result.ecosystem})"
|
|
366
|
+
puts " Version: #{result.version}" if result.version
|
|
367
|
+
puts " PURL: #{result.purl}"
|
|
368
|
+
puts " Similar to existing packages:"
|
|
369
|
+
result.suspicions.each do |s|
|
|
370
|
+
puts " - #{s.name} (#{s.algorithm})"
|
|
371
|
+
puts " registries: #{s.registries.join(", ")}" unless s.registries.empty?
|
|
372
|
+
end
|
|
373
|
+
puts ""
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
puts "Found #{results.length} suspicious package(s)"
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typosquatting
|
|
4
|
+
class Confusion
|
|
5
|
+
attr_reader :ecosystem, :lookup
|
|
6
|
+
|
|
7
|
+
def initialize(ecosystem:)
|
|
8
|
+
@ecosystem = ecosystem.is_a?(String) ? Ecosystems::Base.get(ecosystem) : ecosystem
|
|
9
|
+
@lookup = Lookup.new(ecosystem: @ecosystem)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def check(package_name)
|
|
13
|
+
result = lookup.check(package_name)
|
|
14
|
+
all_registries = lookup.registries
|
|
15
|
+
|
|
16
|
+
registry_status = {}
|
|
17
|
+
all_registries.each do |registry|
|
|
18
|
+
registry_status[registry.name] = result.registries.include?(registry.name)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
ConfusionResult.new(
|
|
22
|
+
name: package_name,
|
|
23
|
+
purl: result.purl,
|
|
24
|
+
registry_status: registry_status,
|
|
25
|
+
exists_anywhere: result.exists?,
|
|
26
|
+
packages: result.packages
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def check_many(package_names)
|
|
31
|
+
package_names.map { |name| check(name) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
ConfusionResult = Struct.new(:name, :purl, :registry_status, :exists_anywhere, :packages, keyword_init: true) do
|
|
35
|
+
def confusion_risk?
|
|
36
|
+
return false unless exists_anywhere
|
|
37
|
+
return false if registry_status.empty?
|
|
38
|
+
|
|
39
|
+
present_count = registry_status.values.count(true)
|
|
40
|
+
absent_count = registry_status.values.count(false)
|
|
41
|
+
|
|
42
|
+
present_count > 0 && absent_count > 0
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def present_registries
|
|
46
|
+
registry_status.select { |_, v| v }.keys
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def absent_registries
|
|
50
|
+
registry_status.reject { |_, v| v }.keys
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def registries
|
|
54
|
+
registry_status
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def to_h
|
|
58
|
+
{
|
|
59
|
+
name: name,
|
|
60
|
+
purl: purl,
|
|
61
|
+
exists_anywhere: exists_anywhere,
|
|
62
|
+
confusion_risk: confusion_risk?,
|
|
63
|
+
registries: registry_status,
|
|
64
|
+
present_registries: present_registries,
|
|
65
|
+
absent_registries: absent_registries
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typosquatting
|
|
4
|
+
module Ecosystems
|
|
5
|
+
class Base
|
|
6
|
+
attr_reader :name, :purl_type
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@name = self.class.name.split("::").last.downcase
|
|
10
|
+
@purl_type = @name
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def valid_name?(name)
|
|
14
|
+
return false if name.nil? || name.empty?
|
|
15
|
+
|
|
16
|
+
!!(name =~ name_pattern)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def normalise(name)
|
|
20
|
+
name
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def name_pattern
|
|
24
|
+
raise NotImplementedError, "Subclasses must implement #name_pattern"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def allowed_characters
|
|
28
|
+
raise NotImplementedError, "Subclasses must implement #allowed_characters"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def allowed_delimiters
|
|
32
|
+
[]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def case_sensitive?
|
|
36
|
+
true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def supports_namespaces?
|
|
40
|
+
false
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def parse_namespace(name)
|
|
44
|
+
[nil, name]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.get(ecosystem)
|
|
48
|
+
registry[ecosystem.to_s.downcase] || raise(ArgumentError, "Unknown ecosystem: #{ecosystem}")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.all
|
|
52
|
+
registry.values
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.register(ecosystem)
|
|
56
|
+
registry[ecosystem.purl_type] = ecosystem
|
|
57
|
+
registry[ecosystem.name] = ecosystem if ecosystem.name != ecosystem.purl_type
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.registry
|
|
61
|
+
@registry ||= {}
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typosquatting
|
|
4
|
+
module Ecosystems
|
|
5
|
+
class Cargo < Base
|
|
6
|
+
def initialize
|
|
7
|
+
super
|
|
8
|
+
@purl_type = "cargo"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def name_pattern
|
|
12
|
+
/\A[a-zA-Z][a-zA-Z0-9_-]*\z/
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def allowed_characters
|
|
16
|
+
/[a-zA-Z0-9_-]/
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def allowed_delimiters
|
|
20
|
+
%w[- _]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def case_sensitive?
|
|
24
|
+
false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def normalise(name)
|
|
28
|
+
name.downcase.tr("_", "-")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def equivalent?(name1, name2)
|
|
32
|
+
normalise(name1) == normalise(name2)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def valid_name?(name)
|
|
36
|
+
return false if name.nil? || name.empty?
|
|
37
|
+
return false if name.length > 64
|
|
38
|
+
|
|
39
|
+
!!(name =~ name_pattern)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
Base.register(Cargo.new)
|
|
44
|
+
end
|
|
45
|
+
end
|