UNPKG

detect-secrets-js

Version:

A JavaScript implementation of Yelp's detect-secrets tool - no Python required

468 lines (467 loc) 19.7 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.enrichSecretsWithBlameInfo = exports.getGitBlameInfo = exports.scanGitHistory = exports.scanRemoteRepository = exports.runGitleaksScan = void 0; const child_process_1 = require("child_process"); const fs = __importStar(require("fs")); const path = __importStar(require("path")); const os = __importStar(require("os")); const path_1 = require("path"); const util_1 = require("util"); const child_process_2 = require("child_process"); /** * Scan a local directory for secrets using Gitleaks * @param directory The directory to scan * @param scanGitHistory Whether to scan git history (commits) * @returns Scan results */ async function runGitleaksScan(directory, scanOptions) { const args = ["detect", "--source", directory]; // Only add --no-git if we're not scanning git history if (!scanOptions?.scanGitHistory) { args.push("--no-git"); } // Add report format options args.push("--report-format", "json", "--report-path", "-"); return new Promise((resolve, reject) => { const gitleaks = (0, child_process_1.spawn)("gitleaks", args); let output = ""; let errorOutput = ""; gitleaks.stdout.on("data", (data) => { output += data.toString(); }); gitleaks.stderr.on("data", (data) => { errorOutput += data.toString(); }); gitleaks.on("close", async (code) => { // Gitleaks returns exit code 1 when it finds secrets, which is not an error // Exit code 0 means no secrets found, exit code 1 means secrets found if (code !== 0 && code !== 1) { reject(new Error(`Gitleaks failed with code ${code}: ${errorOutput}`)); return; } try { // If there's output, try to parse it if (output.trim()) { const results = JSON.parse(output); const secrets = []; for (const result of results) { // Skip node_modules files unless explicitly included if ((!scanOptions?.includeNodeModules && (result.File.includes("/node_modules/") || result.File.includes("\\node_modules\\"))) || result.File.includes("/.next/") || result.File.includes("\\.next\\") || result.File.endsWith("package-lock.json") || result.File.endsWith("yarn.lock") || result.File.endsWith("pnpm-lock.yaml")) { if (scanOptions?.verbose) { if (result.File.includes("node_modules")) { console.log(`Skipping node_modules result: ${result.File}`); } else if (result.File.includes(".next")) { console.log(`Skipping .next build directory result: ${result.File}`); } else if (result.File.endsWith("package-lock.json") || result.File.endsWith("yarn.lock") || result.File.endsWith("pnpm-lock.yaml")) { console.log(`Skipping dependency lock file: ${result.File}`); } } continue; } // Get git blame information for this line const blameInfo = await getGitBlameInfo(path.join(directory, result.File), result.StartLine, scanOptions); secrets.push({ file: result.File, line: result.StartLine, types: [result.RuleID], is_false_positive: false, hashed_secret: "", author: blameInfo.author, email: blameInfo.email, date: blameInfo.date, commit: blameInfo.commit, message: blameInfo.message, }); } resolve({ secrets: secrets, missed_secrets: [], }); } else { // No output means no secrets found resolve({ secrets: [], missed_secrets: [], }); } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); reject(new Error(`Failed to parse Gitleaks output: ${errorMessage}`)); } }); gitleaks.on("error", (error) => { if (error.message.includes("ENOENT")) { reject(new Error("Gitleaks is not installed. Please install Gitleaks first: https://github.com/zricethezav/gitleaks#installation")); } else { reject(error); } }); }); } exports.runGitleaksScan = runGitleaksScan; /** * Clone and scan a remote repository for secrets * @param repoUrl URL of the Git repository to scan * @param branch Optional branch to check out * @returns Scan results */ async function scanRemoteRepository(repoUrl, scanOptions) { // Create temporary directory const tmpDir = path.join(os.tmpdir(), `detect-secrets-js-${Date.now()}`); // Clone the repository await new Promise((resolve, reject) => { const git = (0, child_process_1.spawn)("git", ["clone", repoUrl, tmpDir]); git.on("close", (code) => { if (code !== 0) { reject(new Error(`Failed to clone repository: ${repoUrl}`)); return; } resolve(); }); git.on("error", (error) => { reject(error); }); }); return new Promise((resolve, reject) => { // Scan the cloned repository try { // Pass scanOptions to runGitleaksScan, and set scanGitHistory to true const updatedOptions = { ...scanOptions, scanGitHistory: true, }; runGitleaksScan(tmpDir, updatedOptions) .then(resolve) .catch(reject) .finally(() => { // Clean up temporary directory using cross-platform Node.js fs.rmSync try { if (fs.existsSync(tmpDir)) { fs.rmSync(tmpDir, { recursive: true, force: true }); } } catch (error) { console.warn(`Failed to clean up temporary directory: ${error instanceof Error ? error.message : String(error)}`); } }); } catch (error) { reject(error); } }); } exports.scanRemoteRepository = scanRemoteRepository; /** * Scan Git history (commits) for secrets in a local repository * @param directory The directory containing the Git repository * @param fromCommit Optional starting commit hash * @param toCommit Optional ending commit hash * @returns Scan results */ async function scanGitHistory(directory, fromCommit, toCommit, scanOptions) { const args = ["detect", "--source", directory]; // Always include git history when scanning git history // Add commit range if specified if (fromCommit) { if (toCommit) { // Scan a range of commits args.push("--log-opts", `--all ${fromCommit}..${toCommit}`); } else { // Scan from a specific commit to HEAD args.push("--log-opts", `--all ${fromCommit}..HEAD`); } } else if (toCommit) { // If only toCommit is specified, scan up to that commit args.push("--log-opts", `--all ..${toCommit}`); } else { // Scan all history args.push("--log-opts", "--all"); } // Add report format options args.push("--report-format", "json", "--report-path", "-"); return runGitleaksWithArgs(args, scanOptions); } exports.scanGitHistory = scanGitHistory; /** * Helper function to run Gitleaks with specified arguments * @param args Command line arguments for Gitleaks * @returns Scan results */ function runGitleaksWithArgs(args, scanOptions) { return new Promise((resolve, reject) => { const gitleaks = (0, child_process_1.spawn)("gitleaks", args); let output = ""; let errorOutput = ""; gitleaks.stdout.on("data", (data) => { output += data.toString(); }); gitleaks.stderr.on("data", (data) => { errorOutput += data.toString(); }); gitleaks.on("close", (code) => { // Gitleaks returns exit code 1 when it finds secrets, which is expected behavior // Exit code 0 means no secrets found // Any other code is a real error if (code !== 0 && code !== 1) { reject(new Error(`Gitleaks failed with code ${code}: ${errorOutput}`)); return; } try { // If there's no output, return an empty result if (!output.trim()) { resolve({ secrets: [], missed_secrets: [], }); return; } // Parse the JSON output const results = JSON.parse(output); const secrets = []; for (const result of results) { // Skip node_modules files unless explicitly included if ((!scanOptions?.includeNodeModules && (result.File.includes("/node_modules/") || result.File.includes("\\node_modules\\"))) || result.File.includes("/.next/") || result.File.includes("\\.next\\") || result.File.endsWith("package-lock.json") || result.File.endsWith("yarn.lock") || result.File.endsWith("pnpm-lock.yaml")) { if (scanOptions?.verbose) { if (result.File.includes("node_modules")) { console.log(`Skipping node_modules result: ${result.File}`); } else if (result.File.includes(".next")) { console.log(`Skipping .next build directory result: ${result.File}`); } else if (result.File.endsWith("package-lock.json") || result.File.endsWith("yarn.lock") || result.File.endsWith("pnpm-lock.yaml")) { console.log(`Skipping dependency lock file: ${result.File}`); } } continue; } // Convert Gitleaks result to our Secret format secrets.push({ file: result.File, line: result.StartLine, types: [result.RuleID], is_false_positive: false, hashed_secret: "", author: result.Author || "", email: result.Email || "", date: result.Date || "", commit: result.Commit || "", message: result.Message || "", }); } resolve({ secrets, missed_secrets: [], }); } catch (error) { reject(new Error(`Failed to parse Gitleaks output: ${error instanceof Error ? error.message : String(error)}`)); } }); gitleaks.on("error", (error) => { if (error.message.includes("ENOENT")) { reject(new Error("Gitleaks is not installed. Please install Gitleaks first: https://github.com/zricethezav/gitleaks#installation")); } else { reject(error); } }); }); } /** * Get git blame information for a specific file and line * @param filePath Path to the file * @param lineNumber Line number to blame * @param scanOptions Optional scan options * @returns Object with author, email, date, and commit message */ async function getGitBlameInfo(filePath, lineNumber, scanOptions) { try { // Skip git blame for node_modules files if (filePath.includes("/node_modules/") || filePath.includes("\\node_modules\\")) { return { author: "NodeModule", email: "npm-package", date: "Unknown", commit: "N/A", message: "Third-party module dependency", }; } // Skip git blame for Next.js build files if (filePath.includes("/.next/") || filePath.includes("\\.next\\")) { return { author: "NextJS", email: "build-output", date: "Unknown", commit: "N/A", message: "Next.js build output", }; } // Skip git blame for lock files if (filePath.endsWith("package-lock.json") || filePath.endsWith("yarn.lock") || filePath.endsWith("pnpm-lock.yaml")) { return { author: "PackageManager", email: "auto-generated", date: "Unknown", commit: "N/A", message: "Auto-generated dependency lock file", }; } // Determine which git repository path to use let repoPath = ""; if (scanOptions?.gitRepoPath) { // Use the specified git repository path repoPath = scanOptions.gitRepoPath; } else { // Try to find the repository root for the file try { // Get the directory of the file const fileDir = (0, path_1.dirname)(filePath); // Find the git repository root for this file const { stdout: gitRoot } = await (0, util_1.promisify)(child_process_2.exec)("git rev-parse --show-toplevel", { cwd: fileDir, }); repoPath = gitRoot.trim(); } catch (error) { // If we can't determine the repository root, use the current directory repoPath = process.cwd(); } } // Get the relative path of the file to the repository root let relativeFilePath = filePath; try { // Only calculate relative path if repoPath is valid and different from current directory if (repoPath && repoPath !== process.cwd()) { relativeFilePath = (0, path_1.relative)(repoPath, filePath); } } catch (error) { // If we can't determine the relative path, use the original file path relativeFilePath = filePath; } // Run git blame to get information about who last modified this line const { stdout: blameOutput } = await (0, util_1.promisify)(child_process_2.exec)(`git blame -L ${lineNumber},${lineNumber} --porcelain "${relativeFilePath}"`, { cwd: repoPath }); // Parse the output to extract author, email, date, etc. const commitHash = blameOutput.split("\n")[0].split(" ")[0]; const authorLine = blameOutput .split("\n") .find((line) => line.startsWith("author ")); const emailLine = blameOutput .split("\n") .find((line) => line.startsWith("author-mail ")); const dateLine = blameOutput .split("\n") .find((line) => line.startsWith("author-time ")); const author = authorLine ? authorLine.replace("author ", "") : "Unknown"; const email = emailLine ? emailLine.replace("author-mail ", "").replace(/[<>]/g, "") : "Unknown"; const timestamp = dateLine ? parseInt(dateLine.replace("author-time ", ""), 10) * 1000 : 0; const date = timestamp ? new Date(timestamp).toISOString() : "Unknown"; // Get the commit message const { stdout: messageOutput } = await (0, util_1.promisify)(child_process_2.exec)(`git show -s --format=%B ${commitHash}`, { cwd: repoPath }); const message = messageOutput.trim(); return { author, email, date, commit: commitHash, message, }; } catch (error) { // Return default values if git blame fails return { author: "Unknown", email: "Unknown", date: "Unknown", commit: "Unknown", message: "Unknown", }; } } exports.getGitBlameInfo = getGitBlameInfo; /** * Enrich secrets with git blame information * @param secrets Array of secrets to enrich * @param scanOptions Optional scan options * @returns Enriched secrets with author information */ async function enrichSecretsWithBlameInfo(secrets, scanOptions) { const enrichedSecrets = []; for (const secret of secrets) { try { const blameInfo = await getGitBlameInfo(secret.file, secret.line, scanOptions); // Add blame info to the secret enrichedSecrets.push({ ...secret, author: blameInfo.author, email: blameInfo.email, date: blameInfo.date, commit: blameInfo.commit, message: blameInfo.message, }); } catch (error) { // If blame fails, keep the original secret enrichedSecrets.push(secret); } } return enrichedSecrets; } exports.enrichSecretsWithBlameInfo = enrichSecretsWithBlameInfo;