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

Class Method Summary collapse

Class Method Details

.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_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_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

.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

.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



297
298
299
# File 'lib/envdoctor/scanner.rb', line 297

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

.scan(root) ⇒ Object



152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
# File 'lib/envdoctor/scanner.rb', line 152

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

  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,
                          "used in source code 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

  # --- 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_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

.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

.to_json_array(findings) ⇒ Object

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



284
285
286
287
288
289
290
291
292
293
294
295
# File 'lib/envdoctor/scanner.rb', line 284

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