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.
Files changed (5) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +73 -0
  3. data/bin/envhunter +13 -0
  4. data/lib/envhunter.rb +181 -0
  5. 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: []