@claude-agent/envcheck 1.2.0 → 1.4.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.
Files changed (3) hide show
  1. package/README.md +109 -3
  2. package/package.json +4 -2
  3. package/src/index.js +285 -8
package/README.md CHANGED
@@ -233,6 +233,111 @@ curl -o .git/hooks/pre-commit https://raw.githubusercontent.com/claude-agent-too
233
233
  chmod +x .git/hooks/pre-commit
234
234
  ```
235
235
 
236
+ ## Type Validation
237
+
238
+ envcheck supports **static type validation** - validate variable formats without running your app.
239
+
240
+ ### Using Type Hints in .env.example
241
+
242
+ Add type hints as comments above variables:
243
+
244
+ ```bash
245
+ # type: url
246
+ DATABASE_URL=postgres://localhost/mydb
247
+
248
+ # @type port
249
+ PORT=3000
250
+
251
+ # type: boolean
252
+ DEBUG=false
253
+
254
+ # type: email
255
+ ADMIN_EMAIL=admin@example.com
256
+ ```
257
+
258
+ ### Supported Types
259
+
260
+ | Type | Description | Examples |
261
+ |------|-------------|----------|
262
+ | `url` | Valid URL | `https://example.com`, `postgres://host/db` |
263
+ | `port` | Port number (1-65535) | `3000`, `8080` |
264
+ | `boolean`/`bool` | Boolean values | `true`, `false`, `1`, `0`, `yes`, `no` |
265
+ | `email` | Email address | `user@example.com` |
266
+ | `number` | Any number | `42`, `3.14`, `-10` |
267
+ | `integer`/`int` | Whole numbers | `42`, `-10` |
268
+ | `json` | Valid JSON | `{"key":"value"}`, `[1,2,3]` |
269
+ | `uuid` | UUID format | `550e8400-e29b-41d4-a716-446655440000` |
270
+ | `string`/`str` | Any string (no validation) | anything |
271
+
272
+ ## Secret Detection
273
+
274
+ envcheck can warn you if your `.env` file contains values that look like real secrets:
275
+
276
+ ```javascript
277
+ const result = check('.env', {
278
+ examplePath: '.env.example',
279
+ detectSecrets: true // Warns about potential secrets
280
+ });
281
+ ```
282
+
283
+ ### Detected Secret Patterns
284
+
285
+ - AWS Access Keys (`AKIA...`)
286
+ - GitHub Tokens (`ghp_...`, `gho_...`, etc.)
287
+ - Stripe Keys (`sk_live_...`, `rk_live_...`)
288
+ - Private Keys (`-----BEGIN PRIVATE KEY-----`)
289
+ - Slack Tokens (`xox...`)
290
+ - Twilio Credentials
291
+ - SendGrid API Keys
292
+ - Google API Keys
293
+ - High-entropy hex strings (with sensitive key names)
294
+
295
+ ### Placeholder Detection
296
+
297
+ envcheck won't warn about obvious placeholders like:
298
+ - `your-api-key`, `my-secret`
299
+ - `changeme`, `placeholder`
300
+ - `xxx`, `...`
301
+ - `example`, `test`, `dummy`
302
+
303
+ ### API Usage with Secrets
304
+
305
+ ```javascript
306
+ const { check, validate, detectSecret } = require('@claude-agent/envcheck');
307
+
308
+ // Enable secret detection
309
+ const result = check('.env', {
310
+ examplePath: '.env.example',
311
+ detectSecrets: true // Warns about potential secrets
312
+ });
313
+
314
+ // Check a single value
315
+ const secret = detectSecret('API_KEY', 'sk_live_abc123...');
316
+ if (secret) {
317
+ console.log(`Warning: ${secret.description} detected`);
318
+ }
319
+ ```
320
+
321
+ ### API Usage with Types
322
+
323
+ ```javascript
324
+ const { check, validate } = require('@claude-agent/envcheck');
325
+
326
+ // Explicit type validation
327
+ const result = validate('.env', {
328
+ types: {
329
+ DATABASE_URL: 'url',
330
+ PORT: 'port',
331
+ DEBUG: 'boolean'
332
+ }
333
+ });
334
+
335
+ // Types from example file are used automatically
336
+ const result2 = check('.env', {
337
+ examplePath: '.env.example' // Type hints in example are used
338
+ });
339
+ ```
340
+
236
341
  ## .env File Format
237
342
 
238
343
  Supports standard .env syntax:
@@ -252,7 +357,7 @@ WITH_EQUALS=postgres://user:pass@host/db?opt=val
252
357
  - **Auto-detection** - Finds .env.example automatically
253
358
  - **CI-friendly** - Exit codes and JSON output
254
359
  - **Comprehensive** - Parse, validate, compare, generate
255
- - **Well-tested** - 46 tests covering edge cases
360
+ - **Well-tested** - 72 tests covering edge cases
256
361
 
257
362
  ## vs. dotenv-safe / envalid
258
363
 
@@ -263,10 +368,11 @@ WITH_EQUALS=postgres://user:pass@host/db?opt=val
263
368
  | **Static validation** | ✅ | ❌ | ❌ |
264
369
  | **CI/CD integration** | ✅ GitHub Action | ❌ | ❌ |
265
370
  | **Pre-commit hook** | ✅ | ❌ | ❌ |
266
- | Type validation | | ❌ | ✅ |
371
+ | Type validation | (static) | ❌ | ✅ (runtime) |
372
+ | **Secret detection** | ✅ | ❌ | ❌ |
267
373
  | Zero dependencies | ✅ | ❌ | ❌ |
268
374
 
269
- **Key difference:** envcheck validates *before* deployment (shift-left), while dotenv-safe and envalid validate at runtime when your app starts. Catch missing env vars in CI, not in production.
375
+ **Key difference:** envcheck validates *before* deployment (shift-left), while dotenv-safe and envalid validate at runtime when your app starts. Catch missing env vars and type errors in CI, not in production.
270
376
 
271
377
  ## License
272
378
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claude-agent/envcheck",
3
- "version": "1.2.0",
3
+ "version": "1.4.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,9 @@
20
20
  "github-action",
21
21
  "ci-cd",
22
22
  "pre-commit",
23
- "git-hooks"
23
+ "git-hooks",
24
+ "secrets",
25
+ "security"
24
26
  ],
25
27
  "author": "Claude Agent <claude-agent@agentmail.to>",
26
28
  "license": "MIT",
package/src/index.js CHANGED
@@ -3,6 +3,192 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
 
6
+ /**
7
+ * Type validators for environment variables
8
+ */
9
+ const typeValidators = {
10
+ url: (value) => {
11
+ if (!value) return { valid: true };
12
+ try {
13
+ new URL(value);
14
+ return { valid: true };
15
+ } catch {
16
+ return { valid: false, message: 'must be a valid URL' };
17
+ }
18
+ },
19
+
20
+ port: (value) => {
21
+ if (!value) return { valid: true };
22
+ const num = parseInt(value, 10);
23
+ if (isNaN(num) || num < 1 || num > 65535 || String(num) !== value) {
24
+ return { valid: false, message: 'must be a valid port number (1-65535)' };
25
+ }
26
+ return { valid: true };
27
+ },
28
+
29
+ boolean: (value) => {
30
+ if (!value) return { valid: true };
31
+ const lower = value.toLowerCase();
32
+ const valid = ['true', 'false', '1', '0', 'yes', 'no', 'on', 'off'].includes(lower);
33
+ if (!valid) {
34
+ return { valid: false, message: 'must be a boolean (true/false/1/0/yes/no)' };
35
+ }
36
+ return { valid: true };
37
+ },
38
+ bool: (value) => typeValidators.boolean(value),
39
+
40
+ email: (value) => {
41
+ if (!value) return { valid: true };
42
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
43
+ if (!emailRegex.test(value)) {
44
+ return { valid: false, message: 'must be a valid email address' };
45
+ }
46
+ return { valid: true };
47
+ },
48
+
49
+ number: (value) => {
50
+ if (!value) return { valid: true };
51
+ if (isNaN(parseFloat(value))) {
52
+ return { valid: false, message: 'must be a number' };
53
+ }
54
+ return { valid: true };
55
+ },
56
+
57
+ integer: (value) => {
58
+ if (!value) return { valid: true };
59
+ const num = parseInt(value, 10);
60
+ if (isNaN(num) || String(num) !== value) {
61
+ return { valid: false, message: 'must be an integer' };
62
+ }
63
+ return { valid: true };
64
+ },
65
+ int: (value) => typeValidators.integer(value),
66
+
67
+ string: () => ({ valid: true }),
68
+ str: () => ({ valid: true }),
69
+
70
+ json: (value) => {
71
+ if (!value) return { valid: true };
72
+ try {
73
+ JSON.parse(value);
74
+ return { valid: true };
75
+ } catch {
76
+ return { valid: false, message: 'must be valid JSON' };
77
+ }
78
+ },
79
+
80
+ uuid: (value) => {
81
+ if (!value) return { valid: true };
82
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
83
+ if (!uuidRegex.test(value)) {
84
+ return { valid: false, message: 'must be a valid UUID' };
85
+ }
86
+ return { valid: true };
87
+ }
88
+ };
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
+
6
192
  /**
7
193
  * Parse a .env file into an object
8
194
  * @param {string} content - File content
@@ -13,18 +199,30 @@ function parseEnv(content) {
13
199
  variables: {},
14
200
  errors: [],
15
201
  warnings: [],
16
- lineInfo: {}
202
+ lineInfo: {},
203
+ typeHints: {} // NEW: Store type hints from comments
17
204
  };
18
205
 
19
206
  const lines = content.split('\n');
207
+ let pendingTypeHint = null;
20
208
 
21
209
  for (let i = 0; i < lines.length; i++) {
22
210
  const lineNum = i + 1;
23
211
  const line = lines[i];
24
212
  const trimmed = line.trim();
25
213
 
26
- // Skip empty lines and comments
27
- if (!trimmed || trimmed.startsWith('#')) {
214
+ // Check for type hint in comments: # type: url OR # @type url
215
+ if (trimmed.startsWith('#')) {
216
+ const typeMatch = trimmed.match(/^#\s*(?:type:|@type)\s*(\w+)/i);
217
+ if (typeMatch) {
218
+ pendingTypeHint = typeMatch[1].toLowerCase();
219
+ }
220
+ continue;
221
+ }
222
+
223
+ // Skip empty lines
224
+ if (!trimmed) {
225
+ pendingTypeHint = null;
28
226
  continue;
29
227
  }
30
228
 
@@ -74,6 +272,12 @@ function parseEnv(content) {
74
272
 
75
273
  result.variables[key] = value;
76
274
  result.lineInfo[key] = lineNum;
275
+
276
+ // Store type hint if present
277
+ if (pendingTypeHint) {
278
+ result.typeHints[key] = pendingTypeHint;
279
+ pendingTypeHint = null;
280
+ }
77
281
  }
78
282
 
79
283
  return result;
@@ -129,7 +333,8 @@ function readEnvFile(filePath) {
129
333
  variables: {},
130
334
  errors: [{ message: `File not found: ${absolutePath}` }],
131
335
  warnings: [],
132
- lineInfo: {}
336
+ lineInfo: {},
337
+ typeHints: {}
133
338
  };
134
339
  }
135
340
 
@@ -202,6 +407,20 @@ function compare(envPath, examplePath) {
202
407
  return result;
203
408
  }
204
409
 
410
+ /**
411
+ * Validate a value against a type
412
+ * @param {string} value - Value to validate
413
+ * @param {string} type - Type name
414
+ * @returns {Object} Validation result {valid, message}
415
+ */
416
+ function validateType(value, type) {
417
+ const validator = typeValidators[type.toLowerCase()];
418
+ if (!validator) {
419
+ return { valid: true, message: `Unknown type '${type}'` };
420
+ }
421
+ return validator(value);
422
+ }
423
+
205
424
  /**
206
425
  * Validate an env file
207
426
  * @param {string} filePath - Path to .env file
@@ -209,7 +428,7 @@ function compare(envPath, examplePath) {
209
428
  * @returns {Object} Validation result
210
429
  */
211
430
  function validate(filePath, options = {}) {
212
- const { required = [], noEmpty = false } = options;
431
+ const { required = [], noEmpty = false, types = {}, validateTypes = false, detectSecrets = false } = options;
213
432
 
214
433
  const env = readEnvFile(filePath);
215
434
 
@@ -271,6 +490,38 @@ function validate(filePath, options = {}) {
271
490
  }
272
491
  }
273
492
 
493
+ // Type validation (from options.types or env.typeHints)
494
+ if (validateTypes || Object.keys(types).length > 0) {
495
+ const allTypes = { ...env.typeHints, ...types }; // options.types override hints
496
+ for (const [key, typeName] of Object.entries(allTypes)) {
497
+ if (key in env.variables && env.variables[key] !== '') {
498
+ const typeResult = validateType(env.variables[key], typeName);
499
+ if (!typeResult.valid) {
500
+ result.valid = false;
501
+ result.issues.push({
502
+ type: 'error',
503
+ line: env.lineInfo[key],
504
+ message: `Variable '${key}' ${typeResult.message} (got: ${env.variables[key]})`
505
+ });
506
+ }
507
+ }
508
+ }
509
+ }
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
+
274
525
  // Add warnings
275
526
  for (const warning of env.warnings) {
276
527
  result.issues.push({
@@ -295,7 +546,10 @@ function check(envPath, options = {}) {
295
546
  required = [],
296
547
  noEmpty = false,
297
548
  noExtra = false,
298
- strict = false
549
+ strict = false,
550
+ types = {},
551
+ validateTypes = false,
552
+ detectSecrets = false
299
553
  } = options;
300
554
 
301
555
  const result = {
@@ -307,8 +561,26 @@ function check(envPath, options = {}) {
307
561
  }
308
562
  };
309
563
 
310
- // First validate the env file
311
- const validation = validate(envPath, { required, noEmpty });
564
+ // Get type hints from example file if provided
565
+ let exampleTypeHints = {};
566
+ if (examplePath) {
567
+ const example = readEnvFile(examplePath);
568
+ if (example.exists) {
569
+ exampleTypeHints = example.typeHints || {};
570
+ }
571
+ }
572
+
573
+ // Merge type hints: example hints < explicit types
574
+ const mergedTypes = { ...exampleTypeHints, ...types };
575
+
576
+ // First validate the env file (including type validation and secret detection)
577
+ const validation = validate(envPath, {
578
+ required,
579
+ noEmpty,
580
+ types: mergedTypes,
581
+ validateTypes: validateTypes || Object.keys(mergedTypes).length > 0,
582
+ detectSecrets
583
+ });
312
584
 
313
585
  if (!validation.valid) {
314
586
  result.valid = false;
@@ -417,6 +689,11 @@ module.exports = {
417
689
  readEnvFile,
418
690
  compare,
419
691
  validate,
692
+ validateType,
693
+ typeValidators,
694
+ detectSecret,
695
+ secretPatterns,
696
+ isPlaceholder,
420
697
  check,
421
698
  generate,
422
699
  list,