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 +7 -0
- data/Gemfile +5 -0
- data/LICENSE +21 -0
- data/README.md +151 -0
- data/Rakefile +11 -0
- data/exe/envcheck +9 -0
- data/lib/envvariablecheck/auditor.rb +279 -0
- data/lib/envvariablecheck/classifier.rb +50 -0
- data/lib/envvariablecheck/cli.rb +187 -0
- data/lib/envvariablecheck/common_passwords.rb +65 -0
- data/lib/envvariablecheck/entropy.rb +104 -0
- data/lib/envvariablecheck/load.rb +7 -0
- data/lib/envvariablecheck/loader.rb +50 -0
- data/lib/envvariablecheck/parser.rb +176 -0
- data/lib/envvariablecheck/report.rb +146 -0
- data/lib/envvariablecheck/scorer.rb +244 -0
- data/lib/envvariablecheck/version.rb +5 -0
- data/lib/envvariablecheck.rb +37 -0
- data/test/auditor_test.rb +148 -0
- data/test/cli_test.rb +143 -0
- data/test/loader_test.rb +58 -0
- data/test/parser_test.rb +84 -0
- data/test/scorer_test.rb +95 -0
- data/test/test_helper.rb +23 -0
- metadata +73 -0
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
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
data/exe/envcheck
ADDED
|
@@ -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
|