envprobe 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.
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Shell/Bash Scanner
3
+ * Detects environment variable references in shell script files
4
+ * Supports: $VAR_NAME, ${VAR_NAME}
5
+ *
6
+ * Note: Shell scripts may produce more false positives than other languages
7
+ * due to the prevalence of variable usage in shell scripting.
8
+ */
9
+
10
+ /**
11
+ * Regex patterns for detecting environment variable references
12
+ *
13
+ * Pattern 1: $VAR_NAME (simple variable expansion)
14
+ * - Matches: $DATABASE_URL, $API_KEY
15
+ * - Must be followed by a non-word character or end of string to avoid partial matches
16
+ * - More prone to false positives (e.g., $1, $?, special vars)
17
+ * - We filter these out by requiring uppercase start
18
+ * - This pattern is checked FIRST to maintain consistent ordering in results
19
+ *
20
+ * Pattern 2: ${VAR_NAME} (braced variable expansion)
21
+ * - Matches: ${DATABASE_URL}, ${API_KEY}
22
+ * - Also matches parameter expansion: ${VAR:-default}, ${VAR:=default}, ${VAR:?error}
23
+ * - This is the preferred form and less ambiguous
24
+ * - Captures the variable name inside the braces
25
+ *
26
+ * Both patterns work within double quotes, which is common in shell scripts.
27
+ * Single quotes prevent variable expansion in shell, but we still detect them
28
+ * as they might indicate configuration that needs documentation.
29
+ */
30
+ const PATTERNS = [
31
+ // Simple expansion: $VAR_NAME
32
+ // Uses word boundary or lookahead to ensure we capture the full variable name
33
+ // This pattern is checked FIRST to maintain consistent ordering in results
34
+ /\$([A-Z_][A-Z0-9_]*)(?=\W|$)/g,
35
+
36
+ // Braced expansion: ${VAR_NAME} with optional parameter expansion operators
37
+ // Matches: ${VAR}, ${VAR:-default}, ${VAR:=default}, ${VAR:?error}, ${VAR:+value}
38
+ // Captures the variable name between the braces, before any colon or closing brace
39
+ /\$\{([A-Z_][A-Z0-9_]*)(?:[:\}])/g,
40
+ ];
41
+
42
+ /**
43
+ * Supported file extensions for Shell/Bash scripts
44
+ */
45
+ const SUPPORTED_EXTENSIONS = ['.sh', '.bash', '.zsh'];
46
+
47
+ /**
48
+ * Scan file content for environment variable references
49
+ * @param {string} content - File content to scan
50
+ * @param {string} filePath - Path to the file being scanned
51
+ * @returns {Array<{varName: string, filePath: string, lineNumber: number, pattern: string}>}
52
+ */
53
+ export function scan(content, filePath) {
54
+ const references = [];
55
+ const lines = content.split('\n');
56
+
57
+ for (let i = 0; i < lines.length; i++) {
58
+ references.push(...scanLine(lines[i], filePath, i + 1));
59
+ }
60
+
61
+ return references;
62
+ }
63
+
64
+ export function scanLine(line, filePath, lineNumber) {
65
+ const references = [];
66
+ const seenPerLine = new Set();
67
+
68
+ for (const pattern of PATTERNS) {
69
+ pattern.lastIndex = 0;
70
+ let match;
71
+ while ((match = pattern.exec(line)) !== null) {
72
+ const varName = match[1];
73
+ const matchedPattern = match[0];
74
+
75
+ if (seenPerLine.has(varName)) {
76
+ continue;
77
+ }
78
+
79
+ if (varName && isValidEnvVarName(varName)) {
80
+ references.push({
81
+ varName,
82
+ filePath,
83
+ lineNumber,
84
+ pattern: matchedPattern,
85
+ });
86
+
87
+ seenPerLine.add(varName);
88
+ }
89
+ }
90
+ }
91
+
92
+ return references;
93
+ }
94
+
95
+ /**
96
+ * Validate environment variable name
97
+ * Must start with letter or underscore, contain only uppercase letters, digits, and underscores
98
+ *
99
+ * This helps filter out shell special variables like:
100
+ * - Positional parameters: $1, $2, $@, $*
101
+ * - Special variables: $?, $!, $-
102
+ * - Lowercase local variables: $var, $my_var
103
+ *
104
+ * @param {string} varName - Variable name to validate
105
+ * @returns {boolean}
106
+ */
107
+ function isValidEnvVarName(varName) {
108
+ return /^[A-Z_][A-Z0-9_]*$/.test(varName);
109
+ }
110
+
111
+ /**
112
+ * Get supported file extensions
113
+ * @returns {string[]}
114
+ */
115
+ export function getSupportedExtensions() {
116
+ return SUPPORTED_EXTENSIONS;
117
+ }
118
+
119
+ /**
120
+ * Get regex patterns used for scanning
121
+ * @returns {RegExp[]}
122
+ */
123
+ export function getPatterns() {
124
+ return PATTERNS;
125
+ }
@@ -0,0 +1,411 @@
1
+ /**
2
+ * Security Utilities Module
3
+ *
4
+ * Provides security functions for:
5
+ * - Path sanitization and validation
6
+ * - Input validation and sanitization
7
+ * - Pattern validation
8
+ * - Resource limits
9
+ * - Safe file operations
10
+ */
11
+
12
+ import path from 'path';
13
+ import { isValidEnvVarName } from './utils.js';
14
+
15
+ /**
16
+ * Maximum file size to process (10MB)
17
+ */
18
+ export const MAX_FILE_SIZE = 10 * 1024 * 1024;
19
+
20
+ /**
21
+ * Maximum number of files to scan
22
+ */
23
+ export const MAX_FILES_TO_SCAN = 100000;
24
+
25
+ /**
26
+ * Maximum directory depth
27
+ */
28
+ export const MAX_DIRECTORY_DEPTH = 100;
29
+
30
+ /**
31
+ * Maximum pattern length
32
+ */
33
+ export const MAX_PATTERN_LENGTH = 1000;
34
+
35
+ /**
36
+ * Maximum path length
37
+ */
38
+ export const MAX_PATH_LENGTH = 4096;
39
+
40
+ /**
41
+ * Sanitize a file path to prevent directory traversal attacks
42
+ *
43
+ * @param {string} filePath - Path to sanitize
44
+ * @param {string} basePath - Base directory path (optional)
45
+ * @returns {string} Sanitized path
46
+ * @throws {Error} If path is invalid or attempts traversal
47
+ */
48
+ export function sanitizePath(filePath, basePath = process.cwd()) {
49
+ if (!filePath || typeof filePath !== 'string') {
50
+ throw new Error('Invalid file path');
51
+ }
52
+
53
+ // Check for null bytes
54
+ if (filePath.includes('\x00')) {
55
+ throw new Error('Path contains null bytes');
56
+ }
57
+
58
+ // Check path length
59
+ if (filePath.length > MAX_PATH_LENGTH) {
60
+ throw new Error(`Path too long (max ${MAX_PATH_LENGTH} characters)`);
61
+ }
62
+
63
+ // Resolve to absolute path
64
+ const resolvedPath = path.resolve(basePath, filePath);
65
+ const resolvedBase = path.resolve(basePath);
66
+
67
+ // Ensure path is within base directory
68
+ if (!resolvedPath.startsWith(resolvedBase)) {
69
+ throw new Error('Path traversal detected');
70
+ }
71
+
72
+ return resolvedPath;
73
+ }
74
+
75
+ /**
76
+ * Validate and sanitize a glob pattern
77
+ *
78
+ * @param {string} pattern - Glob pattern to validate
79
+ * @returns {string} Sanitized pattern
80
+ * @throws {Error} If pattern is invalid or malicious
81
+ */
82
+ export function sanitizePattern(pattern) {
83
+ if (!pattern || typeof pattern !== 'string') {
84
+ throw new Error('Invalid pattern');
85
+ }
86
+
87
+ // Check pattern length
88
+ if (pattern.length > MAX_PATTERN_LENGTH) {
89
+ throw new Error(`Pattern too long (max ${MAX_PATTERN_LENGTH} characters)`);
90
+ }
91
+
92
+ // Check for null bytes
93
+ if (pattern.includes('\x00')) {
94
+ throw new Error('Pattern contains null bytes');
95
+ }
96
+
97
+ // Check for shell metacharacters that could cause command injection
98
+ const dangerousChars = /[;&|`$()]/;
99
+ if (dangerousChars.test(pattern)) {
100
+ throw new Error('Pattern contains dangerous characters');
101
+ }
102
+
103
+ // Check for ReDoS patterns (basic check)
104
+ const redosPatterns = [
105
+ /(\(.*\+\))+/, // (a+)+
106
+ /(\(.*\*\))+/, // (a*)*
107
+ /(\(.*\|\))+/ // (a|b)+
108
+ ];
109
+
110
+ for (const redosPattern of redosPatterns) {
111
+ if (redosPattern.test(pattern)) {
112
+ throw new Error('Pattern may cause ReDoS');
113
+ }
114
+ }
115
+
116
+ return pattern;
117
+ }
118
+
119
+ /**
120
+ * Sanitize command-line argument
121
+ *
122
+ * @param {string} arg - Argument to sanitize
123
+ * @returns {string} Sanitized argument
124
+ * @throws {Error} If argument is invalid or malicious
125
+ */
126
+ export function sanitizeArgument(arg) {
127
+ if (typeof arg !== 'string') {
128
+ throw new Error('Invalid argument type');
129
+ }
130
+
131
+ // Check for null bytes
132
+ if (arg.includes('\x00')) {
133
+ throw new Error('Argument contains null bytes');
134
+ }
135
+
136
+ // Check for shell metacharacters
137
+ const shellMetachars = /[;&|`$()<>]/;
138
+ if (shellMetachars.test(arg)) {
139
+ throw new Error('Argument contains shell metacharacters');
140
+ }
141
+
142
+ // Check for newlines (command injection)
143
+ if (arg.includes('\n') || arg.includes('\r')) {
144
+ throw new Error('Argument contains newline characters');
145
+ }
146
+
147
+ return arg;
148
+ }
149
+
150
+ /**
151
+ * Validate environment variable name
152
+ *
153
+ * @param {string} varName - Variable name to validate
154
+ * @returns {boolean} True if valid
155
+ */
156
+ export function validateEnvVarName(varName) {
157
+ if (!varName || typeof varName !== 'string') {
158
+ return false;
159
+ }
160
+
161
+ // Check length
162
+ if (varName.length > 255) {
163
+ return false;
164
+ }
165
+
166
+ // Use existing validation
167
+ return isValidEnvVarName(varName);
168
+ }
169
+
170
+ /**
171
+ * Sanitize error message to prevent information leakage
172
+ *
173
+ * @param {Error} error - Error object
174
+ * @param {boolean} verbose - Include more details
175
+ * @returns {string} Sanitized error message
176
+ */
177
+ export function sanitizeErrorMessage(error, verbose = false) {
178
+ if (!error) {
179
+ return 'Unknown error';
180
+ }
181
+
182
+ let message = error.message || 'Unknown error';
183
+
184
+ if (!verbose) {
185
+ // Remove absolute paths
186
+ message = message.replace(/\/[^\s]+/g, '[path]');
187
+ message = message.replace(/[A-Z]:\\[^\s]+/g, '[path]');
188
+
189
+ // Remove potential secrets (anything that looks like a key/token)
190
+ message = message.replace(/[a-zA-Z0-9_-]{32,}/g, '[redacted]');
191
+
192
+ // Limit message length
193
+ if (message.length > 200) {
194
+ message = message.substring(0, 200) + '...';
195
+ }
196
+ }
197
+
198
+ return message;
199
+ }
200
+
201
+ /**
202
+ * Check if file size is within limits
203
+ *
204
+ * @param {number} size - File size in bytes
205
+ * @returns {boolean} True if within limits
206
+ */
207
+ export function isFileSizeValid(size) {
208
+ return typeof size === 'number' && size >= 0 && size <= MAX_FILE_SIZE;
209
+ }
210
+
211
+ /**
212
+ * Check if directory depth is within limits
213
+ *
214
+ * @param {string} filePath - File path to check
215
+ * @param {string} basePath - Base directory path
216
+ * @returns {boolean} True if within limits
217
+ */
218
+ export function isDepthValid(filePath, basePath = process.cwd()) {
219
+ const relativePath = path.relative(basePath, filePath);
220
+ const depth = relativePath.split(path.sep).length;
221
+ return depth <= MAX_DIRECTORY_DEPTH;
222
+ }
223
+
224
+ /**
225
+ * Rate limiter for file operations
226
+ */
227
+ export class RateLimiter {
228
+ constructor(maxOperations = 1000, windowMs = 1000) {
229
+ this.maxOperations = maxOperations;
230
+ this.windowMs = windowMs;
231
+ this.operations = [];
232
+ }
233
+
234
+ /**
235
+ * Check if operation is allowed
236
+ * @returns {boolean} True if allowed
237
+ */
238
+ allowOperation() {
239
+ const now = Date.now();
240
+
241
+ // Remove old operations outside window
242
+ this.operations = this.operations.filter(
243
+ time => now - time < this.windowMs
244
+ );
245
+
246
+ // Check if under limit
247
+ if (this.operations.length >= this.maxOperations) {
248
+ return false;
249
+ }
250
+
251
+ // Record operation
252
+ this.operations.push(now);
253
+ return true;
254
+ }
255
+
256
+ /**
257
+ * Reset rate limiter
258
+ */
259
+ reset() {
260
+ this.operations = [];
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Timeout wrapper for async operations
266
+ *
267
+ * @param {Promise} promise - Promise to wrap
268
+ * @param {number} timeoutMs - Timeout in milliseconds
269
+ * @returns {Promise} Promise that rejects on timeout
270
+ */
271
+ export function withTimeout(promise, timeoutMs = 30000) {
272
+ return Promise.race([
273
+ promise,
274
+ new Promise((_, reject) =>
275
+ setTimeout(() => reject(new Error('Operation timed out')), timeoutMs)
276
+ )
277
+ ]);
278
+ }
279
+
280
+ /**
281
+ * Validate configuration object
282
+ *
283
+ * @param {Object} config - Configuration to validate
284
+ * @returns {Object} Validated configuration
285
+ * @throws {Error} If configuration is invalid
286
+ */
287
+ export function validateConfiguration(config) {
288
+ if (!config || typeof config !== 'object') {
289
+ throw new Error('Invalid configuration');
290
+ }
291
+
292
+ // Check for prototype pollution - but allow 'path' property
293
+ const dangerousProps = ['__proto__', 'constructor', 'prototype'];
294
+ for (const prop of dangerousProps) {
295
+ if (prop in config) {
296
+ throw new Error('Configuration contains dangerous properties');
297
+ }
298
+ }
299
+
300
+ // Validate each property
301
+ const validated = {};
302
+
303
+ if (config.path !== undefined) {
304
+ if (typeof config.path !== 'string') {
305
+ throw new Error('Invalid path in configuration');
306
+ }
307
+ validated.path = config.path;
308
+ }
309
+
310
+ if (config.envFile !== undefined) {
311
+ if (typeof config.envFile !== 'string') {
312
+ throw new Error('Invalid envFile in configuration');
313
+ }
314
+ validated.envFile = config.envFile;
315
+ }
316
+
317
+ if (config.format !== undefined) {
318
+ if (!['text', 'json', 'github'].includes(config.format)) {
319
+ throw new Error('Invalid format in configuration');
320
+ }
321
+ validated.format = config.format;
322
+ }
323
+
324
+ if (config.failOn !== undefined) {
325
+ if (!['missing', 'unused', 'undocumented', 'all', 'none'].includes(config.failOn)) {
326
+ throw new Error('Invalid failOn in configuration');
327
+ }
328
+ validated.failOn = config.failOn;
329
+ }
330
+
331
+ if (config.ignore !== undefined) {
332
+ if (!Array.isArray(config.ignore)) {
333
+ throw new Error('Invalid ignore in configuration');
334
+ }
335
+ validated.ignore = config.ignore.filter(p => typeof p === 'string');
336
+ }
337
+
338
+ if (config.noColor !== undefined) {
339
+ validated.noColor = Boolean(config.noColor);
340
+ }
341
+
342
+ if (config.quiet !== undefined) {
343
+ validated.quiet = Boolean(config.quiet);
344
+ }
345
+
346
+ if (config.watch !== undefined) {
347
+ validated.watch = Boolean(config.watch);
348
+ }
349
+
350
+ if (config.suggestions !== undefined) {
351
+ validated.suggestions = Boolean(config.suggestions);
352
+ }
353
+
354
+ if (config.progress !== undefined) {
355
+ validated.progress = Boolean(config.progress);
356
+ }
357
+
358
+ if (config.fix !== undefined) {
359
+ validated.fix = Boolean(config.fix);
360
+ }
361
+
362
+ return validated;
363
+ }
364
+
365
+ /**
366
+ * Escape special characters for safe display
367
+ *
368
+ * @param {string} str - String to escape
369
+ * @returns {string} Escaped string
370
+ */
371
+ export function escapeForDisplay(str) {
372
+ if (typeof str !== 'string') {
373
+ return '';
374
+ }
375
+
376
+ return str
377
+ .replace(/&/g, '&amp;')
378
+ .replace(/</g, '&lt;')
379
+ .replace(/>/g, '&gt;')
380
+ .replace(/"/g, '&quot;')
381
+ .replace(/'/g, '&#x27;')
382
+ .replace(/\//g, '&#x2F;');
383
+ }
384
+
385
+ /**
386
+ * Check if path is safe to access
387
+ *
388
+ * @param {string} filePath - Path to check
389
+ * @returns {boolean} True if safe
390
+ */
391
+ export function isSafePath(filePath) {
392
+ if (!filePath || typeof filePath !== 'string') {
393
+ return false;
394
+ }
395
+
396
+ // Check for dangerous patterns
397
+ const dangerousPatterns = [
398
+ /\.\./, // Parent directory
399
+ /^\/etc\//, // System files
400
+ /^\/root\//, // Root home
401
+ /^\/sys\//, // System files
402
+ /^\/proc\//, // Process files
403
+ /^C:\\Windows/i, // Windows system
404
+ /^C:\\Program/i, // Program files
405
+ /\.ssh/, // SSH keys
406
+ /\.aws/, // AWS credentials
407
+ /\.env$/ // Actual env files (not .env.example)
408
+ ];
409
+
410
+ return !dangerousPatterns.some(pattern => pattern.test(filePath));
411
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Intelligent suggestions and fixes for common issues
3
+ */
4
+
5
+ /**
6
+ * Analyze issues and provide actionable suggestions
7
+ */
8
+ export function generateSuggestions(result) {
9
+ const suggestions = [];
10
+
11
+ // Missing variables suggestions
12
+ if (result.missing.length > 0) {
13
+ suggestions.push({
14
+ type: 'missing',
15
+ severity: 'error',
16
+ message: `Found ${result.missing.length} missing environment variable(s)`,
17
+ action: 'Add these variables to your .env.example file:',
18
+ items: result.missing.map(m => ({
19
+ variable: m.varName,
20
+ locations: m.locations.map(l => `${l.filePath}:${l.lineNumber}`),
21
+ suggestion: `${m.varName}=`,
22
+ })),
23
+ });
24
+ }
25
+
26
+ // Unused variables suggestions
27
+ if (result.unused.length > 0) {
28
+ suggestions.push({
29
+ type: 'unused',
30
+ severity: 'warning',
31
+ message: `Found ${result.unused.length} unused environment variable(s)`,
32
+ action: 'Consider removing these from .env.example or use them in your code:',
33
+ items: result.unused.map(u => ({
34
+ variable: u.varName,
35
+ suggestion: `Remove ${u.varName} from .env.example or add usage in code`,
36
+ })),
37
+ });
38
+ }
39
+
40
+ // Undocumented variables suggestions
41
+ if (result.undocumented.length > 0) {
42
+ suggestions.push({
43
+ type: 'undocumented',
44
+ severity: 'info',
45
+ message: `Found ${result.undocumented.length} undocumented environment variable(s)`,
46
+ action: 'Add comments to document these variables:',
47
+ items: result.undocumented.map(u => ({
48
+ variable: u.varName,
49
+ suggestion: `# Description of ${u.varName}\n${u.varName}=`,
50
+ })),
51
+ });
52
+ }
53
+
54
+ return suggestions;
55
+ }
56
+
57
+ /**
58
+ * Generate auto-fix content for .env.example
59
+ */
60
+ export function generateEnvExampleFix(result, existingContent = '') {
61
+ const lines = existingContent.split('\n');
62
+ const additions = [];
63
+
64
+ // Add missing variables
65
+ for (const missing of result.missing) {
66
+ const example = inferExampleValue(missing.varName);
67
+ additions.push(`# ${missing.varName} - Add description here`);
68
+ additions.push(`${missing.varName}=${example}`);
69
+ additions.push('');
70
+ }
71
+
72
+ return [...lines, '', '# Auto-generated additions', ...additions].join('\n');
73
+ }
74
+
75
+ /**
76
+ * Infer example value based on variable name
77
+ */
78
+ function inferExampleValue(varName) {
79
+ const patterns = {
80
+ PORT: '3000',
81
+ HOST: 'localhost',
82
+ URL: 'https://example.com',
83
+ API_KEY: 'your_api_key_here',
84
+ SECRET: 'your_secret_here',
85
+ TOKEN: 'your_token_here',
86
+ PASSWORD: 'your_password_here',
87
+ DATABASE: 'postgres://localhost:5432/dbname',
88
+ REDIS: 'redis://localhost:6379',
89
+ EMAIL: 'user@example.com',
90
+ DEBUG: 'false',
91
+ NODE_ENV: 'development',
92
+ };
93
+
94
+ for (const [pattern, value] of Object.entries(patterns)) {
95
+ if (varName.toUpperCase().includes(pattern)) {
96
+ return value;
97
+ }
98
+ }
99
+
100
+ return 'your_value_here';
101
+ }
102
+
103
+ /**
104
+ * Suggest similar variable names (typo detection)
105
+ */
106
+ export function findSimilarVariables(varName, definedVars) {
107
+ const similar = [];
108
+
109
+ for (const defined of definedVars) {
110
+ const distance = levenshteinDistance(varName.toLowerCase(), defined.toLowerCase());
111
+ const maxLength = Math.max(varName.length, defined.length);
112
+ const similarity = 1 - distance / maxLength;
113
+
114
+ if (similarity > 0.7 && similarity < 1) {
115
+ similar.push({
116
+ name: defined,
117
+ similarity: Math.round(similarity * 100),
118
+ });
119
+ }
120
+ }
121
+
122
+ return similar.sort((a, b) => b.similarity - a.similarity);
123
+ }
124
+
125
+ /**
126
+ * Calculate Levenshtein distance between two strings
127
+ */
128
+ function levenshteinDistance(str1, str2) {
129
+ const matrix = [];
130
+
131
+ for (let i = 0; i <= str2.length; i++) {
132
+ matrix[i] = [i];
133
+ }
134
+
135
+ for (let j = 0; j <= str1.length; j++) {
136
+ matrix[0][j] = j;
137
+ }
138
+
139
+ for (let i = 1; i <= str2.length; i++) {
140
+ for (let j = 1; j <= str1.length; j++) {
141
+ if (str2.charAt(i - 1) === str1.charAt(j - 1)) {
142
+ matrix[i][j] = matrix[i - 1][j - 1];
143
+ } else {
144
+ matrix[i][j] = Math.min(
145
+ matrix[i - 1][j - 1] + 1,
146
+ matrix[i][j - 1] + 1,
147
+ matrix[i - 1][j] + 1
148
+ );
149
+ }
150
+ }
151
+ }
152
+
153
+ return matrix[str2.length][str1.length];
154
+ }