import { spawn, SpawnOptions } from 'child_process'; export interface PcExecOptions { /** Current working directory */ cwd?: string; /** Environment variables */ env?: Record; /** Timeout in milliseconds */ timeout?: number; /** Maximum buffer size for stdout/stderr */ maxBuffer?: number; /** Kill signal */ killSignal?: NodeJS.Signals; /** Shell to use */ shell?: string | boolean; /** UID to run as */ uid?: number; /** GID to run as */ gid?: number; /** Window style (Windows only) */ windowsHide?: boolean; } export interface PcExecResult { /** Standard output */ stdout: string; /** Standard error */ stderr: string; /** Exit code */ exitCode: number; /** Command that was executed */ command: string; } export interface PcExecError extends Error { /** Exit code */ code?: number; /** Signal that terminated the process */ signal?: string; /** Standard output */ stdout: string; /** Standard error */ stderr: string; /** Killed by timeout */ killed?: boolean; } /** * Extract secret-mgr (and legacy pass_mgr) invocations from a command string. * * Supports: * Current: $(secret-mgr get-secret --key ) / `secret-mgr get-secret --key ` * Legacy: $(pass_mgr get-secret --key ) / `pass_mgr get-secret --key ` * Legacy: $(pass_mgr get ) / `pass_mgr get ` * * Returns array of { fullMatch, subcommand, key, binary } where subcommand is * "get" | "get-secret". */ function extractSecretMgrGets( command: string, ): Array<{ key: string; fullMatch: string; subcommand: string; binary: string }> { const results: Array<{ key: string; fullMatch: string; subcommand: string; binary: string }> = []; const seen = new Set(); // secret-mgr get-secret --key const secretMgrPatterns = [ /\$\(\s*secret-mgr\s+get-secret\s+--key\s+(\S+)\s*\)/g, /`\s*secret-mgr\s+get-secret\s+--key\s+(\S+)\s*`/g, ]; // Legacy pass_mgr get-secret --key const newPatterns = [ /\$\(\s*pass_mgr\s+get-secret\s+--key\s+(\S+)\s*\)/g, /`\s*pass_mgr\s+get-secret\s+--key\s+(\S+)\s*`/g, ]; // Legacy pass_mgr get const legacyPatterns = [ /\$\(\s*pass_mgr\s+get\s+(\S+)\s*\)/g, /`\s*pass_mgr\s+get\s+(\S+)\s*`/g, ]; for (const pattern of secretMgrPatterns) { let match; while ((match = pattern.exec(command)) !== null) { if (!seen.has(match[0])) { seen.add(match[0]); results.push({ key: match[1], fullMatch: match[0], subcommand: 'get-secret', binary: 'secret-mgr', }); } } } for (const pattern of newPatterns) { let match; while ((match = pattern.exec(command)) !== null) { if (!seen.has(match[0])) { seen.add(match[0]); results.push({ key: match[1], fullMatch: match[0], subcommand: 'get-secret', binary: 'pass_mgr', }); } } } for (const pattern of legacyPatterns) { let match; while ((match = pattern.exec(command)) !== null) { if (!seen.has(match[0])) { seen.add(match[0]); results.push({ key: match[1], fullMatch: match[0], subcommand: 'get', binary: 'pass_mgr', }); } } } return results; } /** * Execute secret-mgr (or legacy pass_mgr) to retrieve a secret. * Uses the same env vars that the caller passes so pcguard checks pass. */ async function fetchPassword( subcommand: string, key: string, env: Record, binary: string = 'secret-mgr', ): Promise { return new Promise((resolve, reject) => { // Prefer SECRET_MGR_PATH, fall back to PASS_MGR_PATH for legacy compat const binaryPath = env.SECRET_MGR_PATH || env.PASS_MGR_PATH || process.env.SECRET_MGR_PATH || process.env.PASS_MGR_PATH || 'secret-mgr'; const args = subcommand === 'get-secret' ? ['get-secret', '--key', key] : ['get', key]; const child = spawn(binaryPath, args, { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, ...env }, }); let stdout = ''; let stderr = ''; child.stdout.on('data', (d) => (stdout += d.toString())); child.stderr.on('data', (d) => (stderr += d.toString())); child.on('close', (code) => { if (code !== 0) { reject(new Error(`secret-mgr ${subcommand} failed: ${stderr || stdout}`)); } else { resolve(stdout.trim()); } }); child.on('error', reject); }); } /** * Sanitize output by replacing passwords with ###### */ function sanitizeOutput(output: string, passwords: string[]): string { let sanitized = output; for (const password of passwords) { if (password) { const escaped = password.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); sanitized = sanitized.replace(new RegExp(escaped, 'g'), '######'); } } return sanitized; } /** * Pre-resolve secret-mgr (and legacy pass_mgr) invocations, replace them inline, and collect passwords. */ async function replaceSecretMgrGets( command: string, env: Record, ): Promise<{ command: string; passwords: string[] }> { const matches = extractSecretMgrGets(command); const passwords: string[] = []; let replaced = command; for (const { key, fullMatch, subcommand, binary } of matches) { const pw = await fetchPassword(subcommand, key, env, binary); passwords.push(pw); replaced = replaced.split(fullMatch).join(pw); } return { command: replaced, passwords }; } /** * Safe exec wrapper that handles pass_mgr get commands and sanitizes output. */ export async function pcexec( command: string, options: PcExecOptions = {}, ): Promise { // Build environment const env: Record = {}; for (const [k, v] of Object.entries(process.env)) { if (v !== undefined) env[k] = v; } if (options.env) Object.assign(env, options.env); // Pre-resolve passwords let finalCommand = command; let passwords: string[] = []; const resolved = await replaceSecretMgrGets(command, env); finalCommand = resolved.command; passwords = resolved.passwords; return new Promise((resolve, reject) => { const spawnOptions: SpawnOptions = { cwd: options.cwd, env, shell: options.shell, windowsHide: options.windowsHide, uid: options.uid, gid: options.gid, }; const child = spawn('bash', ['-c', finalCommand], spawnOptions); let stdout = ''; let stderr = ''; let killed = false; let timeoutId: NodeJS.Timeout | null = null; if (options.timeout && options.timeout > 0) { timeoutId = setTimeout(() => { killed = true; child.kill(options.killSignal || 'SIGTERM'); }, options.timeout); } child.stdout?.on('data', (data) => { stdout += data.toString(); if (options.maxBuffer && stdout.length > options.maxBuffer) { child.kill(options.killSignal || 'SIGTERM'); } }); child.stderr?.on('data', (data) => { stderr += data.toString(); if (options.maxBuffer && stderr.length > options.maxBuffer) { child.kill(options.killSignal || 'SIGTERM'); } }); child.on('close', (code, signal) => { if (timeoutId) clearTimeout(timeoutId); const sanitizedStdout = sanitizeOutput(stdout, passwords); const sanitizedStderr = sanitizeOutput(stderr, passwords); if (code === 0) { resolve({ stdout: sanitizedStdout, stderr: sanitizedStderr, exitCode: 0, command: finalCommand, }); } else { const error = new Error(`Command failed: ${command}\n${stderr}`) as PcExecError; error.code = code ?? undefined; error.signal = signal ?? undefined; error.stdout = sanitizedStdout; error.stderr = sanitizedStderr; error.killed = killed; reject(error); } }); child.on('error', (err) => { if (timeoutId) clearTimeout(timeoutId); const error = new Error(`Failed to execute command: ${err.message}`) as PcExecError; error.stdout = sanitizeOutput(stdout, passwords); error.stderr = sanitizeOutput(stderr, passwords); reject(error); }); }); } /** * Synchronous version — password substitution is NOT supported here * (use async pcexec for pass_mgr integration). */ export function pcexecSync( command: string, options: PcExecOptions = {}, ): PcExecResult { const { execSync } = require('child_process'); const env: Record = {}; for (const [k, v] of Object.entries(process.env)) { if (v !== undefined) env[k] = v; } if (options.env) Object.assign(env, options.env); try { const stdout = execSync(command, { cwd: options.cwd, env, shell: options.shell as any, encoding: 'utf8', windowsHide: options.windowsHide, uid: options.uid, gid: options.gid, maxBuffer: options.maxBuffer, timeout: options.timeout, killSignal: options.killSignal, }); return { stdout: stdout.toString(), stderr: '', exitCode: 0, command }; } catch (err: any) { const error = new Error(`Command failed: ${command}`) as PcExecError; error.code = err.status; error.signal = err.signal; error.stdout = err.stdout?.toString() || ''; error.stderr = err.stderr?.toString() || ''; throw error; } } export default pcexec;