UNPKG

@cosmstack/blackshield

Version:

A developer-first security toolkit for React/Next.js applications

409 lines (404 loc) 15.1 kB
#!/usr/bin/env node // src/cli/index.ts import { readFileSync } from "fs"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; import { program } from "commander"; // src/core/env-audit.ts import { promises as fs } from "fs"; import path from "path"; import { glob } from "glob"; var DEFAULT_SENSITIVE_PATTERNS = [ /SECRET/i, /KEY/i, /TOKEN/i, /PASSWORD/i, /PRIVATE/i, /API_SECRET/i, /DATABASE/i, /DB_/i, /MONGO/i, /REDIS/i, /JWT/i, /AUTH/i, /STRIPE_SECRET/i, /PAYPAL/i, /WEBHOOK/i, /ENCRYPTION/i, /HASH/i, /SALT/i ]; async function envAudit(options = {}) { const { projectPath = process.cwd(), customPatterns = [], allowedPublicVars = [] } = options; const sensitivePatterns = [...DEFAULT_SENSITIVE_PATTERNS, ...customPatterns]; const sensitiveVars = []; const filesScanned = []; let totalVars = 0; try { const envFiles = await glob("**/.env*", { cwd: projectPath, ignore: ["**/node_modules/**", "**/dist/**", "**/.next/**", "**/build/**"], dot: true }); for (const envFile of envFiles) { const filePath = path.join(projectPath, envFile); filesScanned.push(envFile); try { const content = await fs.readFile(filePath, "utf-8"); const lines = content.split("\n"); lines.forEach((line, index) => { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) return; const envMatch = trimmed.match(/^([A-Z_][A-Z0-9_]*)\s*=/); if (!envMatch) return; const varName = envMatch[1]; totalVars++; if (varName.startsWith("NEXT_PUBLIC_")) { if (allowedPublicVars.includes(varName)) return; const isSensitive = sensitivePatterns.some((pattern) => pattern.test(varName)); if (isSensitive) { sensitiveVars.push({ name: varName, file: envFile, line: index + 1, severity: "error", suggestion: `Remove NEXT_PUBLIC_ prefix or add "${varName}" to allowedPublicVars in config` }); } else { sensitiveVars.push({ name: varName, file: envFile, line: index + 1, severity: "warning", suggestion: `Consider if "${varName}" needs to be public. If not, remove NEXT_PUBLIC_ prefix` }); } } }); } catch (error) { console.warn(`Failed to read env file ${envFile}:`, error); } } } catch (error) { console.warn("Failed to scan for env files:", error); } return { isValid: sensitiveVars.filter((v) => v.severity === "error").length === 0, sensitiveVars, summary: { totalVars, sensitiveCount: sensitiveVars.length, filesScanned } }; } async function scanEnvFiles(projectPath) { const result = await envAudit({ projectPath }); console.log("\n\u{1F50D} Environment Variable Audit\n"); if (result.sensitiveVars.length === 0) { console.log("\u2705 No sensitive environment variables found exposed to client!"); console.log( `\u{1F4CA} Scanned ${result.summary.totalVars} variables in ${result.summary.filesScanned.length} files` ); return; } const errors = result.sensitiveVars.filter((v) => v.severity === "error"); const warnings = result.sensitiveVars.filter((v) => v.severity === "warning"); if (errors.length > 0) { console.log(`\u274C ${errors.length} sensitive variable(s) exposed to client:`); for (const issue of errors) { console.log(` ${issue.file}:${issue.line} - ${issue.name}`); console.log(` \u{1F4A1} ${issue.suggestion}`); } console.log(); } if (warnings.length > 0) { console.log(`\u26A0\uFE0F ${warnings.length} potentially unnecessary public variable(s):`); for (const issue of warnings) { console.log(` ${issue.file}:${issue.line} - ${issue.name}`); console.log(` \u{1F4A1} ${issue.suggestion}`); } console.log(); } console.log(`\u{1F4CA} Summary: ${errors.length} errors, ${warnings.length} warnings`); console.log(`\u{1F4C1} Files scanned: ${result.summary.filesScanned.join(", ")}`); } // src/cli/analyzer.ts import { promises as fs2 } from "fs"; import path2 from "path"; import { glob as glob2 } from "glob"; async function analyzeProject(projectPath, options = {}) { const issues = []; const config = await loadConfig(projectPath, options.configPath); const envIssues = await analyzeEnvironmentVariables(projectPath, config); const xssIssues = await analyzeXSSVulnerabilities(projectPath, config); const boundaryIssues = await analyzeBoundaryViolations(projectPath, config); const serverIssues = await analyzeServerSecurity(projectPath, config); issues.push(...envIssues, ...xssIssues, ...boundaryIssues, ...serverIssues); return { projectPath, timestamp: (/* @__PURE__ */ new Date()).toISOString(), issues, summary: { total: issues.length, errors: issues.filter((i) => i.severity === "error").length, warnings: issues.filter((i) => i.severity === "warning").length, info: issues.filter((i) => i.severity === "info").length }, config }; } async function loadConfig(projectPath, configPath) { const configFile = configPath || path2.join(projectPath, ".blackshieldrc.json"); try { await fs2.access(configFile); const content = await fs2.readFile(configFile, "utf-8"); return JSON.parse(content); } catch { return { envValidation: { allowedPublicVars: ["NEXT_PUBLIC_APP_URL", "NEXT_PUBLIC_API_URL"] }, xssProtection: { autoSanitize: true, customRules: {} }, boundaryProtection: { validateServerProps: true, customValidators: {} } }; } } async function analyzeEnvironmentVariables(projectPath, config) { const issues = []; try { const envFiles = await glob2("**/.env*", { cwd: projectPath, ignore: ["node_modules/**", ".git/**"] }); for (const envFile of envFiles) { const filePath = path2.join(projectPath, envFile); const content = await fs2.readFile(filePath, "utf-8"); const lines = content.split("\n"); lines.forEach((line, index) => { const trimmed = line.trim(); if (trimmed.startsWith("NEXT_PUBLIC_") && trimmed.includes("=")) { const [varName] = trimmed.split("="); const cleanVarName = varName.trim(); const allowedVars = config.envValidation?.allowedPublicVars || []; if (!allowedVars.includes(cleanVarName)) { const sensitivePatterns = ["SECRET", "KEY", "TOKEN", "PASSWORD", "PRIVATE"]; const isSensitive = sensitivePatterns.some( (pattern) => cleanVarName.toUpperCase().includes(pattern) ); issues.push({ type: "env-exposure", severity: isSensitive ? "error" : "warning", file: envFile, line: index + 1, column: 1, message: `Potentially sensitive environment variable "${cleanVarName}" exposed to client`, suggestion: isSensitive ? "Remove NEXT_PUBLIC_ prefix or add to allowedPublicVars in config" : `Consider if "${cleanVarName}" should be public` }); } } }); } } catch (error) { console.warn("Failed to analyze environment variables:", error); } return issues; } async function analyzeXSSVulnerabilities(projectPath, config) { const issues = []; try { const sourceFiles = await glob2("**/*.{ts,tsx,js,jsx}", { cwd: projectPath, ignore: ["**/node_modules/**", "**/dist/**", "**/.next/**", "**/*.test.*", "**/*.spec.*"] }); for (const file of sourceFiles) { const filePath = path2.join(projectPath, file); const content = await fs2.readFile(filePath, "utf-8"); const lines = content.split("\n"); lines.forEach((line, index) => { if (line.includes("dangerouslySetInnerHTML")) { issues.push({ type: "xss-vulnerability", severity: "error", message: "Use of dangerouslySetInnerHTML can lead to XSS vulnerabilities", file, line: index + 1, column: line.indexOf("dangerouslySetInnerHTML") + 1, suggestion: "Use SafeHTML component from @cosmstack/blackshield instead" }); } if (line.includes(".innerHTML =")) { issues.push({ type: "xss-vulnerability", severity: "error", message: "Direct innerHTML assignment can lead to XSS vulnerabilities", file, line: index + 1, column: line.indexOf(".innerHTML") + 1, suggestion: "Use SafeHTML component or sanitizeHTML function" }); } }); } } catch (error) { console.warn("Could not analyze XSS vulnerabilities:", error); } return issues; } async function analyzeBoundaryViolations(projectPath, config) { const issues = []; try { const apiFiles = await glob2("**/api/**/*.{ts,js}", { cwd: projectPath, ignore: ["**/node_modules/**"] }); for (const file of apiFiles) { const filePath = path2.join(projectPath, file); const content = await fs2.readFile(filePath, "utf-8"); const hasProtection = content.includes("protect(") || content.includes("requireAuth") || content.includes("validateServerInput"); if (!hasProtection) { issues.push({ type: "boundary-violation", severity: "warning", message: "API route appears to have no security protection", file, line: 1, column: 1, suggestion: "Consider using protect() middleware from @cosmstack/blackshield/server" }); } } const serverFiles = await glob2("**/app/**/*.{ts,tsx}", { cwd: projectPath, ignore: ["**/node_modules/**"] }); for (const file of serverFiles) { const filePath = path2.join(projectPath, file); const content = await fs2.readFile(filePath, "utf-8"); if (content.includes("'use server'") || content.includes('"use server"')) { const hasProtection = content.includes("protectServerAction") || content.includes("validateServerInput"); if (!hasProtection) { issues.push({ type: "boundary-violation", severity: "warning", message: "Server action appears to have no security protection", file, line: 1, column: 1, suggestion: "Consider using protectServerAction() from @cosmstack/blackshield/server" }); } } } } catch (error) { console.warn("Could not analyze boundary violations:", error); } return issues; } async function analyzeServerSecurity(projectPath, config) { const issues = []; try { const serverFiles = await glob2("**/app/**/*.{ts,tsx}", { cwd: projectPath, ignore: ["**/node_modules/**"] }); for (const file of serverFiles) { const filePath = path2.join(projectPath, file); const content = await fs2.readFile(filePath, "utf-8"); if (content.includes("'use server'") || content.includes('"use server"')) { const hasProtection = content.includes("protectServerAction") || content.includes("validateServerInput"); if (!hasProtection) { issues.push({ type: "boundary-violation", severity: "warning", message: "Server action appears to have no security protection", file, line: 1, column: 1, suggestion: "Consider using protectServerAction() from @cosmstack/blackshield/server" }); } } } } catch (error) { console.warn("Could not analyze server security:", error); } return issues; } // src/cli/index.ts var __filename = fileURLToPath(import.meta.url); var __dirname = dirname(__filename); var packageJsonPath = join(__dirname, "../../package.json"); var packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")); program.name("blackshield").description("Security analysis tool for React/Next.js applications").version(packageJson.version); program.command("check").description("Analyze project for security issues").option("-p, --path <path>", "Project path to analyze", process.cwd()).option("-f, --format <format>", "Output format (json|table)", "table").option("--fix", "Automatically fix issues where possible").option("--config <config>", "Path to blackshield config file").action(async (options) => { try { const results = await analyzeProject(options.path, { format: options.format, fix: options.fix, configPath: options.config }); if (options.format === "json") { console.log(JSON.stringify(results, null, 2)); } else { printTableResults(results); } const hasErrors = results.issues.some((issue) => issue.severity === "error"); process.exit(hasErrors ? 1 : 0); } catch (error) { console.error("Error analyzing project:", error); process.exit(1); } }); program.command("scan-env").description("Scan environment files for sensitive variables exposed to client").option("-p, --path <path>", "Project path to scan", process.cwd()).action(async (options) => { try { await scanEnvFiles(options.path); } catch (error) { console.error("Error scanning environment files:", error); process.exit(1); } }); program.command("init").description("Initialize blackshield configuration").option("-f, --force", "Overwrite existing configuration").action(async (options) => { const { initializeConfig } = await import("./init-IUTQGCZX.js"); await initializeConfig(options.force); }); function printTableResults(results) { console.log("\n\u{1F6E1}\uFE0F Blackshield Security Analysis\n"); if (results.issues.length === 0) { console.log("\u2705 No security issues found!"); return; } const errors = results.issues.filter((i) => i.severity === "error"); const warnings = results.issues.filter((i) => i.severity === "warning"); if (errors.length > 0) { console.log(`\u274C ${errors.length} error(s) found:`); for (const issue of errors) { console.log(` ${issue.file}:${issue.line}:${issue.column} - ${issue.message}`); if (issue.suggestion) { console.log(` \u{1F4A1} ${issue.suggestion}`); } } console.log(); } if (warnings.length > 0) { console.log(`\u26A0\uFE0F ${warnings.length} warning(s) found:`); for (const issue of warnings) { console.log(` ${issue.file}:${issue.line}:${issue.column} - ${issue.message}`); if (issue.suggestion) { console.log(` \u{1F4A1} ${issue.suggestion}`); } } console.log(); } console.log(`\u{1F4CA} Summary: ${errors.length} errors, ${warnings.length} warnings`); } program.parse(); export { program };