@claude-agent/envcheck 1.3.0 → 1.5.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/README.md +170 -17
- package/bin/cli.js +45 -3
- package/package.json +7 -2
- package/src/index.js +402 -5
package/README.md
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/@claude-agent/envcheck)
|
|
5
5
|
[](https://opensource.org/licenses/MIT)
|
|
6
6
|
|
|
7
|
-
> Validate .env files, compare with .env.example, find missing or empty variables.
|
|
7
|
+
> Validate .env files, compare with .env.example, find missing or empty variables. **Now with monorepo support!**
|
|
8
8
|
|
|
9
|
-
Never deploy with missing environment variables again.
|
|
9
|
+
Never deploy with missing environment variables again. Works across entire monorepos with a single command.
|
|
10
10
|
|
|
11
11
|
**Built autonomously by [Claude](https://claude.ai)** - an AI assistant by Anthropic.
|
|
12
12
|
|
|
@@ -34,6 +34,9 @@ envcheck .env.production
|
|
|
34
34
|
# Require specific variables
|
|
35
35
|
envcheck -r "DATABASE_URL,API_KEY"
|
|
36
36
|
|
|
37
|
+
# Scan entire monorepo
|
|
38
|
+
envcheck monorepo
|
|
39
|
+
|
|
37
40
|
# Compare two files
|
|
38
41
|
envcheck compare .env .env.staging
|
|
39
42
|
|
|
@@ -101,6 +104,104 @@ envcheck list .env -j
|
|
|
101
104
|
envcheck get .env DATABASE_URL
|
|
102
105
|
```
|
|
103
106
|
|
|
107
|
+
### Monorepo Command
|
|
108
|
+
|
|
109
|
+
Scan all apps and packages in a monorepo with a single command:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
# Scan from current directory
|
|
113
|
+
envcheck monorepo
|
|
114
|
+
|
|
115
|
+
# Scan specific directory
|
|
116
|
+
envcheck monorepo ./my-monorepo
|
|
117
|
+
|
|
118
|
+
# With verbose output (shows all issues per app)
|
|
119
|
+
envcheck monorepo --verbose
|
|
120
|
+
|
|
121
|
+
# JSON output for CI/CD
|
|
122
|
+
envcheck monorepo --json
|
|
123
|
+
|
|
124
|
+
# Enable secret detection
|
|
125
|
+
envcheck monorepo --secrets
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Monorepo Support
|
|
129
|
+
|
|
130
|
+
envcheck can scan entire monorepos, validating environment variables across all apps and packages.
|
|
131
|
+
|
|
132
|
+
### Supported Structures
|
|
133
|
+
|
|
134
|
+
envcheck automatically detects these common monorepo patterns:
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
my-monorepo/
|
|
138
|
+
├── apps/
|
|
139
|
+
│ ├── web/ # ✓ Scanned
|
|
140
|
+
│ │ ├── .env
|
|
141
|
+
│ │ └── .env.example
|
|
142
|
+
│ └── api/ # ✓ Scanned
|
|
143
|
+
│ ├── .env
|
|
144
|
+
│ └── .env.example
|
|
145
|
+
├── packages/
|
|
146
|
+
│ ├── shared/ # ○ Skipped (no .env.example)
|
|
147
|
+
│ └── utils/ # ✓ Scanned
|
|
148
|
+
│ ├── .env
|
|
149
|
+
│ └── .env.example
|
|
150
|
+
└── .env.example # ✓ Root included if exists
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Supported directories: `apps/`, `packages/`, `workspaces/`, `services/`, `libs/`
|
|
154
|
+
|
|
155
|
+
### Example Output
|
|
156
|
+
|
|
157
|
+
```
|
|
158
|
+
$ envcheck monorepo
|
|
159
|
+
|
|
160
|
+
Monorepo Environment Check
|
|
161
|
+
Root: /path/to/monorepo
|
|
162
|
+
|
|
163
|
+
✓ apps/web: passed
|
|
164
|
+
✗ apps/api: 1 error(s)
|
|
165
|
+
○ packages/shared: skipped (No .env.example found)
|
|
166
|
+
✓ packages/utils: passed
|
|
167
|
+
|
|
168
|
+
Summary: 4 apps scanned
|
|
169
|
+
✓ 2 passed
|
|
170
|
+
✗ 1 failed
|
|
171
|
+
○ 1 skipped
|
|
172
|
+
|
|
173
|
+
✗ 1 error(s), 0 warning(s)
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### Consistency Checks
|
|
177
|
+
|
|
178
|
+
envcheck can detect inconsistencies across apps:
|
|
179
|
+
|
|
180
|
+
- **Shared variables** - Track which variables appear in multiple apps
|
|
181
|
+
- **Type mismatches** - Detect when the same variable has different type hints in different apps
|
|
182
|
+
|
|
183
|
+
```javascript
|
|
184
|
+
const { scanMonorepo } = require('@claude-agent/envcheck');
|
|
185
|
+
|
|
186
|
+
const result = scanMonorepo('.', { checkConsistency: true });
|
|
187
|
+
|
|
188
|
+
console.log(result.consistency.sharedVars);
|
|
189
|
+
// { API_URL: ['apps/web', 'apps/api'] }
|
|
190
|
+
|
|
191
|
+
console.log(result.consistency.mismatches);
|
|
192
|
+
// [{ variable: 'API_URL', issue: 'type_mismatch', details: [...] }]
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### GitHub Action for Monorepos
|
|
196
|
+
|
|
197
|
+
```yaml
|
|
198
|
+
- name: Validate all environment files
|
|
199
|
+
uses: claude-agent-tools/envcheck@v1
|
|
200
|
+
with:
|
|
201
|
+
monorepo: 'true'
|
|
202
|
+
strict: 'true'
|
|
203
|
+
```
|
|
204
|
+
|
|
104
205
|
## API Usage
|
|
105
206
|
|
|
106
207
|
```javascript
|
|
@@ -269,6 +370,55 @@ ADMIN_EMAIL=admin@example.com
|
|
|
269
370
|
| `uuid` | UUID format | `550e8400-e29b-41d4-a716-446655440000` |
|
|
270
371
|
| `string`/`str` | Any string (no validation) | anything |
|
|
271
372
|
|
|
373
|
+
## Secret Detection
|
|
374
|
+
|
|
375
|
+
envcheck can warn you if your `.env` file contains values that look like real secrets:
|
|
376
|
+
|
|
377
|
+
```javascript
|
|
378
|
+
const result = check('.env', {
|
|
379
|
+
examplePath: '.env.example',
|
|
380
|
+
detectSecrets: true // Warns about potential secrets
|
|
381
|
+
});
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
### Detected Secret Patterns
|
|
385
|
+
|
|
386
|
+
- AWS Access Keys (`AKIA...`)
|
|
387
|
+
- GitHub Tokens (`ghp_...`, `gho_...`, etc.)
|
|
388
|
+
- Stripe Keys (`sk_live_...`, `rk_live_...`)
|
|
389
|
+
- Private Keys (`-----BEGIN PRIVATE KEY-----`)
|
|
390
|
+
- Slack Tokens (`xox...`)
|
|
391
|
+
- Twilio Credentials
|
|
392
|
+
- SendGrid API Keys
|
|
393
|
+
- Google API Keys
|
|
394
|
+
- High-entropy hex strings (with sensitive key names)
|
|
395
|
+
|
|
396
|
+
### Placeholder Detection
|
|
397
|
+
|
|
398
|
+
envcheck won't warn about obvious placeholders like:
|
|
399
|
+
- `your-api-key`, `my-secret`
|
|
400
|
+
- `changeme`, `placeholder`
|
|
401
|
+
- `xxx`, `...`
|
|
402
|
+
- `example`, `test`, `dummy`
|
|
403
|
+
|
|
404
|
+
### API Usage with Secrets
|
|
405
|
+
|
|
406
|
+
```javascript
|
|
407
|
+
const { check, validate, detectSecret } = require('@claude-agent/envcheck');
|
|
408
|
+
|
|
409
|
+
// Enable secret detection
|
|
410
|
+
const result = check('.env', {
|
|
411
|
+
examplePath: '.env.example',
|
|
412
|
+
detectSecrets: true // Warns about potential secrets
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
// Check a single value
|
|
416
|
+
const secret = detectSecret('API_KEY', 'sk_live_abc123...');
|
|
417
|
+
if (secret) {
|
|
418
|
+
console.log(`Warning: ${secret.description} detected`);
|
|
419
|
+
}
|
|
420
|
+
```
|
|
421
|
+
|
|
272
422
|
### API Usage with Types
|
|
273
423
|
|
|
274
424
|
```javascript
|
|
@@ -308,21 +458,24 @@ WITH_EQUALS=postgres://user:pass@host/db?opt=val
|
|
|
308
458
|
- **Auto-detection** - Finds .env.example automatically
|
|
309
459
|
- **CI-friendly** - Exit codes and JSON output
|
|
310
460
|
- **Comprehensive** - Parse, validate, compare, generate
|
|
311
|
-
- **
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
|
319
|
-
|
|
|
320
|
-
| **
|
|
321
|
-
| **
|
|
322
|
-
|
|
|
323
|
-
|
|
|
324
|
-
|
|
325
|
-
**
|
|
461
|
+
- **Monorepo support** - Scan all apps/packages in one command
|
|
462
|
+
- **Well-tested** - 87 tests covering edge cases
|
|
463
|
+
|
|
464
|
+
## vs. dotenv-safe / envalid / dotenv-mono
|
|
465
|
+
|
|
466
|
+
| Feature | envcheck | dotenv-safe | envalid | dotenv-mono |
|
|
467
|
+
|---------|----------|-------------|---------|-------------|
|
|
468
|
+
| Validates presence | ✅ | ✅ | ✅ | ❌ |
|
|
469
|
+
| Based on .env.example | ✅ | ✅ | ❌ (schema) | ❌ |
|
|
470
|
+
| **Static validation** | ✅ | ❌ | ❌ | ❌ |
|
|
471
|
+
| **CI/CD integration** | ✅ GitHub Action | ❌ | ❌ | ❌ |
|
|
472
|
+
| **Pre-commit hook** | ✅ | ❌ | ❌ | ❌ |
|
|
473
|
+
| Type validation | ✅ (static) | ❌ | ✅ (runtime) | ❌ |
|
|
474
|
+
| **Secret detection** | ✅ | ❌ | ❌ | ❌ |
|
|
475
|
+
| **Monorepo scan** | ✅ | ❌ | ❌ | ❌ |
|
|
476
|
+
| Zero dependencies | ✅ | ❌ | ❌ | ❌ |
|
|
477
|
+
|
|
478
|
+
**Key difference:** envcheck validates *before* deployment (shift-left), while dotenv-safe and envalid validate at runtime when your app starts. dotenv-mono helps load env vars in monorepos but doesn't validate them. envcheck is the only tool that validates across entire monorepos with a single command.
|
|
326
479
|
|
|
327
480
|
## License
|
|
328
481
|
|
package/bin/cli.js
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
-
const { check, compare, validate, list, get, readEnvFile } = require('../src/index.js');
|
|
4
|
+
const { check, compare, validate, list, get, readEnvFile, scanMonorepo, formatMonorepoResult } = require('../src/index.js');
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const fs = require('fs');
|
|
7
7
|
|
|
8
|
-
const VERSION = '1.
|
|
8
|
+
const VERSION = '1.5.0';
|
|
9
9
|
|
|
10
10
|
const HELP = `
|
|
11
11
|
envcheck - Validate .env files
|
|
12
12
|
|
|
13
13
|
USAGE
|
|
14
14
|
envcheck [options] [file]
|
|
15
|
+
envcheck monorepo [directory]
|
|
15
16
|
envcheck compare <env> <example>
|
|
16
17
|
|
|
17
18
|
COMMANDS
|
|
18
19
|
check (default) Check .env file, optionally against .env.example
|
|
20
|
+
monorepo Scan all apps/packages in a monorepo
|
|
19
21
|
compare Compare two env files
|
|
20
22
|
list List variables in a file
|
|
21
23
|
get <key> Get a specific variable value
|
|
@@ -26,6 +28,8 @@ OPTIONS
|
|
|
26
28
|
--no-empty Warn on empty values
|
|
27
29
|
--no-extra Error on variables not in example
|
|
28
30
|
--strict Treat warnings as errors
|
|
31
|
+
--secrets Enable secret detection (warn about real secrets)
|
|
32
|
+
--verbose Show detailed output (monorepo mode)
|
|
29
33
|
-q, --quiet Only output errors
|
|
30
34
|
-j, --json Output as JSON
|
|
31
35
|
-v, --version Show version
|
|
@@ -35,6 +39,9 @@ EXAMPLES
|
|
|
35
39
|
envcheck Check .env against .env.example
|
|
36
40
|
envcheck .env.production Check specific file
|
|
37
41
|
envcheck -r "API_KEY,DB_URL" Require specific variables
|
|
42
|
+
envcheck monorepo Scan monorepo from current directory
|
|
43
|
+
envcheck monorepo ./my-monorepo Scan specific directory
|
|
44
|
+
envcheck monorepo --verbose Show all issues per app
|
|
38
45
|
envcheck compare .env .env.prod Compare two files
|
|
39
46
|
envcheck list .env List all variables
|
|
40
47
|
envcheck get .env API_KEY Get specific value
|
|
@@ -51,6 +58,8 @@ function parseArgs(args) {
|
|
|
51
58
|
strict: false,
|
|
52
59
|
quiet: false,
|
|
53
60
|
json: false,
|
|
61
|
+
verbose: false,
|
|
62
|
+
detectSecrets: false,
|
|
54
63
|
args: []
|
|
55
64
|
};
|
|
56
65
|
|
|
@@ -78,11 +87,15 @@ function parseArgs(args) {
|
|
|
78
87
|
result.noExtra = true;
|
|
79
88
|
} else if (arg === '--strict') {
|
|
80
89
|
result.strict = true;
|
|
90
|
+
} else if (arg === '--secrets') {
|
|
91
|
+
result.detectSecrets = true;
|
|
92
|
+
} else if (arg === '--verbose') {
|
|
93
|
+
result.verbose = true;
|
|
81
94
|
} else if (arg === '-q' || arg === '--quiet') {
|
|
82
95
|
result.quiet = true;
|
|
83
96
|
} else if (arg === '-j' || arg === '--json') {
|
|
84
97
|
result.json = true;
|
|
85
|
-
} else if (arg === 'compare' || arg === 'list' || arg === 'get') {
|
|
98
|
+
} else if (arg === 'compare' || arg === 'list' || arg === 'get' || arg === 'monorepo') {
|
|
86
99
|
result.command = arg;
|
|
87
100
|
} else if (!arg.startsWith('-')) {
|
|
88
101
|
result.args.push(arg);
|
|
@@ -277,6 +290,32 @@ function runGet(opts) {
|
|
|
277
290
|
return 0;
|
|
278
291
|
}
|
|
279
292
|
|
|
293
|
+
function runMonorepo(opts) {
|
|
294
|
+
const rootDir = opts.args[0] || '.';
|
|
295
|
+
|
|
296
|
+
const result = scanMonorepo(rootDir, {
|
|
297
|
+
noEmpty: opts.noEmpty,
|
|
298
|
+
noExtra: opts.noExtra,
|
|
299
|
+
strict: opts.strict,
|
|
300
|
+
detectSecrets: opts.detectSecrets,
|
|
301
|
+
checkConsistency: true
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
if (opts.json) {
|
|
305
|
+
console.log(JSON.stringify(result, null, 2));
|
|
306
|
+
return result.valid ? 0 : 1;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Use formatted output
|
|
310
|
+
const output = formatMonorepoResult(result, {
|
|
311
|
+
colors: !opts.quiet,
|
|
312
|
+
verbose: opts.verbose
|
|
313
|
+
});
|
|
314
|
+
console.log(output);
|
|
315
|
+
|
|
316
|
+
return result.valid ? 0 : 1;
|
|
317
|
+
}
|
|
318
|
+
|
|
280
319
|
function main() {
|
|
281
320
|
const args = process.argv.slice(2);
|
|
282
321
|
const opts = parseArgs(args);
|
|
@@ -287,6 +326,9 @@ function main() {
|
|
|
287
326
|
case 'check':
|
|
288
327
|
exitCode = runCheck(opts);
|
|
289
328
|
break;
|
|
329
|
+
case 'monorepo':
|
|
330
|
+
exitCode = runMonorepo(opts);
|
|
331
|
+
break;
|
|
290
332
|
case 'compare':
|
|
291
333
|
exitCode = runCompare(opts);
|
|
292
334
|
break;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@claude-agent/envcheck",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Validate .env files, compare with .env.example, find missing or empty variables",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -20,7 +20,12 @@
|
|
|
20
20
|
"github-action",
|
|
21
21
|
"ci-cd",
|
|
22
22
|
"pre-commit",
|
|
23
|
-
"git-hooks"
|
|
23
|
+
"git-hooks",
|
|
24
|
+
"secrets",
|
|
25
|
+
"security",
|
|
26
|
+
"monorepo",
|
|
27
|
+
"turborepo",
|
|
28
|
+
"workspaces"
|
|
24
29
|
],
|
|
25
30
|
"author": "Claude Agent <claude-agent@agentmail.to>",
|
|
26
31
|
"license": "MIT",
|
package/src/index.js
CHANGED
|
@@ -87,6 +87,108 @@ const typeValidators = {
|
|
|
87
87
|
}
|
|
88
88
|
};
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Secret detection patterns
|
|
92
|
+
* Each pattern has: regex, description, and optional keyPattern (for key-specific checks)
|
|
93
|
+
*/
|
|
94
|
+
const secretPatterns = [
|
|
95
|
+
// AWS
|
|
96
|
+
{ regex: /^AKIA[0-9A-Z]{16}$/, description: 'AWS Access Key ID' },
|
|
97
|
+
{ regex: /^[A-Za-z0-9/+=]{40}$/, description: 'AWS Secret Access Key', keyPattern: /aws.*secret|secret.*key/i },
|
|
98
|
+
|
|
99
|
+
// Private keys
|
|
100
|
+
{ regex: /-----BEGIN (?:RSA |DSA |EC |OPENSSH |PGP )?PRIVATE KEY-----/, description: 'Private key' },
|
|
101
|
+
{ regex: /-----BEGIN CERTIFICATE-----/, description: 'Certificate' },
|
|
102
|
+
|
|
103
|
+
// GitHub
|
|
104
|
+
{ regex: /^ghp_[a-zA-Z0-9]{36}$/, description: 'GitHub Personal Access Token' },
|
|
105
|
+
{ regex: /^gho_[a-zA-Z0-9]{36}$/, description: 'GitHub OAuth Access Token' },
|
|
106
|
+
{ regex: /^ghu_[a-zA-Z0-9]{36}$/, description: 'GitHub User-to-Server Token' },
|
|
107
|
+
{ regex: /^ghs_[a-zA-Z0-9]{36}$/, description: 'GitHub Server-to-Server Token' },
|
|
108
|
+
{ regex: /^ghr_[a-zA-Z0-9]{36}$/, description: 'GitHub Refresh Token' },
|
|
109
|
+
|
|
110
|
+
// Slack
|
|
111
|
+
{ regex: /^xox[baprs]-[0-9]{10,}-[0-9]{10,}-[a-zA-Z0-9]{24}$/, description: 'Slack Token' },
|
|
112
|
+
|
|
113
|
+
// Stripe
|
|
114
|
+
{ regex: /^sk_live_[a-zA-Z0-9]{24,}$/, description: 'Stripe Live Secret Key' },
|
|
115
|
+
{ regex: /^rk_live_[a-zA-Z0-9]{24,}$/, description: 'Stripe Live Restricted Key' },
|
|
116
|
+
|
|
117
|
+
// Twilio
|
|
118
|
+
{ regex: /^AC[a-f0-9]{32}$/, description: 'Twilio Account SID' },
|
|
119
|
+
{ regex: /^SK[a-f0-9]{32}$/, description: 'Twilio API Key' },
|
|
120
|
+
|
|
121
|
+
// SendGrid
|
|
122
|
+
{ regex: /^SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}$/, description: 'SendGrid API Key' },
|
|
123
|
+
|
|
124
|
+
// Google
|
|
125
|
+
{ regex: /^AIza[0-9A-Za-z_-]{35}$/, description: 'Google API Key' },
|
|
126
|
+
|
|
127
|
+
// Generic high-entropy (likely real secrets)
|
|
128
|
+
{ regex: /^[a-f0-9]{32}$/, description: 'Hex string (32 chars)', keyPattern: /api.*key|secret|token|password/i },
|
|
129
|
+
{ regex: /^[a-f0-9]{64}$/, description: 'Hex string (64 chars)', keyPattern: /api.*key|secret|token|password/i },
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Placeholder patterns that are NOT secrets
|
|
134
|
+
*/
|
|
135
|
+
const placeholderPatterns = [
|
|
136
|
+
/^your[-_]?/i,
|
|
137
|
+
/^my[-_]?/i,
|
|
138
|
+
/^xxx+$/i,
|
|
139
|
+
/^placeholder/i,
|
|
140
|
+
/^example/i,
|
|
141
|
+
/^test[-_]?/i,
|
|
142
|
+
/^dummy/i,
|
|
143
|
+
/^fake/i,
|
|
144
|
+
/^sample/i,
|
|
145
|
+
/^changeme/i,
|
|
146
|
+
/^replace[-_]?/i,
|
|
147
|
+
/^insert[-_]?/i,
|
|
148
|
+
/^todo/i,
|
|
149
|
+
/^\*+$/,
|
|
150
|
+
/^\.\.\.$/,
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Check if a value looks like a placeholder
|
|
155
|
+
* @param {string} value - Value to check
|
|
156
|
+
* @returns {boolean} True if it looks like a placeholder
|
|
157
|
+
*/
|
|
158
|
+
function isPlaceholder(value) {
|
|
159
|
+
if (!value || value.length < 3) return true;
|
|
160
|
+
return placeholderPatterns.some(pattern => pattern.test(value));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Detect potential secrets in environment variables
|
|
165
|
+
* @param {string} key - Variable name
|
|
166
|
+
* @param {string} value - Variable value
|
|
167
|
+
* @returns {Object|null} Detection result or null if no secret detected
|
|
168
|
+
*/
|
|
169
|
+
function detectSecret(key, value) {
|
|
170
|
+
if (!value || isPlaceholder(value)) {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
for (const pattern of secretPatterns) {
|
|
175
|
+
// If pattern has keyPattern, only check if key matches
|
|
176
|
+
if (pattern.keyPattern && !pattern.keyPattern.test(key)) {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (pattern.regex.test(value)) {
|
|
181
|
+
return {
|
|
182
|
+
detected: true,
|
|
183
|
+
description: pattern.description,
|
|
184
|
+
message: `may contain a real ${pattern.description}`
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
90
192
|
/**
|
|
91
193
|
* Parse a .env file into an object
|
|
92
194
|
* @param {string} content - File content
|
|
@@ -326,7 +428,7 @@ function validateType(value, type) {
|
|
|
326
428
|
* @returns {Object} Validation result
|
|
327
429
|
*/
|
|
328
430
|
function validate(filePath, options = {}) {
|
|
329
|
-
const { required = [], noEmpty = false, types = {}, validateTypes = false } = options;
|
|
431
|
+
const { required = [], noEmpty = false, types = {}, validateTypes = false, detectSecrets = false } = options;
|
|
330
432
|
|
|
331
433
|
const env = readEnvFile(filePath);
|
|
332
434
|
|
|
@@ -406,6 +508,20 @@ function validate(filePath, options = {}) {
|
|
|
406
508
|
}
|
|
407
509
|
}
|
|
408
510
|
|
|
511
|
+
// Secret detection
|
|
512
|
+
if (detectSecrets) {
|
|
513
|
+
for (const [key, value] of Object.entries(env.variables)) {
|
|
514
|
+
const secretResult = detectSecret(key, value);
|
|
515
|
+
if (secretResult) {
|
|
516
|
+
result.issues.push({
|
|
517
|
+
type: 'warning',
|
|
518
|
+
line: env.lineInfo[key],
|
|
519
|
+
message: `Variable '${key}' ${secretResult.message}`
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
409
525
|
// Add warnings
|
|
410
526
|
for (const warning of env.warnings) {
|
|
411
527
|
result.issues.push({
|
|
@@ -432,7 +548,8 @@ function check(envPath, options = {}) {
|
|
|
432
548
|
noExtra = false,
|
|
433
549
|
strict = false,
|
|
434
550
|
types = {},
|
|
435
|
-
validateTypes = false
|
|
551
|
+
validateTypes = false,
|
|
552
|
+
detectSecrets = false
|
|
436
553
|
} = options;
|
|
437
554
|
|
|
438
555
|
const result = {
|
|
@@ -456,12 +573,13 @@ function check(envPath, options = {}) {
|
|
|
456
573
|
// Merge type hints: example hints < explicit types
|
|
457
574
|
const mergedTypes = { ...exampleTypeHints, ...types };
|
|
458
575
|
|
|
459
|
-
// First validate the env file (including type validation)
|
|
576
|
+
// First validate the env file (including type validation and secret detection)
|
|
460
577
|
const validation = validate(envPath, {
|
|
461
578
|
required,
|
|
462
579
|
noEmpty,
|
|
463
580
|
types: mergedTypes,
|
|
464
|
-
validateTypes: validateTypes || Object.keys(mergedTypes).length > 0
|
|
581
|
+
validateTypes: validateTypes || Object.keys(mergedTypes).length > 0,
|
|
582
|
+
detectSecrets
|
|
465
583
|
});
|
|
466
584
|
|
|
467
585
|
if (!validation.valid) {
|
|
@@ -565,6 +683,278 @@ function get(filePath, key) {
|
|
|
565
683
|
return env.variables[key];
|
|
566
684
|
}
|
|
567
685
|
|
|
686
|
+
/**
|
|
687
|
+
* Find directories that might contain apps/packages in a monorepo
|
|
688
|
+
* @param {string} rootDir - Root directory to scan
|
|
689
|
+
* @returns {string[]} Array of directory paths
|
|
690
|
+
*/
|
|
691
|
+
function findMonorepoApps(rootDir) {
|
|
692
|
+
const apps = [];
|
|
693
|
+
const basePath = path.resolve(rootDir);
|
|
694
|
+
|
|
695
|
+
// Common monorepo patterns
|
|
696
|
+
const patterns = ['apps', 'packages', 'workspaces', 'services', 'libs'];
|
|
697
|
+
|
|
698
|
+
for (const pattern of patterns) {
|
|
699
|
+
const dir = path.join(basePath, pattern);
|
|
700
|
+
if (fs.existsSync(dir) && fs.statSync(dir).isDirectory()) {
|
|
701
|
+
// Get all subdirectories
|
|
702
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
703
|
+
for (const entry of entries) {
|
|
704
|
+
if (entry.isDirectory() && !entry.name.startsWith('.')) {
|
|
705
|
+
apps.push(path.join(dir, entry.name));
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Also check root for .env.example (some monorepos have root-level env)
|
|
712
|
+
if (fs.existsSync(path.join(basePath, '.env.example')) ||
|
|
713
|
+
fs.existsSync(path.join(basePath, '.env'))) {
|
|
714
|
+
apps.unshift(basePath);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
return apps;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Scan a monorepo for env file issues
|
|
722
|
+
* @param {string} rootDir - Root directory of monorepo
|
|
723
|
+
* @param {Object} options - Scan options
|
|
724
|
+
* @returns {Object} Monorepo scan result
|
|
725
|
+
*/
|
|
726
|
+
function scanMonorepo(rootDir, options = {}) {
|
|
727
|
+
const {
|
|
728
|
+
noEmpty = false,
|
|
729
|
+
noExtra = false,
|
|
730
|
+
strict = false,
|
|
731
|
+
checkConsistency = true,
|
|
732
|
+
detectSecrets = false
|
|
733
|
+
} = options;
|
|
734
|
+
|
|
735
|
+
const basePath = path.resolve(rootDir);
|
|
736
|
+
const apps = findMonorepoApps(basePath);
|
|
737
|
+
|
|
738
|
+
const result = {
|
|
739
|
+
root: basePath,
|
|
740
|
+
valid: true,
|
|
741
|
+
apps: [],
|
|
742
|
+
summary: {
|
|
743
|
+
total: apps.length,
|
|
744
|
+
passed: 0,
|
|
745
|
+
failed: 0,
|
|
746
|
+
skipped: 0,
|
|
747
|
+
errors: 0,
|
|
748
|
+
warnings: 0
|
|
749
|
+
},
|
|
750
|
+
consistency: {
|
|
751
|
+
sharedVars: {}, // Variables that appear in multiple apps
|
|
752
|
+
mismatches: [] // Variables with different types/values across apps
|
|
753
|
+
}
|
|
754
|
+
};
|
|
755
|
+
|
|
756
|
+
// Track all variables across apps for consistency checking
|
|
757
|
+
const allVars = {}; // { varName: [{ app, value, type }] }
|
|
758
|
+
|
|
759
|
+
for (const appDir of apps) {
|
|
760
|
+
const appName = path.relative(basePath, appDir) || '.';
|
|
761
|
+
const envPath = path.join(appDir, '.env');
|
|
762
|
+
const examplePath = path.join(appDir, '.env.example');
|
|
763
|
+
|
|
764
|
+
const appResult = {
|
|
765
|
+
name: appName,
|
|
766
|
+
path: appDir,
|
|
767
|
+
hasEnv: fs.existsSync(envPath),
|
|
768
|
+
hasExample: fs.existsSync(examplePath),
|
|
769
|
+
valid: true,
|
|
770
|
+
issues: [],
|
|
771
|
+
variables: []
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
// Skip if no example file (can't validate)
|
|
775
|
+
if (!appResult.hasExample) {
|
|
776
|
+
appResult.skipped = true;
|
|
777
|
+
appResult.reason = 'No .env.example found';
|
|
778
|
+
result.apps.push(appResult);
|
|
779
|
+
result.summary.skipped++;
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// If no .env but has example, that's also a problem
|
|
784
|
+
if (!appResult.hasEnv) {
|
|
785
|
+
appResult.valid = false;
|
|
786
|
+
appResult.issues.push({
|
|
787
|
+
type: 'warning',
|
|
788
|
+
message: 'No .env file found (but .env.example exists)'
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// Run check if both files exist
|
|
793
|
+
if (appResult.hasEnv) {
|
|
794
|
+
const checkResult = check(envPath, {
|
|
795
|
+
examplePath,
|
|
796
|
+
noEmpty,
|
|
797
|
+
noExtra,
|
|
798
|
+
strict,
|
|
799
|
+
validateTypes: true,
|
|
800
|
+
detectSecrets
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
appResult.valid = checkResult.valid;
|
|
804
|
+
appResult.issues = checkResult.issues;
|
|
805
|
+
appResult.variables = Object.keys(checkResult.env?.variables || {});
|
|
806
|
+
|
|
807
|
+
// Track variables for consistency check
|
|
808
|
+
if (checkConsistency && checkResult.env?.variables) {
|
|
809
|
+
const example = readEnvFile(examplePath);
|
|
810
|
+
for (const [varName, value] of Object.entries(checkResult.env.variables)) {
|
|
811
|
+
if (!allVars[varName]) {
|
|
812
|
+
allVars[varName] = [];
|
|
813
|
+
}
|
|
814
|
+
allVars[varName].push({
|
|
815
|
+
app: appName,
|
|
816
|
+
value,
|
|
817
|
+
type: example.typeHints?.[varName] || null
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// Update summary
|
|
824
|
+
if (appResult.skipped) {
|
|
825
|
+
// Already counted above
|
|
826
|
+
} else if (appResult.valid) {
|
|
827
|
+
result.summary.passed++;
|
|
828
|
+
} else {
|
|
829
|
+
result.summary.failed++;
|
|
830
|
+
result.valid = false;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const errors = appResult.issues.filter(i => i.type === 'error').length;
|
|
834
|
+
const warnings = appResult.issues.filter(i => i.type === 'warning').length;
|
|
835
|
+
result.summary.errors += errors;
|
|
836
|
+
result.summary.warnings += warnings;
|
|
837
|
+
|
|
838
|
+
result.apps.push(appResult);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// Check consistency across apps
|
|
842
|
+
if (checkConsistency) {
|
|
843
|
+
for (const [varName, occurrences] of Object.entries(allVars)) {
|
|
844
|
+
if (occurrences.length > 1) {
|
|
845
|
+
result.consistency.sharedVars[varName] = occurrences.map(o => o.app);
|
|
846
|
+
|
|
847
|
+
// Check for type mismatches
|
|
848
|
+
const types = new Set(occurrences.map(o => o.type).filter(Boolean));
|
|
849
|
+
if (types.size > 1) {
|
|
850
|
+
result.consistency.mismatches.push({
|
|
851
|
+
variable: varName,
|
|
852
|
+
issue: 'type_mismatch',
|
|
853
|
+
details: occurrences.map(o => ({ app: o.app, type: o.type }))
|
|
854
|
+
});
|
|
855
|
+
result.valid = false;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
return result;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Format monorepo result for CLI output
|
|
866
|
+
* @param {Object} result - Monorepo scan result
|
|
867
|
+
* @param {Object} options - Format options
|
|
868
|
+
* @returns {string} Formatted output
|
|
869
|
+
*/
|
|
870
|
+
function formatMonorepoResult(result, options = {}) {
|
|
871
|
+
const { colors = true, verbose = false } = options;
|
|
872
|
+
|
|
873
|
+
const c = colors ? {
|
|
874
|
+
green: '\x1b[32m',
|
|
875
|
+
red: '\x1b[31m',
|
|
876
|
+
yellow: '\x1b[33m',
|
|
877
|
+
dim: '\x1b[2m',
|
|
878
|
+
reset: '\x1b[0m',
|
|
879
|
+
bold: '\x1b[1m'
|
|
880
|
+
} : { green: '', red: '', yellow: '', dim: '', reset: '', bold: '' };
|
|
881
|
+
|
|
882
|
+
const lines = [];
|
|
883
|
+
|
|
884
|
+
lines.push(`${c.bold}Monorepo Environment Check${c.reset}`);
|
|
885
|
+
lines.push(`Root: ${result.root}`);
|
|
886
|
+
lines.push('');
|
|
887
|
+
|
|
888
|
+
for (const app of result.apps) {
|
|
889
|
+
const icon = app.skipped ? `${c.dim}○${c.reset}` :
|
|
890
|
+
app.valid ? `${c.green}✓${c.reset}` :
|
|
891
|
+
`${c.red}✗${c.reset}`;
|
|
892
|
+
|
|
893
|
+
let status = '';
|
|
894
|
+
if (app.skipped) {
|
|
895
|
+
status = `${c.dim}skipped (${app.reason})${c.reset}`;
|
|
896
|
+
} else {
|
|
897
|
+
const errors = app.issues.filter(i => i.type === 'error').length;
|
|
898
|
+
const warnings = app.issues.filter(i => i.type === 'warning').length;
|
|
899
|
+
|
|
900
|
+
if (errors === 0 && warnings === 0) {
|
|
901
|
+
status = `${c.green}passed${c.reset}`;
|
|
902
|
+
} else if (errors > 0) {
|
|
903
|
+
status = `${c.red}${errors} error(s)${c.reset}`;
|
|
904
|
+
if (warnings > 0) status += `, ${c.yellow}${warnings} warning(s)${c.reset}`;
|
|
905
|
+
} else {
|
|
906
|
+
status = `${c.yellow}${warnings} warning(s)${c.reset}`;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
lines.push(`${icon} ${app.name}: ${status}`);
|
|
911
|
+
|
|
912
|
+
// Show details in verbose mode
|
|
913
|
+
if (verbose && !app.skipped && app.issues.length > 0) {
|
|
914
|
+
for (const issue of app.issues) {
|
|
915
|
+
const prefix = issue.type === 'error' ? `${c.red} ✗${c.reset}` : `${c.yellow} !${c.reset}`;
|
|
916
|
+
lines.push(`${prefix} ${issue.message}`);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
lines.push('');
|
|
922
|
+
|
|
923
|
+
// Summary
|
|
924
|
+
lines.push(`${c.bold}Summary:${c.reset} ${result.summary.total} apps scanned`);
|
|
925
|
+
if (result.summary.passed > 0) {
|
|
926
|
+
lines.push(` ${c.green}✓${c.reset} ${result.summary.passed} passed`);
|
|
927
|
+
}
|
|
928
|
+
if (result.summary.failed > 0) {
|
|
929
|
+
lines.push(` ${c.red}✗${c.reset} ${result.summary.failed} failed`);
|
|
930
|
+
}
|
|
931
|
+
if (result.summary.skipped > 0) {
|
|
932
|
+
lines.push(` ${c.dim}○${c.reset} ${result.summary.skipped} skipped`);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// Consistency issues
|
|
936
|
+
if (result.consistency.mismatches.length > 0) {
|
|
937
|
+
lines.push('');
|
|
938
|
+
lines.push(`${c.yellow}Consistency Issues:${c.reset}`);
|
|
939
|
+
for (const mismatch of result.consistency.mismatches) {
|
|
940
|
+
lines.push(` ${c.yellow}!${c.reset} ${mismatch.variable}: ${mismatch.issue}`);
|
|
941
|
+
for (const detail of mismatch.details) {
|
|
942
|
+
lines.push(` - ${detail.app}: type=${detail.type || 'unspecified'}`);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// Final status
|
|
948
|
+
lines.push('');
|
|
949
|
+
if (result.valid) {
|
|
950
|
+
lines.push(`${c.green}✓ All checks passed${c.reset}`);
|
|
951
|
+
} else {
|
|
952
|
+
lines.push(`${c.red}✗ ${result.summary.errors} error(s), ${result.summary.warnings} warning(s)${c.reset}`);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
return lines.join('\n');
|
|
956
|
+
}
|
|
957
|
+
|
|
568
958
|
module.exports = {
|
|
569
959
|
parse: parseEnv,
|
|
570
960
|
parseValue,
|
|
@@ -573,8 +963,15 @@ module.exports = {
|
|
|
573
963
|
validate,
|
|
574
964
|
validateType,
|
|
575
965
|
typeValidators,
|
|
966
|
+
detectSecret,
|
|
967
|
+
secretPatterns,
|
|
968
|
+
isPlaceholder,
|
|
576
969
|
check,
|
|
577
970
|
generate,
|
|
578
971
|
list,
|
|
579
|
-
get
|
|
972
|
+
get,
|
|
973
|
+
// Monorepo support
|
|
974
|
+
findMonorepoApps,
|
|
975
|
+
scanMonorepo,
|
|
976
|
+
formatMonorepoResult
|
|
580
977
|
};
|