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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ec3513e2cfdb6870cd1608bf7d5a3cf2c831113187ff3ca34ed6fe9539a231d5
4
- data.tar.gz: 76b4d2234288c38d2c20c27ee39db004a69da9adfe945a7ef6a856a5e56f7f5b
3
+ metadata.gz: c56b394b123626a7937ba9451db0598b9825f3a83d93a8216e973b0c067b8d8a
4
+ data.tar.gz: 67da28da5cdd7a8bf767435c77251f031dda10c4dd727f867c5d70f5eca871fe
5
5
  SHA512:
6
- metadata.gz: fc16e6e1ca03e96d1e72a88a05391370afc810790dae1d338a77759d9152b3cdb0ad6ecfd86d51faf093196144eeb2ba66ba352822c3859718114c5a0e0a9fc0
7
- data.tar.gz: fada228584d093fb6fc71ecc9dbda5cd2d7fe20b66c646e490383c981e87d01db9b2d3e56fb79ca0a5ac8d831e9fcfd569d7d42e837aca2af1bff8513d9a7e47
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
- ## What it does
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`). Values are never printed.
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
- One of several native, per-ecosystem ports; the reference implementation lives
33
- in the [main repository](https://github.com/arun-skg/envdoctor).
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?
@@ -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]] ||= Origin.new(path, i + 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
- parse_env(relative(root, f), File.read(f)).each { |k, v| defined[k] ||= v }
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
- findings = []
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
- findings << Finding.new("undefined-in-source", "error", name,
82
- "used in source code but not defined in any environment file",
83
- used[name])
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
- findings << Finding.new("unused", "warning", name,
227
+ warnings << Finding.new("unused", "warning", name,
89
228
  "defined but never referenced in source", defined[name])
90
229
  end
91
- findings
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
@@ -4,5 +4,5 @@ require_relative "envdoctor/scanner"
4
4
  require_relative "envdoctor/cli"
5
5
 
6
6
  module Envdoctor
7
- VERSION = "0.1.0"
7
+ VERSION = "0.1.1"
8
8
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: envdoctor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Arun Natesan