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
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module EnvVariableCheck
|
|
6
|
+
# Renders an audit, including the raw env file when it was read from disk.
|
|
7
|
+
module Report
|
|
8
|
+
LABELS = {
|
|
9
|
+
'password' => 'password',
|
|
10
|
+
'token' => 'token',
|
|
11
|
+
'connection_url' => 'connection url',
|
|
12
|
+
'config' => 'config',
|
|
13
|
+
'identifier' => 'identifier',
|
|
14
|
+
'public' => 'public key'
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def text(audit, color: false)
|
|
20
|
+
secrets, unscored = ordered(audit)
|
|
21
|
+
lines = []
|
|
22
|
+
lines << "envvariablecheck #{VERSION}"
|
|
23
|
+
lines << "File: #{audit.path || '(assignments)'}"
|
|
24
|
+
if secrets.empty?
|
|
25
|
+
lines << 'Overall: nothing to rank'
|
|
26
|
+
else
|
|
27
|
+
rank = paint(audit.overall_rank, audit.overall_rank, color)
|
|
28
|
+
lines << "Overall: #{rank} (weakest secret #{audit.overall_score}/100)"
|
|
29
|
+
end
|
|
30
|
+
lines << ''
|
|
31
|
+
|
|
32
|
+
if secrets.empty?
|
|
33
|
+
lines << 'No secret-like variables found.'
|
|
34
|
+
else
|
|
35
|
+
lines << 'Secrets'
|
|
36
|
+
secrets.each { |variable| lines.concat(secret_lines(variable, color)) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
unless unscored.empty?
|
|
40
|
+
lines << '' if lines.last != ''
|
|
41
|
+
lines << 'Not scored'
|
|
42
|
+
unscored.each { |variable| lines.concat(plain_lines(variable)) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
unless audit.file_findings.empty?
|
|
46
|
+
lines << ''
|
|
47
|
+
lines << 'File'
|
|
48
|
+
audit.file_findings.each { |finding| lines << " - #{finding}" }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
lines << ''
|
|
52
|
+
lines << summary_line(audit)
|
|
53
|
+
lines << 'Length is the primary control (NIST SP 800-63B). Use a unique random value per secret: at least 15 characters for passwords, and at least 32 for API keys and tokens.'
|
|
54
|
+
with_contents(lines.join("\n"), audit)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def json(audit)
|
|
58
|
+
JSON.pretty_generate(audit.to_h) + "\n"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def run_banner(audit, path:, applied:, skipped:, color: false)
|
|
62
|
+
lines = []
|
|
63
|
+
lines << "envcheck: injected #{count_label(applied.length, 'variable')} from #{path}"
|
|
64
|
+
unless skipped.empty?
|
|
65
|
+
lines << "envcheck: kept #{count_label(skipped.length, 'variable')} already set (#{skipped.join(', ')})"
|
|
66
|
+
end
|
|
67
|
+
audit.file_findings.each { |finding| lines << "envcheck: #{finding}" }
|
|
68
|
+
|
|
69
|
+
if audit.secrets.empty?
|
|
70
|
+
lines << 'envcheck: no secret-like variables to rank'
|
|
71
|
+
else
|
|
72
|
+
lines << "envcheck: #{path} security rank #{paint(audit.overall_rank, audit.overall_rank, color)}"
|
|
73
|
+
weak = audit.secrets.select { |variable| variable.rank == 'Critical' || variable.rank == 'Weak' }
|
|
74
|
+
weak.sort_by { |variable| [Auditor::RANK_ORDER.index(variable.rank), variable.score] }.each do |variable|
|
|
75
|
+
lines << format(' %s %s %d/100', variable.name, paint(variable.rank, variable.rank, color), variable.score)
|
|
76
|
+
variable.findings.each { |finding| lines << " - #{finding}" }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
with_contents(lines.join("\n"), audit)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def with_contents(text, audit)
|
|
83
|
+
return text if audit.contents.nil?
|
|
84
|
+
|
|
85
|
+
"#{text}\n\nContents\n#{audit.contents}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def ordered(audit)
|
|
89
|
+
secrets = audit.variables.select(&:scored).sort_by do |variable|
|
|
90
|
+
[Auditor::RANK_ORDER.index(variable.rank), variable.score, variable.name]
|
|
91
|
+
end
|
|
92
|
+
[secrets, audit.variables.reject(&:scored)]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def secret_lines(variable, color)
|
|
96
|
+
bits = variable.charset_bits.nil? ? '?' : "~#{variable.charset_bits.round} bits"
|
|
97
|
+
lines = [variable.name]
|
|
98
|
+
lines << " #{label(variable.kind)} · #{paint(variable.rank, variable.rank, color)} · #{variable.score}/100 · #{bits} · line #{variable.line}"
|
|
99
|
+
variable.findings.each { |finding| lines << " - #{finding}" }
|
|
100
|
+
lines << ''
|
|
101
|
+
lines
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def plain_lines(variable)
|
|
105
|
+
lines = [" #{variable.name} · #{label(variable.kind)} · line #{variable.line}"]
|
|
106
|
+
variable.findings.each { |finding| lines << " - #{finding}" }
|
|
107
|
+
lines
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def summary_line(audit)
|
|
111
|
+
counts = audit.counts
|
|
112
|
+
[
|
|
113
|
+
count_label(audit.variables.length, 'variable'),
|
|
114
|
+
"#{counts['Critical']} critical",
|
|
115
|
+
"#{counts['Weak']} weak",
|
|
116
|
+
"#{counts['Fair']} fair",
|
|
117
|
+
"#{counts['Strong']} strong",
|
|
118
|
+
"#{counts['Excellent']} excellent",
|
|
119
|
+
"#{counts['Unscored']} not scored"
|
|
120
|
+
].join(' · ')
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def label(kind)
|
|
124
|
+
LABELS[kind.to_s] || kind.to_s
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def count_label(count, noun)
|
|
128
|
+
count == 1 ? "1 #{noun}" : "#{count} #{noun}s"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def paint(text, rank, enabled)
|
|
132
|
+
return text unless enabled
|
|
133
|
+
|
|
134
|
+
code = {
|
|
135
|
+
'Critical' => '1;31',
|
|
136
|
+
'Weak' => '31',
|
|
137
|
+
'Fair' => '33',
|
|
138
|
+
'Strong' => '36',
|
|
139
|
+
'Excellent' => '32'
|
|
140
|
+
}[rank]
|
|
141
|
+
return text unless code
|
|
142
|
+
|
|
143
|
+
"\e[#{code}m#{text}\e[0m"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EnvVariableCheck
|
|
4
|
+
# Ranks one secret using password best practices.
|
|
5
|
+
#
|
|
6
|
+
# Length is the main control (NIST SP 800-63B requires at least 8 and this
|
|
7
|
+
# tool expects 15 or more for application passwords). Composition is only a
|
|
8
|
+
# bonus. Common passwords, placeholders, reuse, repetition, and predictable
|
|
9
|
+
# shapes are capped hard so they cannot outscore a random value.
|
|
10
|
+
class Scorer
|
|
11
|
+
Result = Struct.new(:score, :rank, :findings, :charset_bits, keyword_init: true)
|
|
12
|
+
|
|
13
|
+
PLACEHOLDER = Regexp.new(
|
|
14
|
+
'\A(?:changeme\d*|change\s*me\d*|todo|fixme|x{3,}|your[-_].+|example\d*|' \
|
|
15
|
+
'placeholder|sample|dummy|fake|temp(?:orary)?|test(?:ing)?\d*|foo|bar|baz|' \
|
|
16
|
+
'n/a|none|null|nil|undefined|default|replace[-_].+|insert[-_].+|\*{3,}|' \
|
|
17
|
+
'<[^>]*>|\$\{[^}]+\})\z',
|
|
18
|
+
Regexp::IGNORECASE
|
|
19
|
+
).freeze
|
|
20
|
+
|
|
21
|
+
WEAK_LITERALS = %w[
|
|
22
|
+
true false yes no on off dev development prod production staging local
|
|
23
|
+
localhost enable enabled disable disabled debug release
|
|
24
|
+
].each_with_object({}) { |word, hash| hash[word] = true }.freeze
|
|
25
|
+
|
|
26
|
+
PREDICTABLE = [
|
|
27
|
+
[/\A[A-Z][a-z]{2,16}\d{2,4}[^A-Za-z0-9]?\z/, 'a capitalized word, digits, and an optional symbol'],
|
|
28
|
+
[/\A[A-Za-z]{4,16}\d{1,6}\z/, 'a word followed by digits'],
|
|
29
|
+
[/\A[A-Za-z]{4,16}[^A-Za-z0-9]\z/, 'a short word plus one symbol']
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
32
|
+
def self.score(value, kind:, name:, reused_names: [])
|
|
33
|
+
new(value.to_s, kind, name.to_s, reused_names).result
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.rank_for(score)
|
|
37
|
+
if score >= 90
|
|
38
|
+
'Excellent'
|
|
39
|
+
elsif score >= 75
|
|
40
|
+
'Strong'
|
|
41
|
+
elsif score >= 50
|
|
42
|
+
'Fair'
|
|
43
|
+
elsif score >= 25
|
|
44
|
+
'Weak'
|
|
45
|
+
else
|
|
46
|
+
'Critical'
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def initialize(value, kind, name, reused_names)
|
|
51
|
+
@value = value
|
|
52
|
+
@kind = kind
|
|
53
|
+
@name = name
|
|
54
|
+
@reused_names = reused_names
|
|
55
|
+
@findings = []
|
|
56
|
+
@caps = [100]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def result
|
|
60
|
+
if @value.strip.empty?
|
|
61
|
+
return Result.new(score: 0, rank: 'Critical', findings: ['Value is empty.'], charset_bits: 0.0)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
bits = Entropy.estimate_bits(@value)
|
|
65
|
+
add_length_findings
|
|
66
|
+
add_blocklist_findings
|
|
67
|
+
add_shape_findings
|
|
68
|
+
add_reuse_finding
|
|
69
|
+
add_prefix_findings
|
|
70
|
+
|
|
71
|
+
points = length_points + diversity_points + entropy_points(bits)
|
|
72
|
+
@caps << length_cap
|
|
73
|
+
score = points.clamp(0, @caps.min)
|
|
74
|
+
|
|
75
|
+
Result.new(
|
|
76
|
+
score: score,
|
|
77
|
+
rank: self.class.rank_for(score),
|
|
78
|
+
findings: @findings.uniq,
|
|
79
|
+
charset_bits: bits.round(1)
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def add_length_findings
|
|
86
|
+
length = @value.length
|
|
87
|
+
if @kind == :token
|
|
88
|
+
if length < 8
|
|
89
|
+
@findings << 'Below the 8 character minimum from NIST SP 800-63B.'
|
|
90
|
+
elsif length < 32
|
|
91
|
+
@findings << 'Shorter than 32 characters. API keys and tokens should be at least 32 characters from a CSPRNG.'
|
|
92
|
+
end
|
|
93
|
+
elsif length < 8
|
|
94
|
+
@findings << 'Below the 8 character minimum from NIST SP 800-63B.'
|
|
95
|
+
elsif length < 15
|
|
96
|
+
@findings << 'Meets the 8 character minimum, but application secrets should be at least 15 characters.'
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def add_blocklist_findings
|
|
101
|
+
case CommonPasswords.match(@value)
|
|
102
|
+
when :exact
|
|
103
|
+
@findings << 'Matches a commonly used password. Replace it with a value from a CSPRNG.'
|
|
104
|
+
@caps << 12
|
|
105
|
+
when :variant
|
|
106
|
+
@findings << 'Built on a common password with small changes. Those changes are easy to guess.'
|
|
107
|
+
@caps << 20
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
if @value.match?(PLACEHOLDER)
|
|
111
|
+
@findings << 'Looks like a placeholder or example, not a real secret.'
|
|
112
|
+
@caps << 10
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
if WEAK_LITERALS.key?(@value.strip.downcase)
|
|
116
|
+
@findings << 'Value is a common literal such as true, false, or an environment name.'
|
|
117
|
+
@caps << 15
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
if same_as_name?
|
|
121
|
+
@findings << 'Value matches the variable name.'
|
|
122
|
+
@caps << 8
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def add_shape_findings
|
|
127
|
+
pattern = predictable_pattern
|
|
128
|
+
if pattern
|
|
129
|
+
@findings << "Matches a predictable pattern (#{pattern})."
|
|
130
|
+
@caps << 40
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
if Entropy.low_diversity?(@value) && !Entropy.repeated_pattern?(@value)
|
|
134
|
+
@findings << 'Low character diversity for its length, so the value looks repetitive.'
|
|
135
|
+
@caps << 35
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
if Entropy.sequential?(@value)
|
|
139
|
+
@findings << 'Contains a sequential or keyboard run such as 1234, abcd, or qwerty.'
|
|
140
|
+
@caps << (@value.length < 20 ? 35 : 70)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
if Entropy.repeated_pattern?(@value)
|
|
144
|
+
@findings << 'Contains a long repeated character or a repeating chunk.'
|
|
145
|
+
@caps << 20
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
if @value.match?(/\A\d+\z/)
|
|
149
|
+
@findings << 'Digits only. A numeric secret has a small alphabet and needs to be much longer.'
|
|
150
|
+
@caps << (@value.length >= 32 ? 80 : 20)
|
|
151
|
+
elsif single_class? && !@value.match?(/\A[0-9a-f]+\z/i) && @value.length < 20
|
|
152
|
+
@findings << 'Uses only one character class, so length has to carry the strength.'
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def add_reuse_finding
|
|
157
|
+
return if @reused_names.empty?
|
|
158
|
+
|
|
159
|
+
@findings << "Same value is also used by #{@reused_names.join(', ')}. Use a unique secret for each variable."
|
|
160
|
+
@caps << 89
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def add_prefix_findings
|
|
164
|
+
if @value.include?('PRIVATE KEY')
|
|
165
|
+
@findings << 'A PEM private key is embedded in the env file. Prefer a separate file with mode 0600 and keep it out of git.'
|
|
166
|
+
end
|
|
167
|
+
if @value.match?(/\A(?:sk|pk|rk)_test_/i)
|
|
168
|
+
@findings << 'Uses a test-mode key prefix. Test keys are still secrets; keep them out of git.'
|
|
169
|
+
@caps << 89
|
|
170
|
+
end
|
|
171
|
+
return unless @value.match?(/\Apk_(?:live|test)_/i)
|
|
172
|
+
|
|
173
|
+
@findings << 'Uses a publishable-key prefix. Confirm this is not a secret key.'
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def length_points
|
|
177
|
+
length = @value.length
|
|
178
|
+
return 40 if length >= 32
|
|
179
|
+
return 36 if length >= 20
|
|
180
|
+
return 32 if length >= 16
|
|
181
|
+
return 30 if length >= 15
|
|
182
|
+
return 22 if length >= 12
|
|
183
|
+
return 14 if length >= 8
|
|
184
|
+
|
|
185
|
+
4
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def diversity_points
|
|
189
|
+
classes = 0
|
|
190
|
+
classes += 1 if @value.match?(/[a-z]/)
|
|
191
|
+
classes += 1 if @value.match?(/[A-Z]/)
|
|
192
|
+
classes += 1 if @value.match?(/\d/)
|
|
193
|
+
classes += 1 if @value.match?(/[^A-Za-z0-9]/)
|
|
194
|
+
classes * 5
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def entropy_points(bits)
|
|
198
|
+
return 40 if bits >= 128
|
|
199
|
+
return 36 if bits >= 80
|
|
200
|
+
return 28 if bits >= 60
|
|
201
|
+
return 18 if bits >= 40
|
|
202
|
+
return 10 if bits >= 28
|
|
203
|
+
|
|
204
|
+
0
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def length_cap
|
|
208
|
+
length = @value.length
|
|
209
|
+
if @kind == :token
|
|
210
|
+
return 24 if length < 16
|
|
211
|
+
return 74 if length < 32
|
|
212
|
+
|
|
213
|
+
return 100
|
|
214
|
+
end
|
|
215
|
+
return 20 if length < 8
|
|
216
|
+
return 49 if length < 12
|
|
217
|
+
return 74 if length < 15
|
|
218
|
+
|
|
219
|
+
100
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def predictable_pattern
|
|
223
|
+
PREDICTABLE.each do |pattern, label|
|
|
224
|
+
return label if @value.match?(pattern)
|
|
225
|
+
end
|
|
226
|
+
nil
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def same_as_name?
|
|
230
|
+
left = @value.downcase.gsub(/[^a-z0-9]/, '')
|
|
231
|
+
right = @name.downcase.gsub(/[^a-z0-9]/, '')
|
|
232
|
+
!left.empty? && left == right
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def single_class?
|
|
236
|
+
classes = 0
|
|
237
|
+
classes += 1 if @value.match?(/[a-z]/)
|
|
238
|
+
classes += 1 if @value.match?(/[A-Z]/)
|
|
239
|
+
classes += 1 if @value.match?(/\d/)
|
|
240
|
+
classes += 1 if @value.match?(/[^A-Za-z0-9]/)
|
|
241
|
+
classes == 1
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'envvariablecheck/version'
|
|
4
|
+
|
|
5
|
+
module EnvVariableCheck
|
|
6
|
+
# Raised when an env file cannot be read, parsed, or injected.
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
|
|
9
|
+
# Raised for bad commands and flags. The CLI turns this into exit status 2.
|
|
10
|
+
class UsageError < Error; end
|
|
11
|
+
|
|
12
|
+
# Default env file: ~/.env, expanded when a command runs.
|
|
13
|
+
def self.default_path
|
|
14
|
+
File.expand_path('~/.env')
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Read +path+ and merge it into ENV. Existing variables are kept unless
|
|
18
|
+
# +override+ is true. Returns a Loader::LoadResult.
|
|
19
|
+
def self.load!(path: default_path, override: false)
|
|
20
|
+
Loader.load(path, override: override)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Rank the keys and passwords in an env file. The report includes the file text.
|
|
24
|
+
def self.audit(path: default_path)
|
|
25
|
+
Auditor.audit_file(path)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
require 'envvariablecheck/parser'
|
|
30
|
+
require 'envvariablecheck/loader'
|
|
31
|
+
require 'envvariablecheck/classifier'
|
|
32
|
+
require 'envvariablecheck/common_passwords'
|
|
33
|
+
require 'envvariablecheck/entropy'
|
|
34
|
+
require 'envvariablecheck/scorer'
|
|
35
|
+
require 'envvariablecheck/auditor'
|
|
36
|
+
require 'envvariablecheck/report'
|
|
37
|
+
require 'envvariablecheck/cli'
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'test_helper'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
class AuditorTest < Minitest::Test
|
|
7
|
+
STRONG = 'aB3dE6gH9kL2mN5pQ8sT1uV4wX7yZ0cR'
|
|
8
|
+
|
|
9
|
+
def audit(contents)
|
|
10
|
+
assignments = EnvVariableCheck::Parser.parse(contents)
|
|
11
|
+
EnvVariableCheck::Auditor.audit_assignments(assignments)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def variable(report, name)
|
|
15
|
+
report.variables.find { |item| item.name == name }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def test_overall_rank_follows_the_weakest_secret
|
|
19
|
+
report = audit(<<~ENV)
|
|
20
|
+
PORT=3000
|
|
21
|
+
API_TOKEN=#{STRONG}
|
|
22
|
+
DB_PASSWORD=password
|
|
23
|
+
ENV
|
|
24
|
+
assert_equal 'Critical', report.overall_rank
|
|
25
|
+
assert_equal 'Excellent', variable(report, 'API_TOKEN').rank
|
|
26
|
+
refute variable(report, 'PORT').scored
|
|
27
|
+
assert_equal 'config', variable(report, 'PORT').kind.to_s
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def test_connection_url_ranks_the_embedded_password_only
|
|
31
|
+
secret = 'UrlCanarySecret99!'
|
|
32
|
+
report = audit("DATABASE_URL=postgres://app:#{secret}@localhost:5432/app\nAPP_URL=https://example.com\n")
|
|
33
|
+
database = variable(report, 'DATABASE_URL')
|
|
34
|
+
assert database.scored
|
|
35
|
+
assert_includes %w[Strong Excellent], database.rank
|
|
36
|
+
assert_match(/embedded password/, database.findings.join)
|
|
37
|
+
refute_includes EnvVariableCheck::Report.text(report), secret
|
|
38
|
+
|
|
39
|
+
plain = variable(report, 'APP_URL')
|
|
40
|
+
refute plain.scored
|
|
41
|
+
assert_equal 'config', plain.kind.to_s
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def test_url_without_a_password_is_not_scored
|
|
45
|
+
report = audit("DATABASE_URL=postgres://localhost/app\n")
|
|
46
|
+
database = variable(report, 'DATABASE_URL')
|
|
47
|
+
refute database.scored
|
|
48
|
+
assert_match(/no embedded password/, database.findings.join)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def test_percent_encoded_password_is_ranked_and_not_printed
|
|
52
|
+
report = audit("DATABASE_URL=postgres://app:p%40ssword@localhost/app\n")
|
|
53
|
+
text = EnvVariableCheck::Report.text(report)
|
|
54
|
+
assert_equal 'Critical', variable(report, 'DATABASE_URL').rank
|
|
55
|
+
refute_includes text, 'p@ssword'
|
|
56
|
+
refute_includes text, 'p%40ssword'
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def test_key_id_is_an_identifier
|
|
60
|
+
report = audit("AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE\nAWS_SECRET_ACCESS_KEY=#{STRONG}\n")
|
|
61
|
+
identifier = variable(report, 'AWS_ACCESS_KEY_ID')
|
|
62
|
+
refute identifier.scored
|
|
63
|
+
assert_match(/sample or placeholder/, identifier.findings.join)
|
|
64
|
+
assert_equal 'Excellent', variable(report, 'AWS_SECRET_ACCESS_KEY').rank
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def test_reused_secrets_are_named_but_not_printed
|
|
68
|
+
report = audit(<<~ENV)
|
|
69
|
+
API_TOKEN=#{STRONG}
|
|
70
|
+
BACKUP_TOKEN=#{STRONG}
|
|
71
|
+
ENV
|
|
72
|
+
assert_match(/BACKUP_TOKEN/, variable(report, 'API_TOKEN').findings.join)
|
|
73
|
+
refute_includes EnvVariableCheck::Report.text(report), STRONG
|
|
74
|
+
refute_equal 'Excellent', report.overall_rank
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def test_high_entropy_config_is_called_out
|
|
78
|
+
report = audit("ASSET_HASH=#{STRONG}\n")
|
|
79
|
+
asset = variable(report, 'ASSET_HASH')
|
|
80
|
+
refute asset.scored
|
|
81
|
+
assert_match(/does not mark it as a secret/, asset.findings.join)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def test_duplicate_assignment_is_reported
|
|
85
|
+
report = audit("API_TOKEN=first\nAPI_TOKEN=#{STRONG}\n")
|
|
86
|
+
token = variable(report, 'API_TOKEN')
|
|
87
|
+
assert_equal 'Excellent', token.rank
|
|
88
|
+
assert_match(/more than once/, token.findings.join)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def test_loose_permissions_are_reported
|
|
92
|
+
with_env_file("API_TOKEN=#{STRONG}\n", mode: 0644) do |_dir, path|
|
|
93
|
+
report = EnvVariableCheck.audit(path: path)
|
|
94
|
+
assert report.file_findings.any? { |finding| finding.include?('0600') }
|
|
95
|
+
assert report.failing?
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def test_private_file_outside_git_has_no_file_findings
|
|
100
|
+
with_env_file("API_TOKEN=#{STRONG}\n", mode: 0600) do |_dir, path|
|
|
101
|
+
report = EnvVariableCheck.audit(path: path)
|
|
102
|
+
assert_equal 'Excellent', report.overall_rank
|
|
103
|
+
assert_empty report.file_findings
|
|
104
|
+
refute report.failing?
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def test_git_ignore_check
|
|
109
|
+
skip 'git is not available' unless system('git', '--version', out: File::NULL, err: File::NULL)
|
|
110
|
+
|
|
111
|
+
dir = File.expand_path('../tmp/git-check', __dir__)
|
|
112
|
+
FileUtils.rm_rf(dir)
|
|
113
|
+
FileUtils.mkdir_p(dir)
|
|
114
|
+
path = File.join(dir, '.env')
|
|
115
|
+
File.write(path, "API_TOKEN=#{STRONG}\n")
|
|
116
|
+
File.chmod(0600, path)
|
|
117
|
+
template = File.join(dir, 'git-template')
|
|
118
|
+
Dir.mkdir(template)
|
|
119
|
+
_out, err, status = Open3.capture3(
|
|
120
|
+
{ 'GIT_TEMPLATE_DIR' => template },
|
|
121
|
+
'git', 'init', '-q', chdir: dir
|
|
122
|
+
)
|
|
123
|
+
flunk(err) unless status.success?
|
|
124
|
+
|
|
125
|
+
exposed = EnvVariableCheck.audit(path: path)
|
|
126
|
+
assert exposed.file_findings.any? { |finding| finding.include?('not ignored by git') }, exposed.file_findings.inspect
|
|
127
|
+
|
|
128
|
+
File.write(File.join(dir, '.gitignore'), ".env\n")
|
|
129
|
+
ignored = EnvVariableCheck.audit(path: path)
|
|
130
|
+
refute ignored.file_findings.any? { |finding| finding.include?('not ignored') }, ignored.file_findings.inspect
|
|
131
|
+
ensure
|
|
132
|
+
FileUtils.rm_rf(dir) if dir
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def test_json_report_includes_the_file_and_not_a_separate_value_field
|
|
136
|
+
contents = "DB_PASSWORD=JsonCanarySecret99!\nPORT=3000\n"
|
|
137
|
+
with_env_file(contents, mode: 0600) do |_dir, path|
|
|
138
|
+
report = EnvVariableCheck.audit(path: path)
|
|
139
|
+
payload = JSON.parse(EnvVariableCheck::Report.json(report))
|
|
140
|
+
assert_equal contents, payload['contents']
|
|
141
|
+
assert_equal 'DB_PASSWORD', payload['variables'][0]['name']
|
|
142
|
+
assert payload['variables'][0]['scored']
|
|
143
|
+
refute payload['variables'][1]['scored']
|
|
144
|
+
assert_nil payload['variables'][0]['value']
|
|
145
|
+
assert_includes EnvVariableCheck::Report.text(report), contents
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|