detect-secrets-js
Version:
A JavaScript implementation of Yelp's detect-secrets tool - no Python required
468 lines (467 loc) • 19.7 kB
JavaScript
;
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;