@rinorhatashi/envguard 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +350 -0
- package/dist/index.js +659 -0
- package/package.json +65 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rinor Hatashi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="assets/envguard-logo-dark.svg" />
|
|
4
|
+
<img src="assets/envguard-logo-light.svg" alt="EnvGuard" width="440" />
|
|
5
|
+
</picture>
|
|
6
|
+
</p>
|
|
7
|
+
|
|
8
|
+
<p align="center">
|
|
9
|
+
<strong>Know that every environment variable your code needs is configured in every environment — before you deploy.</strong>
|
|
10
|
+
</p>
|
|
11
|
+
|
|
12
|
+
<p align="center">
|
|
13
|
+
EnvGuard statically analyzes your codebase to inventory every environment variable it reads, then cross-references that list against what each of your environments actually provides — surfacing <em>missing</em> variables, staging/production <em>parity drift</em>, and <em>dead</em> configuration no code uses anymore.
|
|
14
|
+
</p>
|
|
15
|
+
|
|
16
|
+
<p align="center">
|
|
17
|
+
<a href="https://www.npmjs.com/package/@rinorhatashi/envguard"><img src="https://img.shields.io/npm/v/@rinorhatashi/envguard" alt="npm version" /></a>
|
|
18
|
+
<a href="https://github.com/rinorhatashi/EnvGuard/actions/workflows/ci.yml"><img src="https://github.com/rinorhatashi/EnvGuard/actions/workflows/ci.yml/badge.svg" alt="CI status" /></a>
|
|
19
|
+
<a href="./LICENSE"><img src="https://img.shields.io/npm/l/@rinorhatashi/envguard" alt="license" /></a>
|
|
20
|
+
<img src="https://img.shields.io/node/v/@rinorhatashi/envguard" alt="node version" />
|
|
21
|
+
</p>
|
|
22
|
+
|
|
23
|
+
<p align="center">
|
|
24
|
+
<a href="#what-it-does">What it does</a> •
|
|
25
|
+
<a href="#how-it-works">How it works</a> •
|
|
26
|
+
<a href="#runs-entirely-on-your-machine">Local & private</a> •
|
|
27
|
+
<a href="#install">Install</a> •
|
|
28
|
+
<a href="#configuration">Configuration</a> •
|
|
29
|
+
<a href="#github-action">GitHub Action</a>
|
|
30
|
+
</p>
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## The problem
|
|
35
|
+
|
|
36
|
+
"It works on staging but breaks in production" is one of the most common — and most avoidable — deployment failures, and environment misconfiguration is a leading cause. The problem is structural:
|
|
37
|
+
|
|
38
|
+
- Your **code** declares what it needs: `process.env.STRIPE_WEBHOOK_SECRET`, `os.environ["DATABASE_URL"]`.
|
|
39
|
+
- Your **infrastructure** declares what it has: secrets in AWS, Vercel, Doppler, a `.env` file.
|
|
40
|
+
- **Nothing checks that the two lists agree.**
|
|
41
|
+
|
|
42
|
+
So variables drift out of sync. One gets added to production at 3am during an incident and never backfilled to staging. One gets deleted from the code but lingers in every secrets manager for years. A new service ships to production missing a variable nobody knew to add.
|
|
43
|
+
|
|
44
|
+
Existing tools don't close this gap. Dotenv linters (`dotenv-linter`, `dotenv-safe`) only compare a single local `.env` against `.env.example`. Infrastructure drift tools (`driftctl`, Terragrunt) only compare cloud resources against Terraform state — not application-level variable usage. **EnvGuard is the missing link between what the code reads and what every environment provides.**
|
|
45
|
+
|
|
46
|
+
## What it does
|
|
47
|
+
|
|
48
|
+
EnvGuard reduces the entire question — *"is every environment configured correctly for this code?"* — to one command, `envguard scan`, and one report with four verdicts:
|
|
49
|
+
|
|
50
|
+
| Status | Meaning | The failure it prevents |
|
|
51
|
+
| :--- | :--- | :--- |
|
|
52
|
+
| 🔴 **`MISSING`** | The code reads it, but **no** environment provides it. | A guaranteed crash the moment that code path runs. |
|
|
53
|
+
| 🟡 **`PARITY`** | Present in **some** environments but not others. | The classic "works on staging, breaks in production" drift. |
|
|
54
|
+
| ⚪ **`DEAD`** | Configured in an environment but **no** code reads it. | Secret sprawl and config rot — stale values that mislead. |
|
|
55
|
+
| 🟢 `OK` | Read by the code and present everywhere. | Nothing to do. |
|
|
56
|
+
|
|
57
|
+
Run it locally before a deploy, or wire up the [GitHub Action](#github-action) to post the report on every pull request and fail the build when something is missing or drifting.
|
|
58
|
+
|
|
59
|
+
## How it works
|
|
60
|
+
|
|
61
|
+
EnvGuard is a three-stage pipeline. It performs **pure static analysis** — your code is never executed, so the scan is fast and safe to run anywhere, including untrusted CI.
|
|
62
|
+
|
|
63
|
+
```mermaid
|
|
64
|
+
flowchart LR
|
|
65
|
+
src["Source code"] --> scan["Scanner<br/>language-aware patterns"]
|
|
66
|
+
scan --> expected["Expected vars<br/>what the code reads"]
|
|
67
|
+
envs["Environments<br/>.env files · secrets managers"] --> resolve["Resolver<br/>source adapters"]
|
|
68
|
+
resolve --> provided["Provided vars<br/>what each env has"]
|
|
69
|
+
expected --> compare{"Compare"}
|
|
70
|
+
provided --> compare
|
|
71
|
+
compare --> report["Report<br/>MISSING · PARITY · DEAD · OK"]
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**1. Scan — build the manifest of what the code expects.**
|
|
75
|
+
EnvGuard walks your source tree and applies a set of language-aware patterns to every supported file, extracting each environment-variable reference together with its file and line number. There's no build step and nothing is run; it simply reads source. The output is the *expected* manifest — the complete set of variables your code depends on.
|
|
76
|
+
|
|
77
|
+
**2. Resolve — find out what each environment provides.**
|
|
78
|
+
For every environment you configure, a **source adapter** reports the set of variable *names* that environment supplies. The built-in `dotenv` adapter reads the keys from a `.env` file; planned adapters query secrets managers like AWS SSM, Vercel, and Doppler. EnvGuard only ever reads variable **names — never secret values**. With no configuration at all, it auto-discovers local `.env.*` files and treats each as an environment.
|
|
79
|
+
|
|
80
|
+
**3. Compare — cross-reference and classify.**
|
|
81
|
+
EnvGuard builds a matrix of *what the code needs* against *what each environment has* and assigns every variable a status. `ignore` rules in your config demote intentional cases (an environment-specific flag, an allowed legacy var) to `OK`, and the `failOn` setting decides which statuses cause a non-zero exit so CI can gate on them.
|
|
82
|
+
|
|
83
|
+
Each variable lands in exactly one bucket:
|
|
84
|
+
|
|
85
|
+
```mermaid
|
|
86
|
+
flowchart TD
|
|
87
|
+
start["Each variable"] --> incode{"Read by<br/>any code?"}
|
|
88
|
+
incode -- no --> dead["DEAD<br/>configured but unused"]
|
|
89
|
+
incode -- yes --> provided{"Provided by<br/>environments?"}
|
|
90
|
+
provided -- "none of them" --> missing["MISSING<br/>code needs it, nothing has it"]
|
|
91
|
+
provided -- "some, not all" --> parity["PARITY<br/>drift across environments"]
|
|
92
|
+
provided -- "all of them" --> ok["OK"]
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The report is then rendered three ways — a colored terminal table for humans, JSON for tooling, and Markdown for pull-request comments.
|
|
96
|
+
|
|
97
|
+
## Runs entirely on your machine
|
|
98
|
+
|
|
99
|
+
EnvGuard is local-first: **no hosted service, no account, no telemetry.** The `envguard` CLI makes **no network calls** — it reads your source and `.env` files from disk, analyzes them in-process, and writes a report. Nothing about your code or configuration is uploaded anywhere, because there's no reason for it to be.
|
|
100
|
+
|
|
101
|
+
```mermaid
|
|
102
|
+
flowchart LR
|
|
103
|
+
subgraph local["Your machine / CI runner — everything happens here"]
|
|
104
|
+
direction LR
|
|
105
|
+
code["Source files"] --> eg["envguard CLI"]
|
|
106
|
+
cfg[".env files and envguard.yml"] --> eg
|
|
107
|
+
eg --> report["Report:<br/>terminal · JSON · Markdown"]
|
|
108
|
+
end
|
|
109
|
+
eg x--x net(["External servers · telemetry"])
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
- **Only names, never values.** EnvGuard compares variable *names*. It never reads, stores, or prints the secret values inside your `.env` files.
|
|
113
|
+
- **Future cloud sources stay direct.** Planned adapters (AWS SSM, Vercel, Doppler, …) will talk **directly** to your own provider with your own credentials to list variable names — still no third-party server in the middle, and still names only.
|
|
114
|
+
|
|
115
|
+
## Install
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
npm install -g @rinorhatashi/envguard
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
…or run it without installing:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
npx @rinorhatashi/envguard scan
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Requires **Node.js 18 or newer**.
|
|
128
|
+
|
|
129
|
+
## Quick start
|
|
130
|
+
|
|
131
|
+
From a project that has `.env.staging` and `.env.production` files, EnvGuard works with **zero configuration** — it discovers them automatically:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
envguard scan
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Point it at a specific directory and/or config file:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
envguard scan ./services/api --config envguard.yml
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Try the bundled demo
|
|
144
|
+
|
|
145
|
+
The repository ships a demo with source files in all five supported languages and two diverging environments:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
git clone https://github.com/rinorhatashi/EnvGuard
|
|
149
|
+
cd EnvGuard
|
|
150
|
+
npm install && npm run build
|
|
151
|
+
node dist/index.js scan examples/demo
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Example output
|
|
155
|
+
|
|
156
|
+
```text
|
|
157
|
+
EnvGuard · scanned 5 files · 2 environments (staging, production)
|
|
158
|
+
|
|
159
|
+
VARIABLE CODE staging production STATUS
|
|
160
|
+
STRIPE_WEBHOOK_SECRET ✓ ✗ ✗ MISSING
|
|
161
|
+
API_KEY ✓ ✓ ✗ PARITY
|
|
162
|
+
DEBUG – ✓ ✗ DEAD
|
|
163
|
+
LEGACY_TOKEN – ✓ ✓ DEAD
|
|
164
|
+
DATABASE_URL ✓ ✓ ✓ OK
|
|
165
|
+
REDIS_URL ✓ ✓ ✓ OK
|
|
166
|
+
SENDGRID_API_KEY ✓ ✓ ✓ OK
|
|
167
|
+
|
|
168
|
+
1 missing · 1 parity · 2 dead · 3 ok
|
|
169
|
+
|
|
170
|
+
MISSING STRIPE_WEBHOOK_SECRET — read at src/billing.ts:3, not set in any environment
|
|
171
|
+
PARITY API_KEY — set in staging; missing in production (read at app/server.py:5)
|
|
172
|
+
DEAD DEBUG — set in staging but not read by any scanned file
|
|
173
|
+
DEAD LEGACY_TOKEN — set in staging, production but not read by any scanned file
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
The same report, as machine-readable JSON or as a Markdown PR comment:
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
envguard scan --format json
|
|
180
|
+
envguard scan --format markdown
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## Supported languages
|
|
184
|
+
|
|
185
|
+
Each language is matched with its own idiomatic access patterns. Adding a language is a small, self-contained change to [`src/scan/patterns.ts`](src/scan/patterns.ts).
|
|
186
|
+
|
|
187
|
+
| Language | Extensions | Detected forms |
|
|
188
|
+
| :--- | :--- | :--- |
|
|
189
|
+
| Node.js / TypeScript | `.js` `.jsx` `.mjs` `.cjs` `.ts` `.tsx` `.mts` `.cts` | `process.env.X`, `process.env['X']`, `import.meta.env.X` |
|
|
190
|
+
| Python | `.py` | `os.environ['X']`, `os.environ.get('X')`, `os.getenv('X')` |
|
|
191
|
+
| Ruby | `.rb` `.erb` `.rake` | `ENV['X']`, `ENV.fetch('X')` |
|
|
192
|
+
| Go | `.go` | `os.Getenv("X")`, `os.LookupEnv("X")` |
|
|
193
|
+
| Rust | `.rs` | `std::env::var("X")`, `env::var("X")`, `std::env::var_os("X")` |
|
|
194
|
+
|
|
195
|
+
## Configuration
|
|
196
|
+
|
|
197
|
+
EnvGuard runs with zero config, but an `envguard.yml` at your project root unlocks declared environments, ignore rules, and CI behavior. **Every field is optional.**
|
|
198
|
+
|
|
199
|
+
```yaml
|
|
200
|
+
# Which environments to compare, and where to read each one's variable names.
|
|
201
|
+
environments:
|
|
202
|
+
staging:
|
|
203
|
+
source: dotenv
|
|
204
|
+
path: .env.staging
|
|
205
|
+
production:
|
|
206
|
+
source: dotenv
|
|
207
|
+
path: .env.production
|
|
208
|
+
|
|
209
|
+
# Statuses that make `envguard scan` exit non-zero. Defaults to MISSING + PARITY.
|
|
210
|
+
failOn:
|
|
211
|
+
- MISSING
|
|
212
|
+
- PARITY
|
|
213
|
+
|
|
214
|
+
# Silence intentional findings by listing variable names.
|
|
215
|
+
ignore:
|
|
216
|
+
parity:
|
|
217
|
+
- DEBUG # intentionally environment-specific — don't flag drift
|
|
218
|
+
dead:
|
|
219
|
+
- LEGACY_TOKEN # allowed legacy var during a cleanup sprint
|
|
220
|
+
missing:
|
|
221
|
+
- OPTIONAL_VAR # read by code but not required in any environment
|
|
222
|
+
|
|
223
|
+
# Narrow what gets scanned. Defaults to every supported file under the root.
|
|
224
|
+
scan:
|
|
225
|
+
include:
|
|
226
|
+
- "src/**"
|
|
227
|
+
- "app/**"
|
|
228
|
+
exclude:
|
|
229
|
+
- "**/*.test.ts"
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
If `environments` is omitted, EnvGuard auto-discovers them from local `.env.*` files (`.env` → `local`, `.env.staging` → `staging`, …), skipping `.example`, `.sample`, and `.local` files.
|
|
233
|
+
|
|
234
|
+
## Sources
|
|
235
|
+
|
|
236
|
+
A **source** answers a single question: *"which variables does this environment provide?"* Adapters implement one small interface ([`src/sources/types.ts`](src/sources/types.ts)) and only ever read variable **names**, never secret values — so coverage grows without touching the core.
|
|
237
|
+
|
|
238
|
+
| Source | Status | Configuration |
|
|
239
|
+
| :--- | :--- | :--- |
|
|
240
|
+
| `dotenv` — local `.env` files | ✅ Available | `path: .env.<environment>` |
|
|
241
|
+
| AWS SSM Parameter Store | 🔜 Planned | — |
|
|
242
|
+
| GitHub Actions secrets | 🔜 Planned | — |
|
|
243
|
+
| Vercel | 🔜 Planned | — |
|
|
244
|
+
| Doppler | 🔜 Planned | — |
|
|
245
|
+
| Railway / Render | 🔜 Planned | — |
|
|
246
|
+
|
|
247
|
+
## CLI reference
|
|
248
|
+
|
|
249
|
+
```
|
|
250
|
+
envguard scan [path]
|
|
251
|
+
|
|
252
|
+
Arguments:
|
|
253
|
+
path directory to scan (default: ".")
|
|
254
|
+
|
|
255
|
+
Options:
|
|
256
|
+
-c, --config <file> path to an envguard.yml config file
|
|
257
|
+
-f, --format <format> output format: table | json | markdown (default: table)
|
|
258
|
+
-o, --output <file> write the report to a file instead of stdout
|
|
259
|
+
--fail-on <list> statuses that cause a non-zero exit:
|
|
260
|
+
missing, parity, dead, none (default: missing,parity)
|
|
261
|
+
--no-color disable colored output
|
|
262
|
+
-v, --version print the EnvGuard version
|
|
263
|
+
-h, --help show help
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
### Exit codes
|
|
267
|
+
|
|
268
|
+
EnvGuard is built to gate a pipeline:
|
|
269
|
+
|
|
270
|
+
| Code | Meaning |
|
|
271
|
+
| :---: | :--- |
|
|
272
|
+
| `0` | No findings in the `fail-on` set. |
|
|
273
|
+
| `1` | At least one finding in the `fail-on` set (e.g. a `MISSING` or `PARITY` variable). |
|
|
274
|
+
| `2` | EnvGuard errored (bad config, unreadable path, …). |
|
|
275
|
+
|
|
276
|
+
## GitHub Action
|
|
277
|
+
|
|
278
|
+
Run EnvGuard on every pull request. It posts the report as a comment that updates in place on each push:
|
|
279
|
+
|
|
280
|
+
```mermaid
|
|
281
|
+
sequenceDiagram
|
|
282
|
+
participant PR as Pull request
|
|
283
|
+
participant CI as GitHub Actions
|
|
284
|
+
participant EG as envguard CLI
|
|
285
|
+
PR->>CI: push / open PR
|
|
286
|
+
CI->>EG: envguard scan
|
|
287
|
+
EG-->>CI: report + exit code
|
|
288
|
+
CI->>PR: create or update the report comment
|
|
289
|
+
CI-->>CI: fail the check if MISSING / PARITY
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
Copy [`examples/github-workflow.yml`](examples/github-workflow.yml) to `.github/workflows/envguard.yml`:
|
|
293
|
+
|
|
294
|
+
```yaml
|
|
295
|
+
name: EnvGuard
|
|
296
|
+
on:
|
|
297
|
+
pull_request:
|
|
298
|
+
push:
|
|
299
|
+
branches: [main]
|
|
300
|
+
|
|
301
|
+
jobs:
|
|
302
|
+
envguard:
|
|
303
|
+
runs-on: ubuntu-latest
|
|
304
|
+
permissions:
|
|
305
|
+
contents: read
|
|
306
|
+
pull-requests: write # required to post the report comment
|
|
307
|
+
steps:
|
|
308
|
+
- uses: actions/checkout@v4
|
|
309
|
+
- uses: rinorhatashi/EnvGuard@v1
|
|
310
|
+
with:
|
|
311
|
+
working-directory: .
|
|
312
|
+
# config: envguard.yml
|
|
313
|
+
# fail-on: missing,parity
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
When a `fail-on` status is present, the job fails — turning environment drift into a red check instead of a 3am page.
|
|
317
|
+
|
|
318
|
+
| Input | Description | Default |
|
|
319
|
+
| :--- | :--- | :--- |
|
|
320
|
+
| `working-directory` | Directory to scan. | `.` |
|
|
321
|
+
| `config` | Path to an `envguard.yml`. | _(auto)_ |
|
|
322
|
+
| `fail-on` | Statuses that fail the job. | _(from config / defaults)_ |
|
|
323
|
+
| `comment` | Post/update the PR comment. | `true` |
|
|
324
|
+
| `version` | `envguard` npm version to run. | `latest` |
|
|
325
|
+
| `github-token` | Token used to post the comment. | `${{ github.token }}` |
|
|
326
|
+
|
|
327
|
+
## Roadmap
|
|
328
|
+
|
|
329
|
+
- Source adapters for AWS SSM, GitHub Actions secrets, Vercel, Doppler, Railway, and Render.
|
|
330
|
+
- Destructuring (`const { FOO } = process.env`) and comment-aware scanning.
|
|
331
|
+
- SARIF output for GitHub code scanning, and a `--baseline` file to accept existing findings.
|
|
332
|
+
- More languages (Java, PHP, .NET, Elixir).
|
|
333
|
+
|
|
334
|
+
## Contributing
|
|
335
|
+
|
|
336
|
+
EnvGuard is designed to be extended in small, isolated pieces:
|
|
337
|
+
|
|
338
|
+
- **Add a language** — append a pattern set to [`src/scan/patterns.ts`](src/scan/patterns.ts) and a test to [`test/extract.test.ts`](test/extract.test.ts).
|
|
339
|
+
- **Add a source** — implement the `SourceAdapter` interface and register it in [`src/sources/index.ts`](src/sources/index.ts).
|
|
340
|
+
|
|
341
|
+
```bash
|
|
342
|
+
npm install
|
|
343
|
+
npm test # run the test suite
|
|
344
|
+
npm run build # compile to dist/
|
|
345
|
+
npm run dev -- scan examples/demo # run straight from source
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
## License
|
|
349
|
+
|
|
350
|
+
[MIT](./LICENSE) © Rinor Hatashi
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { readFileSync } from "fs";
|
|
5
|
+
import { Command } from "commander";
|
|
6
|
+
|
|
7
|
+
// src/commands/scan.ts
|
|
8
|
+
import path4 from "path";
|
|
9
|
+
import { writeFile } from "fs/promises";
|
|
10
|
+
import { createColors } from "picocolors";
|
|
11
|
+
|
|
12
|
+
// src/scan/index.ts
|
|
13
|
+
import { readFile } from "fs/promises";
|
|
14
|
+
import path from "path";
|
|
15
|
+
|
|
16
|
+
// src/scan/walk.ts
|
|
17
|
+
import fg from "fast-glob";
|
|
18
|
+
|
|
19
|
+
// src/scan/patterns.ts
|
|
20
|
+
var IDENT = "[A-Za-z_][A-Za-z0-9_]*";
|
|
21
|
+
var KEY = `[^'"]+`;
|
|
22
|
+
var LANGUAGE_PATTERNS = [
|
|
23
|
+
{
|
|
24
|
+
language: "javascript",
|
|
25
|
+
extensions: [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"],
|
|
26
|
+
patterns: [
|
|
27
|
+
// Dot access: process.env.FOO
|
|
28
|
+
new RegExp(`process\\.env\\.(${IDENT})`, "g"),
|
|
29
|
+
// Bracket access: process.env['FOO'] / process.env["FOO"]
|
|
30
|
+
new RegExp(`process\\.env\\[\\s*['"](${KEY})['"]\\s*\\]`, "g"),
|
|
31
|
+
// Vite-style: import.meta.env.FOO
|
|
32
|
+
new RegExp(`import\\.meta\\.env\\.(${IDENT})`, "g")
|
|
33
|
+
]
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
language: "python",
|
|
37
|
+
extensions: [".py"],
|
|
38
|
+
patterns: [
|
|
39
|
+
// os.environ['FOO'] / environ["FOO"]
|
|
40
|
+
new RegExp(`(?:os\\.)?environ\\[\\s*['"](${KEY})['"]\\s*\\]`, "g"),
|
|
41
|
+
// os.environ.get('FOO') / environ.get("FOO")
|
|
42
|
+
new RegExp(`(?:os\\.)?environ\\.get\\(\\s*['"](${KEY})['"]`, "g"),
|
|
43
|
+
// os.getenv('FOO') / getenv("FOO")
|
|
44
|
+
new RegExp(`(?:os\\.)?getenv\\(\\s*['"](${KEY})['"]`, "g")
|
|
45
|
+
]
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
language: "ruby",
|
|
49
|
+
extensions: [".rb", ".erb", ".rake"],
|
|
50
|
+
patterns: [
|
|
51
|
+
// ENV['FOO'] / ENV["FOO"]
|
|
52
|
+
new RegExp(`ENV\\[\\s*['"](${KEY})['"]\\s*\\]`, "g"),
|
|
53
|
+
// ENV.fetch('FOO') / ENV.fetch("FOO")
|
|
54
|
+
new RegExp(`ENV\\.fetch\\(\\s*['"](${KEY})['"]`, "g")
|
|
55
|
+
]
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
language: "go",
|
|
59
|
+
extensions: [".go"],
|
|
60
|
+
patterns: [
|
|
61
|
+
// os.Getenv("FOO")
|
|
62
|
+
new RegExp(`os\\.Getenv\\(\\s*"(${KEY})"\\s*\\)`, "g"),
|
|
63
|
+
// os.LookupEnv("FOO")
|
|
64
|
+
new RegExp(`os\\.LookupEnv\\(\\s*"(${KEY})"\\s*\\)`, "g")
|
|
65
|
+
]
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
language: "rust",
|
|
69
|
+
extensions: [".rs"],
|
|
70
|
+
patterns: [
|
|
71
|
+
// std::env::var("FOO") / env::var("FOO") / std::env::var_os("FOO")
|
|
72
|
+
new RegExp(`(?:std::)?env::var(?:_os)?\\(\\s*"(${KEY})"\\s*\\)`, "g")
|
|
73
|
+
]
|
|
74
|
+
}
|
|
75
|
+
];
|
|
76
|
+
var ALL_EXTENSIONS = [
|
|
77
|
+
...new Set(LANGUAGE_PATTERNS.flatMap((p) => p.extensions))
|
|
78
|
+
];
|
|
79
|
+
function patternsForExtension(ext) {
|
|
80
|
+
const out = [];
|
|
81
|
+
for (const lp of LANGUAGE_PATTERNS) {
|
|
82
|
+
if (lp.extensions.includes(ext)) out.push(...lp.patterns);
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// src/scan/walk.ts
|
|
88
|
+
var DEFAULT_EXCLUDE = [
|
|
89
|
+
"**/node_modules/**",
|
|
90
|
+
"**/.git/**",
|
|
91
|
+
"**/dist/**",
|
|
92
|
+
"**/build/**",
|
|
93
|
+
"**/.next/**",
|
|
94
|
+
"**/coverage/**",
|
|
95
|
+
"**/vendor/**",
|
|
96
|
+
"**/target/**",
|
|
97
|
+
// Rust build output
|
|
98
|
+
"**/.venv/**",
|
|
99
|
+
"**/venv/**",
|
|
100
|
+
"**/__pycache__/**"
|
|
101
|
+
];
|
|
102
|
+
async function walkSourceFiles(root, opts = {}) {
|
|
103
|
+
const include = opts.include && opts.include.length > 0 ? opts.include : ALL_EXTENSIONS.map((ext) => `**/*${ext}`);
|
|
104
|
+
const ignore = [...DEFAULT_EXCLUDE, ...opts.exclude ?? []];
|
|
105
|
+
return fg(include, {
|
|
106
|
+
cwd: root,
|
|
107
|
+
ignore,
|
|
108
|
+
onlyFiles: true,
|
|
109
|
+
dot: false,
|
|
110
|
+
absolute: false,
|
|
111
|
+
followSymbolicLinks: false,
|
|
112
|
+
suppressErrors: true
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// src/scan/extract.ts
|
|
117
|
+
function extractFromContent(content, ext, relPath) {
|
|
118
|
+
const found = /* @__PURE__ */ new Map();
|
|
119
|
+
const patterns = patternsForExtension(ext);
|
|
120
|
+
if (patterns.length === 0) return found;
|
|
121
|
+
const lines = content.split(/\r?\n/);
|
|
122
|
+
for (let i = 0; i < lines.length; i++) {
|
|
123
|
+
const line = lines[i] ?? "";
|
|
124
|
+
for (const re of patterns) {
|
|
125
|
+
for (const match of line.matchAll(re)) {
|
|
126
|
+
const name = match[1];
|
|
127
|
+
if (!name) continue;
|
|
128
|
+
const refs = found.get(name) ?? [];
|
|
129
|
+
refs.push({ file: relPath, line: i + 1 });
|
|
130
|
+
found.set(name, refs);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return found;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// src/scan/index.ts
|
|
138
|
+
async function scanCode(root, opts = {}) {
|
|
139
|
+
const files = (await walkSourceFiles(root, opts)).filter(
|
|
140
|
+
(f) => ALL_EXTENSIONS.includes(path.extname(f))
|
|
141
|
+
);
|
|
142
|
+
const vars = /* @__PURE__ */ new Map();
|
|
143
|
+
let filesScanned = 0;
|
|
144
|
+
for (const rel of files) {
|
|
145
|
+
let content;
|
|
146
|
+
try {
|
|
147
|
+
content = await readFile(path.join(root, rel), "utf8");
|
|
148
|
+
} catch {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
filesScanned++;
|
|
152
|
+
const fileVars = extractFromContent(content, path.extname(rel), rel);
|
|
153
|
+
for (const [name, refs] of fileVars) {
|
|
154
|
+
const existing = vars.get(name) ?? [];
|
|
155
|
+
existing.push(...refs);
|
|
156
|
+
vars.set(name, existing);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return { vars, filesScanned };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// src/config/load.ts
|
|
163
|
+
import { readFile as readFile2, readdir } from "fs/promises";
|
|
164
|
+
import { existsSync } from "fs";
|
|
165
|
+
import path2 from "path";
|
|
166
|
+
import { parse as parseYaml } from "yaml";
|
|
167
|
+
var CONFIG_NAMES = ["envguard.yml", "envguard.yaml"];
|
|
168
|
+
async function loadConfig(root, explicitPath) {
|
|
169
|
+
let configPath;
|
|
170
|
+
if (explicitPath) {
|
|
171
|
+
configPath = path2.isAbsolute(explicitPath) ? explicitPath : path2.join(root, explicitPath);
|
|
172
|
+
if (!existsSync(configPath)) {
|
|
173
|
+
throw new Error(`config file not found: ${explicitPath}`);
|
|
174
|
+
}
|
|
175
|
+
} else {
|
|
176
|
+
for (const name of CONFIG_NAMES) {
|
|
177
|
+
const candidate = path2.join(root, name);
|
|
178
|
+
if (existsSync(candidate)) {
|
|
179
|
+
configPath = candidate;
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (configPath) {
|
|
185
|
+
const raw = await readFile2(configPath, "utf8");
|
|
186
|
+
const config = parseYaml(raw) ?? {};
|
|
187
|
+
if (!config.environments || Object.keys(config.environments).length === 0) {
|
|
188
|
+
config.environments = await discoverDotenvEnvironments(root);
|
|
189
|
+
return { config, configPath, autoDiscovered: true };
|
|
190
|
+
}
|
|
191
|
+
return { config, configPath, autoDiscovered: false };
|
|
192
|
+
}
|
|
193
|
+
const environments = await discoverDotenvEnvironments(root);
|
|
194
|
+
return { config: { environments }, autoDiscovered: true };
|
|
195
|
+
}
|
|
196
|
+
async function discoverDotenvEnvironments(root) {
|
|
197
|
+
let entries;
|
|
198
|
+
try {
|
|
199
|
+
entries = await readdir(root);
|
|
200
|
+
} catch {
|
|
201
|
+
return {};
|
|
202
|
+
}
|
|
203
|
+
const environments = {};
|
|
204
|
+
for (const file of entries.sort()) {
|
|
205
|
+
if (!file.startsWith(".env")) continue;
|
|
206
|
+
if (/\.(example|sample|local)$/.test(file)) continue;
|
|
207
|
+
let name;
|
|
208
|
+
if (file === ".env") name = "local";
|
|
209
|
+
else if (file.startsWith(".env.")) name = file.slice(".env.".length);
|
|
210
|
+
else continue;
|
|
211
|
+
if (!name) continue;
|
|
212
|
+
environments[name] = { source: "dotenv", path: file };
|
|
213
|
+
}
|
|
214
|
+
return environments;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// src/sources/dotenv.ts
|
|
218
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
219
|
+
import path3 from "path";
|
|
220
|
+
import dotenv from "dotenv";
|
|
221
|
+
var dotenvAdapter = {
|
|
222
|
+
id: "dotenv",
|
|
223
|
+
async load(envName, config, root) {
|
|
224
|
+
const rel = config.path ?? `.env.${envName}`;
|
|
225
|
+
const abs = path3.isAbsolute(rel) ? rel : path3.join(root, rel);
|
|
226
|
+
try {
|
|
227
|
+
const content = await readFile3(abs, "utf8");
|
|
228
|
+
const parsed = dotenv.parse(content);
|
|
229
|
+
return {
|
|
230
|
+
name: envName,
|
|
231
|
+
source: "dotenv",
|
|
232
|
+
origin: rel,
|
|
233
|
+
vars: new Set(Object.keys(parsed)),
|
|
234
|
+
available: true
|
|
235
|
+
};
|
|
236
|
+
} catch {
|
|
237
|
+
return {
|
|
238
|
+
name: envName,
|
|
239
|
+
source: "dotenv",
|
|
240
|
+
origin: rel,
|
|
241
|
+
vars: /* @__PURE__ */ new Set(),
|
|
242
|
+
available: false
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
// src/sources/index.ts
|
|
249
|
+
var ADAPTERS = {
|
|
250
|
+
[dotenvAdapter.id]: dotenvAdapter
|
|
251
|
+
};
|
|
252
|
+
async function resolveEnvironments(config, root) {
|
|
253
|
+
const warnings = [];
|
|
254
|
+
const environments = [];
|
|
255
|
+
for (const [name, envCfg] of Object.entries(config.environments ?? {})) {
|
|
256
|
+
const sourceId = envCfg.source ?? "dotenv";
|
|
257
|
+
const adapter = ADAPTERS[sourceId];
|
|
258
|
+
if (!adapter) {
|
|
259
|
+
warnings.push(
|
|
260
|
+
`environment "${name}": unknown source "${sourceId}" \u2014 skipping`
|
|
261
|
+
);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const env = await adapter.load(name, envCfg, root);
|
|
265
|
+
if (!env.available) {
|
|
266
|
+
warnings.push(
|
|
267
|
+
`environment "${name}": could not read ${env.origin} \u2014 skipping`
|
|
268
|
+
);
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
environments.push(env);
|
|
272
|
+
}
|
|
273
|
+
return { environments, warnings };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// src/compare/index.ts
|
|
277
|
+
var STATUS_ORDER = {
|
|
278
|
+
MISSING: 0,
|
|
279
|
+
PARITY: 1,
|
|
280
|
+
DEAD: 2,
|
|
281
|
+
OK: 3
|
|
282
|
+
};
|
|
283
|
+
function buildReport(code, environments, config, root, generatedAt, warnings = []) {
|
|
284
|
+
const ignoreParity = new Set(config.ignore?.parity ?? []);
|
|
285
|
+
const ignoreDead = new Set(config.ignore?.dead ?? []);
|
|
286
|
+
const ignoreMissing = new Set(config.ignore?.missing ?? []);
|
|
287
|
+
const totalEnvs = environments.length;
|
|
288
|
+
const allVars = /* @__PURE__ */ new Set();
|
|
289
|
+
for (const name of code.vars.keys()) allVars.add(name);
|
|
290
|
+
for (const env of environments) for (const name of env.vars) allVars.add(name);
|
|
291
|
+
const findings = [];
|
|
292
|
+
for (const name of allVars) {
|
|
293
|
+
const inCode = code.vars.has(name);
|
|
294
|
+
const presence = {};
|
|
295
|
+
let presentCount = 0;
|
|
296
|
+
for (const env of environments) {
|
|
297
|
+
const has = env.vars.has(name);
|
|
298
|
+
presence[env.name] = has;
|
|
299
|
+
if (has) presentCount++;
|
|
300
|
+
}
|
|
301
|
+
let status;
|
|
302
|
+
if (!inCode) {
|
|
303
|
+
status = ignoreDead.has(name) ? "OK" : "DEAD";
|
|
304
|
+
} else if (totalEnvs > 0 && presentCount === 0) {
|
|
305
|
+
status = ignoreMissing.has(name) ? "OK" : "MISSING";
|
|
306
|
+
} else if (totalEnvs > 0 && presentCount < totalEnvs) {
|
|
307
|
+
status = ignoreParity.has(name) ? "OK" : "PARITY";
|
|
308
|
+
} else {
|
|
309
|
+
status = "OK";
|
|
310
|
+
}
|
|
311
|
+
findings.push({
|
|
312
|
+
name,
|
|
313
|
+
status,
|
|
314
|
+
inCode,
|
|
315
|
+
references: code.vars.get(name) ?? [],
|
|
316
|
+
presence
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
findings.sort(
|
|
320
|
+
(a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status] || a.name.localeCompare(b.name)
|
|
321
|
+
);
|
|
322
|
+
const summary = {
|
|
323
|
+
MISSING: 0,
|
|
324
|
+
PARITY: 0,
|
|
325
|
+
DEAD: 0,
|
|
326
|
+
OK: 0
|
|
327
|
+
};
|
|
328
|
+
for (const f of findings) summary[f.status]++;
|
|
329
|
+
return {
|
|
330
|
+
generatedAt,
|
|
331
|
+
root,
|
|
332
|
+
environments: environments.map((e) => e.name),
|
|
333
|
+
summary,
|
|
334
|
+
findings,
|
|
335
|
+
warnings
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// src/report/table.ts
|
|
340
|
+
var cell = (text, render) => ({
|
|
341
|
+
text,
|
|
342
|
+
render: render ?? text
|
|
343
|
+
});
|
|
344
|
+
function renderTable(report, opts) {
|
|
345
|
+
const c = opts.colors;
|
|
346
|
+
const lines = [];
|
|
347
|
+
const envCount = report.environments.length;
|
|
348
|
+
const envList = envCount > 0 ? report.environments.join(", ") : "none";
|
|
349
|
+
const fileWord = opts.filesScanned === 1 ? "file" : "files";
|
|
350
|
+
const envWord = envCount === 1 ? "environment" : "environments";
|
|
351
|
+
lines.push(
|
|
352
|
+
c.bold("EnvGuard") + c.dim(
|
|
353
|
+
` \xB7 scanned ${opts.filesScanned} ${fileWord} \xB7 ${envCount} ${envWord} (${envList})`
|
|
354
|
+
)
|
|
355
|
+
);
|
|
356
|
+
lines.push("");
|
|
357
|
+
if (report.findings.length === 0) {
|
|
358
|
+
lines.push(
|
|
359
|
+
c.dim("No environment variables found in code or environments.")
|
|
360
|
+
);
|
|
361
|
+
return lines.join("\n");
|
|
362
|
+
}
|
|
363
|
+
if (envCount === 0) {
|
|
364
|
+
lines.push(
|
|
365
|
+
c.yellow("No environments configured.") + c.dim(
|
|
366
|
+
" Add .env.<environment> files or an envguard.yml to compare against."
|
|
367
|
+
)
|
|
368
|
+
);
|
|
369
|
+
lines.push("");
|
|
370
|
+
lines.push(c.dim(`Variables referenced in code (${report.findings.length}):`));
|
|
371
|
+
for (const f of report.findings) {
|
|
372
|
+
const ref = f.references[0];
|
|
373
|
+
const loc = ref ? c.dim(` ${ref.file}:${ref.line}`) : "";
|
|
374
|
+
lines.push(` ${f.name}${loc}`);
|
|
375
|
+
}
|
|
376
|
+
return lines.join("\n");
|
|
377
|
+
}
|
|
378
|
+
const header = [
|
|
379
|
+
cell("VARIABLE"),
|
|
380
|
+
cell("CODE"),
|
|
381
|
+
...report.environments.map((e) => cell(e)),
|
|
382
|
+
cell("STATUS")
|
|
383
|
+
];
|
|
384
|
+
const aligns = [
|
|
385
|
+
"left",
|
|
386
|
+
"center",
|
|
387
|
+
...report.environments.map(() => "center"),
|
|
388
|
+
"left"
|
|
389
|
+
];
|
|
390
|
+
const rows = [header];
|
|
391
|
+
for (const f of report.findings) {
|
|
392
|
+
const nameCell = f.status === "OK" ? cell(f.name, c.dim(f.name)) : cell(f.name);
|
|
393
|
+
const codeCell = f.inCode ? cell("\u2713", c.cyan("\u2713")) : cell("\u2013", c.dim("\u2013"));
|
|
394
|
+
const envCells = report.environments.map(
|
|
395
|
+
(e) => f.presence[e] ? cell("\u2713", c.green("\u2713")) : cell("\u2717", c.red("\u2717"))
|
|
396
|
+
);
|
|
397
|
+
rows.push([nameCell, codeCell, ...envCells, statusCell(f.status, c)]);
|
|
398
|
+
}
|
|
399
|
+
const widths = header.map(
|
|
400
|
+
(_, col) => Math.max(...rows.map((r) => r[col].text.length))
|
|
401
|
+
);
|
|
402
|
+
const headerRow = rows[0].map((cl) => cell(cl.text, c.dim(cl.text)));
|
|
403
|
+
lines.push(renderRow(headerRow, widths, aligns));
|
|
404
|
+
for (let i = 1; i < rows.length; i++) {
|
|
405
|
+
lines.push(renderRow(rows[i], widths, aligns));
|
|
406
|
+
}
|
|
407
|
+
lines.push("");
|
|
408
|
+
lines.push(summaryLine(report, c));
|
|
409
|
+
const details = renderDetails(report, c);
|
|
410
|
+
if (details.length > 0) {
|
|
411
|
+
lines.push("");
|
|
412
|
+
lines.push(...details);
|
|
413
|
+
}
|
|
414
|
+
if (report.warnings.length > 0) {
|
|
415
|
+
lines.push("");
|
|
416
|
+
for (const w of report.warnings) {
|
|
417
|
+
lines.push(c.yellow("warning: ") + w);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return lines.join("\n");
|
|
421
|
+
}
|
|
422
|
+
function renderRow(cells, widths, aligns) {
|
|
423
|
+
return cells.map((cl, i) => pad(cl, widths[i] ?? cl.text.length, aligns[i] ?? "left")).join(" ").replace(/\s+$/, "");
|
|
424
|
+
}
|
|
425
|
+
function pad(cl, width, align) {
|
|
426
|
+
const space = Math.max(0, width - cl.text.length);
|
|
427
|
+
if (align === "center") {
|
|
428
|
+
const left = Math.floor(space / 2);
|
|
429
|
+
return " ".repeat(left) + cl.render + " ".repeat(space - left);
|
|
430
|
+
}
|
|
431
|
+
return cl.render + " ".repeat(space);
|
|
432
|
+
}
|
|
433
|
+
function statusCell(status, c) {
|
|
434
|
+
switch (status) {
|
|
435
|
+
case "MISSING":
|
|
436
|
+
return cell("MISSING", c.bold(c.red("MISSING")));
|
|
437
|
+
case "PARITY":
|
|
438
|
+
return cell("PARITY", c.yellow("PARITY"));
|
|
439
|
+
case "DEAD":
|
|
440
|
+
return cell("DEAD", c.dim("DEAD"));
|
|
441
|
+
case "OK":
|
|
442
|
+
return cell("OK", c.green("OK"));
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
function summaryLine(report, c) {
|
|
446
|
+
const s = report.summary;
|
|
447
|
+
const parts = [
|
|
448
|
+
s.MISSING > 0 ? c.red(`${s.MISSING} missing`) : c.dim(`${s.MISSING} missing`),
|
|
449
|
+
s.PARITY > 0 ? c.yellow(`${s.PARITY} parity`) : c.dim(`${s.PARITY} parity`),
|
|
450
|
+
c.dim(`${s.DEAD} dead`),
|
|
451
|
+
c.green(`${s.OK} ok`)
|
|
452
|
+
];
|
|
453
|
+
return " " + parts.join(c.dim(" \xB7 "));
|
|
454
|
+
}
|
|
455
|
+
function renderDetails(report, c) {
|
|
456
|
+
const out = [];
|
|
457
|
+
for (const f of report.findings) {
|
|
458
|
+
if (f.status === "OK") continue;
|
|
459
|
+
const first = f.references[0];
|
|
460
|
+
const more = f.references.length > 1 ? c.dim(` (+${f.references.length - 1} more)`) : "";
|
|
461
|
+
const where = first ? c.dim(`${first.file}:${first.line}`) + more : c.dim("\u2014");
|
|
462
|
+
if (f.status === "MISSING") {
|
|
463
|
+
out.push(
|
|
464
|
+
` ${c.bold(c.red("MISSING"))} ${c.bold(f.name)} \u2014 read at ${where}, not set in any environment`
|
|
465
|
+
);
|
|
466
|
+
} else if (f.status === "PARITY") {
|
|
467
|
+
const has = report.environments.filter((e) => f.presence[e]);
|
|
468
|
+
const missing = report.environments.filter((e) => !f.presence[e]);
|
|
469
|
+
out.push(
|
|
470
|
+
` ${c.yellow("PARITY")} ${c.bold(f.name)} \u2014 set in ${has.join(", ")}; missing in ${c.red(missing.join(", "))} (read at ${where})`
|
|
471
|
+
);
|
|
472
|
+
} else if (f.status === "DEAD") {
|
|
473
|
+
const has = report.environments.filter((e) => f.presence[e]);
|
|
474
|
+
out.push(
|
|
475
|
+
` ${c.dim("DEAD")} ${c.bold(f.name)} \u2014 set in ${has.join(", ")} but not read by any scanned file`
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return out;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// src/report/markdown.ts
|
|
483
|
+
var MARKDOWN_MARKER = "<!-- envguard-report -->";
|
|
484
|
+
function renderMarkdown(report) {
|
|
485
|
+
const s = report.summary;
|
|
486
|
+
const out = [];
|
|
487
|
+
out.push(MARKDOWN_MARKER);
|
|
488
|
+
out.push("## EnvGuard \u2014 environment variable report");
|
|
489
|
+
out.push("");
|
|
490
|
+
const envText = report.environments.length ? "`" + report.environments.join("`, `") + "`" : "_no environments configured_";
|
|
491
|
+
out.push(
|
|
492
|
+
`**${s.MISSING} missing \xB7 ${s.PARITY} parity drift \xB7 ${s.DEAD} dead \xB7 ${s.OK} ok** across ${envText}.`
|
|
493
|
+
);
|
|
494
|
+
out.push("");
|
|
495
|
+
const missing = report.findings.filter((f) => f.status === "MISSING");
|
|
496
|
+
const parity = report.findings.filter((f) => f.status === "PARITY");
|
|
497
|
+
const dead = report.findings.filter((f) => f.status === "DEAD");
|
|
498
|
+
if (missing.length > 0) {
|
|
499
|
+
out.push("### \u274C Missing \u2014 code reads it, no environment provides it");
|
|
500
|
+
out.push("");
|
|
501
|
+
out.push("| Variable | Read at |");
|
|
502
|
+
out.push("| --- | --- |");
|
|
503
|
+
for (const f of missing) out.push(`| \`${f.name}\` | ${refList(f)} |`);
|
|
504
|
+
out.push("");
|
|
505
|
+
}
|
|
506
|
+
if (parity.length > 0) {
|
|
507
|
+
out.push(
|
|
508
|
+
"### \u26A0\uFE0F Parity drift \u2014 present in some environments, missing in others"
|
|
509
|
+
);
|
|
510
|
+
out.push("");
|
|
511
|
+
out.push(`| Variable | ${report.environments.join(" | ")} | Read at |`);
|
|
512
|
+
out.push(
|
|
513
|
+
`| --- |${report.environments.map(() => " --- |").join("")} --- |`
|
|
514
|
+
);
|
|
515
|
+
for (const f of parity) {
|
|
516
|
+
const cells = report.environments.map((e) => f.presence[e] ? "\u2705" : "\u274C").join(" | ");
|
|
517
|
+
out.push(`| \`${f.name}\` | ${cells} | ${refList(f)} |`);
|
|
518
|
+
}
|
|
519
|
+
out.push("");
|
|
520
|
+
}
|
|
521
|
+
if (dead.length > 0) {
|
|
522
|
+
out.push("### \u{1F9F9} Dead \u2014 configured but no code reads it");
|
|
523
|
+
out.push("");
|
|
524
|
+
out.push("| Variable | Configured in |");
|
|
525
|
+
out.push("| --- | --- |");
|
|
526
|
+
for (const f of dead) {
|
|
527
|
+
const has = report.environments.filter((e) => f.presence[e]).join(", ");
|
|
528
|
+
out.push(`| \`${f.name}\` | ${has} |`);
|
|
529
|
+
}
|
|
530
|
+
out.push("");
|
|
531
|
+
}
|
|
532
|
+
if (missing.length === 0 && parity.length === 0 && dead.length === 0) {
|
|
533
|
+
out.push(
|
|
534
|
+
"\u2705 Every variable the code reads is present in all configured environments, and nothing is configured that the code doesn't read."
|
|
535
|
+
);
|
|
536
|
+
out.push("");
|
|
537
|
+
}
|
|
538
|
+
if (report.warnings.length > 0) {
|
|
539
|
+
out.push("> **Warnings**");
|
|
540
|
+
for (const w of report.warnings) out.push(`> - ${w}`);
|
|
541
|
+
out.push("");
|
|
542
|
+
}
|
|
543
|
+
out.push(
|
|
544
|
+
"<sub>EnvGuard \xB7 run <code>envguard scan</code> locally to reproduce.</sub>"
|
|
545
|
+
);
|
|
546
|
+
return out.join("\n");
|
|
547
|
+
}
|
|
548
|
+
function refList(f) {
|
|
549
|
+
if (f.references.length === 0) return "\u2014";
|
|
550
|
+
const shown = f.references.slice(0, 3).map((r) => `\`${r.file}:${r.line}\``).join(", ");
|
|
551
|
+
const extra = f.references.length > 3 ? ` +${f.references.length - 3} more` : "";
|
|
552
|
+
return shown + extra;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// src/commands/scan.ts
|
|
556
|
+
var FORMATS = ["table", "json", "markdown"];
|
|
557
|
+
var FAILABLE = ["MISSING", "PARITY", "DEAD"];
|
|
558
|
+
async function runScan(targetPath, options) {
|
|
559
|
+
const root = path4.resolve(process.cwd(), targetPath || ".");
|
|
560
|
+
const format = (options.format ?? "table").toLowerCase();
|
|
561
|
+
if (!FORMATS.includes(format)) {
|
|
562
|
+
throw new Error(
|
|
563
|
+
`unknown format "${options.format}" (expected: ${FORMATS.join(", ")})`
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
const { config } = await loadConfig(root, options.config);
|
|
567
|
+
const code = await scanCode(root, {
|
|
568
|
+
include: config.scan?.include,
|
|
569
|
+
exclude: config.scan?.exclude
|
|
570
|
+
});
|
|
571
|
+
const { environments, warnings } = await resolveEnvironments(config, root);
|
|
572
|
+
const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
573
|
+
const report = buildReport(
|
|
574
|
+
code,
|
|
575
|
+
environments,
|
|
576
|
+
config,
|
|
577
|
+
root,
|
|
578
|
+
generatedAt,
|
|
579
|
+
warnings
|
|
580
|
+
);
|
|
581
|
+
if (format === "json") {
|
|
582
|
+
const payload = { version: 1, ...report, filesScanned: code.filesScanned };
|
|
583
|
+
await emit(JSON.stringify(payload, null, 2) + "\n", options.output);
|
|
584
|
+
} else if (format === "markdown") {
|
|
585
|
+
await emit(renderMarkdown(report) + "\n", options.output);
|
|
586
|
+
} else {
|
|
587
|
+
const enabled = options.color !== false && Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
|
|
588
|
+
const colors = createColors(enabled);
|
|
589
|
+
const text = renderTable(report, {
|
|
590
|
+
colors,
|
|
591
|
+
filesScanned: code.filesScanned
|
|
592
|
+
});
|
|
593
|
+
await emit(text + "\n", options.output);
|
|
594
|
+
for (const w of warnings) {
|
|
595
|
+
process.stderr.write(colors.yellow("warning: ") + w + "\n");
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
const failOn = resolveFailOn(options.failOn, config.failOn);
|
|
599
|
+
const failed = failOn.some((s) => report.summary[s] > 0);
|
|
600
|
+
return failed ? 1 : 0;
|
|
601
|
+
}
|
|
602
|
+
async function emit(content, output) {
|
|
603
|
+
if (output) {
|
|
604
|
+
const dest = path4.isAbsolute(output) ? output : path4.resolve(process.cwd(), output);
|
|
605
|
+
await writeFile(dest, content);
|
|
606
|
+
} else {
|
|
607
|
+
process.stdout.write(content);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
function resolveFailOn(flag, configFailOn) {
|
|
611
|
+
if (flag !== void 0) return parseStatusList(flag);
|
|
612
|
+
if (configFailOn && configFailOn.length > 0) {
|
|
613
|
+
return configFailOn.map((s) => String(s).toUpperCase()).filter((s) => FAILABLE.includes(s));
|
|
614
|
+
}
|
|
615
|
+
return ["MISSING", "PARITY"];
|
|
616
|
+
}
|
|
617
|
+
function parseStatusList(raw) {
|
|
618
|
+
const tokens = raw.split(",").map((t) => t.trim().toUpperCase()).filter(Boolean);
|
|
619
|
+
if (tokens.includes("NONE")) return [];
|
|
620
|
+
const out = [];
|
|
621
|
+
for (const t of tokens) {
|
|
622
|
+
if (FAILABLE.includes(t)) out.push(t);
|
|
623
|
+
else
|
|
624
|
+
throw new Error(
|
|
625
|
+
`invalid --fail-on value "${t}" (expected any of: missing, parity, dead, none)`
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
return out;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// src/index.ts
|
|
632
|
+
var pkg = JSON.parse(
|
|
633
|
+
readFileSync(new URL("../package.json", import.meta.url), "utf8")
|
|
634
|
+
);
|
|
635
|
+
var program = new Command();
|
|
636
|
+
program.name("envguard").description(
|
|
637
|
+
"Inventory the environment variables your code expects and check them against every environment."
|
|
638
|
+
).version(pkg.version, "-v, --version", "print the EnvGuard version");
|
|
639
|
+
program.command("scan", { isDefault: true }).description(
|
|
640
|
+
"Scan source for env var usage and compare it against every configured environment."
|
|
641
|
+
).argument("[path]", "directory to scan", ".").option("-c, --config <file>", "path to an envguard.yml config file").option("-f, --format <format>", "output format: table | json | markdown", "table").option("-o, --output <file>", "write the report to a file instead of stdout").option(
|
|
642
|
+
"--fail-on <list>",
|
|
643
|
+
"comma-separated statuses that cause a non-zero exit: missing, parity, dead, none"
|
|
644
|
+
).option("--no-color", "disable colored output").action(async (pathArg, opts) => {
|
|
645
|
+
try {
|
|
646
|
+
process.exitCode = await runScan(pathArg, {
|
|
647
|
+
config: opts.config,
|
|
648
|
+
format: opts.format,
|
|
649
|
+
output: opts.output,
|
|
650
|
+
failOn: opts.failOn,
|
|
651
|
+
color: opts.color
|
|
652
|
+
});
|
|
653
|
+
} catch (err) {
|
|
654
|
+
process.stderr.write(`envguard: ${err.message}
|
|
655
|
+
`);
|
|
656
|
+
process.exitCode = 2;
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
program.parseAsync(process.argv);
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rinorhatashi/envguard",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"description": "Inventory the environment variables your code expects and check them against every environment — surfacing missing vars, staging/production parity drift, and dead configuration.",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"env",
|
|
10
|
+
"environment-variables",
|
|
11
|
+
"dotenv",
|
|
12
|
+
"configuration",
|
|
13
|
+
"parity",
|
|
14
|
+
"drift",
|
|
15
|
+
"secrets",
|
|
16
|
+
"cli",
|
|
17
|
+
"github-action",
|
|
18
|
+
"devops"
|
|
19
|
+
],
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"author": "Rinor Hatashi",
|
|
22
|
+
"homepage": "https://github.com/rinorhatashi/EnvGuard#readme",
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/rinorhatashi/EnvGuard.git"
|
|
26
|
+
},
|
|
27
|
+
"bugs": {
|
|
28
|
+
"url": "https://github.com/rinorhatashi/EnvGuard/issues"
|
|
29
|
+
},
|
|
30
|
+
"type": "module",
|
|
31
|
+
"bin": {
|
|
32
|
+
"envguard": "dist/index.js"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist",
|
|
36
|
+
"README.md",
|
|
37
|
+
"LICENSE"
|
|
38
|
+
],
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=18"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "tsup",
|
|
44
|
+
"dev": "tsx src/index.ts",
|
|
45
|
+
"envguard": "tsx src/index.ts",
|
|
46
|
+
"typecheck": "tsc --noEmit",
|
|
47
|
+
"test": "vitest run",
|
|
48
|
+
"test:watch": "vitest",
|
|
49
|
+
"prepublishOnly": "npm run build"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"commander": "^12.1.0",
|
|
53
|
+
"dotenv": "^16.4.5",
|
|
54
|
+
"fast-glob": "^3.3.2",
|
|
55
|
+
"picocolors": "^1.1.0",
|
|
56
|
+
"yaml": "^2.5.1"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@types/node": "^22.7.0",
|
|
60
|
+
"tsup": "^8.3.0",
|
|
61
|
+
"tsx": "^4.19.1",
|
|
62
|
+
"typescript": "^5.6.2",
|
|
63
|
+
"vitest": "^2.1.1"
|
|
64
|
+
}
|
|
65
|
+
}
|