Module: Envdoctor::Scanner

Defined in:
lib/envdoctor/scanner.rb

Overview

Core scanner: reconcile ENV usage in Ruby source against .env definitions. Local-first — no network, values never printed.

Defined Under Namespace

Classes: Definition, Finding, Origin

Constant Summary collapse

USAGE_PATTERNS =
[
  /\bENV\[\s*["']([A-Za-z_]\w*)["']\s*\]/,
  /\bENV\.fetch\(\s*["']([A-Za-z_]\w*)["']/
].freeze
ENV_LINE =
/\A\s*(?:export\s+)?([A-Za-z_]\w*)\s*=/.freeze
PUBLIC_PREFIXES =
%w[
  NEXT_PUBLIC_ VITE_ REACT_APP_ EXPO_PUBLIC_ GATSBY_ NUXT_PUBLIC_ VUE_APP_ PUBLIC_
].freeze
SECRET_RE =
/SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE|CREDENTIAL|API_?KEY|ACCESS_?KEY|AUTH/i.freeze
WEAK_VALUE_RE =
/\A(changeme|change_me|placeholder|x{3,}|todo|secret|password|passwd|test|example|sample|dummy|your[_-].*|<.*>|\$\{.*\})\z/i.freeze
COMPOSE_RE =
/\A(docker-)?compose([.-].*)?\.ya?ml\z/i.freeze
INTERP_PATTERNS =
[
  /\$\{([A-Za-z_][A-Za-z0-9_]*)/,
  /\$([A-Za-z_][A-Za-z0-9_]*)/
].freeze
ACTIONS_CONTEXT_RE =
/\b(?:secrets|vars|env)\.([A-Za-z_][A-Za-z0-9_]*)/.freeze
NUMERIC_RE =

Serialize findings to the shared JSON shape. Values never appear.

/\A-?\d+(\.\d+)?\z/.freeze

Class Method Summary collapse

Class Method Details

.collect_names(root) ⇒ Object

Collect the DEFINED (from .env*) and USED (source + infra) name sets for a project. Returns [defined_hash, used_hash] with name => true entries.



246
247
248
249
250
251
252
253
254
255
256
257
258
259
# File 'lib/envdoctor/scanner.rb', line 246

def collect_names(root)
  defined = {}
  discover_env_files(root).each do |f|
    parse_env(relative(root, f), File.read(f)).each_key { |name| defined[name] = true }
  end
  used = {}
  discover_source_files(root).each do |f|
    scan_source(relative(root, f), File.read(f)).each_key { |k| used[k] = true }
  end
  discover_infra_files(root).each do |f, kind|
    scan_infra(relative(root, f), File.read(f), kind).each_key { |k| used[k] = true }
  end
  [defined, used]
end

.defined_by_label(root) ⇒ Object

Map each environment label to the set of variable names defined in it.



210
211
212
213
214
215
216
217
# File 'lib/envdoctor/scanner.rb', line 210

def defined_by_label(root)
  labels = {}
  discover_env_files(root).each do |f|
    set = (labels[env_label(f)] ||= [])
    parse_env(f, File.read(f)).each_key { |name| set << name unless set.include?(name) }
  end
  labels
end

.diff_labels(root, a, b) ⇒ Object



219
220
221
222
223
224
225
226
227
228
# File 'lib/envdoctor/scanner.rb', line 219

def diff_labels(root, a, b)
  labels = defined_by_label(root)
  da = labels[a] || []
  db = labels[b] || []
  {
    "onlyInA" => (da - db).sort,
    "onlyInB" => (db - da).sort,
    "common" => (da & db).sort
  }
end

.discover_env_files(root) ⇒ Object



140
141
142
143
144
# File 'lib/envdoctor/scanner.rb', line 140

def discover_env_files(root)
  files = Dir.glob(File.join(root, ".env"))
  files += Dir.glob(File.join(root, ".env.*")).reject { |f| f.end_with?(".example") }
  files.sort
end

.discover_infra_files(root) ⇒ Object

Walk the project and classify infra YAML files. Returns [[path, kind], ...] where kind is one of :compose, :actions, :k8s.



160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
# File 'lib/envdoctor/scanner.rb', line 160

def discover_infra_files(root)
  out = []
  Dir.glob(File.join(root, "**", "*"), File::FNM_DOTMATCH).each do |path|
    next unless File.file?(path)
    next if skip_infra_path?(path)

    base = File.basename(path)
    segments = path.split(File::SEPARATOR)
    if base.match?(COMPOSE_RE)
      out << [path, :compose]
    elsif base =~ /\.ya?ml\z/i &&
          segments.each_cons(2).any? { |a, b| a == ".github" && b == "workflows" }
      out << [path, :actions]
    elsif base =~ /\.ya?ml\z/i
      content = File.read(path)
      out << [path, :k8s] if content.match?(/^apiVersion:/m) && content.match?(/^kind:/m)
    end
  end
  out.sort_by(&:first)
end

.discover_source_files(root) ⇒ Object



146
147
148
149
150
# File 'lib/envdoctor/scanner.rb', line 146

def discover_source_files(root)
  Dir.glob(File.join(root, "**", "*.rb")).reject do |p|
    p.split(File::SEPARATOR).any? { |part| %w[.git vendor node_modules].include?(part) }
  end.sort
end

.env_example_content(root) ⇒ Object

Exact .env.example content: header, then NAME= per (defined ∪ used), sorted ascending. Values are never written.



263
264
265
266
267
268
# File 'lib/envdoctor/scanner.rb', line 263

def env_example_content(root)
  defined, used = collect_names(root)
  names = (defined.keys + used.keys).uniq.sort
  "# Generated by envdoctor. Fill in values; do not commit secrets.\n" +
    names.map { |n| "#{n}=\n" }.join
end

.env_label(filename) ⇒ Object

Derive the environment label from a .env filename.



83
84
85
86
87
88
89
90
# File 'lib/envdoctor/scanner.rb', line 83

def env_label(filename)
  base = File.basename(filename)
  return "default" if base == ".env"

  label = base.sub(/\A\.env\./, "")
  label = label.sub(/\.local\z/, "") if label.end_with?(".local")
  label
end

.environment_doc_content(root) ⇒ Object

Exact ENVIRONMENT.md content: a table of Defined/Used per variable.



271
272
273
274
275
276
277
278
279
# File 'lib/envdoctor/scanner.rb', line 271

def environment_doc_content(root)
  defined, used = collect_names(root)
  names = (defined.keys + used.keys).uniq.sort
  lines = ["# Environment variables", "", "| Variable | Defined | Used |", "| --- | --- | --- |"]
  names.each do |n|
    lines << "| #{n} | #{defined.key?(n) ? 'yes' : 'no'} | #{used.key?(n) ? 'yes' : 'no'} |"
  end
  lines.join("\n") + "\n"
end

.infer_type(value) ⇒ Object

Infer the coarse type of a value string.



97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
# File 'lib/envdoctor/scanner.rb', line 97

def infer_type(value)
  return "empty" if value.empty?
  return "integer" if value.match?(/\A-?\d+\z/)
  return "float" if value.match?(/\A-?\d+\.\d+\z/)
  return "boolean" if value.match?(/\A(true|false)\z/i)
  return "url" if value.match?(%r{\Ahttps?://})

  if value.start_with?("{", "[")
    begin
      JSON.parse(value)
      return "json"
    rescue JSON::ParserError
      # fall through to string
    end
  end
  "string"
end

.levenshtein(a, b) ⇒ Object



124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
# File 'lib/envdoctor/scanner.rb', line 124

def levenshtein(a, b)
  return b.length if a.empty?
  return a.length if b.empty?

  prev = (0..b.length).to_a
  a.each_char.with_index do |ca, i|
    curr = [i + 1]
    b.each_char.with_index do |cb, j|
      cost = ca == cb ? 0 : 1
      curr << [curr[j] + 1, prev[j + 1] + 1, prev[j] + cost].min
    end
    prev = curr
  end
  prev[b.length]
end

.load_schema(root) ⇒ Object



433
434
435
436
437
438
439
# File 'lib/envdoctor/scanner.rb', line 433

def load_schema(root)
  require "json"
  data = JSON.parse(File.read(File.join(root, "envdoctor.schema.json")))
  data.is_a?(Hash) ? data : {}
rescue StandardError
  {}
end

.parse_env(path, content) ⇒ Object

Returns { name => [Definition, ...] } with ALL occurrences per key in order.



69
70
71
72
73
74
75
76
77
78
79
80
# File 'lib/envdoctor/scanner.rb', line 69

def parse_env(path, content)
  defined = {}
  content.split("\n").each_with_index do |raw, i|
    stripped = raw.strip
    next if stripped.empty? || stripped.start_with?("#")

    if (m = raw.match(ENV_LINE))
      (defined[m[1]] ||= []) << Definition.new(i + 1, parse_value(raw))
    end
  end
  defined
end

.parse_value(raw) ⇒ Object

Parse the VALUE to the right of the first =: trim, then strip one pair of matching surrounding quotes. Values are used ONLY for detection and are never surfaced in any output.



57
58
59
60
61
62
63
64
65
66
# File 'lib/envdoctor/scanner.rb', line 57

def parse_value(raw)
  idx = raw.index("=")
  return "" if idx.nil?

  value = raw[(idx + 1)..].to_s.strip
  if value.length >= 2 && %w[" '].include?(value[0]) && value[-1] == value[0]
    value = value[1..-2]
  end
  value
end

.public_prefix?(name) ⇒ Boolean

Returns:

  • (Boolean)


92
93
94
# File 'lib/envdoctor/scanner.rb', line 92

def public_prefix?(name)
  PUBLIC_PREFIXES.any? { |p| name.start_with?(p) } && SECRET_RE.match?(name)
end

.relative(root, path) ⇒ Object



493
494
495
# File 'lib/envdoctor/scanner.rb', line 493

def relative(root, path)
  path.sub(/\A#{Regexp.escape(root)}#{Regexp.escape(File::SEPARATOR)}?/, "")
end

.scan(root) ⇒ Object



281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
# File 'lib/envdoctor/scanner.rb', line 281

def scan(root)
  defined = {}          # name => Origin (first definition wins)
  defined_value = {}    # name => value at first definition
  labels_of = {}        # name => { label => value } (first value per label)
  project_labels = []
  dup_findings = []

  discover_env_files(root).each do |f|
    rel = relative(root, f)
    label = env_label(f)
    project_labels << label unless project_labels.include?(label)
    parse_env(rel, File.read(f)).each do |name, defs|
      if defs.length >= 2
        lines = defs.map(&:line)
        dup_findings << Finding.new("duplicates", "error", name,
                                    "defined #{defs.length} times in the same file " \
                                    "(lines #{lines.join(', ')})",
                                    Origin.new(rel, defs.first.line))
      end
      # First occurrence (first file wins) counts as the definition.
      unless defined.key?(name)
        defined[name] = Origin.new(rel, defs.first.line)
        defined_value[name] = defs.first.value
      end
      bucket = (labels_of[name] ||= {})
      bucket[label] = defs.first.value unless bucket.key?(label)
    end
  end

  used = {}
  discover_source_files(root).each do |f|
    scan_source(relative(root, f), File.read(f)).each { |k, v| used[k] ||= v }
  end
  # Infra sources (Docker Compose / GitHub Actions / Kubernetes) contribute
  # additional used names; first origin wins.
  discover_infra_files(root).each do |f, kind|
    scan_infra(relative(root, f), File.read(f), kind).each { |k, v| used[k] ||= v }
  end

  errors = []
  warnings = []

  # --- errors: undefined-in-source ---
  used.keys.sort.each do |name|
    next if defined.key?(name)

    errors << Finding.new("undefined-in-source", "error", name,
                          "referenced but not defined in any environment file",
                          used[name])
  end

  # --- errors: duplicates ---
  dup_findings.sort_by(&:name).each { |finding| errors << finding }

  # --- errors: public-prefix ---
  defined.keys.sort.each do |name|
    next unless public_prefix?(name)

    errors << Finding.new("public-prefix", "error", name,
                          "secret-looking variable is exposed to client bundles " \
                          "via a public prefix", defined[name])
  end

  # --- errors: type-mismatch ---
  defined.keys.sort.each do |name|
    labels = labels_of[name] || {}
    next if labels.size < 2

    groups = labels.values.map { |v| infer_type(v) }.reject { |t| t == "empty" }
                   .map { |t| type_group(t) }.uniq
    next if groups.size < 2

    errors << Finding.new("type-mismatch", "error", name,
                          "inferred type differs across environments", defined[name])
  end

  # --- errors: schema-validation ---
  load_schema(root).sort.each do |name, rule|
    next unless rule.is_a?(Hash)

    if defined.key?(name)
      msg = schema_failure(rule, defined_value[name])
      errors << Finding.new("schema-validation", "error", name, msg, defined[name]) if msg
    elsif !rule["optional"]
      errors << Finding.new("schema-validation", "error", name,
                            "required by schema but not defined", nil)
    end
  end

  # --- warnings: unused ---
  defined.keys.sort.each do |name|
    next if used.key?(name)

    warnings << Finding.new("unused", "warning", name,
                            "defined but never referenced in source", defined[name])
  end

  # --- warnings: environment-diff ---
  if project_labels.length >= 2
    defined.keys.sort.each do |name|
      present = (labels_of[name] || {}).keys.sort
      absent = (project_labels - present).sort
      next if present.empty? || absent.empty?

      warnings << Finding.new("environment-diff", "warning", name,
                              "defined in #{present.join(', ')} but missing in " \
                              "#{absent.join(', ')}", defined[name])
    end
  end

  # --- warnings: weak-secret ---
  defined.keys.sort.each do |name|
    next unless SECRET_RE.match?(name)
    next unless weak_secret?(defined_value[name].to_s)

    warnings << Finding.new("weak-secret", "warning", name,
                            "secret-looking variable has a weak or placeholder value",
                            defined[name])
  end

  # --- warnings: typo ---
  defined_names = defined.keys
  used.keys.sort.each do |u|
    next if defined.key?(u)

    best = nil
    best_dist = nil
    defined_names.each do |d|
      next if d == u

      limit = [u.length, d.length].min <= 4 ? 1 : 2
      dist = levenshtein(u, d)
      next if dist > limit

      if best.nil? || dist < best_dist || (dist == best_dist && d < best)
        best = d
        best_dist = dist
      end
    end
    next if best.nil?

    warnings << Finding.new("typo", "warning", u,
                            "\"#{u}\" is not defined; did you mean \"#{best}\"?",
                            used[u])
  end

  errors + warnings
end

.scan_infra(path, content, kind) ⇒ Object

Extract used variable NAMES (with origin) from an infra YAML file. First removes escaped $$ (two spaces, offsets preserved), then scans for interpolation and, for GitHub Actions, the secrets/vars/env contexts.



191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
# File 'lib/envdoctor/scanner.rb', line 191

def scan_infra(path, content, kind)
  text = content.gsub("$$", "  ")
  used = {}
  patterns = INTERP_PATTERNS.dup
  patterns << ACTIONS_CONTEXT_RE if kind == :actions
  patterns.each do |re|
    text.to_enum(:scan, re).each do
      match = Regexp.last_match
      name = match[1]
      next if used.key?(name)

      line = text[0...match.begin(0)].count("\n") + 1
      used[name] = Origin.new(path, line)
    end
  end
  used
end

.scan_source(path, content) ⇒ Object



38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# File 'lib/envdoctor/scanner.rb', line 38

def scan_source(path, content)
  text = strip_noise(content)
  used = {}
  USAGE_PATTERNS.each do |re|
    text.to_enum(:scan, re).each do
      match = Regexp.last_match
      name = match[1]
      next if used.key?(name)

      line = text[0...match.begin(0)].count("\n") + 1
      used[name] = Origin.new(path, line)
    end
  end
  used
end

.schema_failure(rule, value) ⇒ Object



453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
# File 'lib/envdoctor/scanner.rb', line 453

def schema_failure(rule, value)
  t = rule["type"]
  return "value does not match schema type #{t}" if t.is_a?(String) && !schema_type_ok(value, t)

  enum = rule["enum"]
  return "value is not one of the allowed values" if enum.is_a?(Array) && !enum.include?(value)

  pattern = rule["regex"]
  if pattern.is_a?(String)
    begin
      return "value does not match the required pattern" if value !~ Regexp.new(pattern)
    rescue RegexpError
      # ignore invalid pattern
    end
  end

  if value.match?(NUMERIC_RE)
    num = value.to_f
    lo = rule["min"]
    return "value is below the minimum" if lo.is_a?(Numeric) && num < lo

    hi = rule["max"]
    return "value exceeds the maximum" if hi.is_a?(Numeric) && num > hi
  end
  nil
end

.schema_type_ok(value, declared) ⇒ Object



441
442
443
444
445
446
447
448
449
450
451
# File 'lib/envdoctor/scanner.rb', line 441

def schema_type_ok(value, declared)
  case declared
  when "string" then true
  when "integer" then value.match?(/\A-?\d+\z/)
  when "float" then value.match?(/\A-?\d+(\.\d+)?\z/)
  when "boolean" then %w[true false].include?(value.downcase)
  when "url" then value.match?(%r{\Ahttps?://})
  when "json" then (JSON.parse(value); true rescue false)
  else true
  end
end

.skip_infra_path?(path) ⇒ Boolean

Returns:

  • (Boolean)


154
155
156
# File 'lib/envdoctor/scanner.rb', line 154

def skip_infra_path?(path)
  path.split(File::SEPARATOR).any? { |part| %w[.git vendor node_modules target].include?(part) }
end

.strip_noise(code) ⇒ Object

Blank comments and =begin/=end blocks, preserving line structure.



33
34
35
36
# File 'lib/envdoctor/scanner.rb', line 33

def strip_noise(code)
  code = code.gsub(/^=begin\b.*?^=end\b[^\n]*/m) { |m| m.gsub(/[^\n]/, " ") }
  code.gsub(/#[^\n]*/) { |m| " " * m.length }
end

.sync_labels(root, from, to, dry_run: false) ⇒ Object

Append keys present in from but missing from to as KEY= placeholders. Values are never copied.



232
233
234
235
236
237
238
239
240
241
242
# File 'lib/envdoctor/scanner.rb', line 232

def sync_labels(root, from, to, dry_run: false)
  labels = defined_by_label(root)
  missing = ((labels[from] || []) - (labels[to] || [])).sort
  if !missing.empty? && !dry_run
    target = File.join(root, to == "default" ? ".env" : ".env.#{to}")
    existing = File.exist?(target) ? File.read(target) : ""
    prefix = (existing.empty? || existing.end_with?("\n")) ? "" : "\n"
    File.open(target, "a") { |fh| fh.write(prefix + missing.map { |k| "#{k}=\n" }.join) }
  end
  missing
end

.to_json_array(findings) ⇒ Object



480
481
482
483
484
485
486
487
488
489
490
491
# File 'lib/envdoctor/scanner.rb', line 480

def to_json_array(findings)
  JSON.generate(findings.map do |f|
    {
      "rule" => f.rule,
      "severity" => f.severity,
      "name" => f.name,
      "message" => f.message,
      "file" => f.origin&.file,
      "line" => f.origin&.line
    }
  end)
end

.type_group(type) ⇒ Object

Compatibility group for an inferred type (integer/float collapse to numeric).



116
117
118
# File 'lib/envdoctor/scanner.rb', line 116

def type_group(type)
  %w[integer float].include?(type) ? "numeric" : type
end

.weak_secret?(value) ⇒ Boolean

Returns:

  • (Boolean)


120
121
122
# File 'lib/envdoctor/scanner.rb', line 120

def weak_secret?(value)
  value.empty? || value.length < 8 || WEAK_VALUE_RE.match?(value)
end