/**
* Shell argument escaping utilities for secure command execution
* Prevents command injection by properly escaping special characters
*/
/**
* Escapes a shell argument for safe use in CLI commands
* Uses platform-specific escaping rules
*
* @param arg - The argument to escape
* @returns Safely escaped argument ready for shell execution
*
* @example
* ```typescript
* escapeShellArg('my"value') // Windows: "my\"value"
* escapeShellArg("it's") // Unix: 'it'\''s'
* ```
*/
export function escapeShellArg(arg: string): string {
if (!arg) return '""';
// Validate input doesn't contain null bytes (common attack vector)
if (arg.includes('\x00')) {
throw new Error('Null byte detected in shell argument');
}
if (process.platform === 'win32') {
return escapeWindowsArg(arg);
}
return escapePosixArg(arg);
}
/**
* Windows cmd.exe argument escaping
* Follows Windows command line parsing rules
*
* References:
* - https://docs.microsoft.com/en-us/windows/win32/api/shellapi/nf-shellapi-commandlinetoargvw
* - https://ss64.com/nt/syntax-esc.html
*/
function escapeWindowsArg(arg: string): string {
// Escape internal quotes by doubling them
let escaped = arg.replace(/"/g, '""');
// Escape backslashes when followed by quotes
escaped = escaped.replace(/\\+"/g, (match) => {
// Double the backslashes and then add the escaped quote
const backslashes = match.substring(0, match.length - 1);
return backslashes + backslashes + '""';
});
// Wrap in quotes to prevent word splitting and special char interpretation
return `"${escaped}"`;
}
/**
* POSIX shell argument escaping (Linux/macOS/Unix)
* Uses single quotes which prevent ALL shell expansion
*
* Single quotes are the safest option because they:
* - Prevent variable expansion
* - Prevent command substitution
* - Prevent glob expansion
* - Prevent history expansion
*
* The only character that needs special handling is the single quote itself.
*/
function escapePosixArg(arg: string): string {
// Replace single quotes with '\'' (end quote, escaped quote, start quote)
// This is the POSIX standard way to include a literal single quote
const escaped = arg.replace(/'/g, "'\\''");
return `'${escaped}'`;
}
/**
* Validates that a string is safe for use in shell commands
* Returns validation result with details about any issues found
*
* @param arg - The argument to validate
* @returns Validation result
*/
export interface ShellValidationResult {
isValid: boolean;
errors: string[];
warnings: string[];
}
export function validateShellArg(arg: string): ShellValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
// Check for null bytes
if (arg.includes('\x00')) {
errors.push('Null byte detected (possible injection attack)');
}
// Check for suspicious patterns
const suspiciousPatterns = [
{ pattern: /[;&|`$]/, message: 'Command chaining characters detected' },
{ pattern: /\$\(/, message: 'Command substitution detected' },
{ pattern: /`/, message: 'Backtick command substitution detected' },
{ pattern: />\s*\//, message: 'Redirect to root path detected' },
{ pattern: /\|\s*(?:sh|bash|cmd|powershell)/i, message: 'Pipe to shell detected' },
];
for (const { pattern, message } of suspiciousPatterns) {
if (pattern.test(arg)) {
warnings.push(message);
}
}
// Check for excessive length (possible buffer overflow attempt)
if (arg.length > 10000) {
warnings.push('Argument exceeds 10000 characters (possible DoS)');
}
// Check for control characters (except common whitespace)
if (/[\x01-\x08\x0B-\x0C\x0E-\x1F\x7F]/.test(arg)) {
warnings.push('Control characters detected');
}
return {
isValid: errors.length === 0,
errors,
warnings,
};
}
/**
* Builds a safe shell command from a base command and arguments
* Automatically escapes all arguments
*
* @param baseCommand - The base command (e.g., 'az', 'git')
* @param args - Array of arguments to append
* @returns Complete command string with escaped arguments
*
* @example
* ```typescript
* buildSafeCommand('az', ['storage', 'account', 'list', '--name', 'my"account'])
* // Returns: az storage account list --name "my\"account"
* ```
*/
export function buildSafeCommand(baseCommand: string, args: string[]): string {
// Don't escape the base command, but validate it
if (!baseCommand || /[;&|`$]/.test(baseCommand)) {
throw new Error('Invalid base command');
}
const escapedArgs = args.map(arg => escapeShellArg(arg));
return `${baseCommand} ${escapedArgs.join(' ')}`;
}
/**
* Safely builds Azure CLI command with parameter key-value pairs
* Escapes both keys and values
*
* @param subcommand - The Azure CLI subcommand (e.g., 'storage account list')
* @param params - Object with parameter key-value pairs
* @returns Complete Azure CLI command with escaped parameters
*
* @example
* ```typescript
* buildAzCommand('storage account list', {
* 'resource-group': 'my-rg',
* 'name': 'my"account'
* })
* // Returns: az storage account list --resource-group 'my-rg' --name 'my"account'
* ```
*/
export function buildAzCommand(subcommand: string, params: Record<string, string> = {}): string {
const paramArgs: string[] = [];
for (const [key, value] of Object.entries(params)) {
if (!value) continue;
// Validate parameter key (should be alphanumeric with hyphens)
if (!/^[a-z0-9\-]+$/i.test(key)) {
throw new Error(`Invalid parameter key: ${key}`);
}
paramArgs.push(`--${key}`, escapeShellArg(value));
}
const baseCmd = `az ${subcommand}`;
return paramArgs.length > 0 ? `${baseCmd} ${paramArgs.join(' ')}` : baseCmd;
}