envhunter 1.0.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/README.md +73 -0
- data/bin/envhunter +13 -0
- data/lib/envhunter.rb +181 -0
- metadata +115 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: c6513ca7b1df51f0df7e896ab8df2f0da17924fd273238cbbd63cfd9e8378c54
|
4
|
+
data.tar.gz: 18d191b73c700d8ab5bc711fe83241e6fc9dbc8d80890125f8293c7e88f2be7d
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: '0984b505d2ef371ced06a63ae796ed84432299def562606f2915ca5724290e77c567f58968a205141384d7b0bdb4a07bdde96f252150e6be694e8c4766f30288'
|
7
|
+
data.tar.gz: 461c02d75e68f804460fe34ae459b8a5feff104b148c42faca9b0f18341d8b2a95009548601814d5c837eadb31090879d39efd9e8a2d946b105d7222c92f6f61
|
data/README.md
ADDED
@@ -0,0 +1,73 @@
|
|
1
|
+
# EnvHunter ๐
|
2
|
+
|
3
|
+
**EnvHunter** is a Ruby-based CLI tool that searches GitHub repositories or gists for `.env` files containing potentially sensitive high-entropy secrets like API keys or tokens.
|
4
|
+
|
5
|
+
## โ๏ธ Features
|
6
|
+
|
7
|
+
- ๐ Searches GitHub repos or gists for `.env` files
|
8
|
+
- ๐งช Detects `.env` variables with names containing `KEY` or `TOKEN`
|
9
|
+
- ๐ Filters secrets based on entropy (Shannon entropy)
|
10
|
+
- ๐ฌ Outputs results in `terminal`, `json`, or `csv` formats
|
11
|
+
- ๐ Uses GitHub API (requires a Personal Access Token)
|
12
|
+
|
13
|
+
## ๐งฐ Installation
|
14
|
+
|
15
|
+
### As a Ruby Gem
|
16
|
+
|
17
|
+
```bash
|
18
|
+
gem install envhunter
|
19
|
+
```
|
20
|
+
|
21
|
+
Or clone and build locally:
|
22
|
+
|
23
|
+
```bash
|
24
|
+
git clone https://github.com/yourusername/envhunter.git
|
25
|
+
cd envhunter
|
26
|
+
gem build envhunter.gemspec
|
27
|
+
gem install envhunter-1.0.0.gem
|
28
|
+
```
|
29
|
+
|
30
|
+
## ๐ณ Docker
|
31
|
+
|
32
|
+
```bash
|
33
|
+
docker build -t envhunter .
|
34
|
+
docker run --rm -e GITHUB_TOKEN=your_token_here envhunter scan --mode gists --format json
|
35
|
+
```
|
36
|
+
|
37
|
+
## ๐ Usage
|
38
|
+
|
39
|
+
```bash
|
40
|
+
envhunter scan [options]
|
41
|
+
```
|
42
|
+
|
43
|
+
### Options
|
44
|
+
|
45
|
+
| Option | Description |
|
46
|
+
| ---------- | ---------------------------- |
|
47
|
+
| `--mode` | `repos` (default) or `gists` |
|
48
|
+
| `--format` | `terminal`, `json`, or `csv` |
|
49
|
+
|
50
|
+
## ๐ Authentication
|
51
|
+
|
52
|
+
Set your GitHub token:
|
53
|
+
|
54
|
+
```bash
|
55
|
+
export GITHUB_TOKEN=your_token_here
|
56
|
+
```
|
57
|
+
|
58
|
+
## ๐ Output Example
|
59
|
+
|
60
|
+
```plaintext
|
61
|
+
=== High Entropy Keys Found ===
|
62
|
+
|
63
|
+
User: johndoe
|
64
|
+
Repo/Gist: johndoe/myrepo
|
65
|
+
File: .env
|
66
|
+
Match:
|
67
|
+
API_TOKEN=abcd123...
|
68
|
+
----------------------------------------
|
69
|
+
```
|
70
|
+
|
71
|
+
## ๐ License
|
72
|
+
|
73
|
+
MIT License ยฉ 2025 Dave Williams <dave@dave.io>
|
data/bin/envhunter
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
# Try to load from the installed gem first
|
4
|
+
begin
|
5
|
+
require "envhunter"
|
6
|
+
rescue LoadError
|
7
|
+
# If not found, add the lib directory to the load path (for development)
|
8
|
+
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
9
|
+
require "envhunter"
|
10
|
+
end
|
11
|
+
|
12
|
+
# Run the CLI
|
13
|
+
EnvHunter.run_cli
|
data/lib/envhunter.rb
ADDED
@@ -0,0 +1,181 @@
|
|
1
|
+
require 'commander/import'
|
2
|
+
require 'httparty'
|
3
|
+
require 'json'
|
4
|
+
require 'csv'
|
5
|
+
require 'uri'
|
6
|
+
require 'yaml'
|
7
|
+
|
8
|
+
module EnvHunter
|
9
|
+
extend self
|
10
|
+
|
11
|
+
GITHUB_TOKEN = ENV['GITHUB_TOKEN'] || abort("Error: GITHUB_TOKEN environment variable is not set.")
|
12
|
+
ENTROPY_THRESHOLD = 4.0
|
13
|
+
KEYWORDS = /KEY|TOKEN/
|
14
|
+
|
15
|
+
def entropy(str)
|
16
|
+
return 0.0 if str.nil? || str.empty?
|
17
|
+
chars = str.each_char.group_by(&:itself).transform_values(&:count)
|
18
|
+
probs = chars.values.map { |c| c.to_f / str.length }
|
19
|
+
-probs.map { |p| p * Math.log2(p) }.reduce(:+) || 0.0
|
20
|
+
end
|
21
|
+
|
22
|
+
def extract_env_keys(content)
|
23
|
+
results = {}
|
24
|
+
content.each_line do |line|
|
25
|
+
next unless line =~ /^\s*[\w\-]+=(.+)$/
|
26
|
+
key, value = line.strip.split('=', 2)
|
27
|
+
next unless key =~ KEYWORDS
|
28
|
+
next if value.nil?
|
29
|
+
ent = entropy(value.gsub(/['"]/, ''))
|
30
|
+
results[key] = value if ent > ENTROPY_THRESHOLD
|
31
|
+
end
|
32
|
+
results
|
33
|
+
end
|
34
|
+
|
35
|
+
def github_search(query, mode, page = 1, per_page = 30)
|
36
|
+
url = if mode == 'gists'
|
37
|
+
"https://api.github.com/gists/public?page=#{page}&per_page=#{per_page}"
|
38
|
+
else
|
39
|
+
"https://api.github.com/search/code?q=#{URI.encode_www_form_component(query)}&page=#{page}&per_page=#{per_page}"
|
40
|
+
end
|
41
|
+
headers = {
|
42
|
+
"Authorization" => "token #{GITHUB_TOKEN}",
|
43
|
+
"User-Agent" => "EnvHunter"
|
44
|
+
}
|
45
|
+
response = HTTParty.get(url, headers: headers)
|
46
|
+
JSON.parse(response.body)
|
47
|
+
end
|
48
|
+
|
49
|
+
def process_results(data, mode, output_file)
|
50
|
+
items = mode == 'gists' ? (data.is_a?(Array) ? data : []) : data['items'] || []
|
51
|
+
found_count = 0
|
52
|
+
results = []
|
53
|
+
|
54
|
+
items.each do |item|
|
55
|
+
raw_url, user, repo, file = if mode == 'gists'
|
56
|
+
next unless item['files']
|
57
|
+
file_obj = item['files'].values.find { |f| f['filename'] =~ /\.env$/ }
|
58
|
+
next unless file_obj
|
59
|
+
[file_obj['raw_url'], item['owner']['login'], "Gist: #{item['id']}", file_obj['filename']]
|
60
|
+
else
|
61
|
+
raw_url = item['html_url'].gsub('github.com', 'raw.githubusercontent.com').gsub('/blob/', '/')
|
62
|
+
[raw_url, item['repository']['owner']['login'], item['repository']['full_name'], item['name']]
|
63
|
+
end
|
64
|
+
|
65
|
+
# Skip files that are likely not of interest
|
66
|
+
next if file.downcase.include?('example')
|
67
|
+
next if file.downcase.include?('sample')
|
68
|
+
next if file.downcase.include?('template')
|
69
|
+
next if file.downcase.include?('.ex.')
|
70
|
+
next if file.downcase.include?('.bak')
|
71
|
+
next if file.downcase.include?('.bkp')
|
72
|
+
next if file.downcase.include?('staging')
|
73
|
+
next if file.downcase.include?('copy')
|
74
|
+
next if file.downcase.include?('backup')
|
75
|
+
next if file.downcase.include?('old')
|
76
|
+
next if file.downcase.include?('archive')
|
77
|
+
|
78
|
+
begin
|
79
|
+
raw = HTTParty.get(raw_url).body
|
80
|
+
matches = extract_env_keys(raw)
|
81
|
+
if matches.any?
|
82
|
+
result = {
|
83
|
+
user: user,
|
84
|
+
repo: repo,
|
85
|
+
file: file,
|
86
|
+
matches: matches
|
87
|
+
}
|
88
|
+
found_count += 1
|
89
|
+
|
90
|
+
# Always output to terminal
|
91
|
+
key_names = result[:matches].keys.join(', ')
|
92
|
+
puts "#{result[:repo]} (#{result[:file]}) - Found: #{key_names}"
|
93
|
+
|
94
|
+
# Store result for YAML output if needed
|
95
|
+
results << result if output_file
|
96
|
+
end
|
97
|
+
rescue => e
|
98
|
+
warn "Error fetching #{raw_url}: #{e}"
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
[found_count, results]
|
103
|
+
end
|
104
|
+
|
105
|
+
def write_yaml_file(results, filename)
|
106
|
+
# Transform matches into a dictionary with env keys as keys and values as values
|
107
|
+
yaml_results = results.map do |result|
|
108
|
+
{
|
109
|
+
repo: result[:repo],
|
110
|
+
file: result[:file],
|
111
|
+
matches: result[:matches]
|
112
|
+
}
|
113
|
+
end
|
114
|
+
|
115
|
+
# Custom YAML dump to avoid colons before keys
|
116
|
+
yaml_content = "---\n"
|
117
|
+
yaml_results.each do |result|
|
118
|
+
yaml_content += "- repo: #{result[:repo]}\n"
|
119
|
+
yaml_content += " file: #{result[:file]}\n"
|
120
|
+
yaml_content += " matches:\n"
|
121
|
+
result[:matches].each do |key, value|
|
122
|
+
yaml_content += " #{key}: #{value}\n"
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
File.write(filename, yaml_content)
|
127
|
+
puts "\nResults written to #{filename}"
|
128
|
+
end
|
129
|
+
|
130
|
+
def run_cli
|
131
|
+
program :name, 'EnvHunter'
|
132
|
+
program :version, '1.0.0'
|
133
|
+
program :description, 'Search GitHub for secrets in .env files'
|
134
|
+
|
135
|
+
command :scan do |c|
|
136
|
+
c.syntax = 'envhunter scan [options]'
|
137
|
+
c.description = 'Scan GitHub code or gists for secrets'
|
138
|
+
c.option '--mode MODE', String, 'Search mode: repos or gists (default: repos)'
|
139
|
+
c.option '--output OUTPUT', String, 'Output YAML file (optional)'
|
140
|
+
c.option '--limit LIMIT', Integer, 'Maximum number of responses to process (default: 100)'
|
141
|
+
c.action do |args, options|
|
142
|
+
mode = options.mode == 'gists' ? 'gists' : 'code'
|
143
|
+
output_file = options.output
|
144
|
+
limit = options.limit || 100
|
145
|
+
query = 'filename:.env'
|
146
|
+
|
147
|
+
page = 1
|
148
|
+
total_found = 0
|
149
|
+
all_results = []
|
150
|
+
|
151
|
+
begin
|
152
|
+
loop do
|
153
|
+
data = github_search(query, mode, page)
|
154
|
+
break if data.nil? || (mode == 'gists' ? data.empty? : data['items'].empty?)
|
155
|
+
|
156
|
+
found_in_page, page_results = process_results(data, mode, output_file)
|
157
|
+
total_found += found_in_page
|
158
|
+
all_results.concat(page_results) if output_file
|
159
|
+
|
160
|
+
if limit && total_found >= limit
|
161
|
+
break
|
162
|
+
end
|
163
|
+
|
164
|
+
page += 1
|
165
|
+
print "." # Progress indicator
|
166
|
+
STDOUT.flush
|
167
|
+
end
|
168
|
+
rescue Interrupt
|
169
|
+
puts "\nSearch interrupted by user"
|
170
|
+
end
|
171
|
+
|
172
|
+
puts "\nSearch completed. Found #{total_found} matches."
|
173
|
+
|
174
|
+
# Write YAML file if output file is specified
|
175
|
+
write_yaml_file(all_results, output_file) if output_file && all_results.any?
|
176
|
+
end
|
177
|
+
end
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
EnvHunter.run_cli
|
metadata
ADDED
@@ -0,0 +1,115 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: envhunter
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Dave Williams
|
8
|
+
bindir: bin
|
9
|
+
cert_chain: []
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
11
|
+
dependencies:
|
12
|
+
- !ruby/object:Gem::Dependency
|
13
|
+
name: commander
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
15
|
+
requirements:
|
16
|
+
- - "~>"
|
17
|
+
- !ruby/object:Gem::Version
|
18
|
+
version: '5.0'
|
19
|
+
type: :runtime
|
20
|
+
prerelease: false
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
22
|
+
requirements:
|
23
|
+
- - "~>"
|
24
|
+
- !ruby/object:Gem::Version
|
25
|
+
version: '5.0'
|
26
|
+
- !ruby/object:Gem::Dependency
|
27
|
+
name: httparty
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
29
|
+
requirements:
|
30
|
+
- - "~>"
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: '0.23'
|
33
|
+
type: :runtime
|
34
|
+
prerelease: false
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
36
|
+
requirements:
|
37
|
+
- - "~>"
|
38
|
+
- !ruby/object:Gem::Version
|
39
|
+
version: '0.23'
|
40
|
+
- !ruby/object:Gem::Dependency
|
41
|
+
name: json
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
43
|
+
requirements:
|
44
|
+
- - "~>"
|
45
|
+
- !ruby/object:Gem::Version
|
46
|
+
version: '2.10'
|
47
|
+
type: :runtime
|
48
|
+
prerelease: false
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
50
|
+
requirements:
|
51
|
+
- - "~>"
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: '2.10'
|
54
|
+
- !ruby/object:Gem::Dependency
|
55
|
+
name: csv
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
57
|
+
requirements:
|
58
|
+
- - "~>"
|
59
|
+
- !ruby/object:Gem::Version
|
60
|
+
version: '3.3'
|
61
|
+
type: :runtime
|
62
|
+
prerelease: false
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
64
|
+
requirements:
|
65
|
+
- - "~>"
|
66
|
+
- !ruby/object:Gem::Version
|
67
|
+
version: '3.3'
|
68
|
+
- !ruby/object:Gem::Dependency
|
69
|
+
name: yaml
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
71
|
+
requirements:
|
72
|
+
- - "~>"
|
73
|
+
- !ruby/object:Gem::Version
|
74
|
+
version: '0.4'
|
75
|
+
type: :runtime
|
76
|
+
prerelease: false
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
78
|
+
requirements:
|
79
|
+
- - "~>"
|
80
|
+
- !ruby/object:Gem::Version
|
81
|
+
version: '0.4'
|
82
|
+
description: Search GitHub code or gists for .env files containing high-entropy secrets
|
83
|
+
like tokens and keys.
|
84
|
+
email:
|
85
|
+
- dave@dave.io
|
86
|
+
executables:
|
87
|
+
- envhunter
|
88
|
+
extensions: []
|
89
|
+
extra_rdoc_files: []
|
90
|
+
files:
|
91
|
+
- README.md
|
92
|
+
- bin/envhunter
|
93
|
+
- lib/envhunter.rb
|
94
|
+
homepage: https://github.com/daveio/envhunter
|
95
|
+
licenses:
|
96
|
+
- MIT
|
97
|
+
metadata: {}
|
98
|
+
rdoc_options: []
|
99
|
+
require_paths:
|
100
|
+
- lib
|
101
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
102
|
+
requirements:
|
103
|
+
- - ">="
|
104
|
+
- !ruby/object:Gem::Version
|
105
|
+
version: '0'
|
106
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - ">="
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '0'
|
111
|
+
requirements: []
|
112
|
+
rubygems_version: 3.6.7
|
113
|
+
specification_version: 4
|
114
|
+
summary: CLI tool to hunt for secrets in GitHub .env files
|
115
|
+
test_files: []
|