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,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require 'rbconfig'
5
+
6
+ module EnvVariableCheck
7
+ # Command line for injecting .env and printing a security review.
8
+ module CLI
9
+ HELP = <<~'HELP'.freeze
10
+ envcheck — load ~/.env and rank secret strength
11
+
12
+ Usage:
13
+ envcheck run [options] SCRIPT [ARGS...]
14
+ envcheck audit [options] [FILE]
15
+ envcheck export [options] [FILE]
16
+ envcheck version
17
+ envcheck help
18
+
19
+ Commands:
20
+ run Inject ~/.env into the environment, then execute a Ruby script
21
+ audit Rank the keys and passwords in ~/.env
22
+ export Print shell exports for the current terminal
23
+ eval "$(envcheck export)"
24
+
25
+ Options:
26
+ --env PATH Env file to read (default: ~/.env)
27
+ --override Replace variables already set in the environment (run)
28
+ --strict Exit 1 when a secret is Weak or Critical, or the file
29
+ is group-readable or not gitignored
30
+ --format FORMAT audit output: text or json (default: text)
31
+ --quiet On run, do not print the injection summary
32
+ --[no-]color Color the text report
33
+
34
+ Quote rules for the env file follow the shell:
35
+ KEY=value bare values expand $VAR and ${VAR}
36
+ export KEY=value same, with an optional export prefix
37
+ 'literal $VALUE' single quotes are not expanded
38
+ "line\nnext" double quotes expand $VAR plus \n \t \r \\ \" \$
39
+
40
+ Use single quotes for secrets that contain $. The last assignment wins.
41
+ The review prints the env file in full. Nothing is uploaded.
42
+ HELP
43
+
44
+ module_function
45
+
46
+ def start(argv)
47
+ dispatch(Array(argv).dup)
48
+ rescue UsageError => e
49
+ warn "envcheck: #{e.message}"
50
+ warn 'See `envcheck help`.'
51
+ 2
52
+ rescue Error => e
53
+ warn "envcheck: #{e.message}"
54
+ 1
55
+ end
56
+
57
+ def dispatch(argv)
58
+ command = argv.shift
59
+ case command
60
+ when nil, 'help', '-h', '--help'
61
+ $stdout.puts HELP
62
+ 0
63
+ when 'version', '-v', '--version'
64
+ $stdout.puts "envvariablecheck #{VERSION}"
65
+ 0
66
+ when 'audit'
67
+ audit(argv)
68
+ when 'run'
69
+ run(argv)
70
+ when 'export'
71
+ export_cmd(argv)
72
+ else
73
+ raise UsageError, "Unknown command #{command.inspect}."
74
+ end
75
+ end
76
+
77
+ def audit(argv)
78
+ options = defaults
79
+ parser = OptionParser.new do |opts|
80
+ opts.banner = 'Usage: envcheck audit [options] [FILE]'
81
+ opts.on('--env PATH', 'Env file (default: ~/.env)') { |value| options[:env] = value }
82
+ opts.on('--strict', 'Exit 1 for Weak, Critical, or an exposed file') { options[:strict] = true }
83
+ opts.on('--format FORMAT', 'text or json (default: text)') { |value| options[:format] = value }
84
+ opts.on('--[no-]color', 'Colorize the text report') { |value| options[:color] = value }
85
+ end
86
+ parse!(parser, argv)
87
+ path = argv.shift || options[:env]
88
+ raise UsageError, "Unexpected argument #{argv.first.inspect}." unless argv.empty?
89
+
90
+ report = Auditor.audit_file(path)
91
+ case options[:format].to_s.downcase
92
+ when 'text'
93
+ $stdout.puts Report.text(report, color: color_enabled?(options, $stdout))
94
+ when 'json'
95
+ $stdout.print Report.json(report)
96
+ else
97
+ raise UsageError, "Unknown format #{options[:format].inspect}. Use text or json."
98
+ end
99
+ options[:strict] && report.failing? ? 1 : 0
100
+ end
101
+
102
+ def export_cmd(argv)
103
+ options = defaults
104
+ parser = OptionParser.new do |opts|
105
+ opts.banner = 'Usage: envcheck export [options] [FILE]'
106
+ opts.on('--env PATH', 'Env file (default: ~/.env)') { |value| options[:env] = value }
107
+ end
108
+ parse!(parser, argv)
109
+ path = argv.shift || options[:env]
110
+ raise UsageError, "Unexpected argument #{argv.first.inspect}." unless argv.empty?
111
+
112
+ assignments = Parser.parse(Parser.read(path), path: path)
113
+ $stdout.print Loader.shell_exports(assignments)
114
+ 0
115
+ end
116
+
117
+ def run(argv)
118
+ options = defaults
119
+ parser = OptionParser.new do |opts|
120
+ opts.banner = 'Usage: envcheck run [options] SCRIPT [ARGS...]'
121
+ opts.on('--env PATH', 'Env file (default: ~/.env)') { |value| options[:env] = value }
122
+ opts.on('--override', 'Replace variables already set in the environment') { options[:override] = true }
123
+ opts.on('--strict', 'Do not run when the review fails') { options[:strict] = true }
124
+ opts.on('--quiet', 'Do not print the injection summary') { options[:quiet] = true }
125
+ opts.on('--[no-]color', 'Colorize ranks in the summary') { |value| options[:color] = value }
126
+ end
127
+ order!(parser, argv)
128
+ script = argv.shift
129
+ raise UsageError, 'Missing Ruby script. Usage: envcheck run [options] SCRIPT [ARGS...]' if script.nil? || script.empty?
130
+ raise UsageError, "Ruby script not found: #{script}" unless File.file?(script)
131
+
132
+ path = options[:env]
133
+ report = Auditor.audit_file(path)
134
+ if options[:strict] && report.failing?
135
+ $stderr.puts Report.text(report, color: color_enabled?(options, $stderr))
136
+ return 1
137
+ end
138
+
139
+ loaded = Loader.load(path, override: options[:override])
140
+ unless options[:quiet]
141
+ $stderr.puts Report.run_banner(
142
+ report,
143
+ path: path,
144
+ applied: loaded.applied,
145
+ skipped: loaded.skipped,
146
+ color: color_enabled?(options, $stderr)
147
+ )
148
+ loaded.warnings.each { |warning| $stderr.puts "envcheck: #{warning}" }
149
+ end
150
+ $stderr.flush
151
+ $stdout.flush
152
+ exec(RbConfig.ruby, script, *argv)
153
+ rescue SystemCallError => e
154
+ raise Error, "Could not execute #{script}: #{e.message}"
155
+ end
156
+
157
+ def defaults
158
+ {
159
+ env: EnvVariableCheck.default_path,
160
+ override: false,
161
+ strict: false,
162
+ format: 'text',
163
+ quiet: false,
164
+ color: nil
165
+ }
166
+ end
167
+
168
+ def parse!(parser, argv)
169
+ parser.parse!(argv)
170
+ rescue OptionParser::ParseError => e
171
+ raise UsageError, e.message
172
+ end
173
+
174
+ def order!(parser, argv)
175
+ parser.order!(argv)
176
+ rescue OptionParser::ParseError => e
177
+ raise UsageError, e.message
178
+ end
179
+
180
+ def color_enabled?(options, io)
181
+ return options[:color] unless options[:color].nil?
182
+ return false if ENV.key?('NO_COLOR')
183
+
184
+ io.respond_to?(:tty?) && io.tty?
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EnvVariableCheck
4
+ # Local blocklist of commonly chosen passwords. This stays on the machine:
5
+ # secrets are never sent to a breach-search service.
6
+ module CommonPasswords
7
+ BASE = %w[
8
+ 000000 111111 11111111 112233 121212 123123 12345 123456 1234567
9
+ 12345678 123456789 1234567890 123qwe 131313 159753 1q2w3e 1q2w3e4r
10
+ 1q2w3e4r5t 1qaz2wsx 555555 654321 666666 696969 7777777 888888
11
+ 987654321 999999 aaaaaa abc123 abc123456 abcd1234 access admin
12
+ admin123 adobe123 amanda andrew ashley austin azerty baseball batman
13
+ biteme bonjour buster changeme changeme123 charlie cheese chelsea
14
+ computer corvette dallas daniel default dragon dragon1 facebook
15
+ ferrari football freedom ginger guest harley hello hello123 hockey
16
+ hunter hunter2 iloveyou iloveyou1 jennifer jessica jordan letmein
17
+ letmein1 login love maggie master matrix mercedes michael michelle
18
+ money monkey monkey1 mustang nicole pass123 passwd passwd123 password
19
+ password1 password123 passw0rd p@ssw0rd p@ssword pepper princess
20
+ princess1 qazwsx qazwsxedc qwerty qwerty123 qwertyuiop ranger robert
21
+ root secret secret123 shadow soccer starwars summer sunshine superman
22
+ taylor test test123 thomas thunder tigger toor trustno1 welcome
23
+ welcome1 whatever yankees zaq12wsx zxcvbn
24
+ ].freeze
25
+
26
+ PHRASES = [
27
+ 'correct horse battery staple',
28
+ 'correcthorsebatterystaple',
29
+ 'deadbeef'
30
+ ].freeze
31
+
32
+ WORDS = {}
33
+ (BASE + PHRASES).each { |word| WORDS[word] = true }
34
+ WORDS.freeze
35
+
36
+ # :exact when the whole value is a blocked password.
37
+ # :variant when digits, symbols, or light leet-speak are wrapped around one.
38
+ # nil otherwise.
39
+ def self.match(value)
40
+ base = value.to_s.strip.downcase
41
+ return :exact if WORDS.key?(base)
42
+
43
+ squished = base.gsub(/\s+/, '')
44
+ return :exact if WORDS.key?(squished)
45
+ return nil if base.length >= 24
46
+ return nil if base.match?(/\A[0-9a-f]{16,}\z/i)
47
+
48
+ folded = leet_fold(base)
49
+ return :variant if WORDS.key?(folded)
50
+
51
+ # Drop a trailing year, digits, or symbol before folding. Folding first
52
+ # would turn the 0 in 2024 into a letter and hide the base word.
53
+ stem = base.sub(/[^a-z]+\z/, '')
54
+ return nil if stem.length < 4 || stem == base
55
+
56
+ return :variant if WORDS.key?(stem) || WORDS.key?(leet_fold(stem))
57
+
58
+ nil
59
+ end
60
+
61
+ def self.leet_fold(text)
62
+ text.tr('@0$3', 'aose')
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EnvVariableCheck
4
+ # Estimates how hard a value is to guess, and flags shapes that only look random.
5
+ module Entropy
6
+ ALPHABETS = [
7
+ 'abcdefghijklmnopqrstuvwxyz',
8
+ '01234567890',
9
+ 'qwertyuiop',
10
+ 'asdfghjkl',
11
+ 'zxcvbnm',
12
+ 'qwertyuiopasdfghjklzxcvbnm'
13
+ ].freeze
14
+
15
+ def self.charset_bits(value)
16
+ pool = pool_size(value)
17
+ return 0.0 if value.empty? || pool <= 1
18
+
19
+ value.length * Math.log2(pool)
20
+ end
21
+
22
+ def self.shannon_bits(value)
23
+ return 0.0 if value.empty?
24
+
25
+ freq = Hash.new(0)
26
+ value.each_char { |char| freq[char] += 1 }
27
+ length = value.length.to_f
28
+ per_char = freq.each_value.inject(0.0) do |sum, count|
29
+ probability = count / length
30
+ sum - (probability * Math.log2(probability))
31
+ end
32
+ per_char * length
33
+ end
34
+
35
+ def self.low_diversity?(value)
36
+ return false if value.length < 4
37
+
38
+ shannon_bits(value) / value.length < 2.0
39
+ end
40
+
41
+ def self.estimate_bits(value)
42
+ if value.empty? || low_diversity?(value) || repeated_pattern?(value)
43
+ shannon_bits(value)
44
+ else
45
+ charset_bits(value)
46
+ end
47
+ end
48
+
49
+ def self.pool_size(value)
50
+ return 16 if value.match?(/\A[0-9a-f]+\z/i)
51
+ return 64 if value.match?(%r{\A[A-Za-z0-9+/]+=*\z}) && value.match?(%r{[+/]})
52
+
53
+ pool = 0
54
+ pool += 26 if value.match?(/[a-z]/)
55
+ pool += 26 if value.match?(/[A-Z]/)
56
+ pool += 10 if value.match?(/\d/)
57
+ pool += 32 if value.match?(/[^A-Za-z0-9]/)
58
+ pool
59
+ end
60
+
61
+ def self.sequential_run(value)
62
+ downcased = value.downcase
63
+ best = 1
64
+ alphabets.each do |alphabet|
65
+ max = [downcased.length, alphabet.length].min
66
+ 4.upto(max) do |length|
67
+ 0.upto(downcased.length - length) do |index|
68
+ best = length if alphabet.include?(downcased[index, length])
69
+ end
70
+ end
71
+ end
72
+ best
73
+ end
74
+
75
+ def self.sequential?(value)
76
+ run = sequential_run(value)
77
+ return false if run < 4
78
+
79
+ value.length < 16 || (run.to_f / value.length) >= 0.5
80
+ end
81
+
82
+ def self.repeated_pattern?(value)
83
+ return false if value.length < 4
84
+ return true if value.match?(/(.)\1{3,}/)
85
+ return true if value.length.even? && value[0, value.length / 2] * 2 == value
86
+
87
+ limit = [6, value.length / 3].min
88
+ 1.upto(limit) do |size|
89
+ times = value.length / size
90
+ next if times < 3
91
+ next if (times * size) != value.length
92
+
93
+ chunk = value[0, size]
94
+ return true if chunk * times == value
95
+ end
96
+ false
97
+ end
98
+
99
+ def self.alphabets
100
+ reversed = ALPHABETS.map(&:reverse)
101
+ ALPHABETS + reversed
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Inject ~/.env into the current Ruby process.
4
+ # Usage: ruby -r envvariablecheck/load script.rb
5
+ require 'envvariablecheck'
6
+
7
+ EnvVariableCheck.load!
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EnvVariableCheck
4
+ # Merges parsed assignments into the current process environment.
5
+ class Loader
6
+ LoadResult = Struct.new(:applied, :skipped, :warnings, keyword_init: true)
7
+
8
+ def self.load(path, override: false)
9
+ assignments = Parser.parse(Parser.read(path), path: path)
10
+ apply(assignments, override: override)
11
+ end
12
+
13
+ def self.apply(assignments, override: false)
14
+ collapsed, _counts = Parser.collapse(assignments)
15
+ applied = []
16
+ skipped = []
17
+ warnings = []
18
+
19
+ collapsed.each do |assignment|
20
+ if !override && ENV.key?(assignment.key)
21
+ skipped << assignment.key
22
+ next
23
+ end
24
+
25
+ begin
26
+ ENV[assignment.key] = assignment.value
27
+ applied << assignment.key
28
+ rescue ArgumentError => e
29
+ warnings << "#{assignment.key} was not injected (#{e.message})."
30
+ end
31
+ end
32
+
33
+ LoadResult.new(applied: applied, skipped: skipped, warnings: warnings)
34
+ end
35
+
36
+ def self.shell_exports(assignments)
37
+ collapsed, _counts = Parser.collapse(assignments)
38
+ return '' if collapsed.empty?
39
+
40
+ lines = collapsed.map do |assignment|
41
+ "export #{assignment.key}=#{shell_single_quote(assignment.value)}"
42
+ end
43
+ "#{lines.join("\n")}\n"
44
+ end
45
+
46
+ def self.shell_single_quote(value)
47
+ "'" + value.to_s.gsub("'", "'\"'\"'") + "'"
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EnvVariableCheck
4
+ # Parses a shell-style env file. Resolved values are what will be injected.
5
+ # Single quotes are literal. Double quotes and bare values expand $VAR and
6
+ # ${VAR} from earlier keys in the file, then from ENV.
7
+ class Parser
8
+ Assignment = Struct.new(:key, :value, :line, keyword_init: true)
9
+
10
+ LINE = /\A\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\z/.freeze
11
+ INTERPOLATION = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/.freeze
12
+ DOLLAR = "\uE000".freeze
13
+
14
+ def self.read(path)
15
+ if File.directory?(path)
16
+ raise Error, "#{path} is a directory, not an env file."
17
+ end
18
+ unless File.file?(path)
19
+ raise Error, "Env file not found: #{path}"
20
+ end
21
+
22
+ contents = File.binread(path)
23
+ contents.force_encoding('UTF-8')
24
+ contents.valid_encoding? ? contents : contents.scrub
25
+ end
26
+
27
+ def self.parse(contents, path: '.env')
28
+ new(contents.to_s, path).parse
29
+ end
30
+
31
+ # Last assignment wins, matching shell and ENV behavior.
32
+ # Returns [assignments, counts] where counts includes duplicates.
33
+ def self.collapse(assignments)
34
+ order = []
35
+ last = {}
36
+ counts = Hash.new(0)
37
+ assignments.each do |assignment|
38
+ order << assignment.key unless last.key?(assignment.key)
39
+ last[assignment.key] = assignment
40
+ counts[assignment.key] += 1
41
+ end
42
+ [order.map { |key| last[key] }, counts]
43
+ end
44
+
45
+ def initialize(contents, path)
46
+ normalized = contents.dup
47
+ normalized.sub!(/\A\uFEFF/, '')
48
+ normalized.gsub!(/\r\n?/, "\n")
49
+ @path = path
50
+ @lines = normalized.split("\n", -1)
51
+ @index = 0
52
+ @defined = {}
53
+ end
54
+
55
+ def parse
56
+ assignments = []
57
+ while @index < @lines.length
58
+ line = @lines[@index]
59
+ line_no = @index + 1
60
+ if blank_or_comment?(line)
61
+ @index += 1
62
+ next
63
+ end
64
+
65
+ match = line.match(LINE)
66
+ unless match
67
+ raise Error, "Cannot parse #{@path}:#{line_no}. Expected KEY=VALUE."
68
+ end
69
+
70
+ key = match[1]
71
+ style, raw = read_value(match[2], line_no)
72
+ value = resolve(raw, style)
73
+ @defined[key] = value
74
+ assignments << Assignment.new(key: key, value: value, line: line_no)
75
+ end
76
+ assignments
77
+ end
78
+
79
+ private
80
+
81
+ def blank_or_comment?(line)
82
+ stripped = line.strip
83
+ stripped.empty? || stripped.start_with?('#')
84
+ end
85
+
86
+ def read_value(rest, line_no)
87
+ if rest.start_with?("'")
88
+ [:single, read_quoted(rest, "'", line_no)]
89
+ elsif rest.start_with?('"')
90
+ [:double, read_quoted(rest, '"', line_no)]
91
+ else
92
+ @index += 1
93
+ [:bare, rest.sub(/\s+#.*\z/, '').rstrip.sub(/\A[ \t]+/, '')]
94
+ end
95
+ end
96
+
97
+ def read_quoted(rest, quote, line_no)
98
+ body = rest[1..-1].to_s
99
+ chunks = []
100
+ loop do
101
+ idx = quote == "'" ? body.index("'") : index_unescaped_quote(body)
102
+ if idx
103
+ chunks << body[0...idx]
104
+ trailing = body[(idx + 1)..-1].to_s
105
+ unless trailing.strip.empty? || trailing.strip.start_with?('#')
106
+ raise Error, "Unexpected trailing content at #{@path}:#{@index + 1}."
107
+ end
108
+ @index += 1
109
+ return chunks.join("\n")
110
+ end
111
+
112
+ chunks << body
113
+ @index += 1
114
+ if @index >= @lines.length
115
+ raise Error, "Unclosed #{quote} quote starting at #{@path}:#{line_no}."
116
+ end
117
+ body = @lines[@index]
118
+ end
119
+ end
120
+
121
+ def index_unescaped_quote(body)
122
+ search_from = 0
123
+ while (idx = body.index('"', search_from))
124
+ slashes = 0
125
+ cursor = idx - 1
126
+ while cursor >= 0 && body[cursor] == '\\'
127
+ slashes += 1
128
+ cursor -= 1
129
+ end
130
+ return idx if slashes.even?
131
+
132
+ search_from = idx + 1
133
+ end
134
+ nil
135
+ end
136
+
137
+ def resolve(raw, style)
138
+ case style
139
+ when :single
140
+ raw
141
+ when :double
142
+ interpolate(unescape_double(raw))
143
+ else
144
+ interpolate(raw.gsub('\\$', DOLLAR))
145
+ end
146
+ end
147
+
148
+ def unescape_double(value)
149
+ value.gsub(/\\./m) do |seq|
150
+ case seq[1]
151
+ when 'n' then "\n"
152
+ when 'r' then "\r"
153
+ when 't' then "\t"
154
+ when '\\' then '\\'
155
+ when '"' then '"'
156
+ when '$' then DOLLAR
157
+ else seq[1]
158
+ end
159
+ end
160
+ end
161
+
162
+ def interpolate(value)
163
+ expanded = value.gsub(INTERPOLATION) do
164
+ name = Regexp.last_match(1) || Regexp.last_match(2)
165
+ if @defined.key?(name)
166
+ @defined[name]
167
+ elsif ENV.key?(name)
168
+ ENV[name]
169
+ else
170
+ ''
171
+ end
172
+ end
173
+ expanded.gsub(DOLLAR, '$')
174
+ end
175
+ end
176
+ end