commissar 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 75fe669345c8c573be9096107effe4a523b2713a7426ff95f4d8e61bd9f0853e
4
+ data.tar.gz: d221d1a39a1dfb16726c71c5585fdb7e0a814b17cf87787eebb503d6d39eed5f
5
+ SHA512:
6
+ metadata.gz: 15aefe8a25cb03507a22d01bf932d14c9cac049cde6cb003b20e194e71768366be928ddac39c82967d58437994dc6f045c175b9fc99ad71e68e19c26ba9fe158
7
+ data.tar.gz: add075296dd9c27830dbfd7484aa9f59ad272f13557b3d9c21ec95d1fc5db656356d12bae63882bc938988522c204f8b267753101c81149c18f427784ba001d0
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mauro Eldritch
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,141 @@
1
+ # Commissar
2
+
3
+ Static analysis tool for detecting potential supply chain attacks in RubyGems.
4
+
5
+ Scans a gem for malicious indicators before you install it: suspicious network calls, credential access, file reads, obfuscated payloads, dangerous gemspec patterns, and more.
6
+
7
+ ## Install
8
+
9
+ ```
10
+ gem install commissar
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```
16
+ commissar <gem_name> [version] [options]
17
+ commissar --local PATH [options]
18
+ ```
19
+
20
+ Examples:
21
+
22
+ ```
23
+ #Normal use
24
+ commissar rails
25
+
26
+ #Scan a specific version
27
+ commissar nokogiri 1.16.0
28
+
29
+ #Scan a local gem
30
+ commissar --local /tmp/my-gemfile.gem
31
+
32
+ #JSON output to screen or to file
33
+ commissar rails --format json > commissar.json
34
+ commissar rails --format json | jq
35
+
36
+ #Table format
37
+ commissar rails --format table
38
+
39
+ #CSV (format is assumed from the output file extension)
40
+ commissar rails --output results.csv
41
+
42
+ #No colour, please
43
+ commissar rails --no-color
44
+
45
+ #I just want the score for my automation tool/pipeline
46
+ commissar rails --score-only
47
+ ```
48
+
49
+ Output groups findings by category with severity (`CRIT`, `HIGH`, `MED`, `LOW`, `INFO`), file, and line number. A risk score (0–100) and a final recommendation are printed at the end.
50
+
51
+ You can use `--score-only` to quickly integrate Commissar with your CI/CD pipelines. You will get a plain integer representing the risk score alone, without banner or any other information.
52
+
53
+ ## Configuration
54
+
55
+ Pattern lists live in the `conf/` directory of the repo (or the gem's bundled `conf/` when installed). Edit those files directly to add or remove patterns.
56
+
57
+ Files:
58
+
59
+ | File | Purpose |
60
+ |---|---|
61
+ | `suspicious_urls.txt` | Domains associated with exfiltration or staging |
62
+ | `suspicious_functions.txt` | Dangerous Ruby methods and classes |
63
+ | `suspicious_shell.txt` | Shell commands used for data exfiltration or staging |
64
+ | `credential_paths.txt` | Filesystem paths and env vars containing secrets and sensitive info |
65
+ | `clipboard_patterns.txt` | System calls and APIs used for clipboard access |
66
+ | `known_bad_wallets.txt` | OFAC-sanctioned and DOJ-documented wallet addresses |
67
+ | `complex_gems.txt` | Known complex gems that receive a contextual note on elevated scores |
68
+ | `severity.txt` | Numeric weights for each severity level |
69
+ | `post_install_patterns.txt` | Suspicious patterns in post-install messages |
70
+
71
+ Each file is plain text, one entry per line. Lines starting with `#` are ignored.
72
+
73
+ ### Pattern format
74
+
75
+ ```
76
+ SEVERITY:PATTERN
77
+ ```
78
+
79
+ Examples:
80
+
81
+ ```
82
+ HIGH:eval
83
+ CRIT:api.telegram.org
84
+ MED:pastebin.com
85
+ ```
86
+
87
+ ### Antipatterns
88
+
89
+ Any fields after the first pair are treated as antipatterns: if the line matches the pattern but also contains any antipattern, the finding is suppressed. Useful for reducing false positives from legitimate metaprogramming.
90
+
91
+ ```
92
+ SEVERITY:PATTERN:ANTIPATTERN:ANTIPATTERN:...
93
+ ```
94
+
95
+ Examples:
96
+
97
+ ```
98
+ HIGH:eval:&:binding
99
+ HIGH:instance_eval:&
100
+ HIGH:class_eval:&:__FILE__
101
+ ```
102
+
103
+ `::` in Ruby namespace notation is never treated as a separator, so `MED:Net::HTTP` works as expected.
104
+
105
+ ## What it detects
106
+
107
+ - Version published in the last 72 hours
108
+ - Recent owner changes or new maintainer accounts
109
+ - Missing or broken homepage/source URIs
110
+ - Gemspec `extensions` pointing to `extconf.rb` or `Rakefile` (run on `gem install`)
111
+ - Top-level code in the gemspec
112
+ - `eval`, `system`, backticks, and other dangerous function calls
113
+ - Outbound network calls (Telegram bots, Discord webhooks, paste sites, webhook services)
114
+ - `curl`/`wget` POST commands and Ruby HTTP POST equivalents
115
+ - Base64-encoded or zlib-compressed payloads
116
+ - High Shannon entropy strings (>5.5 bits/char)
117
+ - Lines over 500 characters (common in padding and hiding schemes)
118
+ - Access to credentials via `ENV` or filesystem paths
119
+ - Clipboard hijacking (Web3)
120
+ - Hardcoded wallet addresses: known OFAC/DOJ-sanctioned addresses flagged as `CRIT`, unknown addresses as `HIGH`
121
+
122
+ ## Development
123
+
124
+ ```
125
+ bundle install
126
+ rake test
127
+ ```
128
+
129
+ ## Contributing
130
+
131
+ I appreciate your interest! Feel free to tweak the configuration files and detection expressions and share them with me and the community, always happy to hear back from users of my tools.
132
+
133
+ If you believe your change deserves a dedicated fork, go for it!
134
+
135
+ Please report any bugs or false positives, I'll be happy to work on them.
136
+
137
+ / ! \ Remember this tool could mistake some constructions for dangerous code or functions, or could even skip real ones, it's not failproof, so always DYOR.
138
+
139
+ ## License
140
+
141
+ MIT - see [LICENSE](LICENSE).
@@ -0,0 +1,5 @@
1
+ HIGH:xclip
2
+ HIGH:pbcopy
3
+ HIGH:pbpaste
4
+ HIGH:xdotool
5
+ HIGH:Clipboard.
@@ -0,0 +1,28 @@
1
+ bundler:package manager
2
+ rubygems:package manager
3
+ rake:build tool
4
+ thor:build tool
5
+ pry:debugger
6
+ byebug:debugger
7
+ debug:debugger
8
+ ruby-debug:debugger
9
+ binding_of_caller:debugger
10
+ puma:web server
11
+ thin:web server
12
+ unicorn:web server
13
+ passenger:web server
14
+ falcon:web server
15
+ webrick:web server
16
+ rack:web framework
17
+ rackup:web framework
18
+ sinatra:web framework
19
+ rails:web framework
20
+ rubocop:code analyzer
21
+ reek:code analyzer
22
+ brakeman:security scanner
23
+ flog:code analyzer
24
+ flay:code analyzer
25
+ zip:archive tool
26
+ rubyzip:archive tool
27
+ opal:compiler
28
+ nokogiri:html/xml parser
@@ -0,0 +1,48 @@
1
+ HIGH:ENV["AWS_ACCESS_KEY_ID"]
2
+ HIGH:ENV["AWS_SECRET_ACCESS_KEY"]
3
+ HIGH:ENV["AWS_SESSION_TOKEN"]
4
+ HIGH:ENV["GITHUB_TOKEN"]
5
+ HIGH:ENV["GH_TOKEN"]
6
+ HIGH:ENV["RUBYGEMS_API_KEY"]
7
+ HIGH:~/.aws/credentials
8
+ HIGH:~/.ssh/id_rsa
9
+ HIGH:~/.ssh/id_ed25519
10
+ HIGH:~/.ssh/id_ecdsa
11
+ HIGH:~/.gem/credentials
12
+ MED:ENV["NPM_TOKEN"]
13
+ MED:ENV["DATABASE_URL"]
14
+ MED:ENV["SECRET_KEY_BASE"]
15
+ MED:~/.aws/config
16
+ MED:~/.ssh/known_hosts
17
+ MED:~/.npmrc
18
+ MED:~/.bundle/config
19
+ MED:~/.netrc
20
+ MED:.env.local
21
+ MED:.env.production
22
+ MED:.env.development
23
+ CRIT:Login Data
24
+ CRIT:Default/Cookies
25
+ CRIT:Network/Cookies
26
+ CRIT:Default/Web Data
27
+ CRIT:Local Extension Settings
28
+ CRIT:Cookies.binarycookies
29
+ CRIT:login.keychain
30
+ CRIT:System.keychain
31
+ CRIT:Library/Keychains
32
+ CRIT:logins.json
33
+ CRIT:key4.db
34
+ CRIT:cookies.sqlite
35
+ CRIT:Google/Chrome
36
+ CRIT:BraveSoftware/Brave-Browser
37
+ CRIT:Safari/Cookies
38
+ CRIT:Firefox/Profiles
39
+ CRIT:AppData/Local/Google/Chrome
40
+ CRIT:AppData/Roaming/Mozilla/Firefox
41
+ CRIT:AppData/Local/BraveSoftware
42
+ MED:Safari/History.db
43
+ MED:Safari/Bookmarks.plist
44
+ MED:Places.sqlite
45
+ CRIT:wallet.dat
46
+ CRIT:mnemonic
47
+ CRIT:passwords.txt
48
+ CRIT:.kdbx
@@ -0,0 +1,25 @@
1
+ 0x8589427373D6D84E98730D7795D8f6f8731FDA16:Tornado Cash (OFAC)
2
+ 0x722122dF12D4e14e13Ac3b6895a86e84145b6967:Tornado Cash (OFAC)
3
+ 0xDD4c48C0B24039969fC16D1cdF626eaB821d3384:Tornado Cash (OFAC)
4
+ 0xd90e2f925DA726b50C4Ed8D0Fb90Ad053324F31b:Tornado Cash (OFAC)
5
+ 0xd96f2B1c14Db8458374d9Aca76E26c3950113264:Tornado Cash (OFAC)
6
+ 0x4736dCf1b7A3d580672CcE6E7c65cd5cc9cFBa9d:Tornado Cash (OFAC)
7
+ 0xD4B88Df4D29F5CedD6857912842cff3b20C8Cfa3:Tornado Cash (OFAC)
8
+ 0x910Cbd523D972eb0a6f4cAe4618aD62622b39DbF:Tornado Cash (OFAC)
9
+ 0xA160cdAB225685dA1d56aa342Ad8841c3b53f291:Tornado Cash (OFAC)
10
+ 0xFD8610d20aA15b7B2E3Be39B396a1bC3516c7144:Tornado Cash (OFAC)
11
+ 0x07687e702b410Fa43f4cB4Af7FA097918ffD2730:Tornado Cash (OFAC)
12
+ 0x23773E65ed146A459667FFaD1d6Ee592c33E1F0e:Tornado Cash (OFAC)
13
+ 0x22aaA7720ddd5388A3c0A3333430953C68f1849b:Tornado Cash (OFAC)
14
+ 0x03893a7c7463AE47D46bc7f091665f1893656003:Tornado Cash (OFAC)
15
+ 0x2717c5e28cf931547B621a5dddb772Ab6A35B701:Tornado Cash (OFAC)
16
+ 0xD21be7248e0197Ee08E0c20D4a96DEBdaC3D20Af:Tornado Cash (OFAC)
17
+ 0x4F60a160D8C2DDdaAfe16FCC57566dB84D674BD6:Tornado Cash (OFAC)
18
+ 0x1E34A77868E19A6647b1f2F47B51ed72dEDE95DD:Tornado Cash (OFAC)
19
+ 0x9AD122c22B14202B4490eDAf288FDb3C7cb3ff5E:Tornado Cash (OFAC)
20
+ 0x098B716B8Aaf21512996dC57EB0615e2383E2f96:Lazarus Group / Ronin Hack (OFAC)
21
+ 0xa0e1c89Ef1a489c9C7dE96311eD5Ce5D32c20E4b:Lazarus Group (OFAC)
22
+ 0x3Cffd56B47B7b41c56258D9C7731ABaDc360E073:Lazarus Group (OFAC)
23
+ 115p7UMMngoj1pMvkpHijcRdfJNXj6LrLn:WannaCry (DOJ)
24
+ 12t9YDPgwueZ9NyMgw519p7AA8isjr6SMw:WannaCry (DOJ)
25
+ 13AM4VW2dhxYgXeQepoHkHSQuy6NgaEb94:WannaCry (DOJ)
@@ -0,0 +1,5 @@
1
+ MED:http
2
+ MED:curl
3
+ MED:wget
4
+ MED:sudo
5
+ MED:chmod
data/conf/severity.txt ADDED
@@ -0,0 +1,5 @@
1
+ CRIT:25
2
+ HIGH:10
3
+ MED:5
4
+ LOW:3
5
+ INFO:0
@@ -0,0 +1,23 @@
1
+ HIGH:eval:&:binding:--eval
2
+ HIGH:instance_eval:&:{: do:__FILE__
3
+ HIGH:class_eval:&:__FILE__:{: do
4
+ HIGH:module_eval:&:__FILE__:{: do:get_file_and_line_from_caller
5
+ HIGH:Kernel.system:(*
6
+ HIGH:Kernel.exec:(*
7
+ HIGH:Kernel.spawn
8
+ HIGH:Process.spawn
9
+ HIGH:IO.popen:%w[
10
+ HIGH:Open3.popen2
11
+ HIGH:Open3.popen3
12
+ HIGH:Open3.capture2
13
+ HIGH:Open3.capture3
14
+ HIGH:Open3.pipeline
15
+ HIGH:Base64.decode64
16
+ HIGH:Base64.strict_decode64
17
+ HIGH:Zlib::Inflate
18
+ MED:Net::HTTP:Gem::Net::HTTP
19
+ MED:URI.open
20
+ MED:open-uri
21
+ MED:TCPSocket:kind_of?
22
+ MED:UDPSocket
23
+ MED:Socket.new: engine
@@ -0,0 +1,15 @@
1
+ HIGH:curl -X POST
2
+ HIGH:curl --data
3
+ HIGH:curl -d
4
+ HIGH:curl -F
5
+ HIGH:curl -T
6
+ HIGH:wget --post-data
7
+ HIGH:wget --post-file
8
+ HIGH:wget -O-
9
+ MED:Net::HTTP::Post.new
10
+ MED:Net::HTTP.post_form
11
+ MED:RestClient.post
12
+ MED:HTTParty.post
13
+ MED:Faraday.post
14
+ MED:Excon.post
15
+ MED:http.post
@@ -0,0 +1,19 @@
1
+ CRIT:api.telegram.org
2
+ CRIT:t.me
3
+ CRIT:discord.com/api/webhooks
4
+ CRIT:hooks.slack.com
5
+ MED:pastebin.com
6
+ MED:paste.ee
7
+ MED:hastebin.com
8
+ MED:transfer.sh
9
+ MED:0x0.st
10
+ MED:file.io
11
+ MED:anonfiles.com
12
+ MED:gofile.io
13
+ MED:webhook.site
14
+ MED:requestbin.com
15
+ MED:pipedream.net
16
+ LOW:ngrok.io
17
+ LOW:ngrok-free.app
18
+ LOW:serveo.net
19
+ LOW:localhost.run
data/conf/top_gems.txt ADDED
@@ -0,0 +1,133 @@
1
+ rake
2
+ rspec
3
+ bundler
4
+ minitest
5
+ nokogiri
6
+ activesupport
7
+ actionpack
8
+ activerecord
9
+ railties
10
+ actionview
11
+ rack
12
+ actionmailer
13
+ activejob
14
+ activemodel
15
+ activestorage
16
+ actioncable
17
+ actiontext
18
+ actionmailbox
19
+ json
20
+ tzinfo
21
+ concurrent-ruby
22
+ i18n
23
+ thor
24
+ zeitwerk
25
+ rubocop
26
+ yard
27
+ pry
28
+ byebug
29
+ listen
30
+ spring
31
+ faraday
32
+ httparty
33
+ rest-client
34
+ devise
35
+ sidekiq
36
+ resque
37
+ kaminari
38
+ capybara
39
+ selenium-webdriver
40
+ factory_bot
41
+ faker
42
+ webmock
43
+ simplecov
44
+ aws-sdk-s3
45
+ stripe
46
+ redis
47
+ pg
48
+ mysql2
49
+ sqlite3
50
+ jwt
51
+ bcrypt
52
+ dotenv
53
+ colorize
54
+ rainbow
55
+ mechanize
56
+ rubocop-rails
57
+ rubocop-rspec
58
+ rubocop-performance
59
+ rspec-rails
60
+ rails
61
+ sinatra
62
+ puma
63
+ unicorn
64
+ passenger
65
+ capistrano
66
+ whenever
67
+ carrierwave
68
+ paperclip
69
+ geocoder
70
+ ransack
71
+ pundit
72
+ cancancan
73
+ omniauth
74
+ warden
75
+ rolify
76
+ activeadmin
77
+ administrate
78
+ sorbet
79
+ steep
80
+ dry-rb
81
+ hanami
82
+ grape
83
+ fast_jsonapi
84
+ jsonapi-serializer
85
+ grpc
86
+ google-protobuf
87
+ aws-sdk-core
88
+ net-ssh
89
+ net-scp
90
+ sshkit
91
+ mina
92
+ foreman
93
+ rerun
94
+ guard
95
+ rubygems-update
96
+ thin
97
+ eventmachine
98
+ rack
99
+ rack-test
100
+ rack-protection
101
+ pry-rails
102
+ rspec-mocks
103
+ rspec-support
104
+ rspec-expectations
105
+ rspec-core
106
+ webrick
107
+ net-http
108
+ addressable
109
+ excon
110
+ typhoeus
111
+ concurrent-ruby
112
+ activejob
113
+ activerecord
114
+ actiontext
115
+ actionmailbox
116
+ bootsnap
117
+ sprockets
118
+ importmap-rails
119
+ turbo-rails
120
+ stimulus-rails
121
+ tailwindcss-rails
122
+ cssbundling-rails
123
+ jsbundling-rails
124
+ erb_lint
125
+ rubocop-minitest
126
+ debug
127
+ irb
128
+ rdoc
129
+ psych
130
+ ostruct
131
+ logger
132
+ base64
133
+ resolv
data/exe/commissar ADDED
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "optparse"
4
+ require_relative "../lib/commissar"
5
+
6
+ options = { format: nil, color: true, output: nil, local: nil, score_only: false }
7
+
8
+ OptionParser.new do |opts|
9
+ opts.banner = <<~BANNER
10
+ Usage:
11
+ commissar <gem_name> [version] [options]
12
+ commissar --local PATH [options]
13
+
14
+ Examples:
15
+ commissar rails
16
+ commissar nokogiri 1.16.0
17
+ commissar --local /tmp/evil.gem
18
+ commissar rails --no-color
19
+ commissar rails --format table
20
+ commissar rails --format json --output results.json
21
+ commissar rails --format json | jq
22
+ commissar rails --output results.csv
23
+ commissar rails --score-only
24
+ [ $(commissar rails --score-only) -lt 40 ] && gem install rails
25
+
26
+ Options:
27
+ BANNER
28
+
29
+ opts.on("--local PATH", "Scan a local .gem file") do |path|
30
+ options[:local] = path
31
+ end
32
+
33
+ opts.on("--no-color", "Disable colored output") do
34
+ options[:color] = false
35
+ end
36
+
37
+ opts.on("--output FILE", "Write output to FILE") do |file|
38
+ options[:output] = file
39
+ end
40
+
41
+ opts.on("--format FORMAT", %w[text csv json table], "Output format: text, csv, json, table") do |fmt|
42
+ options[:format] = fmt.to_sym
43
+ end
44
+
45
+ opts.on("--score-only", "Output only the integer risk score (for CI/CD integration)") do
46
+ options[:score_only] = true
47
+ end
48
+
49
+ opts.on("-h", "--help", "Show this help") do
50
+ puts opts
51
+ exit
52
+ end
53
+ end.tap { |parser| @parser = parser }.parse!
54
+
55
+ String.disable_colorization = !options[:color]
56
+
57
+ if options[:local]
58
+ local_path = File.expand_path(options[:local])
59
+ unless File.exist?(local_path)
60
+ warn "File not found: #{local_path}".colorize(:red)
61
+ exit 1
62
+ end
63
+ gem_name = File.basename(local_path, ".gem").sub(/-\d+\.\d+\.\d+.*$/, "")
64
+ scanner = Commissar::Scanner.new(gem_name, local_path: local_path)
65
+ elsif ARGV.empty?
66
+ puts @parser
67
+ exit 1
68
+ else
69
+ scanner = Commissar::Scanner.new(ARGV[0], version: ARGV[1])
70
+ end
71
+
72
+ if options[:score_only]
73
+ scanner.scan(quiet: true)
74
+ puts scanner.risk_score
75
+ exit
76
+ end
77
+
78
+ quiet = %i[json csv].include?(options[:format])
79
+ scanner.scan(quiet: quiet)
80
+
81
+ terminal_format = options[:format] || :text
82
+ scanner.report(format: terminal_format)
83
+
84
+ if options[:output]
85
+ file_format = options[:format]
86
+ unless file_format
87
+ ext = File.extname(options[:output]).downcase
88
+ file_format = case ext
89
+ when ".csv" then :csv
90
+ when ".json" then :json
91
+ else :text
92
+ end
93
+ end
94
+
95
+ String.disable_colorization = true
96
+ File.open(options[:output], "w") do |f|
97
+ scanner.report(format: file_format, io: f)
98
+ end
99
+ String.disable_colorization = !options[:color]
100
+ warn "Output written to #{options[:output]}".colorize(:cyan)
101
+ end
data/lib/commissar.rb ADDED
@@ -0,0 +1,673 @@
1
+ require "rubygems"
2
+ require "rubygems/package"
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require "yaml"
7
+ require "tmpdir"
8
+ require "fileutils"
9
+ require "time"
10
+ require "set"
11
+ require "csv"
12
+ require "colorize"
13
+
14
+ module Commissar
15
+ VERSION = "0.1.0"
16
+
17
+ module Config
18
+ BUNDLED_DIR = File.expand_path("../../conf", __FILE__)
19
+
20
+ def self.load(filename)
21
+ path = resolve(filename)
22
+ return [] unless path
23
+
24
+ File.readlines(path, chomp: true)
25
+ .reject { |l| l.strip.empty? || l.strip.start_with?("#") }
26
+ end
27
+
28
+ def self.load_weights
29
+ path = resolve("severity.txt")
30
+ return {} unless path
31
+
32
+ File.readlines(path, chomp: true)
33
+ .reject { |l| l.strip.empty? || l.strip.start_with?("#") }
34
+ .each_with_object({}) do |line, hash|
35
+ k, v = line.split(":", 2)
36
+ hash[k.strip] = v.to_i if k && v
37
+ end
38
+ end
39
+
40
+ def self.load_complex_gems
41
+ path = resolve("complex_gems.txt")
42
+ return {} unless path
43
+
44
+ File.readlines(path, chomp: true)
45
+ .reject { |l| l.strip.empty? || l.strip.start_with?("#") }
46
+ .each_with_object({}) do |line, hash|
47
+ name, category = line.split(":", 2)
48
+ hash[name.strip] = category.strip if name && category
49
+ end
50
+ end
51
+
52
+ def self.load_known_wallets
53
+ path = resolve("known_bad_wallets.txt")
54
+ return {} unless path
55
+
56
+ File.readlines(path, chomp: true)
57
+ .reject { |l| l.strip.empty? || l.strip.start_with?("#") }
58
+ .each_with_object({}) do |line, hash|
59
+ addr, label = line.split(":", 2)
60
+ next unless addr && label
61
+ hash[addr.strip] = label.strip
62
+ end
63
+ end
64
+
65
+ def self.resolve(filename)
66
+ candidates = [
67
+ File.join(Dir.pwd, "conf", filename),
68
+ File.join(BUNDLED_DIR, filename)
69
+ ]
70
+ candidates.find { |p| File.exist?(p) }
71
+ end
72
+ end
73
+
74
+ SEVERITY_WEIGHT = Config.load_weights.freeze
75
+
76
+ Finding = Struct.new(:category, :severity, :message, :file, :line, :snippet, keyword_init: true) do
77
+ def weight
78
+ SEVERITY_WEIGHT.fetch(severity, 0)
79
+ end
80
+
81
+ def to_s
82
+ loc = [file, line].compact.join(":")
83
+ loc_str = loc.empty? ? "" : " => #{loc}"
84
+ "[#{severity.ljust(4)}] #{message}#{loc_str}"
85
+ end
86
+ end
87
+
88
+ class Scanner
89
+ RUBYGEMS_API = "https://rubygems.org/api/v1"
90
+ HOMOGLYPH_RE = /[аеорсхѕіїӏορᴏᴀ]/
91
+
92
+ attr_reader :gem_name, :version, :findings
93
+
94
+ def initialize(gem_name, version: nil, local_path: nil)
95
+ @gem_name = gem_name
96
+ @version = version
97
+ @local_path = local_path
98
+ @findings = []
99
+ @metadata = {}
100
+ @files = {}
101
+ @spec = nil
102
+ @owners = :pending
103
+ @suspicious_urls = Config.load("suspicious_urls.txt")
104
+ @suspicious_functions = Config.load("suspicious_functions.txt")
105
+ @suspicious_shell = Config.load("suspicious_shell.txt")
106
+ @credential_paths = Config.load("credential_paths.txt")
107
+ @clipboard_patterns = Config.load("clipboard_patterns.txt")
108
+ @post_install_patterns = Config.load("post_install_patterns.txt")
109
+ @known_bad_wallets = Config.load_known_wallets
110
+ @complex_gems = Config.load_complex_gems
111
+ @top_gems = Config.load("top_gems.txt")
112
+ end
113
+
114
+ def scan(quiet: false)
115
+ puts "\n#{"[*] Scanning: #{gem_name}".colorize(:white)} #{version_label}" unless quiet
116
+ fetch_metadata unless @local_path
117
+ fetch_and_unpack
118
+ run_metadata_checks
119
+ run_diff_checks unless @local_path
120
+ run_gemspec_checks
121
+ run_function_checks
122
+ run_url_checks
123
+ run_shell_checks
124
+ run_encoding_checks
125
+ run_credential_checks
126
+ run_web3_checks
127
+ self
128
+ end
129
+
130
+ def risk_score
131
+ raw = @findings.sum(&:weight)
132
+ [raw, 100].min
133
+ end
134
+
135
+ def report(format: :text, io: $stdout)
136
+ case format
137
+ when :csv then report_csv(io)
138
+ when :json then report_json(io)
139
+ when :table then report_table(io)
140
+ else report_text(io)
141
+ end
142
+ end
143
+
144
+ private
145
+
146
+ def report_text(io)
147
+ grouped = @findings.group_by(&:category)
148
+ if grouped.empty?
149
+ io.puts " #{"No findings.".colorize(:green)}"
150
+ else
151
+ grouped.each do |category, items|
152
+ io.puts "\n#{category_header(category)} (#{items.size})"
153
+ items.each do |f|
154
+ io.puts " └─ #{colorize_finding(f)}"
155
+ io.puts " #{f.snippet.colorize(:light_black)}" if f.snippet
156
+ end
157
+ end
158
+ end
159
+ io.puts "\n#{score_line}"
160
+ io.puts complex_gem_note.colorize(:cyan) if complex_gem_note
161
+ end
162
+
163
+ def report_csv(io)
164
+ io.puts CSV.generate_line(%w[gem version category severity message file line snippet])
165
+ v = @version || @metadata["version"] || ""
166
+ @findings.each do |f|
167
+ io.puts CSV.generate_line([gem_name, v, f.category, f.severity, f.message, f.file, f.line, f.snippet])
168
+ end
169
+ end
170
+
171
+ def report_json(io)
172
+ v = @version || @metadata["version"]
173
+ payload = {
174
+ gem: gem_name,
175
+ version: v,
176
+ scanned_at: Time.now.iso8601,
177
+ risk_score: risk_score,
178
+ verdict: verdict_text,
179
+ findings: @findings.map { |f|
180
+ { category: f.category, severity: f.severity, message: f.message,
181
+ file: f.file, line: f.line, snippet: f.snippet }
182
+ }
183
+ }
184
+ payload[:complex_gem_note] = complex_gem_note if complex_gem_note
185
+ io.puts JSON.pretty_generate(payload)
186
+ end
187
+
188
+ def report_table(io)
189
+ if @findings.empty?
190
+ io.puts "No findings."
191
+ io.puts "\n#{score_line}"
192
+ return
193
+ end
194
+
195
+ col_defs = [
196
+ { header: "SEV", max: 4 },
197
+ { header: "CATEGORY", max: 22 },
198
+ { header: "MESSAGE", max: 40 },
199
+ { header: "FILE", max: 28 },
200
+ { header: "LINE", max: 5 },
201
+ { header: "SNIPPET", max: 50 },
202
+ ]
203
+ rows = @findings.map { |f|
204
+ [f.severity.to_s, f.category.to_s, f.message.to_s, f.file.to_s, f.line.to_s, f.snippet.to_s]
205
+ }
206
+ widths = col_defs.each_with_index.map do |col, i|
207
+ content_max = rows.map { |r| r[i].length }.max || 0
208
+ [col[:header].length, [content_max, col[:max]].min].max
209
+ end
210
+
211
+ sep = "+" + widths.map { |w| "-" * (w + 2) }.join("+") + "+"
212
+ io.puts sep
213
+ io.puts "|" + col_defs.each_with_index.map { |c, i| " #{c[:header].ljust(widths[i])} " }.join("|") + "|"
214
+ io.puts sep
215
+ rows.each do |row|
216
+ io.puts "|" + row.each_with_index.map { |cell, i|
217
+ s = cell.length > widths[i] ? "#{cell[0, widths[i] - 1]}…" : cell
218
+ " #{s.ljust(widths[i])} "
219
+ }.join("|") + "|"
220
+ end
221
+ io.puts sep
222
+ io.puts "\n#{score_line}"
223
+ io.puts complex_gem_note.colorize(:cyan) if complex_gem_note
224
+ end
225
+
226
+ def fetch_metadata
227
+ uri = URI("#{RUBYGEMS_API}/gems/#{gem_name}.json")
228
+ response = Net::HTTP.get_response(uri)
229
+ unless response.is_a?(Net::HTTPSuccess)
230
+ warn " Could not fetch metadata for #{gem_name}".colorize(:yellow)
231
+ return
232
+ end
233
+ @metadata = JSON.parse(response.body)
234
+ @version ||= @metadata["version"]
235
+ end
236
+
237
+ def fetch_and_unpack
238
+ if @local_path
239
+ @files, @spec = unpack_gem(@local_path)
240
+ return
241
+ end
242
+
243
+ Dir.mktmpdir("commissar_") do |tmpdir|
244
+ gem_path = download_gem(tmpdir)
245
+ return unless gem_path
246
+ @files, @spec = unpack_gem(gem_path)
247
+ end
248
+ end
249
+
250
+ def download_gem(dir, version: nil)
251
+ ver = version || @version || @metadata["version"]
252
+ uri = URI("https://rubygems.org/gems/#{gem_name}-#{ver}.gem")
253
+ response = Net::HTTP.get_response(uri)
254
+ unless response.is_a?(Net::HTTPSuccess)
255
+ warn " Could not download .gem for #{gem_name} #{ver}".colorize(:yellow)
256
+ return nil
257
+ end
258
+ path = File.join(dir, "#{gem_name}-#{ver}.gem")
259
+ File.binwrite(path, response.body)
260
+ path
261
+ end
262
+
263
+ def unpack_gem(gem_path)
264
+ extract_dir = nil
265
+ files = {}
266
+ extract_dir = Dir.mktmpdir("commissar_x_")
267
+ pkg = Gem::Package.new(gem_path)
268
+ spec = pkg.spec
269
+ pkg.extract_files(extract_dir)
270
+ pkg.contents.each do |entry|
271
+ full_path = File.join(extract_dir, entry)
272
+ next unless File.file?(full_path)
273
+ next if File.size(full_path) > 1_048_576
274
+ files[entry] = safe_read(full_path)
275
+ end
276
+ [files, spec]
277
+ rescue => e
278
+ warn " Could not unpack gem: #{e.message}".colorize(:yellow)
279
+ [{}, nil]
280
+ ensure
281
+ FileUtils.rm_rf(extract_dir) if extract_dir
282
+ end
283
+
284
+ def safe_read(path)
285
+ File.binread(path).encode("UTF-8", "binary", invalid: :replace, undef: :replace, replace: "")
286
+ rescue
287
+ nil
288
+ end
289
+
290
+ def run_metadata_checks
291
+ check_typosquatting
292
+ return if @metadata.empty?
293
+ check_version_age
294
+ check_owner_changes
295
+ check_missing_uris
296
+ end
297
+
298
+ def check_typosquatting
299
+ return if @top_gems.empty?
300
+ closest = @top_gems.min_by { |g| levenshtein(gem_name, g) }
301
+ dist = levenshtein(gem_name, closest)
302
+ return if dist == 0 || dist > 2
303
+ severity = dist == 1 ? "MED" : "LOW"
304
+ add_finding(
305
+ category: "METADATA",
306
+ severity: severity,
307
+ message: "Possible typosquat of '#{closest}' (Levenshtein distance: #{dist})"
308
+ )
309
+ end
310
+
311
+ def run_diff_checks
312
+ return if @files.empty?
313
+ versions = fetch_versions
314
+ return unless versions && versions.size >= 2
315
+ prev_version_num = versions.map { |v| v["number"] }.reject { |n| n == @version }.first
316
+ return unless prev_version_num
317
+ Dir.mktmpdir("commissar_diff_") do |tmpdir|
318
+ gem_path = download_gem(tmpdir, version: prev_version_num)
319
+ return unless gem_path
320
+ prev_files, _prev_spec = unpack_gem(gem_path)
321
+ check_version_diff(prev_files, prev_version_num)
322
+ end
323
+ end
324
+
325
+ def check_version_diff(prev_files, prev_version)
326
+ current_keys = Set.new(@files.keys)
327
+ prev_keys = Set.new(prev_files.keys)
328
+ (current_keys - prev_keys).each do |f|
329
+ add_finding(category: "DIFF", severity: "INFO", message: "New file vs #{prev_version}: #{f}")
330
+ end
331
+ (prev_keys - current_keys).each do |f|
332
+ add_finding(category: "DIFF", severity: "INFO", message: "Removed vs #{prev_version}: #{f}")
333
+ end
334
+ (current_keys & prev_keys).each do |f|
335
+ next if @files[f] == prev_files[f]
336
+ add_finding(category: "DIFF", severity: "INFO", message: "Modified vs #{prev_version}: #{f}")
337
+ end
338
+ end
339
+
340
+ def fetch_versions
341
+ uri = URI("#{RUBYGEMS_API}/versions/#{gem_name}.json")
342
+ response = Net::HTTP.get_response(uri)
343
+ return nil unless response.is_a?(Net::HTTPSuccess)
344
+ JSON.parse(response.body)
345
+ rescue
346
+ nil
347
+ end
348
+
349
+ def check_version_age
350
+ return unless @metadata["version_created_at"]
351
+ published_at = Time.parse(@metadata["version_created_at"])
352
+ age_hours = (Time.now - published_at) / 3600
353
+ return unless age_hours < 72
354
+ add_finding(
355
+ category: "METADATA",
356
+ severity: "LOW",
357
+ message: "Version published #{age_hours.round}h ago (threshold: 72h)"
358
+ )
359
+ end
360
+
361
+ def check_owner_changes
362
+ @owners = fetch_owners if @owners == :pending
363
+ return if @owners.nil? || !@owners.is_a?(Array) || @owners.empty?
364
+ return unless @owners.size == 1
365
+ handle = @owners.first["handle"]
366
+ gem_age = gem_age_days
367
+ severity = (gem_age && gem_age < 30) ? "MED" : "LOW"
368
+ label = (severity == "MED") ? "new gem with single maintainer" : "single maintainer"
369
+ add_finding(category: "METADATA", severity: severity, message: "#{label.capitalize}: #{handle}")
370
+ end
371
+
372
+ def fetch_owners
373
+ uri = URI("#{RUBYGEMS_API}/gems/#{gem_name}/owners.yaml")
374
+ response = Net::HTTP.get_response(uri)
375
+ return nil unless response.is_a?(Net::HTTPSuccess)
376
+ YAML.safe_load(response.body)
377
+ end
378
+
379
+ def gem_age_days
380
+ return nil unless @metadata["created_at"]
381
+ (Time.now - Time.parse(@metadata["created_at"])) / 86400
382
+ end
383
+
384
+ def check_missing_uris
385
+ homepage = @metadata["homepage_uri"].to_s.strip
386
+ source_uri = @metadata["source_code_uri"].to_s.strip
387
+ if homepage.empty? && source_uri.empty?
388
+ add_finding(category: "METADATA", severity: "MED", message: "No source URIs available (no homepage_uri or source_code_uri)")
389
+ return
390
+ end
391
+ if homepage.empty?
392
+ add_finding(category: "METADATA", severity: "LOW", message: "Missing homepage_uri")
393
+ end
394
+ if source_uri.empty?
395
+ add_finding(category: "METADATA", severity: "LOW", message: "Missing source_code_uri")
396
+ end
397
+ end
398
+
399
+ def run_gemspec_checks
400
+ return unless @spec
401
+ check_gemspec_extensions
402
+ check_gemspec_post_install
403
+ end
404
+
405
+ def check_gemspec_extensions
406
+ return if @spec.extensions.empty?
407
+ @spec.extensions.each do |ext|
408
+ if ext.match?(/Rakefile/i)
409
+ add_finding(
410
+ category: "GEMSPEC", severity: "HIGH",
411
+ message: "Native extension executes on gem install: #{ext}"
412
+ )
413
+ else
414
+ add_finding(
415
+ category: "GEMSPEC", severity: "MED",
416
+ message: "Native extension declared in gemspec: #{ext}"
417
+ )
418
+ end
419
+ end
420
+ end
421
+
422
+ def check_gemspec_post_install
423
+ msg = @spec.post_install_message.to_s.strip
424
+ return if msg.empty?
425
+ @post_install_patterns.each do |entry|
426
+ severity, pattern, antipatterns = parse_config_entry(entry)
427
+ next unless msg.include?(pattern)
428
+ next if antipatterns.any? { |ap| msg.include?(ap) }
429
+ add_finding(
430
+ category: "GEMSPEC", severity: severity,
431
+ message: "Suspicious post_install_message content",
432
+ snippet: truncate(msg)
433
+ )
434
+ break
435
+ end
436
+ end
437
+
438
+ def run_function_checks
439
+ scan_files_for_patterns(@suspicious_functions, "DANGEROUS FUNCTIONS", files: ruby_source_files, word_boundary: true)
440
+ end
441
+
442
+ def run_url_checks
443
+ scan_files_for_patterns(@suspicious_urls, "SUSPICIOUS URLS", word_boundary: true)
444
+ end
445
+
446
+ def run_shell_checks
447
+ scan_files_for_patterns(@suspicious_shell, "SHELL/EXFIL")
448
+ end
449
+
450
+ def run_encoding_checks
451
+ ruby_source_files.each do |filename, content|
452
+ next if content.nil?
453
+ scan_lines(content, filename).each do |line, file, lineno|
454
+ next if line.lstrip.start_with?("#")
455
+ check_entropy(line, file, lineno)
456
+ check_line_length(line, file, lineno)
457
+ check_homoglyphs(line, file, lineno)
458
+ end
459
+ end
460
+ end
461
+
462
+ def check_entropy(line, file, lineno)
463
+ return if line.length < 20
464
+ e = shannon_entropy(line)
465
+ return unless e > 5.5
466
+ add_finding(
467
+ category: "ENCODING", severity: "HIGH",
468
+ message: "High entropy line (#{e.round(1)} bits/char, #{line.length} chars)",
469
+ file: file, line: lineno, snippet: line
470
+ )
471
+ end
472
+
473
+ def check_line_length(line, file, lineno)
474
+ return unless line.length > 500
475
+ add_finding(
476
+ category: "ENCODING", severity: "MED",
477
+ message: "Long line (#{line.length} chars)",
478
+ file: file, line: lineno, snippet: line
479
+ )
480
+ end
481
+
482
+ def check_homoglyphs(line, file, lineno)
483
+ return unless line.match?(HOMOGLYPH_RE)
484
+ add_finding(
485
+ category: "ENCODING", severity: "HIGH",
486
+ message: "Homoglyph characters detected",
487
+ file: file, line: lineno, snippet: line
488
+ )
489
+ end
490
+
491
+ def shannon_entropy(str)
492
+ return 0.0 if str.empty?
493
+ freq = Hash.new(0)
494
+ str.each_char { |c| freq[c] += 1 }
495
+ len = str.length.to_f
496
+ -freq.values.sum { |count| (p = count / len) * Math.log2(p) }
497
+ end
498
+
499
+ def run_credential_checks
500
+ scan_files_for_patterns(@credential_paths, "CREDENTIALS")
501
+ end
502
+
503
+ def run_web3_checks
504
+ return if @files.empty?
505
+ wallet_patterns = [
506
+ ["ETH wallet address", /0x[a-fA-F0-9]{40}\b/],
507
+ ["BTC wallet address", /\b[13][a-km-zA-HJ-NP-Z1-9]{25,34}\b/],
508
+ ["BTC bech32 address", /\bbc1[a-z0-9]{6,87}\b/i]
509
+ ]
510
+ scannable_files.each do |filename, content|
511
+ next if content.nil?
512
+ scan_lines(content, filename).each do |line, file, lineno|
513
+ next if line.lstrip.start_with?("#")
514
+ wallet_patterns.each do |label, re|
515
+ m = line.match(re)
516
+ next unless m
517
+ addr = m[0]
518
+ bad_label = @known_bad_wallets[addr] || @known_bad_wallets[addr.downcase]
519
+ if bad_label
520
+ add_finding(category: "WEB3", severity: "CRIT", message: "Known malicious wallet (#{bad_label}): #{addr}", file: file, line: lineno, snippet: line)
521
+ else
522
+ add_finding(category: "WEB3", severity: "HIGH", message: label, file: file, line: lineno, snippet: line)
523
+ end
524
+ end
525
+ @clipboard_patterns.each do |entry|
526
+ severity, pattern, antipatterns = parse_config_entry(entry)
527
+ next unless line.include?(pattern)
528
+ next if antipatterns.any? { |ap| line.include?(ap) }
529
+ add_finding(category: "WEB3", severity: severity, message: "Clipboard access: #{pattern}", file: file, line: lineno, snippet: line)
530
+ end
531
+ end
532
+ end
533
+ end
534
+
535
+ def add_finding(category:, severity:, message:, file: nil, line: nil, snippet: nil)
536
+ @findings << Finding.new(
537
+ category: category,
538
+ severity: severity,
539
+ message: message,
540
+ file: file,
541
+ line: line,
542
+ snippet: snippet ? truncate(snippet.strip) : nil
543
+ )
544
+ end
545
+
546
+ def truncate(str, max = 100)
547
+ str.length > max ? "#{str[0, max]}…" : str
548
+ end
549
+
550
+ def verdict_text
551
+ score = risk_score
552
+ if score >= 70 then "DANGEROUS, DO NOT INSTALL"
553
+ elsif score >= 40 then "REVIEW CAREFULLY"
554
+ else "Looks safe but exercise caution"
555
+ end
556
+ end
557
+
558
+ def levenshtein(a, b)
559
+ return b.length if a.empty?
560
+ return a.length if b.empty?
561
+ prev = (0..b.length).to_a
562
+ a.each_char.with_index(1) do |ca, i|
563
+ curr = [i]
564
+ b.each_char.with_index(1) do |cb, j|
565
+ cost = ca == cb ? 0 : 1
566
+ curr << [prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost].min
567
+ end
568
+ prev = curr
569
+ end
570
+ prev[b.length]
571
+ end
572
+
573
+ RUBY_EXTENSIONS = %w[.rb .gemspec .rake .ru].freeze
574
+ RUBY_NAMES = %w[Rakefile Gemfile].freeze
575
+
576
+ SKIP_EXTENSIONS = %w[.md .rdoc .ronn].freeze
577
+
578
+ def scannable_files
579
+ @files.reject { |name, _| SKIP_EXTENSIONS.include?(File.extname(name)) }
580
+ end
581
+
582
+ def ruby_source_files
583
+ scannable_files.select { |name, _|
584
+ RUBY_EXTENSIONS.include?(File.extname(name)) ||
585
+ RUBY_NAMES.include?(File.basename(name))
586
+ }
587
+ end
588
+
589
+ def scan_files_for_patterns(patterns, category, files: nil, word_boundary: false)
590
+ target = files || scannable_files
591
+ return if target.empty?
592
+ patterns.each do |entry|
593
+ severity, pattern, antipatterns = parse_config_entry(entry)
594
+ matcher = word_boundary ? /\b#{Regexp.escape(pattern)}\b/ : nil
595
+ target.each do |filename, content|
596
+ scan_lines(content, filename).each do |line, file, lineno|
597
+ next if line.lstrip.start_with?("#")
598
+ next unless matcher ? line.match?(matcher) : line.include?(pattern)
599
+ next if antipatterns.any? { |ap| line.include?(ap) }
600
+ add_finding(category: category, severity: severity, message: pattern, file: file, line: lineno, snippet: line)
601
+ end
602
+ end
603
+ end
604
+ end
605
+
606
+ def parse_config_entry(entry)
607
+ parts = entry.split(/(?<!:):(?!:)/, -1)
608
+ if parts.first =~ /\A(CRIT|HIGH|MED|LOW)\z/
609
+ severity = parts.shift
610
+ pattern = parts.shift
611
+ antipatterns = parts.reject(&:empty?)
612
+ [severity, pattern, antipatterns]
613
+ else
614
+ ["MED", entry, []]
615
+ end
616
+ end
617
+
618
+ def scan_lines(content, file_name)
619
+ return [] if content.nil?
620
+ content.each_line.with_index(1).map { |line, num| [line.chomp, file_name, num] }
621
+ end
622
+
623
+ def version_label
624
+ v = @version || @metadata["version"]
625
+ v ? "(#{v})".colorize(:light_black) : ""
626
+ end
627
+
628
+ def category_header(cat)
629
+ titles = {
630
+ "METADATA" => "[*] METADATA",
631
+ "GEMSPEC" => "[*] GEMSPEC",
632
+ "DANGEROUS FUNCTIONS" => "[*] DANGEROUS FUNCTIONS",
633
+ "SUSPICIOUS URLS" => "[*] SUSPICIOUS URLS",
634
+ "SHELL/EXFIL" => "[*] SHELL/EXFIL",
635
+ "ENCODING" => "[*] ENCODED PAYLOADS",
636
+ "CREDENTIALS" => "[*] CREDENTIALS",
637
+ "WEB3" => "[*] WEB3",
638
+ "DIFF" => "[*] DIFF"
639
+ }
640
+ titles.fetch(cat, cat).colorize(:yellow)
641
+ end
642
+
643
+ def colorize_finding(finding)
644
+ color = case finding.severity
645
+ when "CRIT" then :magenta
646
+ when "HIGH" then :red
647
+ when "MED" then :yellow
648
+ when "LOW" then :light_black
649
+ when "INFO" then :cyan
650
+ end
651
+ finding.to_s.colorize(color)
652
+ end
653
+
654
+ def complex_gem_note
655
+ category = @complex_gems[gem_name]
656
+ return nil unless category
657
+ "[i] #{gem_name} is a known complex gem (#{category}). Elevated scores may reflect legitimate functionality, review findings manually!"
658
+ end
659
+
660
+ def score_line
661
+ score = risk_score
662
+ color = if score >= 70 then :red
663
+ elsif score >= 40 then :yellow
664
+ else :green
665
+ end
666
+ icon = if score >= 70 then "[!]"
667
+ elsif score >= 40 then "[?]"
668
+ else "[*]"
669
+ end
670
+ "#{icon} Risk score: #{score}/100 — #{verdict_text}".colorize(color)
671
+ end
672
+ end
673
+ end
metadata ADDED
@@ -0,0 +1,75 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: commissar
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Mauro Eldritch
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 2026-05-06 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: colorize
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.1'
26
+ description: 'Static analysis tool that scans RubyGems for indicators of supply chain
27
+ compromise: malicious gemspecs, suspicious URLs, credential exfiltration, obfuscated
28
+ payloads, and more.'
29
+ email:
30
+ - mauroeldritch@gmail.com
31
+ executables:
32
+ - commissar
33
+ extensions: []
34
+ extra_rdoc_files: []
35
+ files:
36
+ - LICENSE
37
+ - README.md
38
+ - conf/clipboard_patterns.txt
39
+ - conf/complex_gems.txt
40
+ - conf/credential_paths.txt
41
+ - conf/known_bad_wallets.txt
42
+ - conf/post_install_patterns.txt
43
+ - conf/severity.txt
44
+ - conf/suspicious_functions.txt
45
+ - conf/suspicious_shell.txt
46
+ - conf/suspicious_urls.txt
47
+ - conf/top_gems.txt
48
+ - exe/commissar
49
+ - lib/commissar.rb
50
+ homepage: https://github.com/mauroeldritch/commissar
51
+ licenses:
52
+ - MIT
53
+ metadata:
54
+ bug_tracker_uri: https://github.com/mauroeldritch/commissar/issues
55
+ changelog_uri: https://github.com/mauroeldritch/commissar/blob/main/CHANGELOG.md
56
+ source_code_uri: https://github.com/mauroeldritch/commissar
57
+ rubygems_mfa_required: 'true'
58
+ rdoc_options: []
59
+ require_paths:
60
+ - lib
61
+ required_ruby_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '3.1'
66
+ required_rubygems_version: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: '0'
71
+ requirements: []
72
+ rubygems_version: 4.0.9
73
+ specification_version: 4
74
+ summary: Supply chain attack detector for RubyGems
75
+ test_files: []