trufflehog-js
Version:
TypeScript wrapper for TruffleHog secret scanner
341 lines (290 loc) • 8.37 kB
text/typescript
/**
* 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);
}
}