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.
data/test/cli_test.rb ADDED
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+
5
+ class CliTest < Minitest::Test
6
+ STRONG = 'aB3dE6gH9kL2mN5pQ8sT1uV4wX7yZ0cR'
7
+
8
+ def test_help_and_version
9
+ status = nil
10
+ out, err = capture_io { status = EnvVariableCheck::CLI.start([]) }
11
+ assert_equal 0, status
12
+ assert_match(/envcheck run/, out)
13
+ assert_match(/prints the env file in full/, out)
14
+ assert_equal '', err
15
+
16
+ status = nil
17
+ out, _err = capture_io { status = EnvVariableCheck::CLI.start(['version']) }
18
+ assert_equal 0, status
19
+ assert_equal "envvariablecheck #{EnvVariableCheck::VERSION}\n", out
20
+ end
21
+
22
+ def test_unknown_command_and_missing_file
23
+ status = nil
24
+ _out, err = capture_io { status = EnvVariableCheck::CLI.start(['nope']) }
25
+ assert_equal 2, status
26
+ assert_match(/Unknown command/, err)
27
+
28
+ status = nil
29
+ _out, err = capture_io { status = EnvVariableCheck::CLI.start(['audit', 'missing.env']) }
30
+ assert_equal 1, status
31
+ assert_match(/not found/, err)
32
+ end
33
+
34
+ def test_audit_text_and_json_include_the_file
35
+ contents = "DB_PASSWORD=ZzCanary!\nPORT=3000\n"
36
+ with_env_file(contents, mode: 0600) do |_dir, path|
37
+ status = nil
38
+ out, err = capture_io { status = EnvVariableCheck::CLI.start(['audit', '--env', path, '--no-color']) }
39
+ assert_equal 0, status, err
40
+ assert_match(/Overall: Weak/, out)
41
+ assert_match(/DB_PASSWORD/, out)
42
+ assert_match(/PORT/, out)
43
+ assert_includes out, contents
44
+ assert_equal '', err
45
+
46
+ json_status = nil
47
+ json_out, json_err = capture_io do
48
+ json_status = EnvVariableCheck::CLI.start(['audit', '--format', 'json', '--strict', path])
49
+ end
50
+ assert_equal 1, json_status, json_err
51
+ payload = JSON.parse(json_out)
52
+ assert_equal 'Weak', payload['overall_rank']
53
+ assert_equal contents, payload['contents']
54
+ end
55
+ end
56
+
57
+ def test_run_injects_env_and_arguments
58
+ with_env_file("FROM_DOTENV=loaded-from-file\nAPI_TOKEN=#{STRONG}\n", mode: 0600) do |dir, path|
59
+ script = File.join(dir, 'app.rb')
60
+ File.write(script, "puts [ENV.fetch('FROM_DOTENV'), ARGV.join(',')].join('|')\n")
61
+ stdout, stderr, status = Open3.capture3(
62
+ RbConfig.ruby, EnvCheckTest::EXE, 'run', '--env', path, script, 'hello', 'world'
63
+ )
64
+ assert status.success?, stderr
65
+ assert_equal "loaded-from-file|hello,world\n", stdout
66
+ assert_match(/injected/, stderr)
67
+ assert_match(/Excellent/, stderr)
68
+ assert_includes stderr, File.read(path)
69
+ end
70
+ end
71
+
72
+ def test_run_keeps_existing_variables_unless_override
73
+ with_env_file("FROM_DOTENV=from-file\nNEW_ONE=from-file\nAPI_TOKEN=#{STRONG}\n", mode: 0600) do |dir, path|
74
+ script = File.join(dir, 'app.rb')
75
+ File.write(script, "puts [ENV.fetch('FROM_DOTENV'), ENV.fetch('NEW_ONE')].join('|')\n")
76
+ stdout, stderr, status = Open3.capture3(
77
+ { 'FROM_DOTENV' => 'from-shell' },
78
+ RbConfig.ruby, EnvCheckTest::EXE, 'run', '--quiet', '--env', path, script
79
+ )
80
+ assert status.success?, stderr
81
+ assert_equal "from-shell|from-file\n", stdout
82
+
83
+ stdout, stderr, status = Open3.capture3(
84
+ { 'FROM_DOTENV' => 'from-shell' },
85
+ RbConfig.ruby, EnvCheckTest::EXE, 'run', '--quiet', '--override', '--env', path, script
86
+ )
87
+ assert status.success?, stderr
88
+ assert_equal "from-file|from-file\n", stdout
89
+ end
90
+ end
91
+
92
+ def test_strict_run_does_not_execute_a_weak_file
93
+ secret = 'ZzCanary!'
94
+ with_env_file("DB_PASSWORD=#{secret}\n", mode: 0600) do |dir, path|
95
+ marker = File.join(dir, 'ran.txt')
96
+ script = File.join(dir, 'app.rb')
97
+ File.write(script, "File.write(#{marker.inspect}, 'ran')\n")
98
+ stdout, stderr, status = Open3.capture3(
99
+ RbConfig.ruby, EnvCheckTest::EXE, 'run', '--strict', '--env', path, script
100
+ )
101
+ refute status.success?
102
+ assert_equal '', stdout
103
+ refute File.exist?(marker)
104
+ assert_match(/Critical|Weak/, stderr)
105
+ assert_includes stderr, File.read(path)
106
+ end
107
+ end
108
+
109
+ def test_export_command
110
+ with_env_file("FROM_DOTENV=loaded\n", mode: 0600) do |_dir, path|
111
+ status = nil
112
+ out, err = capture_io { status = EnvVariableCheck::CLI.start(['export', path]) }
113
+ assert_equal 0, status, err
114
+ assert_equal "export FROM_DOTENV='loaded'\n", out
115
+ end
116
+ end
117
+
118
+ def test_require_load_injects_before_the_script
119
+ with_env_file("FROM_DOTENV=from-require\n", mode: 0600) do |dir, _path|
120
+ stdout, stderr, status = Open3.capture3(
121
+ { 'HOME' => dir },
122
+ RbConfig.ruby, '-I', EnvCheckTest::LIB, '-r', 'envvariablecheck/load', '-e', 'puts ENV.fetch("FROM_DOTENV")'
123
+ )
124
+ assert status.success?, stderr
125
+ assert_equal "from-require\n", stdout
126
+ end
127
+ end
128
+
129
+ def test_audit_reads_home_dotenv_by_default
130
+ contents = "FROM_DOTENV=from-home\nAPI_TOKEN=#{STRONG}\n"
131
+ original_home = ENV['HOME']
132
+ with_env_file(contents, mode: 0600) do |dir, path|
133
+ ENV['HOME'] = dir
134
+ status = nil
135
+ out, err = capture_io { status = EnvVariableCheck::CLI.start(['audit', '--no-color']) }
136
+ assert_equal 0, status, err
137
+ assert_includes out, "File: #{path}"
138
+ assert_includes out, contents
139
+ end
140
+ ensure
141
+ ENV['HOME'] = original_home
142
+ end
143
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+
5
+ class LoaderTest < Minitest::Test
6
+ def teardown
7
+ %w[ENVCHECK_LOAD_A ENVCHECK_LOAD_B ENVCHECK_LOAD_NUL].each { |key| ENV.delete(key) }
8
+ end
9
+
10
+ def test_load_keeps_existing_variables_unless_override
11
+ Dir.mktmpdir do |dir|
12
+ path = File.join(dir, '.env')
13
+ File.write(path, "ENVCHECK_LOAD_A=from-file\nENVCHECK_LOAD_B=from-file\n")
14
+ ENV['ENVCHECK_LOAD_A'] = 'from-shell'
15
+ ENV.delete('ENVCHECK_LOAD_B')
16
+
17
+ result = EnvVariableCheck.load!(path: path)
18
+ assert_equal 'from-shell', ENV['ENVCHECK_LOAD_A']
19
+ assert_equal 'from-file', ENV['ENVCHECK_LOAD_B']
20
+ assert_includes result.skipped, 'ENVCHECK_LOAD_A'
21
+ assert_includes result.applied, 'ENVCHECK_LOAD_B'
22
+
23
+ ENV['ENVCHECK_LOAD_A'] = 'from-shell'
24
+ overridden = EnvVariableCheck.load!(path: path, override: true)
25
+ assert_equal 'from-file', ENV['ENVCHECK_LOAD_A']
26
+ assert_includes overridden.applied, 'ENVCHECK_LOAD_A'
27
+ end
28
+ end
29
+
30
+ def test_null_bytes_are_skipped
31
+ Dir.mktmpdir do |dir|
32
+ path = File.join(dir, '.env')
33
+ File.binwrite(path, "ENVCHECK_LOAD_NUL=a\0b\n")
34
+ result = EnvVariableCheck.load!(path: path)
35
+ refute ENV.key?('ENVCHECK_LOAD_NUL')
36
+ assert_match(/ENVCHECK_LOAD_NUL/, result.warnings.join)
37
+ end
38
+ end
39
+
40
+ def test_missing_file
41
+ error = assert_raises(EnvVariableCheck::Error) do
42
+ EnvVariableCheck.load!(path: 'missing-env-file.env')
43
+ end
44
+ assert_match(/not found/, error.message)
45
+ end
46
+
47
+ def test_shell_exports_round_trip_special_characters
48
+ value = "p@ss'w$rd;$(whoami)`id`"
49
+ Dir.mktmpdir do |dir|
50
+ path = File.join(dir, '.env')
51
+ File.write(path, %q(FOO="p@ss'w\$rd;$(whoami)`id`") + "\n")
52
+ exported = EnvVariableCheck::Loader.shell_exports(EnvVariableCheck::Parser.parse(File.read(path), path: path))
53
+ stdout, stderr, status = Open3.capture3('sh', '-c', 'eval "$1"; printf "%s" "$FOO"', 'sh', exported)
54
+ assert status.success?, stderr
55
+ assert_equal value, stdout
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+
5
+ class ParserTest < Minitest::Test
6
+ def parse(contents)
7
+ EnvVariableCheck::Parser.parse(contents)
8
+ end
9
+
10
+ def test_comments_blanks_export_and_inline_comments
11
+ assignments = parse(<<~ENV)
12
+ # comment
13
+
14
+ export PORT=3000
15
+ NAME=value # trailing
16
+ HASH=value#keep
17
+ ENV
18
+ assert_equal %w[PORT NAME HASH], assignments.map(&:key)
19
+ assert_equal '3000', assignments[0].value
20
+ assert_equal 'value', assignments[1].value
21
+ assert_equal 'value#keep', assignments[2].value
22
+ end
23
+
24
+ def test_quotes_escapes_and_multiline
25
+ literal = parse("LITERAL='${KEEP} $KEEP'\n")
26
+ assert_equal '${KEEP} $KEEP', literal[0].value
27
+
28
+ escaped = parse('ESCAPED="a\nb\t\"quote\""' + "\n")
29
+ assert_equal "a\nb\t\"quote\"", escaped[0].value
30
+
31
+ multi = parse("MULTI=\"one\ntwo\"\n")
32
+ assert_equal "one\ntwo", multi[0].value
33
+ end
34
+
35
+ def test_interpolation_prefers_earlier_keys_then_env
36
+ ENV['ENVCHECK_PARSER_OUTSIDE'] = 'out'
37
+ assignments = parse(<<~'ENV')
38
+ INSIDE=in
39
+ MIXED=${INSIDE}-$ENVCHECK_PARSER_OUTSIDE
40
+ MISSING=pre-$ENVCHECK_PARSER_MISSING-post
41
+ QUOTED='${INSIDE}'
42
+ ENV
43
+ assert_equal 'in-out', assignments[1].value
44
+ assert_equal 'pre--post', assignments[2].value
45
+ assert_equal '${INSIDE}', assignments[3].value
46
+ ensure
47
+ ENV.delete('ENVCHECK_PARSER_OUTSIDE')
48
+ end
49
+
50
+ def test_dollar_escapes_are_literal
51
+ assignments = parse(<<~'ENV')
52
+ BARE=pass\$word
53
+ QUOTED="pass\$word"
54
+ ENV
55
+ assert_equal 'pass$word', assignments[0].value
56
+ assert_equal 'pass$word', assignments[1].value
57
+ end
58
+
59
+ def test_crlf_and_bom
60
+ assignments = parse("\uFEFFKEY=value\r\nNEXT=2\r\n")
61
+ assert_equal 'value', assignments[0].value
62
+ assert_equal '2', assignments[1].value
63
+ end
64
+
65
+ def test_last_assignment_wins_when_collapsed
66
+ assignments = parse("A=1\nA=2\n")
67
+ collapsed, counts = EnvVariableCheck::Parser.collapse(assignments)
68
+ assert_equal '2', collapsed[0].value
69
+ assert_equal 2, counts['A']
70
+ end
71
+
72
+ def test_parse_errors_do_not_include_the_line_contents
73
+ secret = 'canary-secret-value'
74
+ error = assert_raises(EnvVariableCheck::Error) { parse("NOT VALID=#{secret}\n") }
75
+ refute_includes error.message, secret
76
+ assert_match(/:1/, error.message)
77
+ end
78
+
79
+ def test_unclosed_quote
80
+ error = assert_raises(EnvVariableCheck::Error) { parse("KEY=\"abc\n") }
81
+ assert_match(/Unclosed/, error.message)
82
+ refute_includes error.message, 'abc'
83
+ end
84
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+
5
+ class ScorerTest < Minitest::Test
6
+ TOKEN = 'aB3dE6gH9kL2mN5pQ8sT1uV4wX7yZ0cR'
7
+ HEX = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'
8
+
9
+ def score(value, kind: :password, name: 'PASSWORD', reused: [])
10
+ EnvVariableCheck::Scorer.score(value, kind: kind, name: name, reused_names: reused)
11
+ end
12
+
13
+ def test_rank_boundaries
14
+ assert_equal 'Critical', EnvVariableCheck::Scorer.rank_for(0)
15
+ assert_equal 'Critical', EnvVariableCheck::Scorer.rank_for(24)
16
+ assert_equal 'Weak', EnvVariableCheck::Scorer.rank_for(25)
17
+ assert_equal 'Weak', EnvVariableCheck::Scorer.rank_for(49)
18
+ assert_equal 'Fair', EnvVariableCheck::Scorer.rank_for(50)
19
+ assert_equal 'Fair', EnvVariableCheck::Scorer.rank_for(74)
20
+ assert_equal 'Strong', EnvVariableCheck::Scorer.rank_for(75)
21
+ assert_equal 'Strong', EnvVariableCheck::Scorer.rank_for(89)
22
+ assert_equal 'Excellent', EnvVariableCheck::Scorer.rank_for(90)
23
+ assert_equal 'Excellent', EnvVariableCheck::Scorer.rank_for(100)
24
+ end
25
+
26
+ def test_empty_and_common_passwords_are_critical
27
+ assert_equal 0, score('').score
28
+ assert_equal 'Critical', score('').rank
29
+
30
+ common = score('password')
31
+ assert_equal 'Critical', common.rank
32
+ assert_operator common.score, :<=, 12
33
+ assert_match(/commonly used/, common.findings.join)
34
+
35
+ assert_equal 'Critical', score('qwerty').rank
36
+ assert_equal 'Critical', score('changeme').rank
37
+ assert_equal 'Critical', score('correct horse battery staple').rank
38
+ end
39
+
40
+ def test_predictable_decoration_does_not_outrank_a_common_base
41
+ decorated = score('Summer2024!')
42
+ assert_equal 'Critical', decorated.rank
43
+ assert_operator decorated.score, :<=, 20
44
+ refute_includes decorated.findings.join, 'Summer2024!'
45
+ end
46
+
47
+ def test_length_caps
48
+ eight = score('aB3dE6gH')
49
+ twelve = score('aB3dE6gH9kL2')
50
+ twenty = score('aB3dE6gH9kL2mN5pQ8sT')
51
+ assert_equal 8, 'aB3dE6gH'.length
52
+ assert_equal 12, 'aB3dE6gH9kL2'.length
53
+ assert_equal 20, 'aB3dE6gH9kL2mN5pQ8sT'.length
54
+
55
+ assert_includes %w[Critical Weak], eight.rank
56
+ assert_equal 'Fair', twelve.rank
57
+ assert_includes %w[Strong Excellent], twenty.rank
58
+ end
59
+
60
+ def test_random_token_and_hex_are_excellent
61
+ assert_equal 32, TOKEN.length
62
+ assert_equal 64, HEX.length
63
+ token = score(TOKEN, kind: :token, name: 'API_TOKEN')
64
+ hex = score(HEX, kind: :token, name: 'API_TOKEN')
65
+ assert_equal 'Excellent', token.rank, token.findings.inspect
66
+ assert_equal 'Excellent', hex.rank, hex.findings.inspect
67
+ refute_includes token.findings.join, TOKEN
68
+ end
69
+
70
+ def test_short_tokens_cannot_rank_above_fair
71
+ short = score('aB3dE6gH9kL2mN5pQ8sT', kind: :token, name: 'API_TOKEN')
72
+ assert_operator short.score, :<=, 74
73
+ assert_match(/32 characters/, short.findings.join)
74
+ end
75
+
76
+ def test_reuse_blocks_excellent
77
+ reused = score(TOKEN, kind: :token, name: 'API_TOKEN', reused: ['OTHER_TOKEN'])
78
+ assert_operator reused.score, :<=, 89
79
+ refute_equal 'Excellent', reused.rank
80
+ assert_match(/OTHER_TOKEN/, reused.findings.join)
81
+ refute_includes reused.findings.join, TOKEN
82
+ end
83
+
84
+ def test_value_matching_the_name_is_critical
85
+ result = score('db_password', name: 'DB_PASSWORD')
86
+ assert_equal 'Critical', result.rank
87
+ assert_match(/variable name/, result.findings.join)
88
+ end
89
+
90
+ def test_placeholder_and_literal
91
+ assert_equal 'Critical', score('your_api_key').rank
92
+ assert_equal 'Critical', score('true', name: 'API_SECRET').rank
93
+ assert_equal 'Critical', score('xxx').rank
94
+ end
95
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'minitest/autorun'
4
+ require 'open3'
5
+ require 'rbconfig'
6
+ require 'tmpdir'
7
+ require 'json'
8
+ require 'envvariablecheck'
9
+
10
+ module EnvCheckTest
11
+ ROOT = File.expand_path('..', __dir__)
12
+ EXE = File.join(ROOT, 'exe', 'envcheck')
13
+ LIB = File.join(ROOT, 'lib')
14
+ end
15
+
16
+ def with_env_file(contents, mode: 0600)
17
+ Dir.mktmpdir('envcheck') do |dir|
18
+ path = File.join(dir, '.env')
19
+ File.write(path, contents)
20
+ File.chmod(mode, path)
21
+ yield dir, path
22
+ end
23
+ end
metadata ADDED
@@ -0,0 +1,73 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: envvariablecheck
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - kellysec
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-10-15 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: |
14
+ envvariablecheck loads ~/.env into the environment before a Ruby
15
+ script runs, and reviews the file's keys and passwords against password best
16
+ practices. It ranks each secret, checks for reused and placeholder values,
17
+ and reports loose file permissions and a missing gitignore entry. The
18
+ review prints the env file in full.
19
+ email:
20
+ - 150852464+kellysec@users.noreply.github.com
21
+ executables:
22
+ - envcheck
23
+ extensions: []
24
+ extra_rdoc_files: []
25
+ files:
26
+ - Gemfile
27
+ - LICENSE
28
+ - README.md
29
+ - Rakefile
30
+ - exe/envcheck
31
+ - lib/envvariablecheck.rb
32
+ - lib/envvariablecheck/auditor.rb
33
+ - lib/envvariablecheck/classifier.rb
34
+ - lib/envvariablecheck/cli.rb
35
+ - lib/envvariablecheck/common_passwords.rb
36
+ - lib/envvariablecheck/entropy.rb
37
+ - lib/envvariablecheck/load.rb
38
+ - lib/envvariablecheck/loader.rb
39
+ - lib/envvariablecheck/parser.rb
40
+ - lib/envvariablecheck/report.rb
41
+ - lib/envvariablecheck/scorer.rb
42
+ - lib/envvariablecheck/version.rb
43
+ - test/auditor_test.rb
44
+ - test/cli_test.rb
45
+ - test/loader_test.rb
46
+ - test/parser_test.rb
47
+ - test/scorer_test.rb
48
+ - test/test_helper.rb
49
+ homepage:
50
+ licenses:
51
+ - MIT
52
+ metadata:
53
+ rubygems_mfa_required: 'true'
54
+ post_install_message:
55
+ rdoc_options: []
56
+ require_paths:
57
+ - lib
58
+ required_ruby_version: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: 2.6.0
63
+ required_rubygems_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ requirements: []
69
+ rubygems_version: 3.0.3.1
70
+ signing_key:
71
+ specification_version: 4
72
+ summary: Inject ~/.env into a Ruby process and rank secret strength.
73
+ test_files: []