UNPKG

@slathar-dev/mcp-sensitive-read

Version:

MCP server for secure file reading within project boundaries

446 lines 18.3 kB
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