import * as childProcess from "node:child_process"; import * as fs from "node:fs"; import * as path from "node:path"; import { type CompiledSecretPatternRule, findSecretMatches, sanitizeTextForLogging, } from "./secret-patterns.js"; /** * Scanner limits. * * MAX_SCANNED_BYTES — files larger than this are silently skipped. * Applies to working-tree reads (stat-based), git-index reads (git show :path), * and committed-file reads (git show commit:path). * * MAX_PUSH_COMMITS — maximum number of unpublished commits to inspect during * a git-push scan. Older commits beyond this cap are silently skipped. * git rev-list returns newest-first, so the cap trims the oldest commits. * * MAX_PUSH_FILES_PER_COMMIT — maximum number of changed files to inspect in * a single unpublished commit during a git-push scan. * * The tokenizer (`tokenizeShellCommand`) has no explicit limit; it processes * the entire command string. For extremely long commands this is acceptable * because the input is a single user-provided command line, not arbitrary * file content. */ const MAX_SCANNED_BYTES = 1024 * 1024; // 1 MiB const MAX_PUSH_COMMITS = 100; const MAX_PUSH_FILES_PER_COMMIT = 500; export interface ParsedGitCommand { repoCwd: string; subcommand: "add" | "commit" | "push"; args: string[]; } export interface GitSecretViolation { reason: string; matchedPatternNames: string[]; safeCommand: string; } export interface GitSecretCheckOptions { command: string; cwd: string; secretPatterns: CompiledSecretPatternRule[]; isPathExcluded?: (filePath: string, repoCwd: string) => boolean; } export function tokenizeShellCommand(command: string): string[] { const tokens: string[] = []; let current = ""; let quote: "single" | "double" | null = null; let escaping = false; for (const char of command) { if (escaping) { current += char; escaping = false; continue; } if (char === "\\" && quote !== "single") { escaping = true; continue; } if (quote === "single") { if (char === "'") { quote = null; } else { current += char; } continue; } if (quote === "double") { if (char === '"') { quote = null; } else { current += char; } continue; } if (char === "'") { quote = "single"; continue; } if (char === '"') { quote = "double"; continue; } if (/\s/.test(char)) { if (current.length > 0) { tokens.push(current); current = ""; } continue; } current += char; } if (escaping) current += "\\"; if (current.length > 0) tokens.push(current); return tokens; } export function parseGitCommand( command: string, cwd: string, ): ParsedGitCommand | null { const tokens = tokenizeShellCommand(command); if (tokens.length === 0 || tokens[0] !== "git") return null; let repoCwd = cwd; let index = 1; while (index < tokens.length) { const token = tokens[index]; if (token === "-C" && index + 1 < tokens.length) { repoCwd = path.resolve(repoCwd, tokens[index + 1]); index += 2; continue; } if ( token === "--git-dir" || token === "--work-tree" || token === "-c" || token === "--config-env" || token === "--exec-path" || token === "--namespace" ) { index += 2; continue; } if (token.startsWith("--git-dir=") || token.startsWith("--work-tree=")) { index += 1; continue; } if (token.startsWith("-")) { index += 1; continue; } if (token === "add" || token === "commit" || token === "push") { return { repoCwd, subcommand: token, args: tokens.slice(index + 1), }; } return null; } return null; } export function sanitizeGitCommandForLogging(command: string): string { const remoteSanitized = command.replace(/https?:\/\/[^/\s@]+@/gi, (match) => { const protocol = match.startsWith("https://") ? "https://" : "http://"; return `${protocol}[REDACTED GIT REMOTE CREDENTIAL]@`; }); return sanitizeTextForLogging(remoteSanitized); } export function evaluateGitSecretBlock( options: GitSecretCheckOptions, ): GitSecretViolation | null { const parsed = parseGitCommand(options.command, options.cwd); if (!parsed) return null; const safeCommand = sanitizeGitCommandForLogging(options.command); const repoRoot = getRepoRoot(parsed.repoCwd); if (!repoRoot) return null; const inlineCredentialReason = detectInlineRemoteCredential(options.command); if (inlineCredentialReason) { return { reason: `Git ${parsed.subcommand} blocked: ${inlineCredentialReason}`, matchedPatternNames: ["Git remote credential"], safeCommand, }; } const argumentMatches = findSecretMatches( collectGitCommandArguments(parsed).join("\n"), options.secretPatterns, ); if (argumentMatches.length > 0) { return { reason: `Git ${parsed.subcommand} blocked: command arguments contain secret-like content (${argumentMatches.join(", ")}).`, matchedPatternNames: argumentMatches, safeCommand, }; } try { switch (parsed.subcommand) { case "add": return scanGitAdd({ repoRoot, args: parsed.args, secretPatterns: options.secretPatterns, isPathExcluded: options.isPathExcluded, safeCommand, }); case "commit": return scanGitCommit({ repoRoot, secretPatterns: options.secretPatterns, isPathExcluded: options.isPathExcluded, safeCommand, }); case "push": return scanGitPush({ repoRoot, secretPatterns: options.secretPatterns, isPathExcluded: options.isPathExcluded, safeCommand, }); } } catch (error) { return { reason: `Git ${parsed.subcommand} blocked: unable to inspect repository content for secret scanning (${error instanceof Error ? error.message : String(error)}).`, matchedPatternNames: [], safeCommand, }; } } function collectGitCommandArguments(parsed: ParsedGitCommand): string[] { const argumentsToScan = [...parsed.args]; if (parsed.subcommand === "commit") { argumentsToScan.push(...extractCommitMessages(parsed.args)); } return argumentsToScan; } function extractCommitMessages(args: string[]): string[] { const messages: string[] = []; for (let index = 0; index < args.length; index += 1) { const token = args[index]; if (token === "-m" || token === "--message") { if (index + 1 < args.length) messages.push(args[index + 1]); index += 1; continue; } if (token.startsWith("--message=")) { messages.push(token.slice("--message=".length)); continue; } if (/^-m.+/.test(token)) { messages.push(token.slice(2)); continue; } if (/^-[^-]*m$/.test(token) || /^-[^-]*m[^-]*$/.test(token)) { if (index + 1 < args.length) messages.push(args[index + 1]); index += 1; } } return messages; } function detectInlineRemoteCredential(command: string): string | null { if (/https?:\/\/[^/\s@]+@/i.test(command)) { return "command contains an inline remote credential."; } return null; } function scanGitAdd(options: { repoRoot: string; args: string[]; secretPatterns: CompiledSecretPatternRule[]; isPathExcluded?: (filePath: string, repoCwd: string) => boolean; safeCommand: string; }): GitSecretViolation | null { const candidateFiles = listGitAddCandidateFiles( options.repoRoot, options.args, ); for (const relativePath of candidateFiles) { if (options.isPathExcluded?.(relativePath, options.repoRoot)) continue; const content = readWorkingTreeFile(options.repoRoot, relativePath); if (content === null) continue; const matches = findSecretMatches(content, options.secretPatterns); if (matches.length > 0) { return { reason: `Git add blocked: ${relativePath} contains secret-like content (${matches.join(", ")}).`, matchedPatternNames: matches, safeCommand: options.safeCommand, }; } } return null; } function scanGitCommit(options: { repoRoot: string; secretPatterns: CompiledSecretPatternRule[]; isPathExcluded?: (filePath: string, repoCwd: string) => boolean; safeCommand: string; }): GitSecretViolation | null { const stagedFiles = splitNullSeparated( runGitText(options.repoRoot, [ "diff", "--cached", "--name-only", "-z", "--diff-filter=ACMRTUXB", ]), ); for (const relativePath of stagedFiles) { if (options.isPathExcluded?.(relativePath, options.repoRoot)) continue; const content = readIndexedFile(options.repoRoot, relativePath); if (content === null) continue; const matches = findSecretMatches(content, options.secretPatterns); if (matches.length > 0) { return { reason: `Git commit blocked: staged file ${relativePath} contains secret-like content (${matches.join(", ")}).`, matchedPatternNames: matches, safeCommand: options.safeCommand, }; } } return null; } function scanGitPush(options: { repoRoot: string; secretPatterns: CompiledSecretPatternRule[]; isPathExcluded?: (filePath: string, repoCwd: string) => boolean; safeCommand: string; }): GitSecretViolation | null { const allUnpublishedCommits = runGitText(options.repoRoot, [ "rev-list", "HEAD", "--not", "--remotes", ]) .split("\n") .map((line) => line.trim()) .filter(Boolean); // Cap the number of commits scanned to prevent excessive work on // repos with large unpublished histories. rev-list returns newest // commits first, so slicing from the front scans the most recent ones. const unpublishedCommits = allUnpublishedCommits.slice(0, MAX_PUSH_COMMITS); for (const commit of unpublishedCommits) { const allFiles = splitNullSeparated( runGitText(options.repoRoot, [ "diff-tree", "--no-commit-id", "--name-only", "-r", "-z", "--diff-filter=ACMRTUXB", commit, ]), ); // Cap the number of files inspected per commit. const files = allFiles.slice(0, MAX_PUSH_FILES_PER_COMMIT); for (const relativePath of files) { if (options.isPathExcluded?.(relativePath, options.repoRoot)) continue; const content = readCommittedFile(options.repoRoot, commit, relativePath); if (content === null) continue; const matches = findSecretMatches(content, options.secretPatterns); if (matches.length > 0) { return { reason: `Git push blocked: unpublished commit ${commit.slice(0, 12)} contains secret-like content in ${relativePath} (${matches.join(", ")}).`, matchedPatternNames: matches, safeCommand: options.safeCommand, }; } } } return null; } function listGitAddCandidateFiles(repoRoot: string, args: string[]): string[] { const pathspecs = extractPathspecs(args); const updateOnly = args.includes("-u") || args.includes("--update"); const baseArgs = pathspecs.length > 0 ? ["--", ...pathspecs] : []; const modified = splitNullSeparated( runGitText(repoRoot, ["ls-files", "-m", "-z", ...baseArgs]), ); const untracked = updateOnly ? [] : splitNullSeparated( runGitText(repoRoot, [ "ls-files", "-o", "--exclude-standard", "-z", ...baseArgs, ]), ); return [...new Set([...modified, ...untracked])]; } function extractPathspecs(args: string[]): string[] { const pathspecs: string[] = []; let literal = false; for (let index = 0; index < args.length; index += 1) { const token = args[index]; if (!literal && token === "--") { literal = true; continue; } if (!literal && token.startsWith("-")) { if ( token === "-C" || token === "-c" || token === "-m" || token === "--chmod" || token === "--pathspec-from-file" ) { index += 1; } continue; } pathspecs.push(token); } return pathspecs; } function readWorkingTreeFile( repoRoot: string, relativePath: string, ): string | null { const absolutePath = path.join(repoRoot, relativePath); // Skip symlinks that point outside the repository root. // Symlinks pointing inside the repo are followed normally. // Broken symlinks are silently skipped. try { const lstat = fs.lstatSync(absolutePath); if (!lstat.isFile() && !lstat.isSymbolicLink()) return null; if (lstat.isSymbolicLink()) { const realTarget = fs.realpathSync(absolutePath); if ( !realTarget.startsWith(repoRoot + path.sep) && realTarget !== repoRoot ) { return null; } } } catch { return null; } const stat = fs.statSync(absolutePath); if (!stat.isFile()) return null; if (stat.size > MAX_SCANNED_BYTES) return null; const content = fs.readFileSync(absolutePath); return decodeTextContent(content); } function readIndexedFile( repoRoot: string, relativePath: string, ): string | null { const content = runGitBuffer(repoRoot, ["show", `:${relativePath}`], true); if (content && content.length > MAX_SCANNED_BYTES) return null; return decodeTextContent(content); } function readCommittedFile( repoRoot: string, commit: string, relativePath: string, ): string | null { const content = runGitBuffer( repoRoot, ["show", `${commit}:${relativePath}`], true, ); if (content && content.length > MAX_SCANNED_BYTES) return null; return decodeTextContent(content); } function decodeTextContent(content: Buffer | null): string | null { if (!content || content.length === 0) return content ? "" : null; if (content.includes(0)) return null; return content.toString("utf8"); } function getRepoRoot(cwd: string): string | null { try { return runGitText(cwd, ["rev-parse", "--show-toplevel"]).trim(); } catch { return null; } } function runGitText(cwd: string, args: string[]): string { return childProcess.execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }); } function runGitBuffer( cwd: string, args: string[], allowFailure = false, ): Buffer | null { try { return childProcess.execFileSync("git", args, { cwd, encoding: "buffer", stdio: ["ignore", "pipe", "pipe"], }) as Buffer; } catch (error) { if (allowFailure) return null; throw error; } } function splitNullSeparated(input: string): string[] { return input.split("\0").filter(Boolean); }