@anhuijie/envguard 1.0.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 +444 -0
- package/package.json +39 -0
- package/src/cli.js +118 -0
- package/src/commands/check.js +63 -0
- package/src/commands/diff.js +59 -0
- package/src/commands/docs.js +45 -0
- package/src/commands/init.js +101 -0
- package/src/commands/validate.js +72 -0
- package/src/core/diff.js +111 -0
- package/src/core/docs.js +120 -0
- package/src/core/index.js +22 -0
- package/src/core/schema.js +74 -0
- package/src/core/security.js +128 -0
- package/src/core/validator.js +170 -0
- package/src/index.js +9 -0
- package/src/utils/file.js +102 -0
- package/src/utils/format.js +68 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sensitive information detection in environment variables
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// Patterns for detecting secrets and sensitive data
|
|
6
|
+
const SECRET_PATTERNS = [
|
|
7
|
+
{
|
|
8
|
+
name: 'AWS Access Key',
|
|
9
|
+
pattern: /AKIA[0-9A-Z]{16}/,
|
|
10
|
+
severity: 'critical',
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
name: 'AWS Secret Key',
|
|
14
|
+
pattern: /aws(.{0,20})?(secret|key).{0,20}[A-Za-z0-9/+=]{40}/i,
|
|
15
|
+
severity: 'critical',
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: 'GitHub Token',
|
|
19
|
+
pattern: /gh[ps]_[A-Za-z0-9_]{36,}/,
|
|
20
|
+
severity: 'critical',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: 'GitLab Token',
|
|
24
|
+
pattern: /glpat-[A-Za-z0-9\-]{20}/,
|
|
25
|
+
severity: 'critical',
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: 'Slack Token',
|
|
29
|
+
pattern: /xox[baprs]-[0-9]{10,}-[A-Za-z0-9]+/,
|
|
30
|
+
severity: 'critical',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: 'Stripe Key',
|
|
34
|
+
pattern: /sk_live_[A-Za-z0-9]{24,}/,
|
|
35
|
+
severity: 'critical',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: 'Private Key',
|
|
39
|
+
pattern: /-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----/,
|
|
40
|
+
severity: 'critical',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: 'JWT Secret',
|
|
44
|
+
pattern: /jwt(.{0,20})?(secret|key).{0,20}[A-Za-z0-9\-._~+/]+=*/i,
|
|
45
|
+
severity: 'high',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: 'Database URL with Password',
|
|
49
|
+
pattern: /(mysql|postgres|mongodb|redis):\/\/[^\s:]+:[^\s@]+@[^\s]+/i,
|
|
50
|
+
severity: 'high',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'Generic API Key',
|
|
54
|
+
pattern: /(api[_-]?key|apikey|api[_-]?secret)\s*[=:]\s*['"]?[A-Za-z0-9\-._]{20,}['"]?/i,
|
|
55
|
+
severity: 'high',
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: 'Generic Password',
|
|
59
|
+
pattern: /(password|passwd|pwd)\s*[=:]\s*['"]?[^\s'"]{8,}['"]?/i,
|
|
60
|
+
severity: 'high',
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: 'Generic Secret',
|
|
64
|
+
pattern: /(secret|token|auth)\s*[=:]\s*['"]?[A-Za-z0-9\-._]{20,}['"]?/i,
|
|
65
|
+
severity: 'medium',
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
// Keys that are commonly sensitive by name
|
|
70
|
+
const SENSITIVE_KEY_PATTERNS = [
|
|
71
|
+
/password/i,
|
|
72
|
+
/passwd/i,
|
|
73
|
+
/pwd$/i,
|
|
74
|
+
/secret/i,
|
|
75
|
+
/token/i,
|
|
76
|
+
/api[_-]?key/i,
|
|
77
|
+
/auth/i,
|
|
78
|
+
/credential/i,
|
|
79
|
+
/private[_-]?key/i,
|
|
80
|
+
/access[_-]?key/i,
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
function scanForSecrets(envObj, options = {}) {
|
|
84
|
+
const findings = [];
|
|
85
|
+
const ignoredKeys = new Set(options.ignoreKeys || []);
|
|
86
|
+
const minSeverity = options.minSeverity || 'medium';
|
|
87
|
+
const severityOrder = { low: 0, medium: 1, high: 2, critical: 3 };
|
|
88
|
+
|
|
89
|
+
for (const [key, value] of Object.entries(envObj)) {
|
|
90
|
+
if (ignoredKeys.has(key)) continue;
|
|
91
|
+
if (value === undefined || value === '') continue;
|
|
92
|
+
|
|
93
|
+
// Check value against secret patterns
|
|
94
|
+
for (const { name, pattern, severity } of SECRET_PATTERNS) {
|
|
95
|
+
if (severityOrder[severity] < severityOrder[minSeverity]) continue;
|
|
96
|
+
|
|
97
|
+
if (pattern.test(value)) {
|
|
98
|
+
findings.push({
|
|
99
|
+
key,
|
|
100
|
+
severity,
|
|
101
|
+
type: name,
|
|
102
|
+
message: `Potential ${name} detected in variable "${key}"`,
|
|
103
|
+
});
|
|
104
|
+
break; // One finding per variable to avoid duplicates
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Check if key name suggests sensitive data
|
|
109
|
+
const isSensitiveKey = SENSITIVE_KEY_PATTERNS.some((p) => p.test(key));
|
|
110
|
+
if (isSensitiveKey && !findings.some((f) => f.key === key)) {
|
|
111
|
+
findings.push({
|
|
112
|
+
key,
|
|
113
|
+
severity: 'medium',
|
|
114
|
+
type: 'Sensitive Key Name',
|
|
115
|
+
message: `Variable "${key}" appears to contain sensitive data based on its name`,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
findings,
|
|
122
|
+
hasCritical: findings.some((f) => f.severity === 'critical'),
|
|
123
|
+
hasHigh: findings.some((f) => f.severity === 'high'),
|
|
124
|
+
total: findings.length,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
module.exports = { scanForSecrets, SECRET_PATTERNS, SENSITIVE_KEY_PATTERNS };
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment variable validation engine
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const validators = {
|
|
6
|
+
string: (value) => typeof value === 'string',
|
|
7
|
+
|
|
8
|
+
number: (value, rule) => {
|
|
9
|
+
const num = Number(value);
|
|
10
|
+
if (isNaN(num)) return { valid: false, error: `"${value}" is not a valid number` };
|
|
11
|
+
if (rule.min !== undefined && num < rule.min) {
|
|
12
|
+
return { valid: false, error: `${num} is less than minimum ${rule.min}` };
|
|
13
|
+
}
|
|
14
|
+
if (rule.max !== undefined && num > rule.max) {
|
|
15
|
+
return { valid: false, error: `${num} exceeds maximum ${rule.max}` };
|
|
16
|
+
}
|
|
17
|
+
return { valid: true };
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
boolean: (value) => {
|
|
21
|
+
const lower = String(value).toLowerCase();
|
|
22
|
+
if (['true', 'false', '1', '0', 'yes', 'no'].includes(lower)) {
|
|
23
|
+
return { valid: true };
|
|
24
|
+
}
|
|
25
|
+
return { valid: false, error: `"${value}" is not a valid boolean (true/false, 1/0, yes/no)` };
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
url: (value) => {
|
|
29
|
+
try {
|
|
30
|
+
new URL(value);
|
|
31
|
+
return { valid: true };
|
|
32
|
+
} catch {
|
|
33
|
+
return { valid: false, error: `"${value}" is not a valid URL` };
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
email: (value) => {
|
|
38
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
39
|
+
return emailRegex.test(value)
|
|
40
|
+
? { valid: true }
|
|
41
|
+
: { valid: false, error: `"${value}" is not a valid email address` };
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
json: (value) => {
|
|
45
|
+
try {
|
|
46
|
+
JSON.parse(value);
|
|
47
|
+
return { valid: true };
|
|
48
|
+
} catch {
|
|
49
|
+
return { valid: false, error: `"${value}" is not valid JSON` };
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
regex: (value) => {
|
|
54
|
+
try {
|
|
55
|
+
new RegExp(value);
|
|
56
|
+
return { valid: true };
|
|
57
|
+
} catch {
|
|
58
|
+
return { valid: false, error: `"${value}" is not a valid regex pattern` };
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
port: (value) => {
|
|
63
|
+
const num = Number(value);
|
|
64
|
+
if (isNaN(num) || !Number.isInteger(num) || num < 0 || num > 65535) {
|
|
65
|
+
return { valid: false, error: `"${value}" is not a valid port (0-65535)` };
|
|
66
|
+
}
|
|
67
|
+
return { valid: true };
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function validateValue(value, rule) {
|
|
72
|
+
const type = rule.type || 'string';
|
|
73
|
+
const validator = validators[type];
|
|
74
|
+
|
|
75
|
+
if (!validator) {
|
|
76
|
+
return { valid: false, error: `Unknown type: ${type}` };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const result = validator(value, rule);
|
|
80
|
+
|
|
81
|
+
// Validator can return boolean or { valid, error }
|
|
82
|
+
if (typeof result === 'boolean') {
|
|
83
|
+
return { valid: result, error: result ? undefined : `Value does not match type "${type}"` };
|
|
84
|
+
}
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function validateEnv(envObj, schema) {
|
|
89
|
+
const results = {
|
|
90
|
+
valid: true,
|
|
91
|
+
errors: [],
|
|
92
|
+
warnings: [],
|
|
93
|
+
checked: 0,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
for (const [key, rule] of Object.entries(schema)) {
|
|
97
|
+
results.checked++;
|
|
98
|
+
const value = envObj[key];
|
|
99
|
+
|
|
100
|
+
// Check required
|
|
101
|
+
if (value === undefined || value === '') {
|
|
102
|
+
if (rule.required) {
|
|
103
|
+
results.valid = false;
|
|
104
|
+
results.errors.push({
|
|
105
|
+
key,
|
|
106
|
+
type: 'missing',
|
|
107
|
+
message: `Required variable "${key}" is not set`,
|
|
108
|
+
});
|
|
109
|
+
} else if (rule.default !== undefined) {
|
|
110
|
+
// Will use default, just note it
|
|
111
|
+
results.warnings.push({
|
|
112
|
+
key,
|
|
113
|
+
type: 'default_used',
|
|
114
|
+
message: `Variable "${key}" not set, will use default: ${rule.default}`,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Check type
|
|
121
|
+
const typeResult = validateValue(value, rule);
|
|
122
|
+
if (!typeResult.valid) {
|
|
123
|
+
results.valid = false;
|
|
124
|
+
results.errors.push({
|
|
125
|
+
key,
|
|
126
|
+
type: 'type_mismatch',
|
|
127
|
+
message: `Variable "${key}": ${typeResult.error}`,
|
|
128
|
+
});
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Check enum
|
|
133
|
+
if (rule.enum && !rule.enum.includes(value)) {
|
|
134
|
+
results.valid = false;
|
|
135
|
+
results.errors.push({
|
|
136
|
+
key,
|
|
137
|
+
type: 'enum_mismatch',
|
|
138
|
+
message: `Variable "${key}": value "${value}" is not one of: ${rule.enum.join(', ')}`,
|
|
139
|
+
});
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Check pattern
|
|
144
|
+
if (rule.pattern) {
|
|
145
|
+
const regex = new RegExp(rule.pattern);
|
|
146
|
+
if (!regex.test(value)) {
|
|
147
|
+
results.valid = false;
|
|
148
|
+
results.errors.push({
|
|
149
|
+
key,
|
|
150
|
+
type: 'pattern_mismatch',
|
|
151
|
+
message: `Variable "${key}": value does not match pattern /${rule.pattern}/`,
|
|
152
|
+
});
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Check deprecation
|
|
158
|
+
if (rule.deprecated) {
|
|
159
|
+
results.warnings.push({
|
|
160
|
+
key,
|
|
161
|
+
type: 'deprecated',
|
|
162
|
+
message: `Variable "${key}" is deprecated${rule.replacement ? `, use "${rule.replacement}" instead` : ''}`,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return results;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
module.exports = { validateEnv, validateValue, validators };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File system utilities for finding and loading .env files
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
|
|
8
|
+
const ENV_FILE_PATTERNS = [
|
|
9
|
+
'.env',
|
|
10
|
+
'.env.local',
|
|
11
|
+
'.env.development',
|
|
12
|
+
'.env.staging',
|
|
13
|
+
'.env.production',
|
|
14
|
+
'.env.test',
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
function findEnvFiles(dir, options = {}) {
|
|
18
|
+
const recursive = options.recursive || false;
|
|
19
|
+
const maxDepth = options.maxDepth || 3;
|
|
20
|
+
const envFiles = [];
|
|
21
|
+
|
|
22
|
+
function scan(currentDir, depth) {
|
|
23
|
+
if (depth > maxDepth) return;
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
27
|
+
|
|
28
|
+
for (const entry of entries) {
|
|
29
|
+
if (entry.name.startsWith('.') && entry.name.endsWith('.env')) {
|
|
30
|
+
envFiles.push(path.join(currentDir, entry.name));
|
|
31
|
+
} else if (entry.name === '.env') {
|
|
32
|
+
envFiles.push(path.join(currentDir, entry.name));
|
|
33
|
+
} else if (entry.name.startsWith('.env.')) {
|
|
34
|
+
envFiles.push(path.join(currentDir, entry.name));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Also check for env files without leading dot
|
|
38
|
+
if (entry.name === 'env' || entry.name.startsWith('env.')) {
|
|
39
|
+
envFiles.push(path.join(currentDir, entry.name));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (recursive && entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
|
|
43
|
+
scan(path.join(currentDir, entry.name), depth + 1);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
// Permission denied or other FS errors — skip
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
scan(dir, 0);
|
|
52
|
+
return [...new Set(envFiles)];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function findConfigFile(dir) {
|
|
56
|
+
const configNames = [
|
|
57
|
+
'envguard.config.js',
|
|
58
|
+
'envguard.config.cjs',
|
|
59
|
+
'.envguardrc.js',
|
|
60
|
+
'.envguardrc.cjs',
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
for (const name of configNames) {
|
|
64
|
+
const fullPath = path.join(dir, name);
|
|
65
|
+
if (fs.existsSync(fullPath)) {
|
|
66
|
+
return fullPath;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function loadEnvFile(filePath) {
|
|
74
|
+
if (!fs.existsSync(filePath)) {
|
|
75
|
+
return {};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
79
|
+
const vars = {};
|
|
80
|
+
|
|
81
|
+
for (const line of content.split('\n')) {
|
|
82
|
+
const trimmed = line.trim();
|
|
83
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
84
|
+
|
|
85
|
+
const eqIndex = trimmed.indexOf('=');
|
|
86
|
+
if (eqIndex === -1) continue;
|
|
87
|
+
|
|
88
|
+
const key = trimmed.slice(0, eqIndex).trim();
|
|
89
|
+
let value = trimmed.slice(eqIndex + 1).trim();
|
|
90
|
+
|
|
91
|
+
// Remove surrounding quotes
|
|
92
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
93
|
+
value = value.slice(1, -1);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
vars[key] = value;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return vars;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = { findEnvFiles, findConfigFile, loadEnvFile, ENV_FILE_PATTERNS };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output formatting utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const COLORS = {
|
|
6
|
+
reset: '\x1b[0m',
|
|
7
|
+
red: '\x1b[31m',
|
|
8
|
+
green: '\x1b[32m',
|
|
9
|
+
yellow: '\x1b[33m',
|
|
10
|
+
blue: '\x1b[34m',
|
|
11
|
+
magenta: '\x1b[35m',
|
|
12
|
+
cyan: '\x1b[36m',
|
|
13
|
+
bold: '\x1b[1m',
|
|
14
|
+
dim: '\x1b[2m',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function colorize(text, color) {
|
|
18
|
+
if (process.env.NO_COLOR) return text;
|
|
19
|
+
return `${COLORS[color] || ''}${text}${COLORS.reset}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function formatError(error) {
|
|
23
|
+
return colorize(`✗ ${error.key}: ${error.message}`, 'red');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function formatWarning(warning) {
|
|
27
|
+
return colorize(`⚠ ${warning.key}: ${warning.message}`, 'yellow');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function formatSuccess(key) {
|
|
31
|
+
return colorize(`✓ ${key}`, 'green');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function formatFinding(finding) {
|
|
35
|
+
const severityColors = {
|
|
36
|
+
critical: 'red',
|
|
37
|
+
high: 'magenta',
|
|
38
|
+
medium: 'yellow',
|
|
39
|
+
low: 'blue',
|
|
40
|
+
};
|
|
41
|
+
const color = severityColors[finding.severity] || 'yellow';
|
|
42
|
+
const label = colorize(`[${finding.severity.toUpperCase()}]`, color);
|
|
43
|
+
return `${label} ${finding.message}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function printHeader(title) {
|
|
47
|
+
console.log('');
|
|
48
|
+
console.log(colorize(` ${'═'.repeat(40)}`, 'cyan'));
|
|
49
|
+
console.log(colorize(` ${title}`, 'bold'));
|
|
50
|
+
console.log(colorize(` ${'═'.repeat(40)}`, 'cyan'));
|
|
51
|
+
console.log('');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function printSummary(results) {
|
|
55
|
+
console.log('');
|
|
56
|
+
if (results.valid) {
|
|
57
|
+
console.log(colorize(' ✓ All checks passed!', 'green'));
|
|
58
|
+
} else {
|
|
59
|
+
console.log(colorize(` ✗ ${results.errors.length} error(s) found`, 'red'));
|
|
60
|
+
}
|
|
61
|
+
if (results.warnings && results.warnings.length > 0) {
|
|
62
|
+
console.log(colorize(` ⚠ ${results.warnings.length} warning(s)`, 'yellow'));
|
|
63
|
+
}
|
|
64
|
+
console.log(` Checked: ${results.checked} variable(s)`);
|
|
65
|
+
console.log('');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = { colorize, formatError, formatWarning, formatSuccess, formatFinding, printHeader, printSummary };
|