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.
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EnvVariableCheck
4
+ VERSION = '0.1.0'
5
+ 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