UNPKG

trufflehog-js

Version:

TypeScript wrapper for TruffleHog secret scanner

341 lines (290 loc) 8.37 kB
/** * Copyright (c) 2025 maloma7. All rights reserved. * SPDX-License-Identifier: MIT */ import type { ScanResult, TruffleHogScanResult, ScanOptions, TruffleHogCliArgs, Logger, } from "./types.ts"; import { BinaryNotFoundError, BinaryExecutionError, ScanTimeoutError, SecretsFoundError, } from "./errors.ts"; import { defaultLogger } from "./logger.ts"; import { getPlatformInfo } from "./platform.ts"; import { BinaryCache } from "./cache.ts"; import { downloadBinary } from "./download.ts"; export class TruffleHogScanner { private readonly logger: Logger; private readonly binaryPath?: string | undefined; private readonly timeout: number; constructor( options: { binaryPath?: string | undefined; timeout?: number; logger?: Logger; } = {}, ) { this.logger = options.logger ?? defaultLogger; this.binaryPath = options.binaryPath ?? undefined; this.timeout = options.timeout ?? 120000; // 2 minutes default } async scanStagedFiles(options: ScanOptions = {}): Promise<ScanResult[]> { // Ensure binary exists first before checking git operations await this.ensureBinary(); const { GitRepository } = await import("./git.ts"); const git = new GitRepository(".", this.logger); await git.ensureHasStagedFiles(); const stagedFiles = await git.getStagedFilePaths(); if (stagedFiles.length === 0) { this.logger.info("No staged files to scan"); return []; } return await this.scanFiles(stagedFiles, { ...options, staged: true, }); } async scanFiles( files: string[], options: ScanOptions = {}, ): Promise<ScanResult[]> { if (files.length === 0) { return []; } const binaryPath = await this.ensureBinary(); const args = await this.buildCliArgs(files, options); this.logger.debug(`Scanning ${files.length} files with TruffleHog`); this.logger.debug(`Command: ${binaryPath} ${args.flags.join(" ")}`); const result = await this.executeTruffleHog(binaryPath, args); return this.parseScanResults(result); } async scanText( content: string, _filename: string = "stdin", ): Promise<ScanResult[]> { const tempFile = `/tmp/trufflehog-scan-${Date.now()}.tmp`; try { await Bun.write(tempFile, content); return await this.scanFiles([tempFile], { verify: false }); } finally { try { await Bun.$`rm -f ${tempFile}`; } catch { // Ignore cleanup errors } } } private async ensureBinary(): Promise<string> { // Check environment override first const envBinaryPath = Bun.env.TRUFFLEHOG_BINARY_PATH; if (envBinaryPath) { const exists = await Bun.file(envBinaryPath).exists(); if (!exists) { throw new BinaryNotFoundError(envBinaryPath); } return envBinaryPath; } // Use provided binary path if (this.binaryPath) { const exists = await Bun.file(this.binaryPath).exists(); if (!exists) { throw new BinaryNotFoundError(this.binaryPath); } return this.binaryPath; } // Try to get from cache or download const platformInfo = getPlatformInfo(); const cache = new BinaryCache(undefined, this.logger); const cachedPath = await cache.getBinaryPath(platformInfo); if (cachedPath) { return cachedPath; } // Download binary this.logger.info("Downloading TruffleHog binary..."); const targetPath = cache.getCachedBinaryPath(platformInfo); await downloadBinary(platformInfo, targetPath, this.logger); await cache.setCachedBinary(platformInfo, targetPath); return targetPath; } private async buildCliArgs( files: string[], options: ScanOptions, ): Promise<TruffleHogCliArgs> { const flags: string[] = []; // Core command structure: trufflehog scan filesystem . flags.push("scan"); flags.push("filesystem"); flags.push("."); // JSON output for parsing flags.push("--json"); // Disable auto-updates flags.push("--no-update"); // Include only specified files if (files.length > 0) { const includePattern = files.join(","); flags.push("--include-paths", includePattern); } // Verification if (options.verify === false) { flags.push("--verify=false"); } else if (options.verify === true) { flags.push("--verify=true"); } // Detector filtering if (options.includeDetectors && options.includeDetectors.length > 0) { flags.push("--include-detectors", options.includeDetectors.join(",")); } if (options.excludeDetectors && options.excludeDetectors.length > 0) { flags.push("--exclude-detectors", options.excludeDetectors.join(",")); } // Path filtering if (options.excludePaths && options.excludePaths.length > 0) { flags.push("--exclude-paths", options.excludePaths.join(",")); } return { command: "scan", subcommand: "filesystem", path: ".", flags, }; } private async executeTruffleHog( binaryPath: string, args: TruffleHogCliArgs, ): Promise<string> { try { const proc = Bun.spawn([binaryPath, ...args.flags], { stdout: "pipe", stderr: "pipe", env: { ...Bun.env, // Disable TruffleHog analytics TRUFFLEHOG_ANALYTICS: "false", }, }); let isTimedOut = false; const timeoutId = setTimeout(() => { isTimedOut = true; proc.kill(); }, this.timeout); const result = await proc.exited; clearTimeout(timeoutId); // Check if the process was killed due to timeout if (isTimedOut) { throw new ScanTimeoutError( this.timeout, new Error("Process timed out"), ); } const stdout = await new Response(proc.stdout).text(); const stderr = await new Response(proc.stderr).text(); this.logger.debug(`TruffleHog exit code: ${result}`); if (result === 0) { // No secrets found return stdout; } else if (result === 1) { // Secrets found return stdout; } else { // Error occurred throw new BinaryExecutionError( `${binaryPath} ${args.flags.join(" ")}`, stderr || "Unknown error", ); } } catch (error) { if ( error instanceof BinaryExecutionError || error instanceof ScanTimeoutError ) { throw error; } // Check if it's a timeout if ( (error as { name?: string }).name === "TimeoutError" || (error as Error).message.includes("timeout") ) { throw new ScanTimeoutError(this.timeout, error as Error); } throw new BinaryExecutionError( `${binaryPath} ${args.flags.join(" ")}`, (error as Error).message, error as Error, ); } } private parseScanResults(output: string): ScanResult[] { if (!output.trim()) { return []; } const results: ScanResult[] = []; const lines = output.split("\n").filter((line) => line.trim() !== ""); for (const line of lines) { try { const rawResult = JSON.parse(line) as TruffleHogScanResult; const scanResult = this.convertToScanResult(rawResult); if (scanResult) { results.push(scanResult); } } catch (_error) { this.logger.debug( `Failed to parse JSON line: ${line.substring(0, 100)}...`, ); } } return results; } private convertToScanResult(raw: TruffleHogScanResult): ScanResult | null { try { const file = raw.SourceMetadata?.Data?.Filesystem?.file || "unknown"; const detector = raw.DetectorName || "unknown"; const verified = raw.Verified || false; const secret = raw.Redacted || `${raw.Raw?.substring(0, 20)}...` || "redacted"; return { detector, file, line: 0, // TruffleHog doesn't always provide line numbers in JSON output verified, secret, raw: JSON.stringify(raw), }; } catch (error) { this.logger.debug( `Failed to convert scan result: ${(error as Error).message}`, ); return null; } } } export async function scanStagedFiles( options: ScanOptions = {}, ): Promise<ScanResult[]> { const scanner = new TruffleHogScanner(); return await scanner.scanStagedFiles(options); } export async function scanFiles( files: string[], options: ScanOptions = {}, ): Promise<ScanResult[]> { const scanner = new TruffleHogScanner(); return await scanner.scanFiles(files, options); } export async function scanText( content: string, filename?: string, ): Promise<ScanResult[]> { const scanner = new TruffleHogScanner(); return await scanner.scanText(content, filename); } export function throwIfSecretsFound(results: ScanResult[]): void { if (results.length > 0) { throw new SecretsFoundError(results.length); } }