envvariablecheck 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: 539d08ccfcb79bf98abb1635af6e813c5bc2ac0ea77b910a976cb29ec0f77b2d
4
+ data.tar.gz: 440b23e45504d8142f0be5ade66e12d41c9811df412ed070c55b1ffb57b6ae66
5
+ SHA512:
6
+ metadata.gz: 3ca8a0e2755be719d4d78f0ffdda648290981967c4b7803ee9fd4931b39dded1313ab8908a076142dcfd7cd867f13e8c1b7894e77a12421e7c0357a54638eaa1
7
+ data.tar.gz: fd0947623002d2df2c84f491823b4c80da8ab3c57a84a08b01b7bbab144d408e3d283f897fbb8c7929e81aef4f1bb26edb66afb4b15a860f8dd087e30a750979
data/Gemfile ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kellysec
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,151 @@
1
+ # envvariablecheck
2
+
3
+ Load `~/.env` into a Ruby process, then rank how strong its keys and passwords are.
4
+
5
+ Secret values stay on the machine. The review prints the env file in full, then the rank of each key and password. Nothing is uploaded.
6
+
7
+ ## Install
8
+
9
+ From this repository:
10
+
11
+ ```sh
12
+ gem build envvariablecheck.gemspec
13
+ gem install ./envvariablecheck-0.1.0.gem
14
+ ```
15
+
16
+ While developing, you can skip the install:
17
+
18
+ ```sh
19
+ ruby -Ilib exe/envcheck audit
20
+ ```
21
+
22
+ Requires Ruby 2.6 or newer. The gem has no runtime dependencies.
23
+
24
+ ## Inject, then run a script
25
+
26
+ `envcheck run` reads `~/.env`, merges it into the environment, and replaces the process with your Ruby script. The script sees the variables in `ENV` the same way it would if the shell had exported them. Pass `--env` to use a different file.
27
+
28
+ ```sh
29
+ envcheck run script.rb
30
+ envcheck run --env .env.local script.rb --port 3000
31
+ ```
32
+
33
+ Variables already set in the environment are kept. Pass `--override` to let the file win.
34
+
35
+ ```sh
36
+ envcheck run --override script.rb
37
+ ```
38
+
39
+ `--strict` refuses to start the script when any secret ranks Weak or Critical, when the file is readable by group or others, or when it is inside a git repo and not gitignored.
40
+
41
+ ```sh
42
+ envcheck run --strict script.rb
43
+ ```
44
+
45
+ To inject `~/.env` into the current terminal session instead of a child process:
46
+
47
+ ```sh
48
+ eval "$(envcheck export)"
49
+ ```
50
+
51
+ To inject it from inside a Ruby program, before the rest of the program runs:
52
+
53
+ ```ruby
54
+ require 'envvariablecheck'
55
+ EnvVariableCheck.load!
56
+ ```
57
+
58
+ Or load it before the script starts:
59
+
60
+ ```sh
61
+ ruby -r envvariablecheck/load script.rb
62
+ ```
63
+
64
+ ## Review keys and passwords
65
+
66
+ ```sh
67
+ envcheck audit
68
+ envcheck audit .env.production
69
+ envcheck audit --format json
70
+ envcheck audit --strict
71
+ ```
72
+
73
+ `--strict` exits 1 when the review fails, which makes it usable as a check before deploy.
74
+
75
+ Config names such as `PORT` and `RACK_ENV` are listed and not password-scored. These names are scored:
76
+
77
+ - passwords (`PASSWORD`, `PASSWD`, `PWD`)
78
+ - tokens and keys (`SECRET`, `TOKEN`, `API_KEY`, `MASTER_KEY`, `PRIVATE_KEY`, and similar)
79
+ - connection URLs (`DATABASE_URL`, `REDIS_URL`, and any URL that embeds `user:password`)
80
+
81
+ A connection URL is ranked on the embedded password only. `AWS_ACCESS_KEY_ID` and other key ids are identifiers, not passwords.
82
+
83
+ ### Ranks
84
+
85
+ | Rank | Score | Meaning |
86
+ | --- | --- | --- |
87
+ | Excellent | 90–100 | Long, high-entropy, no practice issues |
88
+ | Strong | 75–89 | Solid, or strong with a smaller practice issue such as reuse |
89
+ | Fair | 50–74 | Usable, but below the bar this tool applies |
90
+ | Weak | 25–49 | Too short, patterned, or low diversity |
91
+ | Critical | 0–24 | Empty, common, placeholder, or far too short |
92
+
93
+ The rubric follows password best practices:
94
+
95
+ - Length comes first. NIST SP 800-63B requires at least 8 characters. This tool still caps application passwords under 15 characters at Fair, and tokens under 32 characters at Fair.
96
+ - Character classes add a bonus. They are not required. A long random value beats a short value with a symbol tacked on.
97
+ - A local blocklist catches common passwords, light leet-speak (`p@ssw0rd`), and the same word with digits added (`Summer2024!`).
98
+ - Placeholders (`changeme`, `your_api_key`, `example`), environment names (`production`), and a value that repeats the variable name are capped at Critical.
99
+ - Sequential runs (`abcd`, `1234`, `qwerty`), repeated chunks, and digits-only values are capped.
100
+ - A secret copied into two variables cannot rank Excellent.
101
+ - The file should be mode `0600`. In a git repo it should be ignored.
102
+
103
+ The bit estimate is the size of the character set the value actually uses, times its length. That number assumes the value was generated at random. The pattern and blocklist checks exist so a value like `Password1!` cannot hide behind a high estimate.
104
+
105
+ Replace a weak secret with a random value:
106
+
107
+ ```sh
108
+ ruby -e 'require "securerandom"; puts SecureRandom.hex(32)'
109
+ ```
110
+
111
+ This is a local heuristic, not a search of a breach corpus. Checking a secret against a remote breach list would send the secret off the machine, so this gem does not do that.
112
+
113
+ ## Env file format
114
+
115
+ ```
116
+ # comment
117
+ PORT=3000
118
+ export APP_ENV=development
119
+ PASSWORD='single quotes are literal, including $'
120
+ TOKEN="double quotes expand $OTHER and \n"
121
+ ```
122
+
123
+ - `export` is optional.
124
+ - Bare and double-quoted values expand `$NAME` and `${NAME}` from earlier lines, then from the current environment. An unknown name expands to empty.
125
+ - Single quotes do not expand. Use them for secrets that contain `$`.
126
+ - Put a value that contains a single quote in double quotes, for example `"it's"`.
127
+ - Unquoted values are trimmed. Quote a value to keep surrounding spaces.
128
+ - The last assignment wins when a key is repeated.
129
+
130
+ ## Library
131
+
132
+ ```ruby
133
+ require 'envvariablecheck'
134
+
135
+ EnvVariableCheck.load!(override: false)
136
+ report = EnvVariableCheck.audit
137
+
138
+ puts report.overall_rank
139
+ report.variables.each do |variable|
140
+ next unless variable.scored
141
+ puts "#{variable.name}: #{variable.rank} (#{variable.score})"
142
+ end
143
+ ```
144
+
145
+ `report.contents` is the env file text. `variable.findings` explains each rank.
146
+
147
+ ## Tests
148
+
149
+ ```sh
150
+ rake test
151
+ ```
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rake/testtask'
4
+
5
+ Rake::TestTask.new(:test) do |t|
6
+ t.libs << 'lib' << 'test'
7
+ t.test_files = FileList['test/**/*_test.rb']
8
+ t.warning = false
9
+ end
10
+
11
+ task default: :test
data/exe/envcheck ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ lib = File.expand_path('../lib', __dir__)
5
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
6
+
7
+ require 'envvariablecheck'
8
+
9
+ exit EnvVariableCheck::CLI.start(ARGV)
@@ -0,0 +1,279 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module EnvVariableCheck
6
+ # Reviews every variable in an env file and the file's own exposure.
7
+ class Auditor
8
+ RANK_ORDER = %w[Critical Weak Fair Strong Excellent].freeze
9
+
10
+ Variable = Struct.new(
11
+ :name, :kind, :line, :score, :rank, :charset_bits, :findings, :scored,
12
+ keyword_init: true
13
+ ) do
14
+ def to_h
15
+ {
16
+ 'name' => name,
17
+ 'kind' => kind.to_s,
18
+ 'line' => line,
19
+ 'scored' => scored,
20
+ 'score' => score,
21
+ 'rank' => rank,
22
+ 'estimated_bits' => charset_bits,
23
+ 'findings' => findings
24
+ }
25
+ end
26
+ end
27
+
28
+ Audit = Struct.new(:path, :variables, :file_findings, :contents, keyword_init: true) do
29
+ def secrets
30
+ variables.select(&:scored)
31
+ end
32
+
33
+ def overall_rank
34
+ return nil if secrets.empty?
35
+
36
+ secrets.min_by { |variable| RANK_ORDER.index(variable.rank) }.rank
37
+ end
38
+
39
+ def overall_score
40
+ return nil if secrets.empty?
41
+
42
+ secrets.map(&:score).min
43
+ end
44
+
45
+ def failing?
46
+ secrets.any? { |variable| variable.rank == 'Critical' || variable.rank == 'Weak' } ||
47
+ !file_findings.empty?
48
+ end
49
+
50
+ def counts
51
+ result = {
52
+ 'Critical' => 0,
53
+ 'Weak' => 0,
54
+ 'Fair' => 0,
55
+ 'Strong' => 0,
56
+ 'Excellent' => 0,
57
+ 'Unscored' => 0
58
+ }
59
+ variables.each do |variable|
60
+ key = variable.scored ? variable.rank : 'Unscored'
61
+ result[key] += 1
62
+ end
63
+ result
64
+ end
65
+
66
+ def to_h
67
+ {
68
+ 'file' => path,
69
+ 'overall_rank' => overall_rank,
70
+ 'overall_score' => overall_score,
71
+ 'counts' => counts,
72
+ 'file_findings' => file_findings,
73
+ 'contents' => contents,
74
+ 'variables' => variables.map(&:to_h)
75
+ }
76
+ end
77
+ end
78
+
79
+ Prepared = Struct.new(
80
+ :name, :value, :material, :kind, :line, :embedded, :score_kind,
81
+ keyword_init: true
82
+ )
83
+
84
+ def self.audit_file(path)
85
+ contents = Parser.read(path)
86
+ assignments = Parser.parse(contents, path: path)
87
+ audit_assignments(assignments, path: path, contents: contents)
88
+ end
89
+
90
+ def self.audit_assignments(assignments, path: nil, contents: nil)
91
+ Audit.new(
92
+ path: path,
93
+ variables: rank_variables(assignments),
94
+ file_findings: file_findings_for(path),
95
+ contents: contents
96
+ )
97
+ end
98
+
99
+ def self.rank_variables(assignments)
100
+ collapsed, counts = Parser.collapse(assignments)
101
+ items = collapsed.map { |assignment| prepare(assignment) }
102
+ index = Hash.new { |hash, key| hash[key] = [] }
103
+ items.each do |item|
104
+ next if item.material.nil? || item.material.strip.empty?
105
+
106
+ index[item.material] << item.name
107
+ end
108
+
109
+ items.map do |item|
110
+ reused = []
111
+ if item.material && !item.material.strip.empty?
112
+ reused = index[item.material].reject { |name| name == item.name }
113
+ end
114
+ build(item, reused, counts[item.name])
115
+ end
116
+ end
117
+
118
+ def self.prepare(assignment)
119
+ kind = Classifier.classify(assignment.key, assignment.value)
120
+ embedded = false
121
+ material = nil
122
+ score_kind = nil
123
+
124
+ case kind
125
+ when :connection_url
126
+ extracted = Classifier.extract_password(assignment.value)
127
+ if !extracted.nil?
128
+ embedded = true
129
+ material = extracted
130
+ score_kind = :password
131
+ elsif !Classifier.url?(assignment.value)
132
+ material = assignment.value
133
+ score_kind = :password
134
+ end
135
+ when :password
136
+ material = assignment.value
137
+ score_kind = :password
138
+ when :token
139
+ material = assignment.value
140
+ score_kind = :token
141
+ else
142
+ material = assignment.value
143
+ end
144
+
145
+ Prepared.new(
146
+ name: assignment.key,
147
+ value: assignment.value,
148
+ material: material,
149
+ kind: kind,
150
+ line: assignment.line,
151
+ embedded: embedded,
152
+ score_kind: score_kind
153
+ )
154
+ end
155
+
156
+ def self.build(item, reused, count)
157
+ findings = []
158
+ if count > 1
159
+ findings << 'Assigned more than once. The last value is the one that gets injected.'
160
+ end
161
+
162
+ if item.score_kind
163
+ scored(item, reused, findings)
164
+ else
165
+ unscored(item, findings)
166
+ end
167
+ end
168
+
169
+ def self.scored(item, reused, findings)
170
+ if item.kind == :connection_url && item.embedded
171
+ findings << 'Ranked the embedded password. The rest of the URL was not treated as secret material.'
172
+ elsif item.kind == :connection_url
173
+ findings << 'Name looks like a connection URL, but the value is not a URL. Ranked the whole value.'
174
+ end
175
+
176
+ result = Scorer.score(item.material, kind: item.score_kind, name: item.name, reused_names: reused)
177
+ Variable.new(
178
+ name: item.name,
179
+ kind: item.kind,
180
+ line: item.line,
181
+ score: result.score,
182
+ rank: result.rank,
183
+ charset_bits: result.charset_bits,
184
+ findings: findings + result.findings,
185
+ scored: true
186
+ )
187
+ end
188
+
189
+ def self.unscored(item, findings)
190
+ case item.kind
191
+ when :identifier
192
+ if item.value.strip.empty?
193
+ findings << 'Identifier is empty.'
194
+ elsif item.value.include?('EXAMPLE')
195
+ findings << 'Looks like a sample or placeholder identifier.'
196
+ else
197
+ findings << 'Identifier, not a secret. Strength is not ranked.'
198
+ end
199
+ when :public
200
+ findings << 'Public key material. Strength is not ranked as a password.'
201
+ when :connection_url
202
+ findings << 'Connection URL has no embedded password to assess.'
203
+ when :config
204
+ if high_entropy_blob?(item.value)
205
+ findings << 'Value looks long and random, but the name does not mark it as a secret. Confirm it is not a credential.'
206
+ end
207
+ end
208
+
209
+ Variable.new(
210
+ name: item.name,
211
+ kind: item.kind,
212
+ line: item.line,
213
+ score: nil,
214
+ rank: nil,
215
+ charset_bits: nil,
216
+ findings: findings,
217
+ scored: false
218
+ )
219
+ end
220
+
221
+ def self.high_entropy_blob?(value)
222
+ return false if value.nil? || value.length < 24
223
+ return false if Classifier.url?(value)
224
+ return false if value.match?(/\s/)
225
+
226
+ Entropy.charset_bits(value) >= 100
227
+ end
228
+
229
+ def self.file_findings_for(path)
230
+ return [] if path.nil? || !File.exist?(path)
231
+
232
+ findings = permission_findings(path)
233
+ git = gitignore_finding(path)
234
+ findings << git if git
235
+ findings
236
+ end
237
+
238
+ def self.permission_findings(path)
239
+ findings = []
240
+ if File.symlink?(path)
241
+ findings << 'File is a symlink. Confirm the target is not shared or world-readable.'
242
+ end
243
+ mode = File.stat(path).mode & 0777
244
+ if mode & 0077 != 0
245
+ findings << format('File mode is %04o, so group or others can read it. Restrict it to 0600.', mode)
246
+ end
247
+ findings
248
+ rescue SystemCallError
249
+ []
250
+ end
251
+
252
+ def self.gitignore_finding(path)
253
+ root = git_root(File.dirname(File.expand_path(path)))
254
+ return nil unless root
255
+
256
+ _output, status = Open3.capture2(
257
+ 'git', '-C', root, 'check-ignore', '-q', '--', File.expand_path(path)
258
+ )
259
+ return nil if status.success?
260
+ return nil unless status.exitstatus == 1
261
+
262
+ "#{File.basename(path)} is not ignored by git. Add it to .gitignore before committing secrets."
263
+ rescue Errno::ENOENT, SystemCallError
264
+ nil
265
+ end
266
+
267
+ def self.git_root(start)
268
+ dir = File.expand_path(start)
269
+ loop do
270
+ return dir if File.exist?(File.join(dir, '.git'))
271
+
272
+ parent = File.dirname(dir)
273
+ return nil if parent == dir
274
+
275
+ dir = parent
276
+ end
277
+ end
278
+ end
279
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EnvVariableCheck
4
+ # Decides whether a variable should be ranked as a secret.
5
+ class Classifier
6
+ PASSWORD_NAME = /(PASSWORD|PASSWD|PWD)/i.freeze
7
+ PUBLIC_NAME = /(PUBLIC[_-]?KEY|PUBLISHABLE[_-]?KEY)/i.freeze
8
+ IDENTIFIER_NAME = /(KEY_ID|ACCESS_KEY_ID|CLIENT_ID)\z/i.freeze
9
+ CONNECTION_NAME = /(DATABASE|DB|REDIS|MONGO(?:DB)?|MYSQL|POSTGRES(?:QL)?|AMQP|RABBIT(?:MQ)?|BROKER|CACHE|ELASTIC(?:SEARCH)?)[_-]?(?:URL|URI|DSN)/i.freeze
10
+ SECRET_NAME = /(SECRET|TOKEN|API[_-]?KEY|PRIVATE[_-]?KEY|MASTER[_-]?KEY|CLIENT[_-]?SECRET|CREDENTIAL|SIGNING[_-]?KEY|ENCRYPTION[_-]?KEY|SESSION[_-]?(?:SECRET|KEY)|JWT|SALT|AUTH[_-]?(?:TOKEN|SECRET|KEY)|ACCESS[_-]?KEY)/i.freeze
11
+ EMBEDDED_PASSWORD = %r{\A[A-Za-z][A-Za-z0-9+.\-]*://[^/\s]*:[^/\s]*@}i.freeze
12
+ CREDENTIAL_VALUE = /\A(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]+\z/.freeze
13
+
14
+ def self.classify(name, value)
15
+ value = value.to_s
16
+ return :token if value.include?('PRIVATE KEY') || value.match?(CREDENTIAL_VALUE)
17
+ return :identifier if name.match?(IDENTIFIER_NAME)
18
+ return :connection_url if name.match?(CONNECTION_NAME) || value.match?(EMBEDDED_PASSWORD)
19
+ return :password if name.match?(PASSWORD_NAME)
20
+ return :public if name.match?(PUBLIC_NAME)
21
+ return :token if name.match?(SECRET_NAME)
22
+
23
+ :config
24
+ end
25
+
26
+ # Password embedded in a connection URL, or nil when the URL has no
27
+ # user:password section. An empty string means the password is present
28
+ # and blank.
29
+ def self.extract_password(value)
30
+ match = value.to_s.match(%r{\A[A-Za-z][A-Za-z0-9+.\-]*://([^/\s@]*)@}i)
31
+ return nil unless match
32
+
33
+ userinfo = match[1]
34
+ return nil unless userinfo.include?(':')
35
+
36
+ _user, password = userinfo.split(':', 2)
37
+ percent_decode(password.to_s)
38
+ end
39
+
40
+ def self.percent_decode(text)
41
+ decoded = text.gsub(/%([0-9A-Fa-f]{2})/) { [Regexp.last_match(1).to_i(16)].pack('C') }
42
+ decoded.force_encoding('UTF-8')
43
+ decoded.valid_encoding? ? decoded : decoded.scrub
44
+ end
45
+
46
+ def self.url?(value)
47
+ value.to_s.match?(%r{\A[A-Za-z][A-Za-z0-9+.\-]*://}i)
48
+ end
49
+ end
50
+ end