@cosmstack/blackshield
Version:
A developer-first security toolkit for React/Next.js applications
409 lines (404 loc) • 15.1 kB
JavaScript
// 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
};