envdoctor 0.1.0 → 0.1.1
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 +4 -4
- data/README.md +32 -5
- data/lib/envdoctor/cli.rb +7 -0
- data/lib/envdoctor/scanner.rb +212 -9
- data/lib/envdoctor.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c56b394b123626a7937ba9451db0598b9825f3a83d93a8216e973b0c067b8d8a
|
|
4
|
+
data.tar.gz: 67da28da5cdd7a8bf767435c77251f031dda10c4dd727f867c5d70f5eca871fe
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 14f9554eedeb73d00fd139a20d24c7491470c56acce2902c3f2fa27039069d918807776bdd85198b479ee88a3ea178e47a9db210f3b86b9e645211ad013fddf0
|
|
7
|
+
data.tar.gz: 0ee4ca3cf10a6e51b323f8d57ff7babf7b1a6b9050ff1ea6657907129bc66eed67c51a5b874cdda180d9cfafad592ec8e74c825b8c789188916db40e7a9cc28f
|
data/README.md
CHANGED
|
@@ -3,12 +3,21 @@
|
|
|
3
3
|
Native Ruby port of [envdoctor](https://github.com/arun-skg/envdoctor) — a
|
|
4
4
|
local-first environment-variable consistency checker, packaged as a gem.
|
|
5
5
|
|
|
6
|
+
## Install
|
|
7
|
+
|
|
6
8
|
```bash
|
|
7
9
|
gem install envdoctor
|
|
8
|
-
envdoctor scan --dir .
|
|
9
10
|
```
|
|
10
11
|
|
|
11
|
-
##
|
|
12
|
+
## Quick start
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
envdoctor scan --dir . # audit; exit 1 on errors
|
|
16
|
+
envdoctor scan --strict # treat warnings as errors too
|
|
17
|
+
envdoctor scan --json # emit findings as a JSON array (values never included)
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## What it detects
|
|
12
21
|
|
|
13
22
|
Reconciles variables **used** in Ruby source (`ENV["X"]`, `ENV['X']`,
|
|
14
23
|
`ENV.fetch("X")`) against those **defined** in `.env` files:
|
|
@@ -16,10 +25,23 @@ Reconciles variables **used** in Ruby source (`ENV["X"]`, `ENV['X']`,
|
|
|
16
25
|
| Rule | Severity | Meaning |
|
|
17
26
|
|------|----------|---------|
|
|
18
27
|
| `undefined-in-source` | error | Used in code but not defined in any `.env` file |
|
|
28
|
+
| `duplicates` | error | Same key defined 2+ times in a single `.env` file |
|
|
29
|
+
| `public-prefix` | error | Secret-looking variable exposed to client bundles via a public prefix (`NEXT_PUBLIC_`, `VITE_`, `REACT_APP_`, …) |
|
|
30
|
+
| `type-mismatch` | error | Variable's inferred value type differs across environments (e.g. integer vs string) |
|
|
19
31
|
| `unused` | warning | Defined in `.env` but never referenced in source |
|
|
32
|
+
| `environment-diff` | warning | Defined in some environments but missing from others |
|
|
33
|
+
| `weak-secret` | warning | Secret-looking variable has an empty, short, or placeholder value |
|
|
34
|
+
| `typo` | warning | Used name closely matches a defined name (likely misspelling) |
|
|
35
|
+
|
|
36
|
+
Environment labels come from the `.env` filename (`.env`→`default`,
|
|
37
|
+
`.env.local`→`local`, `.env.production`→`production`,
|
|
38
|
+
`.env.production.local`→`production`); `*.example` files are skipped. Values are
|
|
39
|
+
read only to power detection and are **never** included in any output.
|
|
20
40
|
|
|
21
41
|
Comments and `=begin/=end` blocks are stripped before scanning. `scan` exits
|
|
22
|
-
`1` on errors (or warnings with `--strict`).
|
|
42
|
+
`1` on errors (or warnings with `--strict`). Pass `--json` to emit the findings
|
|
43
|
+
as a JSON array (keys: `rule`, `severity`, `name`, `message`, `file`, `line`) —
|
|
44
|
+
still without any values.
|
|
23
45
|
|
|
24
46
|
## Development
|
|
25
47
|
|
|
@@ -29,5 +51,10 @@ ruby -Ilib test/test_scanner.rb
|
|
|
29
51
|
gem build envdoctor.gemspec
|
|
30
52
|
```
|
|
31
53
|
|
|
32
|
-
|
|
33
|
-
|
|
54
|
+
## Other languages
|
|
55
|
+
|
|
56
|
+
envdoctor ships as a standalone native port for each ecosystem:
|
|
57
|
+
|
|
58
|
+
- [Node (reference)](..) · [Python](../python) · [Go](../go) · [PHP](../php) · [Java](../java) · [Perl](../perl)
|
|
59
|
+
- 📖 Docs: [arun-skg.github.io/envdoctor](https://arun-skg.github.io/envdoctor/)
|
|
60
|
+
- Main repository: [github.com/arun-skg/envdoctor](https://github.com/arun-skg/envdoctor)
|
data/lib/envdoctor/cli.rb
CHANGED
|
@@ -11,10 +11,12 @@ module Envdoctor
|
|
|
11
11
|
def run(argv)
|
|
12
12
|
dir = "."
|
|
13
13
|
strict = false
|
|
14
|
+
json = false
|
|
14
15
|
parser = OptionParser.new do |o|
|
|
15
16
|
o.banner = "Usage: envdoctor scan [options]"
|
|
16
17
|
o.on("-d", "--dir DIR", "Project root (default: cwd)") { |v| dir = v }
|
|
17
18
|
o.on("--strict", "Treat warnings as errors") { strict = true }
|
|
19
|
+
o.on("--json", "Emit findings as a JSON array") { json = true }
|
|
18
20
|
end
|
|
19
21
|
args = argv.dup
|
|
20
22
|
args.shift if args.first == "scan"
|
|
@@ -25,6 +27,11 @@ module Envdoctor
|
|
|
25
27
|
errors = findings.select { |f| f.severity == "error" }
|
|
26
28
|
warnings = findings.select { |f| f.severity == "warning" }
|
|
27
29
|
|
|
30
|
+
if json
|
|
31
|
+
puts Scanner.to_json_array(findings)
|
|
32
|
+
return (!errors.empty? || (strict && !warnings.empty?)) ? 1 : 0
|
|
33
|
+
end
|
|
34
|
+
|
|
28
35
|
puts "ENVIRONMENT AUDIT"
|
|
29
36
|
puts "=" * 40
|
|
30
37
|
if findings.empty?
|
data/lib/envdoctor/scanner.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
3
5
|
module Envdoctor
|
|
4
6
|
# Core scanner: reconcile ENV usage in Ruby source against .env definitions.
|
|
5
7
|
# Local-first — no network, values never printed.
|
|
@@ -13,7 +15,18 @@ module Envdoctor
|
|
|
13
15
|
|
|
14
16
|
ENV_LINE = /\A\s*(?:export\s+)?([A-Za-z_]\w*)\s*=/.freeze
|
|
15
17
|
|
|
18
|
+
PUBLIC_PREFIXES = %w[
|
|
19
|
+
NEXT_PUBLIC_ VITE_ REACT_APP_ EXPO_PUBLIC_ GATSBY_ NUXT_PUBLIC_ VUE_APP_ PUBLIC_
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
SECRET_RE = /SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE|CREDENTIAL|API_?KEY|ACCESS_?KEY|AUTH/i.freeze
|
|
23
|
+
|
|
24
|
+
WEAK_VALUE_RE =
|
|
25
|
+
/\A(changeme|change_me|placeholder|x{3,}|todo|secret|password|passwd|test|example|sample|dummy|your[_-].*|<.*>|\$\{.*\})\z/i.freeze
|
|
26
|
+
|
|
16
27
|
Origin = Struct.new(:file, :line)
|
|
28
|
+
# A single .env definition occurrence: its line and parsed value.
|
|
29
|
+
Definition = Struct.new(:line, :value)
|
|
17
30
|
Finding = Struct.new(:rule, :severity, :name, :message, :origin)
|
|
18
31
|
|
|
19
32
|
# Blank comments and =begin/=end blocks, preserving line structure.
|
|
@@ -38,6 +51,21 @@ module Envdoctor
|
|
|
38
51
|
used
|
|
39
52
|
end
|
|
40
53
|
|
|
54
|
+
# Parse the VALUE to the right of the first `=`: trim, then strip one pair
|
|
55
|
+
# of matching surrounding quotes. Values are used ONLY for detection and are
|
|
56
|
+
# never surfaced in any output.
|
|
57
|
+
def parse_value(raw)
|
|
58
|
+
idx = raw.index("=")
|
|
59
|
+
return "" if idx.nil?
|
|
60
|
+
|
|
61
|
+
value = raw[(idx + 1)..].to_s.strip
|
|
62
|
+
if value.length >= 2 && %w[" '].include?(value[0]) && value[-1] == value[0]
|
|
63
|
+
value = value[1..-2]
|
|
64
|
+
end
|
|
65
|
+
value
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Returns { name => [Definition, ...] } with ALL occurrences per key in order.
|
|
41
69
|
def parse_env(path, content)
|
|
42
70
|
defined = {}
|
|
43
71
|
content.split("\n").each_with_index do |raw, i|
|
|
@@ -45,12 +73,70 @@ module Envdoctor
|
|
|
45
73
|
next if stripped.empty? || stripped.start_with?("#")
|
|
46
74
|
|
|
47
75
|
if (m = raw.match(ENV_LINE))
|
|
48
|
-
defined[m[1]] ||=
|
|
76
|
+
(defined[m[1]] ||= []) << Definition.new(i + 1, parse_value(raw))
|
|
49
77
|
end
|
|
50
78
|
end
|
|
51
79
|
defined
|
|
52
80
|
end
|
|
53
81
|
|
|
82
|
+
# Derive the environment label from a .env filename.
|
|
83
|
+
def env_label(filename)
|
|
84
|
+
base = File.basename(filename)
|
|
85
|
+
return "default" if base == ".env"
|
|
86
|
+
|
|
87
|
+
label = base.sub(/\A\.env\./, "")
|
|
88
|
+
label = label.sub(/\.local\z/, "") if label.end_with?(".local")
|
|
89
|
+
label
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def public_prefix?(name)
|
|
93
|
+
PUBLIC_PREFIXES.any? { |p| name.start_with?(p) } && SECRET_RE.match?(name)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Infer the coarse type of a value string.
|
|
97
|
+
def infer_type(value)
|
|
98
|
+
return "empty" if value.empty?
|
|
99
|
+
return "integer" if value.match?(/\A-?\d+\z/)
|
|
100
|
+
return "float" if value.match?(/\A-?\d+\.\d+\z/)
|
|
101
|
+
return "boolean" if value.match?(/\A(true|false)\z/i)
|
|
102
|
+
return "url" if value.match?(%r{\Ahttps?://})
|
|
103
|
+
|
|
104
|
+
if value.start_with?("{", "[")
|
|
105
|
+
begin
|
|
106
|
+
JSON.parse(value)
|
|
107
|
+
return "json"
|
|
108
|
+
rescue JSON::ParserError
|
|
109
|
+
# fall through to string
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
"string"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Compatibility group for an inferred type (integer/float collapse to numeric).
|
|
116
|
+
def type_group(type)
|
|
117
|
+
%w[integer float].include?(type) ? "numeric" : type
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def weak_secret?(value)
|
|
121
|
+
value.empty? || value.length < 8 || WEAK_VALUE_RE.match?(value)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def levenshtein(a, b)
|
|
125
|
+
return b.length if a.empty?
|
|
126
|
+
return a.length if b.empty?
|
|
127
|
+
|
|
128
|
+
prev = (0..b.length).to_a
|
|
129
|
+
a.each_char.with_index do |ca, i|
|
|
130
|
+
curr = [i + 1]
|
|
131
|
+
b.each_char.with_index do |cb, j|
|
|
132
|
+
cost = ca == cb ? 0 : 1
|
|
133
|
+
curr << [curr[j] + 1, prev[j + 1] + 1, prev[j] + cost].min
|
|
134
|
+
end
|
|
135
|
+
prev = curr
|
|
136
|
+
end
|
|
137
|
+
prev[b.length]
|
|
138
|
+
end
|
|
139
|
+
|
|
54
140
|
def discover_env_files(root)
|
|
55
141
|
files = Dir.glob(File.join(root, ".env"))
|
|
56
142
|
files += Dir.glob(File.join(root, ".env.*")).reject { |f| f.end_with?(".example") }
|
|
@@ -64,9 +150,32 @@ module Envdoctor
|
|
|
64
150
|
end
|
|
65
151
|
|
|
66
152
|
def scan(root)
|
|
67
|
-
defined = {}
|
|
153
|
+
defined = {} # name => Origin (first definition wins)
|
|
154
|
+
defined_value = {} # name => value at first definition
|
|
155
|
+
labels_of = {} # name => { label => value } (first value per label)
|
|
156
|
+
project_labels = []
|
|
157
|
+
dup_findings = []
|
|
158
|
+
|
|
68
159
|
discover_env_files(root).each do |f|
|
|
69
|
-
|
|
160
|
+
rel = relative(root, f)
|
|
161
|
+
label = env_label(f)
|
|
162
|
+
project_labels << label unless project_labels.include?(label)
|
|
163
|
+
parse_env(rel, File.read(f)).each do |name, defs|
|
|
164
|
+
if defs.length >= 2
|
|
165
|
+
lines = defs.map(&:line)
|
|
166
|
+
dup_findings << Finding.new("duplicates", "error", name,
|
|
167
|
+
"defined #{defs.length} times in the same file " \
|
|
168
|
+
"(lines #{lines.join(', ')})",
|
|
169
|
+
Origin.new(rel, defs.first.line))
|
|
170
|
+
end
|
|
171
|
+
# First occurrence (first file wins) counts as the definition.
|
|
172
|
+
unless defined.key?(name)
|
|
173
|
+
defined[name] = Origin.new(rel, defs.first.line)
|
|
174
|
+
defined_value[name] = defs.first.value
|
|
175
|
+
end
|
|
176
|
+
bucket = (labels_of[name] ||= {})
|
|
177
|
+
bucket[label] = defs.first.value unless bucket.key?(label)
|
|
178
|
+
end
|
|
70
179
|
end
|
|
71
180
|
|
|
72
181
|
used = {}
|
|
@@ -74,21 +183,115 @@ module Envdoctor
|
|
|
74
183
|
scan_source(relative(root, f), File.read(f)).each { |k, v| used[k] ||= v }
|
|
75
184
|
end
|
|
76
185
|
|
|
77
|
-
|
|
186
|
+
errors = []
|
|
187
|
+
warnings = []
|
|
188
|
+
|
|
189
|
+
# --- errors: undefined-in-source ---
|
|
78
190
|
used.keys.sort.each do |name|
|
|
79
191
|
next if defined.key?(name)
|
|
80
192
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
193
|
+
errors << Finding.new("undefined-in-source", "error", name,
|
|
194
|
+
"used in source code but not defined in any environment file",
|
|
195
|
+
used[name])
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# --- errors: duplicates ---
|
|
199
|
+
dup_findings.sort_by(&:name).each { |finding| errors << finding }
|
|
200
|
+
|
|
201
|
+
# --- errors: public-prefix ---
|
|
202
|
+
defined.keys.sort.each do |name|
|
|
203
|
+
next unless public_prefix?(name)
|
|
204
|
+
|
|
205
|
+
errors << Finding.new("public-prefix", "error", name,
|
|
206
|
+
"secret-looking variable is exposed to client bundles " \
|
|
207
|
+
"via a public prefix", defined[name])
|
|
84
208
|
end
|
|
209
|
+
|
|
210
|
+
# --- errors: type-mismatch ---
|
|
211
|
+
defined.keys.sort.each do |name|
|
|
212
|
+
labels = labels_of[name] || {}
|
|
213
|
+
next if labels.size < 2
|
|
214
|
+
|
|
215
|
+
groups = labels.values.map { |v| infer_type(v) }.reject { |t| t == "empty" }
|
|
216
|
+
.map { |t| type_group(t) }.uniq
|
|
217
|
+
next if groups.size < 2
|
|
218
|
+
|
|
219
|
+
errors << Finding.new("type-mismatch", "error", name,
|
|
220
|
+
"inferred type differs across environments", defined[name])
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# --- warnings: unused ---
|
|
85
224
|
defined.keys.sort.each do |name|
|
|
86
225
|
next if used.key?(name)
|
|
87
226
|
|
|
88
|
-
|
|
227
|
+
warnings << Finding.new("unused", "warning", name,
|
|
89
228
|
"defined but never referenced in source", defined[name])
|
|
90
229
|
end
|
|
91
|
-
|
|
230
|
+
|
|
231
|
+
# --- warnings: environment-diff ---
|
|
232
|
+
if project_labels.length >= 2
|
|
233
|
+
defined.keys.sort.each do |name|
|
|
234
|
+
present = (labels_of[name] || {}).keys.sort
|
|
235
|
+
absent = (project_labels - present).sort
|
|
236
|
+
next if present.empty? || absent.empty?
|
|
237
|
+
|
|
238
|
+
warnings << Finding.new("environment-diff", "warning", name,
|
|
239
|
+
"defined in #{present.join(', ')} but missing in " \
|
|
240
|
+
"#{absent.join(', ')}", defined[name])
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# --- warnings: weak-secret ---
|
|
245
|
+
defined.keys.sort.each do |name|
|
|
246
|
+
next unless SECRET_RE.match?(name)
|
|
247
|
+
next unless weak_secret?(defined_value[name].to_s)
|
|
248
|
+
|
|
249
|
+
warnings << Finding.new("weak-secret", "warning", name,
|
|
250
|
+
"secret-looking variable has a weak or placeholder value",
|
|
251
|
+
defined[name])
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# --- warnings: typo ---
|
|
255
|
+
defined_names = defined.keys
|
|
256
|
+
used.keys.sort.each do |u|
|
|
257
|
+
next if defined.key?(u)
|
|
258
|
+
|
|
259
|
+
best = nil
|
|
260
|
+
best_dist = nil
|
|
261
|
+
defined_names.each do |d|
|
|
262
|
+
next if d == u
|
|
263
|
+
|
|
264
|
+
limit = [u.length, d.length].min <= 4 ? 1 : 2
|
|
265
|
+
dist = levenshtein(u, d)
|
|
266
|
+
next if dist > limit
|
|
267
|
+
|
|
268
|
+
if best.nil? || dist < best_dist || (dist == best_dist && d < best)
|
|
269
|
+
best = d
|
|
270
|
+
best_dist = dist
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
next if best.nil?
|
|
274
|
+
|
|
275
|
+
warnings << Finding.new("typo", "warning", u,
|
|
276
|
+
"\"#{u}\" is not defined; did you mean \"#{best}\"?",
|
|
277
|
+
used[u])
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
errors + warnings
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Serialize findings to the shared JSON shape. Values never appear.
|
|
284
|
+
def to_json_array(findings)
|
|
285
|
+
JSON.generate(findings.map do |f|
|
|
286
|
+
{
|
|
287
|
+
"rule" => f.rule,
|
|
288
|
+
"severity" => f.severity,
|
|
289
|
+
"name" => f.name,
|
|
290
|
+
"message" => f.message,
|
|
291
|
+
"file" => f.origin&.file,
|
|
292
|
+
"line" => f.origin&.line
|
|
293
|
+
}
|
|
294
|
+
end)
|
|
92
295
|
end
|
|
93
296
|
|
|
94
297
|
def relative(root, path)
|
data/lib/envdoctor.rb
CHANGED