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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/LICENSE +21 -0
  5. data/README.md +218 -0
  6. data/Rakefile +8 -0
  7. data/exe/typosquatting +6 -0
  8. data/lib/typosquatting/algorithms/addition.rb +20 -0
  9. data/lib/typosquatting/algorithms/base.rb +34 -0
  10. data/lib/typosquatting/algorithms/delimiter.rb +48 -0
  11. data/lib/typosquatting/algorithms/homoglyph.rb +61 -0
  12. data/lib/typosquatting/algorithms/misspelling.rb +78 -0
  13. data/lib/typosquatting/algorithms/numeral.rb +45 -0
  14. data/lib/typosquatting/algorithms/omission.rb +16 -0
  15. data/lib/typosquatting/algorithms/plural.rb +74 -0
  16. data/lib/typosquatting/algorithms/repetition.rb +16 -0
  17. data/lib/typosquatting/algorithms/replacement.rb +59 -0
  18. data/lib/typosquatting/algorithms/transposition.rb +17 -0
  19. data/lib/typosquatting/algorithms/vowel_swap.rb +27 -0
  20. data/lib/typosquatting/algorithms/word_order.rb +25 -0
  21. data/lib/typosquatting/cli.rb +380 -0
  22. data/lib/typosquatting/confusion.rb +70 -0
  23. data/lib/typosquatting/ecosystems/base.rb +65 -0
  24. data/lib/typosquatting/ecosystems/cargo.rb +45 -0
  25. data/lib/typosquatting/ecosystems/composer.rb +64 -0
  26. data/lib/typosquatting/ecosystems/golang.rb +56 -0
  27. data/lib/typosquatting/ecosystems/hex.rb +42 -0
  28. data/lib/typosquatting/ecosystems/maven.rb +64 -0
  29. data/lib/typosquatting/ecosystems/npm.rb +66 -0
  30. data/lib/typosquatting/ecosystems/nuget.rb +41 -0
  31. data/lib/typosquatting/ecosystems/pub.rb +43 -0
  32. data/lib/typosquatting/ecosystems/pypi.rb +38 -0
  33. data/lib/typosquatting/ecosystems/rubygems.rb +42 -0
  34. data/lib/typosquatting/generator.rb +58 -0
  35. data/lib/typosquatting/lookup.rb +138 -0
  36. data/lib/typosquatting/sbom.rb +98 -0
  37. data/lib/typosquatting/version.rb +5 -0
  38. data/lib/typosquatting.rb +103 -0
  39. data/sig/typosquatting.rbs +4 -0
  40. 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