UNPKG

bws-secure

Version:

Secure environment management with Bitwarden Secrets Manager

872 lines (769 loc) 28.2 kB
/** * requiredRuntimeVars.js * ----------------------- * This script scans specified directories in your JavaScript/TypeScript project * (or default directories if none are specified) to find references to `process.env.*`. * It supports: * 1. Default directory scanning (e.g. "functions", "api", "my-next-app-project-files") * 2. Named directory searches ("functions", "api") throughout the entire repo * 3. Direct paths ("my-next-app/functions/react") * 4. Wildcard / glob patterns ("my-next-app/**\/*.js") * It generates a report listing all required environment variables found, grouped * by folder. The main output file is `requiredVars.env` which will appear * in the parent directory (bws-secure). * * Usage examples: * - pnpm scan * - pnpm scan "functions" * - pnpm scan "my-next-app/functions/react" * - pnpm scan "my-next-app/**\/*.js" * - pnpm scan "functions,api,my-next-app/**\/*.js" */ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { promises as fsPromises } from 'node:fs'; import { findFiles } from '../src/fileUtils.js'; import { promisify } from 'node:util'; // Get the directory name in ESM const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); /** * --------------------------------------------- * CHANGE: Use DEBUG=true instead of VAR_SCANNER_VERBOSE * --------------------------------------------- */ const VERBOSE = process.env.DEBUG === 'true'; /** * The root of the repository is determined by going up two levels ("../../..") * from this file's directory. That typically means: * /check-vars -> repoRoot */ const repoRoot = path.resolve(process.cwd()); // Default directories to check if none are specified const DEFAULT_DIRECTORIES = [ 'functions', 'api', 'apps/web/src', 'packages', 'src', 'apps/**/src', 'packages/**/src' ]; // Get directories to check from command line args, or use defaults const directoriesToCheck = process.argv[2] ? process.argv[2].split(',').map((d) => d.trim()) : DEFAULT_DIRECTORIES; /** * Reads .gitignore (if it exists) from the current working directory. * - Appends each line that isn't a comment to the ignorePatterns array. * - Always ignores "node_modules" by default. * This is used later to skip certain folders/files from scanning. */ function getIgnorePatterns() { const patterns = ['node_modules']; // Always ignore node_modules try { const gitignore = fs.readFileSync('.gitignore', 'utf-8'); const additionalPatterns = gitignore .split('\n') .map((line) => line.trim()) .filter((line) => line && !line.startsWith('#')); patterns.push(...additionalPatterns); } catch (error) { console.warn('No .gitignore found, using default exclusions'); } return patterns; } /** * Checks a given filePath to see if it matches any of the ignore patterns * (like those from .gitignore). If it matches, we skip scanning that path. */ function shouldIgnorePath(filePath, ignorePatterns) { // Normalize Windows backslashes to forward slashes const normalizedPath = filePath.replace(/\\/g, '/'); // Quick check for common patterns before logging - to reduce debug noise const commonPatterns = [ 'node_modules/', '.git/', 'dist/', 'build/', '.next/', 'cms-sync/', 'packages/graphql-runner/cms-sync/' ]; // Pre-check without logging for very common patterns for (const pattern of commonPatterns) { if (normalizedPath.includes(pattern)) { return true; } } // Always ignore these patterns (more specific than before) const defaultIgnores = [ 'node_modules/', '.git/', 'dist/', 'build/', '.next/', '.cache/', 'coverage/', '.turbo/', 'cms-sync/', // Specific patterns for the cms-sync directory 'packages/graphql-runner/cms-sync/', // Additional build output folders '.nuxt/', 'out/', 'public/build/', 'storybook-static/', '.vscode/', '.idea/', '.vercel/', '.netlify/', // Files to always ignore '.env', '.env.*', '*.min.js', '*.bundle.js', '*.map' ]; const allPatterns = [...new Set([...defaultIgnores, ...ignorePatterns])]; if (VERBOSE) { console.log(`DEBUG: Checking path against ignore patterns: ${normalizedPath}`); } // Add a timeout to prevent infinite loops const startTime = Date.now(); const TIMEOUT = 5000; // 5 seconds for (const pattern of allPatterns) { // Check for timeout if (Date.now() - startTime > TIMEOUT) { console.warn('WARNING: Pattern matching timeout - skipping remaining patterns'); return true; } try { // Handle directory patterns that end with / if (pattern.endsWith('/')) { if (normalizedPath.includes(`/${pattern}`) || normalizedPath.startsWith(pattern)) { if (VERBOSE) { console.log(`DEBUG: Path ${normalizedPath} matched directory pattern ${pattern}`); } return true; } continue; } // Handle exact matches first if (normalizedPath === pattern || normalizedPath.endsWith(`/${pattern}`)) { if (VERBOSE) { console.log(`DEBUG: Path ${normalizedPath} matched exact pattern ${pattern}`); } return true; } // Handle glob patterns if (pattern.includes('*') || pattern.includes('?')) { const regexPattern = pattern .replace(/\./g, '\\.') .replace(/\*/g, '[^/]*') .replace(/\?/g, '[^/]') .replace(/\//g, '\\/'); const regex = new RegExp(`^${regexPattern}$|/${regexPattern}$`); if (regex.test(normalizedPath)) { if (VERBOSE) { console.log(`DEBUG: Path ${normalizedPath} matched glob pattern ${pattern}`); } return true; } } } catch (error) { console.warn(`WARNING: Error matching pattern ${pattern}:`, error.message); } } return false; } /** * List of file extensions we want to scan for environment variables. * These are typically files that could contain runtime code. */ const VALID_EXTENSIONS = new Set([ '.js', '.jsx', '.ts', '.tsx', '.vue', '.mjs', '.cjs', '.mts', '.cts' ]); /** * Check if a file should be scanned based on its extension */ function isValidFileType(filePath) { const ext = path.extname(filePath).toLowerCase(); return VALID_EXTENSIONS.has(ext); } /** * For a given directory, returns all JavaScript and TypeScript files. * @param {string} dir - The directory to scan * @param {string[]} ignorePatterns - Patterns to ignore * @returns {Promise<string[]>} - Array of file paths */ async function getAllFiles(dir, ignorePatterns = []) { try { return await findFiles('**/*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}', { cwd: dir, ignore: ignorePatterns }); } catch (error) { console.error(`Error scanning directory ${dir}:`, error); return []; } } /** * Comment patterns for different file types */ const COMMENT_PATTERNS = { '.js': { single: '//', multi: [['/*', '*/']] }, '.jsx': { single: '//', multi: [['/*', '*/']] }, '.ts': { single: '//', multi: [['/*', '*/']] }, '.tsx': { single: '//', multi: [['/*', '*/']] }, '.vue': { single: '//', multi: [ ['/*', '*/'], ['<!--', '-->'] ] }, '.mjs': { single: '//', multi: [['/*', '*/']] }, '.cjs': { single: '//', multi: [['/*', '*/']] }, '.mts': { single: '//', multi: [['/*', '*/']] }, '.cts': { single: '//', multi: [['/*', '*/']] } }; /** * Reads the content of a file and searches for references to process.env.<VAR>. * It returns an array of variable names discovered. For example, if the file * contains "process.env.API_KEY", the array will include "API_KEY". * * --------------------------------------------- * CHANGE #2: Support lower-case letters, i.e. * ([a-zA-Z0-9_]+) in the capturing group * --------------------------------------------- */ async function findEnvVarsInFile(filePath) { const content = await fsPromises.readFile(filePath, 'utf-8'); const ext = path.extname(filePath).toLowerCase(); const commentStyle = COMMENT_PATTERNS[ext] || { single: '//', multi: [['/*', '*/']] }; // Remove multi-line comments for this file type let noComments = content; commentStyle.multi.forEach(([start, end]) => { const multiLineRegex = new RegExp(`${escapeRegExp(start)}[\\s\\S]*?${escapeRegExp(end)}`, 'g'); noComments = noComments.replace(multiLineRegex, ''); }); // Split into lines and filter out single-line comments const activeLines = noComments .split('\n') .filter((line) => !line.trim().startsWith(commentStyle.single)); // Rejoin the lines and find env vars const activeContent = activeLines.join('\n'); const matches = activeContent.match(/process\.env\.([a-zA-Z0-9_]+)/g) || []; return matches.map((match) => match.replace('process.env.', '')); } // Helper function to escape special regex characters function escapeRegExp(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } /** * Detects if the user input might be a direct path or a wildcard path. * We treat user inputs that include a slash, or are ".", "..", or contain a * wildcard character (* or ?), as "full paths" or globs. Otherwise, we consider * them directory names to be discovered in the repo (like "functions"). */ function isFullPathOrGlob(p) { return p.includes('/') || p === '.' || p === '..' || isGlobPath(p); } /** * Checks if the user input string is a wildcard pattern. We consider it a glob * if it includes an asterisk (*) or question mark (?). */ function isGlobPath(p) { return p.includes('*') || p.includes('?'); } /** * Converts a wildcard pattern like "**\/*.js" or "*.ts" into a JavaScript * regular expression. We'll use this to filter only the files that match * the user-supplied glob pattern. */ function wildcardToRegex(pattern) { // First, escape literal dots let regexStr = pattern.replace(/\./g, '\\.'); // Replace ** with a placeholder that we'll later replace with ".*" regexStr = regexStr.replace(/\*\*/g, '___GLOBSTAR___'); // Replace single-star with "[^/]*", meaning any sequence of characters not including a slash regexStr = regexStr.replace(/\*/g, '[^/]*'); // Replace the placeholder (___GLOBSTAR___) with ".*", letting us match multiple directory levels regexStr = regexStr.replace(/___GLOBSTAR___/g, '.*'); // Replace the question mark with ".", which in regex means match exactly one character regexStr = regexStr.replace(/\?/g, '.'); // Finally, anchor the regex so it must match the entire path return new RegExp(`^${regexStr}$`); } /** * For a userPath like "myNextApp/**\/*.js", we split it into: * - baseDir: "myNextApp" * - pattern: "**\/*.js" */ async function handleGlobPattern(userPath, ignorePatterns) { try { const files = await findFiles(userPath, { cwd: repoRoot, ignore: ignorePatterns }); if (files.length === 0) { console.log(`No files found matching pattern: ${userPath}`); return []; } return files.filter(isValidFileType); } catch (error) { console.error(`Error processing glob pattern ${userPath}:`, error); return []; } } /** * For a userPath like "myNextApp/**\/*.js", we split it into: * - baseDir: "myNextApp" * - pattern: "**\/*.js" * If there's no slash before the wildcard, or it starts with "**", * we treat the entire userPath as the pattern and set baseDir = repoRoot by default. */ function parseGlobPath(userPath) { const slashIndex = userPath.indexOf('/'); const starIndex = userPath.search(/[\*\?]/); // If the wildcard (* or ?) appears before an actual slash, or no slash at all, // assume the user typed something like "**/*.js" with no base directory if (starIndex >= 0 && (slashIndex < 0 || starIndex < slashIndex)) { return { baseDir: repoRoot, pattern: userPath }; } // Otherwise, find the last slash before the wildcard so we can separate // the base directory from the glob pattern that follows. let lastSlash = userPath.lastIndexOf('/', starIndex); if (lastSlash === -1) { // No slash, but a wildcard is present return { baseDir: repoRoot, pattern: userPath }; } // Everything up to lastSlash is the base directory, everything after is the pattern return { baseDir: path.resolve(repoRoot, userPath.slice(0, lastSlash)), pattern: userPath.slice(lastSlash + 1) }; } /** * Filters a given array of file paths, returning only those that match the wildcard pattern. * E.g. if pattern = "**\/*.js", we keep only .js files within nested folders. */ function filterByPattern(files, pattern) { const regex = wildcardToRegex(pattern); return files.filter((file) => { // Convert to a relative path from repoRoot. This helps the wildcard match properly. const relPath = path.relative(repoRoot, file).replace(/\\/g, '/'); return regex.test(relPath); }); } /** * For a given directory name (like "functions" or "api"), finds all matching * directories in the repository, including those nested in monorepo structures. */ async function findAllNamedDirs(root, dirName, ignorePatterns) { const matchingDirs = []; async function walk(dir) { try { const entries = await fsPromises.readdir(dir, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory()) continue; const fullPath = path.join(dir, entry.name); const relativePath = path.relative(root, fullPath); // Skip if path should be ignored if (shouldIgnorePath(relativePath, ignorePatterns)) { if (VERBOSE) console.log(`DEBUG: Ignoring path (matched .gitignore): ${relativePath}`); continue; } // If this directory matches what we're looking for if (entry.name === dirName) { matchingDirs.push(fullPath); } // Always recurse into packages/apps directories in monorepos if ( entry.name === 'packages' || entry.name === 'apps' || !relativePath.includes('node_modules') ) { await walk(fullPath); } } } catch (error) { if (VERBOSE) console.log(`DEBUG: Error walking directory ${dir}:`, error); } } await walk(root); return matchingDirs; } /** * If the user didn't provide custom directories, we do a fallback: * We search the entire repo for "functions" or "api" directories by name, * recurring on subfolders. This helps automatically gather typical function * or API directories used in many frameworks. */ async function findFunctionsDirs(dir, ignorePatterns) { const functionsDirs = []; const entries = await fsPromises.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); const relativePath = path.relative(process.cwd(), fullPath); if (shouldIgnorePath(relativePath, ignorePatterns)) { continue; } if (entry.isDirectory()) { // If the folder is literally named "functions" or "api", // or if "functions"/"api" is part of the path, we include it. if ( entry.name === 'functions' || entry.name === 'api' || relativePath.includes('/functions/') || relativePath.includes('/api/') ) { functionsDirs.push(fullPath); } // Recurse deeper to find more possible matches functionsDirs.push(...(await findFunctionsDirs(fullPath, ignorePatterns))); } } return functionsDirs; } /** * When scanning function directories discovered by findFunctionsDirs, we gather * only .js, .ts, or .tsx files. This is a narrower approach than getAllFiles because * we assume function code is typically in these file extensions. */ async function getFunctionFiles(dir) { let files = []; const entries = await fsPromises.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { files = files.concat(await getFunctionFiles(fullPath)); } else { files.push(fullPath); } } return files; } /** * Removes any previous requiredVars.env file so we can write a fresh one. */ const cleanupExistingReport = async () => { const reportPath = path.join(process.cwd(), 'requiredVars.env'); try { await fsPromises.unlink(reportPath); if (VERBOSE) { console.log(`DEBUG: Removed old report file at ${reportPath}`); } } catch (error) { // Ignore if file doesn't exist if (error.code !== 'ENOENT') { console.warn(`Warning: Could not remove old report file: ${error.message}`); } } }; /** * Read turbo.json and extract environment variables if available */ function getTurboVars() { try { const turboConfigPath = path.join(repoRoot, 'turbo.json'); // If no turbo.json exists, return empty set silently if (!fs.existsSync(turboConfigPath)) { if (VERBOSE) { console.log('DEBUG: No turbo.json found - skipping turbo variable scanning'); } return new Set(); } let turboConfig; try { const rawConfig = fs.readFileSync(turboConfigPath, 'utf8'); turboConfig = JSON.parse(rawConfig); } catch (parseError) { console.log('\n⚠️ Warning: Found turbo.json but could not parse it:'); console.log(` ${parseError.message}`); console.log(' Continuing scan without turbo.json variables...\n'); return new Set(); } // Collect variables from different sources in turbo.json const turboVars = new Set(); // Add globalDependencies that look like environment variables const globalDeps = turboConfig?.globalDependencies || []; globalDeps.forEach((dep) => { // Only add if it looks like an environment variable (starts with $, no path-like characters) if (dep.startsWith('$') && !dep.includes('/') && !dep.includes('.')) { turboVars.add(dep.substring(1)); // Remove the $ prefix } else if (dep.match(/^[A-Z][A-Z0-9_]*$/)) { // Add if it's all caps with underscores (typical env var format) turboVars.add(dep); } }); // Add task-specific environment variables if (turboConfig.pipeline) { Object.values(turboConfig.pipeline).forEach((task) => { if (task.env) { task.env.forEach((v) => turboVars.add(v)); } if (task.dependsOn) { task.dependsOn.forEach((dep) => { if (dep.startsWith('$') && !dep.includes('/')) { turboVars.add(dep.substring(1)); } }); } }); } if (VERBOSE) { if (turboVars.size > 0) { console.log('DEBUG: Found variables in turbo.json:', Array.from(turboVars)); } else { console.log('DEBUG: No variables found in turbo.json'); } } return turboVars; } catch (error) { console.log('\n⚠️ Warning: Error accessing turbo.json:'); console.log(` ${error.message}`); console.log(' Continuing scan without turbo.json variables...\n'); return new Set(); } } /** * Main execution function. Orchestrates all logic: * 1. Decide which folders to scan * 2. Collect environment vars * 3. Writes out the final `requiredVars.env` */ async function main() { // Build a human-readable report that we can write to disk const reportLines = [ '# Environment Variables Required by Directory', `# Generated: ${new Date().toLocaleString()}`, '#', '# This file maps which environment variables are required in each directory.', '#' ]; try { await cleanupExistingReport(); } catch (error) { if (error.code !== 'ENOENT') { console.error('Error cleaning up report file:', error); } } console.log('Scanning for environment variables...'); // Get turbo.json variables before starting the scan const turboVars = getTurboVars(); // Add turbo.json variables section to report if (turboVars.size > 0) { reportLines.push('# Variables from turbo.json:'); Array.from(turboVars) .sort() .forEach((v) => { reportLines.push(`# - ${v}`); }); } reportLines.push('#\n'); // Gather the patterns to ignore based on .gitignore const ignorePatterns = getIgnorePatterns(); // We'll store absolute file paths that we want to scan for environment var references let pathsToScan = []; // If the user specified a list of directories or patterns: if (directoriesToCheck.length > 0) { console.log('Using selected paths:', directoriesToCheck); // Loop through each user-supplied path or pattern for (const userPath of directoriesToCheck) { // If userPath has slashes or wildcard, treat it as direct path or glob if (isFullPathOrGlob(userPath)) { // If it's a direct path with no wildcard (like "path/foo/bar") if (!isGlobPath(userPath)) { const absolutePath = path.resolve(repoRoot, userPath); // If the path is outside our repoRoot, skip if (!absolutePath.startsWith(repoRoot)) { console.log(`Skipping ${absolutePath} - outside repo root`); continue; } // Attempt to verify the path exists, and then gather all files try { await fsPromises.access(absolutePath); const files = await getAllFiles(absolutePath, ignorePatterns); pathsToScan = pathsToScan.concat(files); } catch (error) { console.log(`Path not found: ${absolutePath} - skipping`); } } else { // It's a glob pattern (contains * or ?) const globFiles = await handleGlobPattern(userPath, ignorePatterns); pathsToScan = pathsToScan.concat(globFiles); } } else { // It's a simple directory name to find in the repo (like "functions") const foundPaths = await findAllNamedDirs(repoRoot, userPath, ignorePatterns); for (const foundPath of foundPaths) { const files = await getAllFiles(foundPath, ignorePatterns); pathsToScan = pathsToScan.concat(files); } } } } else { // If user provided no custom directories, we do a fallback search for "functions" or "api" const functionsDirs = await findFunctionsDirs(repoRoot, ignorePatterns); console.log(`Found ${functionsDirs.length} matching directories:`); functionsDirs.forEach((dir) => console.log(`- ${path.relative(repoRoot, dir)}`)); // Now gather JS/TS files from those discovered function/api directories for (const dir of functionsDirs) { const files = await getFunctionFiles(dir); pathsToScan = pathsToScan.concat(files); } } // If no files ended up in pathsToScan, we let the user know and exit gracefully if (pathsToScan.length === 0) { console.log('\nNo files found to scan. To scan specific directories, you can:'); console.log("1. Create a 'functions' or 'api' directory in your project"); console.log('2. Pass custom directories or patterns as an argument:'); console.log(' pnpm scan "src,pages,components"'); console.log(' pnpm scan "my-next-app/**/*.js"'); process.exit(0); } console.log(`\nScanning ${pathsToScan.length} files...`); let processedFiles = 0; const startTime = Date.now(); /** * We'll create a Map keyed by directory, with a Set of environment variable names * that we found in that directory's files. */ const envVarsByDir = new Map(); const varTotalReferences = new Map(); const varOccurrences = new Map(); let totalFileCount = 0; for (const file of pathsToScan) { processedFiles++; if (processedFiles % 100 === 0 || VERBOSE) { const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); console.log(`Progress: ${processedFiles}/${pathsToScan.length} files (${elapsed}s)`); } if (VERBOSE) { console.log(`DEBUG: Scanning file: ${path.relative(repoRoot, file)}`); } const vars = await findEnvVarsInFile(file); if (vars.length) { const dir = path.dirname(file); if (!envVarsByDir.has(dir)) { envVarsByDir.set(dir, new Set()); } // Count each occurrence in the file vars.forEach((v) => { envVarsByDir.get(dir).add(v); varTotalReferences.set(v, (varTotalReferences.get(v) || 0) + 1); }); totalFileCount++; } } // Count directory occurrences for (const vars of envVarsByDir.values()) { for (const v of vars) { varOccurrences.set(v, (varOccurrences.get(v) || 0) + 1); } } // Calculate total references (actual sum of all variable references) const totalReferences = Array.from(varTotalReferences.values()).reduce( (sum, count) => sum + count, 0 ); // Get unique variables across all directories const allEnvVars = new Set(); for (const vars of envVarsByDir.values()) { for (const v of vars) { allEnvVars.add(v); } } // Find variables used in multiple directories const repeatedVars = Array.from(varOccurrences.entries()) .filter(([_, count]) => count > 1) .sort((a, b) => b[1] - a[1]); // Sort by occurrence count descending console.log('\nEnvironment Variable Statistics:'); console.log(`- ${allEnvVars.size} unique environment variables`); console.log(`- ${totalReferences} total variable references`); console.log(`- ${envVarsByDir.size} directories containing environment variables`); console.log(`- ${totalFileCount} files containing environment variables`); if (repeatedVars.length > 0) { console.log('\nVariables used in multiple directories:'); repeatedVars.forEach(([varName, count]) => { const totalRefs = varTotalReferences.get(varName); console.log(`- ${varName}: used in ${count} directories (${totalRefs} total references)`); }); } // After scanning is complete, add turbo vars to allEnvVars if (turboVars.size > 0) { console.log('\nAdding variables from turbo.json:'); const turboOnlyVars = Array.from(turboVars).filter((v) => !allEnvVars.has(v)); if (turboOnlyVars.length > 0) { reportLines.push('\n# Additional variables from turbo.json:'); turboOnlyVars.sort().forEach((v) => { console.log(`- ${v} (from turbo.json)`); reportLines.push(v); allEnvVars.add(v); }); } } // Group results by the first directory segment const groupedDirs = new Map(); for (const [dir, vars] of envVarsByDir) { const relativePath = path.relative(process.cwd(), dir); const parentDir = relativePath.split('/')[0] || '.'; if (!groupedDirs.has(parentDir)) { groupedDirs.set(parentDir, []); } groupedDirs.get(parentDir).push([dir, vars]); } // For each parent dir group, list subdirectories and environment variables discovered for (const [parentDir, entries] of groupedDirs) { // Turn headings into comments reportLines.push(`# ${parentDir}`); reportLines.push(''); for (const [dir, vars] of entries) { const relativePath = path.relative(process.cwd(), dir); reportLines.push(`# ${relativePath}`); const sortedVars = Array.from(vars).sort(); for (const envVar of sortedVars) { reportLines.push(envVar); } reportLines.push(''); } reportLines.push(''); } // Write out the final formatted report to a single file in this directory (check-vars). const OUTPUT_PATH = path.join(process.cwd(), 'requiredVars.env'); const outputDir = path.dirname(OUTPUT_PATH); try { await fsPromises.mkdir(outputDir, { recursive: true }); } catch (error) { if (error.code !== 'EEXIST ') { throw error; } } await fsPromises.writeFile(OUTPUT_PATH, reportLines.join('\n'), 'utf8'); console.log(`Environment variable report written to ${OUTPUT_PATH}`); } // Execute the main function main().catch((error) => { console.error('Error:', error.message); process.exit(1); });