gitleaks-secret-scanner
Version:
A powerful, intelligent wrapper for the Gitleaks engine that provides accurate and safe secret scanning for local pre-commit hooks and CI/CD pipelines.
365 lines (340 loc) • 11.7 kB
JavaScript
const { spawn, execSync } = require("child_process");
const fs = require("fs");
const path = require("path");
const os = require("os");
const { generateHtmlReport } = require("./report-generator");
const runScan = async (binaryPath, config) => {
if (!fs.existsSync(binaryPath)) {
throw new Error("Gitleaks binary missing");
}
if (config.isHelpRequest || config.additionalArgs.includes("--help")) {
return runPassThroughCommand(binaryPath, [
"detect",
...config.additionalArgs,
]).then(() => false);
}
let finalLeaks = [];
switch (config.diffMode) {
case "ci":
finalLeaks = await runCiScan(binaryPath, config);
break;
case "all":
finalLeaks = await runAllUncommittedScan(binaryPath, config);
break;
case "history":
finalLeaks = await runHistoryScan(binaryPath, config);
break;
case "staged":
default:
finalLeaks = await runStagedScan(binaryPath, config);
break;
}
printConsoleSummary(finalLeaks, config);
if (config.htmlReport || config.reportFormat) {
generateReportFiles(finalLeaks, config);
}
return finalLeaks.length > 0;
};
const runPassThroughCommand = (binaryPath, args) => {
return new Promise((resolve) => {
const gitleaks = spawn(binaryPath, args, { stdio: "inherit" });
gitleaks.on("error", (err) => {
console.error(`Failed to start Gitleaks: ${err.message}`);
resolve(1);
});
gitleaks.on("close", (code) => {
resolve(code);
});
});
};
async function runCiScan(binaryPath, config) {
const baseSha = process.env.BASE_SHA;
const headSha = process.env.HEAD_SHA;
// A missing HEAD_SHA is always a fatal error.
if (!headSha) {
throw new Error(
"For --diff-mode ci, the HEAD_SHA environment variable must be set, but it was not found."
);
}
// If BASE_SHA is missing or identical to HEAD_SHA, it means there's no diff.
if (!baseSha || baseSha === headSha) {
console.log(
"✅ BASE_SHA not provided or is identical to HEAD_SHA. Concluding there are no changes to scan."
);
return [];
}
console.log(
`Scanning final state of changed files between ${baseSha.slice(
0,
7
)} and ${headSha.slice(0, 7)}...`
);
//Get a list of all files that have changed in the pull request.
const changedFilesOutput = execSync(
`git diff --name-only ${baseSha}..${headSha}`
)
.toString()
.trim();
if (!changedFilesOutput) {
console.log("✅ No files changed in this PR to scan.");
return [];
}
const changedFiles = changedFilesOutput.split("\n");
console.log(`Found ${changedFiles.length} changed file(s) to scan...`);
//Run Gitleaks on ONLY the final state of those changed files.
const args = ["detect", "--no-git"];
const filesToScan = [];
for (const file of changedFiles) {
if (fs.existsSync(file)) {
args.push("--source", file);
filesToScan.push(file);
}
}
if (filesToScan.length === 0) {
console.log("✅ All changes were deletions, no files to scan.");
return [];
}
const leaks = await executeGitleaks(binaryPath, args, config);
if (leaks.length === 0) {
return [];
}
//Enrich the findings with author data using `git blame`.
console.log(`Enriching ${leaks.length} finding(s) with author data...`);
const enrichedLeaks = [];
for (const leak of leaks) {
try {
const blameOutput = execSync(
`git blame -L ${leak.StartLine},${leak.StartLine} --porcelain ${headSha} -- "${leak.File}"`
).toString();
const commitMatch = blameOutput.match(/^([a-f0-9]{40})/m);
const authorMatch = blameOutput.match(/^author (.+)/m);
const mailMatch = blameOutput.match(/^author-mail <(.+)>/m);
const timeMatch = blameOutput.match(/^author-time ([0-9]+)/m);
if (commitMatch) leak.Commit = commitMatch[1];
if (authorMatch) leak.Author = authorMatch[1];
if (mailMatch) leak.Email = mailMatch[1];
if (timeMatch)
leak.Date = new Date(parseInt(timeMatch[1], 10) * 1000).toISOString();
enrichedLeaks.push(leak);
} catch (e) {
console.warn(
`Could not run git blame on file ${leak.File}, some report data may be missing.`
);
enrichedLeaks.push(leak);
}
}
return enrichedLeaks;
}
async function runAllUncommittedScan(binaryPath, config) {
console.log(
"Scanning all uncommitted changes (staged, unstaged, and untracked)..."
);
// Part 1: Get rich report for staged changes. This is unchanged.
const stagedLeaks = await runStagedScan(binaryPath, config, true);
// Part 2 & 3: Scan both unstaged AND untracked files.
let otherLeaks = [];
// Get all files that are modified but NOT staged.
const unstagedFiles = execSync("git diff --name-only")
.toString()
.trim()
.split("\n")
.filter(Boolean);
// Get all brand new files that are not staged and not in .gitignore.
const untrackedFiles = execSync("git ls-files --others --exclude-standard")
.toString()
.trim()
.split("\n")
.filter(Boolean);
const filesToScan = [...new Set([...unstagedFiles, ...untrackedFiles])];
if (filesToScan.length > 0) {
let tempDir;
try {
console.log(
`Scanning ${filesToScan.length} unstaged/untracked file(s)...`
);
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "gitleaks-uncommitted-"));
for (const file of filesToScan) {
const sourcePath = path.join(process.cwd(), file);
// We check for existence because a file could have been deleted (unstaged deletion).
if (fs.existsSync(sourcePath) && fs.lstatSync(sourcePath).isFile()) {
const tempFilePath = path.join(tempDir, file);
fs.mkdirSync(path.dirname(tempFilePath), { recursive: true });
fs.copyFileSync(sourcePath, tempFilePath);
}
}
// Only run the scan if the temp directory is not empty
if (fs.readdirSync(tempDir).length > 0) {
const args = ["detect", "--source", tempDir, "--no-git"];
const rawLeaks = await executeGitleaks(binaryPath, args, config);
// Clean up the paths to be relative to the project root.
otherLeaks = rawLeaks.map((leak) => {
const originalPath = leak.File;
const relativePath = path
.relative(tempDir, originalPath)
.replace(/\\/g, "/");
leak.File = relativePath;
leak.Fingerprint = leak.Fingerprint.replace(
originalPath,
relativePath
);
return leak;
});
}
} finally {
if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true });
}
}
// Combine and de-duplicate the results.
const allLeaks = [...stagedLeaks];
const stagedFingerprints = new Set(stagedLeaks.map((l) => l.Fingerprint));
for (const leak of otherLeaks) {
if (!stagedFingerprints.has(leak.Fingerprint)) {
allLeaks.push(leak);
}
}
return allLeaks;
}
async function runStagedScan(binaryPath, config, silent = false) {
const stagedFiles = execSync("git diff --cached --name-only")
.toString()
.trim();
if (!stagedFiles) {
if (!silent) console.log("✅ No staged changes to scan.");
return [];
}
try {
if (!silent) console.log("Running Scan on staged changes...");
const treeHash = execSync("git write-tree").toString().trim();
const commitHash = execSync(
`echo "gitleaks-secret-scanner virtual commit" | git commit-tree ${treeHash} -p HEAD`
)
.toString()
.trim();
const args = [
"detect",
"--source",
".",
"--log-opts",
`HEAD..${commitHash}`,
];
return await executeGitleaks(binaryPath, args, config);
} catch (err) {
throw new Error(`Staged scan failed: ${err.message}`);
}
}
async function runHistoryScan(binaryPath, config) {
const args = ["detect", "--source", "."];
if (config.scanDepth && config.scanDepth > 0) {
// If a depth is provided, use it to limit the scan.
console.log(
`Scanning the last ${config.scanDepth} commit(s) of repository history...`
);
args.push("--log-opts", `--max-count=${config.scanDepth}`);
} else {
// Otherwise, scan the entire history and report the total count.
const commitCount = parseInt(
execSync(`git rev-list --count HEAD`).toString().trim(),
10
);
console.log(
`Scanning ${commitCount} total commits in repository history...`
);
}
return executeGitleaks(binaryPath, args, config);
}
function executeGitleaks(binaryPath, args, config) {
return new Promise((resolve, reject) => {
const tempReportPath = path.join(
os.tmpdir(),
`gitleaks-report-${Date.now()}.json`
);
const finalArgs = [
...args,
"--report-format",
"json",
"--report-path",
tempReportPath,
];
if (config.configPath) finalArgs.push("--config", config.configPath);
if (config.additionalArgs) finalArgs.push(...config.additionalArgs);
const gitleaks = spawn(binaryPath, finalArgs);
let stderr = "";
gitleaks.stderr.on("data", (data) => {
stderr += data;
});
gitleaks.on("error", (err) =>
reject(new Error(`Gitleaks execution failed: ${err.message}`))
);
gitleaks.on("close", (code) => {
try {
if (code === 0 || code === 1) {
const output = fs.existsSync(tempReportPath)
? fs.readFileSync(tempReportPath, "utf8")
: "[]";
resolve(JSON.parse(output));
} else {
reject(
new Error(
`Gitleaks exited with unexpected code ${code}:\n${stderr}`
)
);
}
} catch (err) {
reject(err);
} finally {
if (fs.existsSync(tempReportPath)) fs.unlinkSync(tempReportPath);
}
});
});
}
function printConsoleSummary(leaks, config) {
if (!config.additionalArgs.includes("--no-banner")) {
console.log("\n ○\n │╲\n │ ○\n ○ ░\n ░ gitleaks\n");
}
if (leaks.length > 0) {
for (const leak of leaks) {
const parts = [
`Finding: ${leak.Description}`,
`Secret: REDACTED`,
`RuleID: ${leak.RuleID}`,
`File: ${leak.File}`,
`Line: ${leak.StartLine}`,
];
if (leak.Commit) parts.push(`Commit: ${leak.Commit}`);
if (leak.Author) parts.push(`Author: ${leak.Author}`);
if (leak.Date) parts.push(`Date: ${leak.Date}`);
console.log(parts.join("\n"));
console.log("----------------------------------------------------");
}
console.log(`\nWRN leaks found: ${leaks.length}`);
} else {
console.log("INF no leaks found");
}
}
function generateReportFiles(leaks, config) {
function getDefaultReportPath(format) {
const ext =
{ json: "json", csv: "csv", sarif: "sarif", junit: "xml" }[format] ||
format;
return `gitleaks-report.${ext}`;
}
if (config.htmlReport) {
console.log(`\nGenerating HTML report...`);
const reportPath =
config.htmlReport === true ? "gitleaks-report.html" : config.htmlReport;
generateHtmlReport(leaks, reportPath);
console.log(`✅ HTML report generated: ${reportPath}`);
} else if (config.reportFormat) {
console.log(`\nGenerating ${config.reportFormat.toUpperCase()} report...`);
const reportPath =
config.reportPath || getDefaultReportPath(config.reportFormat);
fs.writeFileSync(reportPath, JSON.stringify(leaks, null, 2));
console.log(
`✅ ${config.reportFormat.toUpperCase()} report generated: ${reportPath}`
);
}
}
module.exports = {
runScan,
runPassThroughCommand,
};