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 +7 -0
- data/LICENSE +21 -0
- data/README.md +141 -0
- data/conf/clipboard_patterns.txt +5 -0
- data/conf/complex_gems.txt +28 -0
- data/conf/credential_paths.txt +48 -0
- data/conf/known_bad_wallets.txt +25 -0
- data/conf/post_install_patterns.txt +5 -0
- data/conf/severity.txt +5 -0
- data/conf/suspicious_functions.txt +23 -0
- data/conf/suspicious_shell.txt +15 -0
- data/conf/suspicious_urls.txt +19 -0
- data/conf/top_gems.txt +133 -0
- data/exe/commissar +101 -0
- data/lib/commissar.rb +673 -0
- metadata +75 -0
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,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,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: []
|