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,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,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
|