envdoctor 0.1.0 → 0.1.2
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 +75 -7
- data/lib/envdoctor/cli.rb +115 -2
- data/lib/envdoctor/scanner.rb +408 -9
- data/lib/envdoctor.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2d2670c59c370dc3e03b3f3e91a28285d064670f6551093d6775eb235ec2b6bb
|
|
4
|
+
data.tar.gz: 82c7e7a3c9fa5bb3c171cb78cbd90c646a54f699d8063afdf8ce847f4e593082
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9a800d6307b081f2509adf2a262de63c5bae54d0b29a441ffb8dfc6daa746097e94410126e255cdaf2c94b8f8aa10667a6955bf4f3ca85284bd6bf0b19cd6ba1
|
|
7
|
+
data.tar.gz: fc5b091e8345c4da7c7fe02dfa5df00319ef12b8e437b9f910733dadb375e7ab6061773adaed398c09df120f3b27fd87891cec31967503021fcab6322f919fbe
|
data/README.md
CHANGED
|
@@ -3,23 +3,49 @@
|
|
|
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
|
-
`ENV.fetch("X")`) against those **defined** in `.env` files
|
|
23
|
+
`ENV.fetch("X")`) against those **defined** in `.env` files. Interpolated
|
|
24
|
+
references in **Docker Compose** (`${VAR}`), **GitHub Actions** workflows
|
|
25
|
+
(`${{ secrets.X }}`, `${{ vars.X }}`, `${{ env.X }}`) and **Kubernetes**
|
|
26
|
+
manifests (`${VAR}`) also count as usage, so those files feed the same
|
|
27
|
+
missing/undefined and unused checks:
|
|
15
28
|
|
|
16
29
|
| Rule | Severity | Meaning |
|
|
17
30
|
|------|----------|---------|
|
|
18
|
-
| `undefined-in-source` | error |
|
|
31
|
+
| `undefined-in-source` | error | Referenced (in source or infra files) but not defined in any `.env` file |
|
|
32
|
+
| `duplicates` | error | Same key defined 2+ times in a single `.env` file |
|
|
33
|
+
| `public-prefix` | error | Secret-looking variable exposed to client bundles via a public prefix (`NEXT_PUBLIC_`, `VITE_`, `REACT_APP_`, …) |
|
|
34
|
+
| `type-mismatch` | error | Variable's inferred value type differs across environments (e.g. integer vs string) |
|
|
19
35
|
| `unused` | warning | Defined in `.env` but never referenced in source |
|
|
36
|
+
| `environment-diff` | warning | Defined in some environments but missing from others |
|
|
37
|
+
| `weak-secret` | warning | Secret-looking variable has an empty, short, or placeholder value |
|
|
38
|
+
| `typo` | warning | Used name closely matches a defined name (likely misspelling) |
|
|
39
|
+
|
|
40
|
+
Environment labels come from the `.env` filename (`.env`→`default`,
|
|
41
|
+
`.env.local`→`local`, `.env.production`→`production`,
|
|
42
|
+
`.env.production.local`→`production`); `*.example` files are skipped. Values are
|
|
43
|
+
read only to power detection and are **never** included in any output.
|
|
20
44
|
|
|
21
45
|
Comments and `=begin/=end` blocks are stripped before scanning. `scan` exits
|
|
22
|
-
`1` on errors (or warnings with `--strict`).
|
|
46
|
+
`1` on errors (or warnings with `--strict`). Pass `--json` to emit the findings
|
|
47
|
+
as a JSON array (keys: `rule`, `severity`, `name`, `message`, `file`, `line`) —
|
|
48
|
+
still without any values.
|
|
23
49
|
|
|
24
50
|
## Development
|
|
25
51
|
|
|
@@ -29,5 +55,47 @@ ruby -Ilib test/test_scanner.rb
|
|
|
29
55
|
gem build envdoctor.gemspec
|
|
30
56
|
```
|
|
31
57
|
|
|
32
|
-
|
|
33
|
-
|
|
58
|
+
## Subcommands
|
|
59
|
+
|
|
60
|
+
Alongside `scan`, every port shares two environment subcommands:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
envdoctor diff <envA> <envB> # compare two environments (add --json)
|
|
64
|
+
envdoctor sync <from> <to> # copy missing keys (add --dry-run)
|
|
65
|
+
envdoctor init # generate .env.example + ENVIRONMENT.md (add --force)
|
|
66
|
+
envdoctor fix # always (re)generate both files
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`diff` reports which variable names are only in one environment; `sync` appends
|
|
70
|
+
the missing keys to the target `.env` file as empty `KEY=` placeholders — values
|
|
71
|
+
are never copied.
|
|
72
|
+
|
|
73
|
+
`init` / `fix` generate two files at the project root from the union of defined
|
|
74
|
+
(`.env*`) and used (source + Compose/Actions/K8s) variable names: `.env.example`
|
|
75
|
+
(one `KEY=` per variable) and `ENVIRONMENT.md` (a Defined/Used table). Values are
|
|
76
|
+
never written. `init` writes each file only if absent (`--force` overwrites);
|
|
77
|
+
`fix` always rewrites both. Both accept `-d/--dir PATH`.
|
|
78
|
+
|
|
79
|
+
## Schema validation
|
|
80
|
+
|
|
81
|
+
Add an `envdoctor.schema.json` at your project root to validate `.env` values:
|
|
82
|
+
|
|
83
|
+
```json
|
|
84
|
+
{
|
|
85
|
+
"PORT": { "type": "integer", "min": 1, "max": 65535 },
|
|
86
|
+
"LEVEL": { "enum": ["debug", "info", "warn", "error"] },
|
|
87
|
+
"TOKEN": { "type": "string", "optional": true }
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Supported rule fields: `type` (string/integer/float/boolean/url/json), `enum`,
|
|
92
|
+
`regex`, `min`, `max`, `optional`. Values that fail are reported as
|
|
93
|
+
`schema-validation` errors (values are never printed).
|
|
94
|
+
|
|
95
|
+
## Other languages
|
|
96
|
+
|
|
97
|
+
envdoctor ships as a standalone native port for each ecosystem:
|
|
98
|
+
|
|
99
|
+
- [Node (reference)](..) · [Python](../python) · [Go](../go) · [PHP](../php) · [Java](../java) · [Perl](../perl)
|
|
100
|
+
- 📖 Docs: [arun-skg.github.io/envdoctor](https://arun-skg.github.io/envdoctor/)
|
|
101
|
+
- Main repository: [github.com/arun-skg/envdoctor](https://github.com/arun-skg/envdoctor)
|
data/lib/envdoctor/cli.rb
CHANGED
|
@@ -9,12 +9,19 @@ module Envdoctor
|
|
|
9
9
|
module_function
|
|
10
10
|
|
|
11
11
|
def run(argv)
|
|
12
|
+
return run_diff(argv[1..]) if argv.first == "diff"
|
|
13
|
+
return run_sync(argv[1..]) if argv.first == "sync"
|
|
14
|
+
return run_init(argv[1..]) if argv.first == "init"
|
|
15
|
+
return run_fix(argv[1..]) if argv.first == "fix"
|
|
16
|
+
|
|
12
17
|
dir = "."
|
|
13
18
|
strict = false
|
|
19
|
+
json = false
|
|
14
20
|
parser = OptionParser.new do |o|
|
|
15
21
|
o.banner = "Usage: envdoctor scan [options]"
|
|
16
22
|
o.on("-d", "--dir DIR", "Project root (default: cwd)") { |v| dir = v }
|
|
17
23
|
o.on("--strict", "Treat warnings as errors") { strict = true }
|
|
24
|
+
o.on("--json", "Emit findings as a JSON array") { json = true }
|
|
18
25
|
end
|
|
19
26
|
args = argv.dup
|
|
20
27
|
args.shift if args.first == "scan"
|
|
@@ -25,6 +32,11 @@ module Envdoctor
|
|
|
25
32
|
errors = findings.select { |f| f.severity == "error" }
|
|
26
33
|
warnings = findings.select { |f| f.severity == "warning" }
|
|
27
34
|
|
|
35
|
+
if json
|
|
36
|
+
puts Scanner.to_json_array(findings)
|
|
37
|
+
return (!errors.empty? || (strict && !warnings.empty?)) ? 1 : 0
|
|
38
|
+
end
|
|
39
|
+
|
|
28
40
|
puts "ENVIRONMENT AUDIT"
|
|
29
41
|
puts "=" * 40
|
|
30
42
|
if findings.empty?
|
|
@@ -32,17 +44,118 @@ module Envdoctor
|
|
|
32
44
|
return 0
|
|
33
45
|
end
|
|
34
46
|
|
|
47
|
+
loc = ->(f) { f.origin ? " #{f.origin.file}:#{f.origin.line}" : "" }
|
|
35
48
|
unless errors.empty?
|
|
36
49
|
puts "\nErrors"
|
|
37
|
-
errors.each { |f| puts " x #{f.name}
|
|
50
|
+
errors.each { |f| puts " x #{f.name}#{loc.call(f)} #{f.message}" }
|
|
38
51
|
end
|
|
39
52
|
unless warnings.empty?
|
|
40
53
|
puts "\nWarnings"
|
|
41
|
-
warnings.each { |f| puts " ! #{f.name}
|
|
54
|
+
warnings.each { |f| puts " ! #{f.name}#{loc.call(f)} #{f.message}" }
|
|
42
55
|
end
|
|
43
56
|
puts "\nSummary: #{errors.length} error(s), #{warnings.length} warning(s)"
|
|
44
57
|
|
|
45
58
|
(!errors.empty? || (strict && !warnings.empty?)) ? 1 : 0
|
|
46
59
|
end
|
|
60
|
+
|
|
61
|
+
def run_diff(argv)
|
|
62
|
+
dir = "."
|
|
63
|
+
json = false
|
|
64
|
+
pos = []
|
|
65
|
+
OptionParser.new do |o|
|
|
66
|
+
o.on("-d", "--dir DIR") { |v| dir = v }
|
|
67
|
+
o.on("--json") { json = true }
|
|
68
|
+
end.order!(argv.dup) { |a| pos << a }
|
|
69
|
+
a, b = pos[0], pos[1]
|
|
70
|
+
d = Scanner.diff_labels(File.expand_path(dir), a, b)
|
|
71
|
+
if json
|
|
72
|
+
require "json"
|
|
73
|
+
puts JSON.generate({ "a" => a, "b" => b }.merge(d))
|
|
74
|
+
return 0
|
|
75
|
+
end
|
|
76
|
+
puts "ENVIRONMENT DIFF: #{a} vs #{b}"
|
|
77
|
+
puts "=" * 40
|
|
78
|
+
unless d["onlyInA"].empty?
|
|
79
|
+
puts "Only in #{a}:"
|
|
80
|
+
d["onlyInA"].each { |k| puts " + #{k}" }
|
|
81
|
+
end
|
|
82
|
+
unless d["onlyInB"].empty?
|
|
83
|
+
puts "Only in #{b}:"
|
|
84
|
+
d["onlyInB"].each { |k| puts " + #{k}" }
|
|
85
|
+
end
|
|
86
|
+
puts "Common: #{d['common'].length} variable(s)"
|
|
87
|
+
0
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Generate `.env.example` and `ENVIRONMENT.md`, writing each only if absent
|
|
91
|
+
# (or always with --force).
|
|
92
|
+
def run_init(argv)
|
|
93
|
+
dir = "."
|
|
94
|
+
force = false
|
|
95
|
+
OptionParser.new do |o|
|
|
96
|
+
o.on("-d", "--dir DIR") { |v| dir = v }
|
|
97
|
+
o.on("--force") { force = true }
|
|
98
|
+
end.parse!(argv.dup)
|
|
99
|
+
|
|
100
|
+
root = File.expand_path(dir)
|
|
101
|
+
files = {
|
|
102
|
+
".env.example" => Scanner.env_example_content(root),
|
|
103
|
+
"ENVIRONMENT.md" => Scanner.environment_doc_content(root)
|
|
104
|
+
}
|
|
105
|
+
files.each do |name, content|
|
|
106
|
+
path = File.join(root, name)
|
|
107
|
+
if File.exist?(path) && !force
|
|
108
|
+
puts "skipped #{name} (exists)"
|
|
109
|
+
else
|
|
110
|
+
File.write(path, content)
|
|
111
|
+
puts "#{force ? 'wrote' : 'created'} #{name}"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
0
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Always (re)write both generated files.
|
|
118
|
+
def run_fix(argv)
|
|
119
|
+
dir = "."
|
|
120
|
+
OptionParser.new do |o|
|
|
121
|
+
o.on("-d", "--dir DIR") { |v| dir = v }
|
|
122
|
+
end.parse!(argv.dup)
|
|
123
|
+
|
|
124
|
+
root = File.expand_path(dir)
|
|
125
|
+
example = Scanner.env_example_content(root)
|
|
126
|
+
doc = Scanner.environment_doc_content(root)
|
|
127
|
+
File.write(File.join(root, ".env.example"), example)
|
|
128
|
+
puts "wrote .env.example"
|
|
129
|
+
File.write(File.join(root, "ENVIRONMENT.md"), doc)
|
|
130
|
+
puts "wrote ENVIRONMENT.md"
|
|
131
|
+
0
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def run_sync(argv)
|
|
135
|
+
dir = "."
|
|
136
|
+
json = false
|
|
137
|
+
dry = false
|
|
138
|
+
pos = []
|
|
139
|
+
OptionParser.new do |o|
|
|
140
|
+
o.on("-d", "--dir DIR") { |v| dir = v }
|
|
141
|
+
o.on("--dry-run") { dry = true }
|
|
142
|
+
o.on("--json") { json = true }
|
|
143
|
+
end.order!(argv.dup) { |a| pos << a }
|
|
144
|
+
from, to = pos[0], pos[1]
|
|
145
|
+
added = Scanner.sync_labels(File.expand_path(dir), from, to, dry_run: dry)
|
|
146
|
+
if json
|
|
147
|
+
require "json"
|
|
148
|
+
puts JSON.generate({ "from" => from, "to" => to, "added" => added, "dryRun" => dry })
|
|
149
|
+
return 0
|
|
150
|
+
end
|
|
151
|
+
if added.empty?
|
|
152
|
+
puts "Already in sync."
|
|
153
|
+
return 0
|
|
154
|
+
end
|
|
155
|
+
verb = dry ? "Would sync" : "Synced"
|
|
156
|
+
puts "#{verb} #{added.length} variable(s) from #{from} to #{to}:"
|
|
157
|
+
added.each { |k| puts " + #{k}" }
|
|
158
|
+
0
|
|
159
|
+
end
|
|
47
160
|
end
|
|
48
161
|
end
|
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") }
|
|
@@ -63,32 +149,345 @@ module Envdoctor
|
|
|
63
149
|
end.sort
|
|
64
150
|
end
|
|
65
151
|
|
|
66
|
-
|
|
152
|
+
COMPOSE_RE = /\A(docker-)?compose([.-].*)?\.ya?ml\z/i.freeze
|
|
153
|
+
|
|
154
|
+
def skip_infra_path?(path)
|
|
155
|
+
path.split(File::SEPARATOR).any? { |part| %w[.git vendor node_modules target].include?(part) }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Walk the project and classify infra YAML files. Returns [[path, kind], ...]
|
|
159
|
+
# where kind is one of :compose, :actions, :k8s.
|
|
160
|
+
def discover_infra_files(root)
|
|
161
|
+
out = []
|
|
162
|
+
Dir.glob(File.join(root, "**", "*"), File::FNM_DOTMATCH).each do |path|
|
|
163
|
+
next unless File.file?(path)
|
|
164
|
+
next if skip_infra_path?(path)
|
|
165
|
+
|
|
166
|
+
base = File.basename(path)
|
|
167
|
+
segments = path.split(File::SEPARATOR)
|
|
168
|
+
if base.match?(COMPOSE_RE)
|
|
169
|
+
out << [path, :compose]
|
|
170
|
+
elsif base =~ /\.ya?ml\z/i &&
|
|
171
|
+
segments.each_cons(2).any? { |a, b| a == ".github" && b == "workflows" }
|
|
172
|
+
out << [path, :actions]
|
|
173
|
+
elsif base =~ /\.ya?ml\z/i
|
|
174
|
+
content = File.read(path)
|
|
175
|
+
out << [path, :k8s] if content.match?(/^apiVersion:/m) && content.match?(/^kind:/m)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
out.sort_by(&:first)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
INTERP_PATTERNS = [
|
|
182
|
+
/\$\{([A-Za-z_][A-Za-z0-9_]*)/,
|
|
183
|
+
/\$([A-Za-z_][A-Za-z0-9_]*)/
|
|
184
|
+
].freeze
|
|
185
|
+
|
|
186
|
+
ACTIONS_CONTEXT_RE = /\b(?:secrets|vars|env)\.([A-Za-z_][A-Za-z0-9_]*)/.freeze
|
|
187
|
+
|
|
188
|
+
# Extract used variable NAMES (with origin) from an infra YAML file. First
|
|
189
|
+
# removes escaped `$$` (two spaces, offsets preserved), then scans for
|
|
190
|
+
# interpolation and, for GitHub Actions, the secrets/vars/env contexts.
|
|
191
|
+
def scan_infra(path, content, kind)
|
|
192
|
+
text = content.gsub("$$", " ")
|
|
193
|
+
used = {}
|
|
194
|
+
patterns = INTERP_PATTERNS.dup
|
|
195
|
+
patterns << ACTIONS_CONTEXT_RE if kind == :actions
|
|
196
|
+
patterns.each do |re|
|
|
197
|
+
text.to_enum(:scan, re).each do
|
|
198
|
+
match = Regexp.last_match
|
|
199
|
+
name = match[1]
|
|
200
|
+
next if used.key?(name)
|
|
201
|
+
|
|
202
|
+
line = text[0...match.begin(0)].count("\n") + 1
|
|
203
|
+
used[name] = Origin.new(path, line)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
used
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Map each environment label to the set of variable names defined in it.
|
|
210
|
+
def defined_by_label(root)
|
|
211
|
+
labels = {}
|
|
212
|
+
discover_env_files(root).each do |f|
|
|
213
|
+
set = (labels[env_label(f)] ||= [])
|
|
214
|
+
parse_env(f, File.read(f)).each_key { |name| set << name unless set.include?(name) }
|
|
215
|
+
end
|
|
216
|
+
labels
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def diff_labels(root, a, b)
|
|
220
|
+
labels = defined_by_label(root)
|
|
221
|
+
da = labels[a] || []
|
|
222
|
+
db = labels[b] || []
|
|
223
|
+
{
|
|
224
|
+
"onlyInA" => (da - db).sort,
|
|
225
|
+
"onlyInB" => (db - da).sort,
|
|
226
|
+
"common" => (da & db).sort
|
|
227
|
+
}
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Append keys present in `from` but missing from `to` as `KEY=` placeholders.
|
|
231
|
+
# Values are never copied.
|
|
232
|
+
def sync_labels(root, from, to, dry_run: false)
|
|
233
|
+
labels = defined_by_label(root)
|
|
234
|
+
missing = ((labels[from] || []) - (labels[to] || [])).sort
|
|
235
|
+
if !missing.empty? && !dry_run
|
|
236
|
+
target = File.join(root, to == "default" ? ".env" : ".env.#{to}")
|
|
237
|
+
existing = File.exist?(target) ? File.read(target) : ""
|
|
238
|
+
prefix = (existing.empty? || existing.end_with?("\n")) ? "" : "\n"
|
|
239
|
+
File.open(target, "a") { |fh| fh.write(prefix + missing.map { |k| "#{k}=\n" }.join) }
|
|
240
|
+
end
|
|
241
|
+
missing
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Collect the DEFINED (from .env*) and USED (source + infra) name sets for a
|
|
245
|
+
# project. Returns [defined_hash, used_hash] with name => true entries.
|
|
246
|
+
def collect_names(root)
|
|
67
247
|
defined = {}
|
|
68
248
|
discover_env_files(root).each do |f|
|
|
69
|
-
parse_env(relative(root, f), File.read(f)).
|
|
249
|
+
parse_env(relative(root, f), File.read(f)).each_key { |name| defined[name] = true }
|
|
250
|
+
end
|
|
251
|
+
used = {}
|
|
252
|
+
discover_source_files(root).each do |f|
|
|
253
|
+
scan_source(relative(root, f), File.read(f)).each_key { |k| used[k] = true }
|
|
254
|
+
end
|
|
255
|
+
discover_infra_files(root).each do |f, kind|
|
|
256
|
+
scan_infra(relative(root, f), File.read(f), kind).each_key { |k| used[k] = true }
|
|
257
|
+
end
|
|
258
|
+
[defined, used]
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Exact `.env.example` content: header, then `NAME=` per (defined ∪ used),
|
|
262
|
+
# sorted ascending. Values are never written.
|
|
263
|
+
def env_example_content(root)
|
|
264
|
+
defined, used = collect_names(root)
|
|
265
|
+
names = (defined.keys + used.keys).uniq.sort
|
|
266
|
+
"# Generated by envdoctor. Fill in values; do not commit secrets.\n" +
|
|
267
|
+
names.map { |n| "#{n}=\n" }.join
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Exact `ENVIRONMENT.md` content: a table of Defined/Used per variable.
|
|
271
|
+
def environment_doc_content(root)
|
|
272
|
+
defined, used = collect_names(root)
|
|
273
|
+
names = (defined.keys + used.keys).uniq.sort
|
|
274
|
+
lines = ["# Environment variables", "", "| Variable | Defined | Used |", "| --- | --- | --- |"]
|
|
275
|
+
names.each do |n|
|
|
276
|
+
lines << "| #{n} | #{defined.key?(n) ? 'yes' : 'no'} | #{used.key?(n) ? 'yes' : 'no'} |"
|
|
277
|
+
end
|
|
278
|
+
lines.join("\n") + "\n"
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def scan(root)
|
|
282
|
+
defined = {} # name => Origin (first definition wins)
|
|
283
|
+
defined_value = {} # name => value at first definition
|
|
284
|
+
labels_of = {} # name => { label => value } (first value per label)
|
|
285
|
+
project_labels = []
|
|
286
|
+
dup_findings = []
|
|
287
|
+
|
|
288
|
+
discover_env_files(root).each do |f|
|
|
289
|
+
rel = relative(root, f)
|
|
290
|
+
label = env_label(f)
|
|
291
|
+
project_labels << label unless project_labels.include?(label)
|
|
292
|
+
parse_env(rel, File.read(f)).each do |name, defs|
|
|
293
|
+
if defs.length >= 2
|
|
294
|
+
lines = defs.map(&:line)
|
|
295
|
+
dup_findings << Finding.new("duplicates", "error", name,
|
|
296
|
+
"defined #{defs.length} times in the same file " \
|
|
297
|
+
"(lines #{lines.join(', ')})",
|
|
298
|
+
Origin.new(rel, defs.first.line))
|
|
299
|
+
end
|
|
300
|
+
# First occurrence (first file wins) counts as the definition.
|
|
301
|
+
unless defined.key?(name)
|
|
302
|
+
defined[name] = Origin.new(rel, defs.first.line)
|
|
303
|
+
defined_value[name] = defs.first.value
|
|
304
|
+
end
|
|
305
|
+
bucket = (labels_of[name] ||= {})
|
|
306
|
+
bucket[label] = defs.first.value unless bucket.key?(label)
|
|
307
|
+
end
|
|
70
308
|
end
|
|
71
309
|
|
|
72
310
|
used = {}
|
|
73
311
|
discover_source_files(root).each do |f|
|
|
74
312
|
scan_source(relative(root, f), File.read(f)).each { |k, v| used[k] ||= v }
|
|
75
313
|
end
|
|
314
|
+
# Infra sources (Docker Compose / GitHub Actions / Kubernetes) contribute
|
|
315
|
+
# additional used names; first origin wins.
|
|
316
|
+
discover_infra_files(root).each do |f, kind|
|
|
317
|
+
scan_infra(relative(root, f), File.read(f), kind).each { |k, v| used[k] ||= v }
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
errors = []
|
|
321
|
+
warnings = []
|
|
76
322
|
|
|
77
|
-
|
|
323
|
+
# --- errors: undefined-in-source ---
|
|
78
324
|
used.keys.sort.each do |name|
|
|
79
325
|
next if defined.key?(name)
|
|
80
326
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
327
|
+
errors << Finding.new("undefined-in-source", "error", name,
|
|
328
|
+
"referenced but not defined in any environment file",
|
|
329
|
+
used[name])
|
|
84
330
|
end
|
|
331
|
+
|
|
332
|
+
# --- errors: duplicates ---
|
|
333
|
+
dup_findings.sort_by(&:name).each { |finding| errors << finding }
|
|
334
|
+
|
|
335
|
+
# --- errors: public-prefix ---
|
|
336
|
+
defined.keys.sort.each do |name|
|
|
337
|
+
next unless public_prefix?(name)
|
|
338
|
+
|
|
339
|
+
errors << Finding.new("public-prefix", "error", name,
|
|
340
|
+
"secret-looking variable is exposed to client bundles " \
|
|
341
|
+
"via a public prefix", defined[name])
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# --- errors: type-mismatch ---
|
|
345
|
+
defined.keys.sort.each do |name|
|
|
346
|
+
labels = labels_of[name] || {}
|
|
347
|
+
next if labels.size < 2
|
|
348
|
+
|
|
349
|
+
groups = labels.values.map { |v| infer_type(v) }.reject { |t| t == "empty" }
|
|
350
|
+
.map { |t| type_group(t) }.uniq
|
|
351
|
+
next if groups.size < 2
|
|
352
|
+
|
|
353
|
+
errors << Finding.new("type-mismatch", "error", name,
|
|
354
|
+
"inferred type differs across environments", defined[name])
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# --- errors: schema-validation ---
|
|
358
|
+
load_schema(root).sort.each do |name, rule|
|
|
359
|
+
next unless rule.is_a?(Hash)
|
|
360
|
+
|
|
361
|
+
if defined.key?(name)
|
|
362
|
+
msg = schema_failure(rule, defined_value[name])
|
|
363
|
+
errors << Finding.new("schema-validation", "error", name, msg, defined[name]) if msg
|
|
364
|
+
elsif !rule["optional"]
|
|
365
|
+
errors << Finding.new("schema-validation", "error", name,
|
|
366
|
+
"required by schema but not defined", nil)
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# --- warnings: unused ---
|
|
85
371
|
defined.keys.sort.each do |name|
|
|
86
372
|
next if used.key?(name)
|
|
87
373
|
|
|
88
|
-
|
|
374
|
+
warnings << Finding.new("unused", "warning", name,
|
|
89
375
|
"defined but never referenced in source", defined[name])
|
|
90
376
|
end
|
|
91
|
-
|
|
377
|
+
|
|
378
|
+
# --- warnings: environment-diff ---
|
|
379
|
+
if project_labels.length >= 2
|
|
380
|
+
defined.keys.sort.each do |name|
|
|
381
|
+
present = (labels_of[name] || {}).keys.sort
|
|
382
|
+
absent = (project_labels - present).sort
|
|
383
|
+
next if present.empty? || absent.empty?
|
|
384
|
+
|
|
385
|
+
warnings << Finding.new("environment-diff", "warning", name,
|
|
386
|
+
"defined in #{present.join(', ')} but missing in " \
|
|
387
|
+
"#{absent.join(', ')}", defined[name])
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# --- warnings: weak-secret ---
|
|
392
|
+
defined.keys.sort.each do |name|
|
|
393
|
+
next unless SECRET_RE.match?(name)
|
|
394
|
+
next unless weak_secret?(defined_value[name].to_s)
|
|
395
|
+
|
|
396
|
+
warnings << Finding.new("weak-secret", "warning", name,
|
|
397
|
+
"secret-looking variable has a weak or placeholder value",
|
|
398
|
+
defined[name])
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# --- warnings: typo ---
|
|
402
|
+
defined_names = defined.keys
|
|
403
|
+
used.keys.sort.each do |u|
|
|
404
|
+
next if defined.key?(u)
|
|
405
|
+
|
|
406
|
+
best = nil
|
|
407
|
+
best_dist = nil
|
|
408
|
+
defined_names.each do |d|
|
|
409
|
+
next if d == u
|
|
410
|
+
|
|
411
|
+
limit = [u.length, d.length].min <= 4 ? 1 : 2
|
|
412
|
+
dist = levenshtein(u, d)
|
|
413
|
+
next if dist > limit
|
|
414
|
+
|
|
415
|
+
if best.nil? || dist < best_dist || (dist == best_dist && d < best)
|
|
416
|
+
best = d
|
|
417
|
+
best_dist = dist
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
next if best.nil?
|
|
421
|
+
|
|
422
|
+
warnings << Finding.new("typo", "warning", u,
|
|
423
|
+
"\"#{u}\" is not defined; did you mean \"#{best}\"?",
|
|
424
|
+
used[u])
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
errors + warnings
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# Serialize findings to the shared JSON shape. Values never appear.
|
|
431
|
+
NUMERIC_RE = /\A-?\d+(\.\d+)?\z/.freeze
|
|
432
|
+
|
|
433
|
+
def load_schema(root)
|
|
434
|
+
require "json"
|
|
435
|
+
data = JSON.parse(File.read(File.join(root, "envdoctor.schema.json")))
|
|
436
|
+
data.is_a?(Hash) ? data : {}
|
|
437
|
+
rescue StandardError
|
|
438
|
+
{}
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def schema_type_ok(value, declared)
|
|
442
|
+
case declared
|
|
443
|
+
when "string" then true
|
|
444
|
+
when "integer" then value.match?(/\A-?\d+\z/)
|
|
445
|
+
when "float" then value.match?(/\A-?\d+(\.\d+)?\z/)
|
|
446
|
+
when "boolean" then %w[true false].include?(value.downcase)
|
|
447
|
+
when "url" then value.match?(%r{\Ahttps?://})
|
|
448
|
+
when "json" then (JSON.parse(value); true rescue false)
|
|
449
|
+
else true
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def schema_failure(rule, value)
|
|
454
|
+
t = rule["type"]
|
|
455
|
+
return "value does not match schema type #{t}" if t.is_a?(String) && !schema_type_ok(value, t)
|
|
456
|
+
|
|
457
|
+
enum = rule["enum"]
|
|
458
|
+
return "value is not one of the allowed values" if enum.is_a?(Array) && !enum.include?(value)
|
|
459
|
+
|
|
460
|
+
pattern = rule["regex"]
|
|
461
|
+
if pattern.is_a?(String)
|
|
462
|
+
begin
|
|
463
|
+
return "value does not match the required pattern" if value !~ Regexp.new(pattern)
|
|
464
|
+
rescue RegexpError
|
|
465
|
+
# ignore invalid pattern
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
if value.match?(NUMERIC_RE)
|
|
470
|
+
num = value.to_f
|
|
471
|
+
lo = rule["min"]
|
|
472
|
+
return "value is below the minimum" if lo.is_a?(Numeric) && num < lo
|
|
473
|
+
|
|
474
|
+
hi = rule["max"]
|
|
475
|
+
return "value exceeds the maximum" if hi.is_a?(Numeric) && num > hi
|
|
476
|
+
end
|
|
477
|
+
nil
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def to_json_array(findings)
|
|
481
|
+
JSON.generate(findings.map do |f|
|
|
482
|
+
{
|
|
483
|
+
"rule" => f.rule,
|
|
484
|
+
"severity" => f.severity,
|
|
485
|
+
"name" => f.name,
|
|
486
|
+
"message" => f.message,
|
|
487
|
+
"file" => f.origin&.file,
|
|
488
|
+
"line" => f.origin&.line
|
|
489
|
+
}
|
|
490
|
+
end)
|
|
92
491
|
end
|
|
93
492
|
|
|
94
493
|
def relative(root, path)
|
data/lib/envdoctor.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: envdoctor
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Arun Natesan
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08-
|
|
11
|
+
date: 2026-08-23 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: 'Reconciles ENV usage in Ruby source against .env files: reports undefined-in-source
|
|
14
14
|
(error) and unused (warning). Local-first, no network.'
|