@slathar-dev/mcp-sensitive-read
Version:
MCP server for secure file reading within project boundaries
446 lines • 18.3 kB
JavaScript
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import { createWriteStream } from "node:fs";
import { pipeline } from "node:stream/promises";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// File-based logger for debugging
const logFilePath = path.join(os.tmpdir(), "mcp-sensitive-read-debug.log");
const debugLog = async (message) => {
try {
const timestamp = new Date().toISOString();
await fs.appendFile(logFilePath, `[${timestamp}] GitLeaks: ${message}\n`, "utf8");
}
catch (error) {
// Ignore logging errors
}
};
export class GitLeaksManager {
binaryPath;
config;
cache;
constructor(config = {}) {
this.config = {
binaryPath: config.binaryPath || "",
maxFileSize: config.maxFileSize || 10 * 1024 * 1024, // 10MB default
enableCache: config.enableCache ?? true,
cacheTimeout: config.cacheTimeout || 5 * 60 * 1000, // 5 minutes default
};
this.cache = new Map();
this.binaryPath = "";
}
async initialize() {
await debugLog("Starting Gitleaks initialization");
const binaryName = this.getBinaryName();
const cacheDir = this.getCacheDir();
await debugLog(`Binary name: ${binaryName}, Cache dir: ${cacheDir}`);
// Check locations in priority order (only trusted locations):
// 1. Persistent cache directory (~/.claude/.mcp-sensitive-read/bin/)
this.binaryPath = path.join(cacheDir, binaryName);
try {
await this.verifyBinary();
await debugLog(`Gitleaks binary found and verified in cache: ${this.binaryPath}`);
return;
}
catch (error) {
await debugLog(`Gitleaks binary not found in cache: ${error}`);
}
// 2. Local dist/bin directory (fallback)
const binDir = path.join(__dirname, "..", "dist", "bin");
await fs.mkdir(binDir, { recursive: true });
this.binaryPath = path.join(binDir, binaryName);
try {
await this.verifyBinary();
await debugLog(`Gitleaks binary found locally: ${this.binaryPath}`);
}
catch (error) {
await debugLog(`Gitleaks binary not found locally, downloading to cache: ${error}`);
// Always download to cache directory
this.binaryPath = path.join(cacheDir, binaryName);
await this.downloadBinary();
await this.verifyBinary();
await debugLog(`Gitleaks binary downloaded and verified: ${this.binaryPath}`);
}
}
getCacheDir() {
const homeDir = os.homedir();
return path.join(homeDir, ".claude", ".mcp-sensitive-read", "bin");
}
getBinaryName() {
const platform = process.platform;
const arch = process.arch;
if (platform === "win32") {
return "gitleaks.exe";
}
else if (platform === "darwin") {
return arch === "arm64" ? "gitleaks-darwin-arm64" : "gitleaks-darwin-amd64";
}
else if (platform === "linux") {
return arch === "arm64" ? "gitleaks-linux-arm64" : "gitleaks-linux-amd64";
}
else {
throw new Error(`Unsupported platform: ${platform}-${arch}`);
}
}
async verifyBinary() {
try {
await fs.access(this.binaryPath, fs.constants.X_OK);
// Test the binary works by running version command
return new Promise((resolve, reject) => {
const child = spawn(this.binaryPath, ["version"], {
stdio: ["ignore", "pipe", "pipe"]
});
let stdout = "";
let stderr = "";
child.stdout?.on("data", (data) => {
stdout += data.toString();
});
child.stderr?.on("data", (data) => {
stderr += data.toString();
});
child.on("close", (code) => {
if (code === 0 && (stdout.length > 0 || stderr.includes("8."))) {
resolve();
}
else {
reject(new Error(`Gitleaks binary verification failed: ${stderr}`));
}
});
child.on("error", (error) => {
reject(new Error(`Failed to execute gitleaks binary: ${error.message}`));
});
});
}
catch (error) {
throw new Error(`Gitleaks binary not accessible: ${error}`);
}
}
async downloadBinary() {
const version = "v8.21.2"; // Latest version as of plan creation
const platform = process.platform;
const arch = process.arch;
let downloadUrl;
let extractedBinaryName;
if (platform === "win32") {
downloadUrl = `https://github.com/gitleaks/gitleaks/releases/download/${version}/gitleaks_${version.slice(1)}_windows_x64.zip`;
extractedBinaryName = "gitleaks.exe";
}
else if (platform === "darwin") {
const archSuffix = arch === "arm64" ? "arm64" : "x64";
downloadUrl = `https://github.com/gitleaks/gitleaks/releases/download/${version}/gitleaks_${version.slice(1)}_darwin_${archSuffix}.tar.gz`;
extractedBinaryName = "gitleaks";
}
else if (platform === "linux") {
const archSuffix = arch === "arm64" ? "arm64" : "x64";
downloadUrl = `https://github.com/gitleaks/gitleaks/releases/download/${version}/gitleaks_${version.slice(1)}_linux_${archSuffix}.tar.gz`;
extractedBinaryName = "gitleaks";
}
else {
throw new Error(`Unsupported platform for download: ${platform}-${arch}`);
}
// Ensure the cache directory exists
const cacheDir = path.dirname(this.binaryPath);
await fs.mkdir(cacheDir, { recursive: true });
const response = await fetch(downloadUrl);
if (!response.ok) {
throw new Error(`Failed to download Gitleaks: ${response.statusText}`);
}
const tempFile = path.join(cacheDir, `gitleaks-download-${Date.now()}`);
try {
// Download to temporary file
const fileStream = createWriteStream(tempFile);
if (!response.body) {
throw new Error("No response body");
}
await pipeline(response.body, fileStream);
// Extract the binary
if (platform === "win32") {
await this.extractZip(tempFile, cacheDir, extractedBinaryName);
}
else {
await this.extractTarGz(tempFile, cacheDir, extractedBinaryName);
}
// Make executable on Unix systems
if (platform !== "win32") {
await fs.chmod(this.binaryPath, 0o755);
}
// Gitleaks binary downloaded and installed successfully
}
finally {
// Clean up temp file
try {
await fs.unlink(tempFile);
}
catch {
// Ignore cleanup errors
}
}
}
async extractZip(zipPath, targetDir, binaryName) {
// For now, throw an error - we'd need a zip library for proper implementation
// In production, you'd use a library like 'yauzl' or 'node-stream-zip'
throw new Error("ZIP extraction not implemented - please install gitleaks manually on Windows");
}
async extractTarGz(tarPath, targetDir, binaryName) {
return new Promise((resolve, reject) => {
// First extract the entire tar.gz to get all files
const child = spawn("tar", ["-xzf", tarPath, "-C", targetDir], {
stdio: ["ignore", "pipe", "pipe"]
});
let stdout = "";
let stderr = "";
child.stdout?.on("data", (data) => {
stdout += data.toString();
});
child.stderr?.on("data", (data) => {
stderr += data.toString();
});
child.on("close", async (code) => {
if (code === 0) {
try {
// Find the extracted gitleaks binary in the target directory
const files = await fs.readdir(targetDir);
const gitleaksBinary = files.find(file => file === "gitleaks" || file === "gitleaks.exe");
if (gitleaksBinary) {
const sourcePath = path.join(targetDir, gitleaksBinary);
const targetPath = this.binaryPath;
// Move/rename the binary to the expected location
await fs.rename(sourcePath, targetPath);
resolve();
}
else {
reject(new Error(`Gitleaks binary not found in extracted files: ${files.join(", ")}`));
}
}
catch (error) {
reject(new Error(`Failed to locate extracted binary: ${error}`));
}
}
else {
reject(new Error(`tar extraction failed with code ${code}: ${stderr}`));
}
});
child.on("error", (error) => {
reject(new Error(`tar extraction failed: ${error.message}`));
});
});
}
generateContentHash(content) {
// Simple hash for caching purposes
let hash = 0;
for (let i = 0; i < content.length; i++) {
const char = content.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32-bit integer
}
return hash.toString(36);
}
getCacheKey(filePath) {
return `gitleaks:${filePath}`;
}
async getFromCache(filePath, content) {
if (!this.config.enableCache)
return null;
const cacheKey = this.getCacheKey(filePath);
const cached = this.cache.get(cacheKey);
if (!cached)
return null;
const now = Date.now();
if (now - cached.timestamp > this.config.cacheTimeout) {
this.cache.delete(cacheKey);
return null;
}
const contentHash = this.generateContentHash(content);
if (cached.hash !== contentHash) {
this.cache.delete(cacheKey);
return null;
}
return cached.content;
}
setCache(filePath, content, redactedContent) {
if (!this.config.enableCache)
return;
const cacheKey = this.getCacheKey(filePath);
const contentHash = this.generateContentHash(content);
this.cache.set(cacheKey, {
content: redactedContent,
timestamp: Date.now(),
hash: contentHash
});
}
async scanFile(filePath) {
try {
await fs.access(filePath, fs.constants.R_OK);
}
catch (error) {
return {
findings: [],
success: false,
error: `File not accessible: ${error}`
};
}
const stats = await fs.stat(filePath);
if (stats.size > this.config.maxFileSize) {
return {
findings: [],
success: false,
error: `File too large: ${stats.size} bytes (max: ${this.config.maxFileSize})`
};
}
// Create temporary file for JSON output
const tempReportPath = path.join(__dirname, "..", "temp", `gitleaks-report-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
const tempDir = path.dirname(tempReportPath);
await fs.mkdir(tempDir, { recursive: true });
return new Promise(async (resolve) => {
const args = [
"detect",
"--no-banner",
"--no-git",
"--source",
filePath,
"--report-format=json",
"--report-path",
tempReportPath
];
await debugLog(`Executing: ${this.binaryPath} ${args.join(' ')}`);
await debugLog(`Temp report path: ${tempReportPath}`);
const child = spawn(this.binaryPath, args, {
stdio: ["ignore", "pipe", "pipe"]
});
let stderr = "";
child.stderr?.on("data", (data) => {
stderr += data.toString();
});
child.on("close", async (code) => {
await debugLog(`Gitleaks scan completed with exit code: ${code}, stderr: ${stderr}`);
try {
if (code === 0) {
// No secrets found
await debugLog(`No secrets found (exit code 0)`);
resolve({
findings: [],
success: true
});
}
else if (code === 1) {
// Secrets found - read the JSON report
try {
const reportContent = await fs.readFile(tempReportPath, "utf8");
const findings = JSON.parse(reportContent);
resolve({
findings: Array.isArray(findings) ? findings : [],
success: true
});
}
catch (parseError) {
// Failed to read or parse report file: ${parseError}
resolve({
findings: [],
success: false,
error: `Failed to parse Gitleaks report: ${parseError}`
});
}
}
else {
resolve({
findings: [],
success: false,
error: `Gitleaks scan failed with code ${code}: ${stderr}`
});
}
}
finally {
// Clean up temp file
try {
await fs.unlink(tempReportPath);
}
catch {
// Ignore cleanup errors
}
}
});
child.on("error", async (error) => {
// Clean up temp file on error
try {
await fs.unlink(tempReportPath);
}
catch {
// Ignore cleanup errors
}
resolve({
findings: [],
success: false,
error: `Failed to execute Gitleaks: ${error.message}`
});
});
});
}
async scanAndRedactContent(filePath, content) {
await debugLog(`scanAndRedactContent called for ${filePath}`);
// Check cache first
const cached = await this.getFromCache(filePath, content);
if (cached !== null) {
await debugLog(`Cache hit for ${filePath}`);
return cached;
}
await debugLog(`No cache hit for ${filePath}, proceeding with scan`);
// SECURITY FIX: Scan the original file directly instead of creating temp copies
await debugLog(`Scanning original file directly: ${filePath}`);
const scanResult = await this.scanFile(filePath);
if (!scanResult.success) {
// If scanning fails, return original content (safer than throwing)
await debugLog(`Gitleaks scan failed for ${filePath}: ${scanResult.error}`);
return content;
}
if (scanResult.findings.length === 0) {
// No secrets found, cache and return original
await debugLog(`No secrets found in ${filePath}`);
this.setCache(filePath, content, content);
return content;
}
// Redact secrets
await debugLog(`Found ${scanResult.findings.length} secrets in ${filePath}, redacting...`);
const redactedContent = this.redactSecrets(content, scanResult.findings);
await debugLog(`Redaction complete for ${filePath}`);
this.setCache(filePath, content, redactedContent);
return redactedContent;
}
redactSecrets(content, findings) {
let redactedContent = content;
// Sort findings by position (descending) to avoid index issues when replacing
const sortedFindings = [...findings].sort((a, b) => {
if (a.StartLine !== b.StartLine) {
return b.StartLine - a.StartLine;
}
return b.StartColumn - a.StartColumn;
});
for (const finding of sortedFindings) {
const secret = finding.Secret || finding.Match;
if (secret) {
// Handle multi-line secrets by replacing the exact secret content
if (secret.includes('\n')) {
// For multi-line secrets (like private keys), replace the entire secret
redactedContent = redactedContent.replace(secret, "REDACTED");
}
else {
// For single-line secrets, replace just the secret part
redactedContent = redactedContent.replace(secret, "REDACTED");
}
}
}
return redactedContent;
}
async clearCache() {
this.cache.clear();
}
getCacheStats() {
return {
size: this.cache.size,
entries: Array.from(this.cache.keys())
};
}
}
//# sourceMappingURL=gitleaks-manager.js.map