import { ALLOWED_COMMANDS } from "../constants.js";
import { escapeShellArg } from "./path-security.js";
/**
* Safe command character pattern.
* Allows alphanumeric, underscore, hyphen, and forward slash (for paths like /usr/bin/grep).
*/
const SAFE_COMMAND_PATTERN = /^[a-zA-Z0-9_\-/]+$/;
/**
* Parses a command string into parts for allowlist validation.
*
* IMPORTANT LIMITATION: This function does NOT handle shell-quoted arguments.
* It splits purely on whitespace, meaning commands like:
* `grep "hello world" file.txt`
* Will be parsed as: `["grep", '"hello', 'world"', "file.txt"]`
*
* This is acceptable for our use case because:
* 1. Arguments are escaped via escapeShellArg() in buildSafeShellCommand()
* 2. Callers should pass arguments as separate array elements when possible
* 3. This function is primarily for allowlist validation, not shell execution
*
* If you need quoted argument handling, pass arguments separately to the
* underlying service functions rather than as a single command string.
*
* @param command - Command string to parse (whitespace-delimited)
* @returns Array of command parts, with empty parts filtered out
* @example
* ```typescript
* parseCommandParts("docker ps -a"); // ["docker", "ps", "-a"]
* parseCommandParts(" systemctl status "); // ["systemctl", "status"]
* ```
*/
export function parseCommandParts(command: string): string[] {
return command
.trim()
.split(/\s+/)
.filter((part) => part.length > 0);
}
/**
* Validates a command against the allowlist and returns the parsed parts.
*
* Security: Prevents CWE-78 (OS Command Injection) by restricting commands to a
* predefined allowlist. No bypass mechanism is provided - use test mocks for testing.
* @see https://cwe.mitre.org/data/definitions/78.html
*
* @param command - Command string to validate (e.g., "docker ps -a")
* @returns Array of parsed command parts [baseCommand, ...args]
* @throws {Error} If command is empty or base command not in allowlist
* @example
* ```typescript
* validateCommandAllowlist("docker ps"); // ["docker", "ps"] (if docker is allowed)
* validateCommandAllowlist("rm -rf /"); // throws Error (rm not in allowlist)
* validateCommandAllowlist(""); // throws Error (empty command)
* ```
*/
export function validateCommandAllowlist(command: string): string[] {
const parts = parseCommandParts(command);
if (parts.length === 0) {
throw new Error("Command cannot be empty");
}
const baseCommand = parts[0];
// CRITICAL SECURITY: Validate characters to prevent injection attacks via shell metacharacters
if (!SAFE_COMMAND_PATTERN.test(baseCommand)) {
throw new Error(
`Base command '${baseCommand}' contains unsafe characters. Only alphanumeric, underscore, hyphen, and forward slash are allowed.`
);
}
// Enforce allowlist - no bypass mechanism for security
if (!ALLOWED_COMMANDS.has(baseCommand)) {
throw new Error(
`Command '${baseCommand}' not in allowed list. Use test mocks for testing with disallowed commands.`
);
}
return parts;
}
/**
* Validates that the base command contains only safe characters.
*
* Security: Prevents CWE-78 (OS Command Injection) via command names.
* Only alphanumeric, underscore, hyphen, and forward slash (for paths like
* /usr/bin/grep) are permitted. This validation happens before allowlist checking.
* @see https://cwe.mitre.org/data/definitions/78.html
*
* @param baseCommand - The base command to validate (first part of command string)
* @throws {Error} If command contains unsafe characters (e.g., shell metacharacters)
* @example
* ```typescript
* validateBaseCommand("docker"); // valid
* validateBaseCommand("/usr/bin/grep"); // valid
* validateBaseCommand("rm;ls"); // throws Error (contains semicolon)
* ```
*/
export function validateBaseCommand(baseCommand: string): void {
if (!SAFE_COMMAND_PATTERN.test(baseCommand)) {
throw new Error(
`Base command '${baseCommand}' contains unsafe characters. Only alphanumeric, underscore, hyphen, and forward slash are allowed.`
);
}
}
/**
* Validates command against allowlist and escapes arguments for safe shell usage.
*
* Security: Prevents CWE-78 (OS Command Injection) through multiple layers:
* 1. Command is validated against allowlist (no bypass mechanism)
* 2. Base command is validated against safe character pattern
* 3. All arguments are escaped via escapeShellArg() to neutralize metacharacters
* @see https://cwe.mitre.org/data/definitions/78.html
*
* @param command - Command string to validate and escape (e.g., "docker ps -a")
* @returns Safely escaped shell command string ready for execution
* @throws {Error} If command validation fails (empty, not in allowlist, or unsafe characters)
* @example
* ```typescript
* buildSafeShellCommand("docker ps"); // "docker 'ps'" (if allowed)
* buildSafeShellCommand("grep hello world.txt"); // "grep 'hello' 'world.txt'"
* buildSafeShellCommand("rm -rf /"); // throws Error (not in allowlist)
* ```
*/
export function buildSafeShellCommand(command: string): string {
// validateCommandAllowlist now handles both character validation and allowlist checks
const parts = validateCommandAllowlist(command);
const baseCommand = parts[0];
if (parts.length === 1) {
return baseCommand;
}
const escapedArgs = parts.slice(1).map((arg) => escapeShellArg(arg));
return `${baseCommand} ${escapedArgs.join(" ")}`;
}