//! Hook content sanitization. //! //! Validates custom hook scripts to prevent dangerous commands. use crate::error::{GitError, GitResult}; /// Commands/patterns that are never allowed in custom hook scripts. /// This is a blocklist approach - we also add pattern-based detection. const FORBIDDEN_PATTERNS: &[&str] = &[ "rm -rf", "rm -r /", "chmod 777", "chmod 666", "mkfs", "dd if=", ":(){ :|:& };:", // fork bomb "> /dev/sda", "curl -o /", "wget -O /", "/etc/passwd", "/etc/shadow", "shutdown", "reboot", "init 0", "init 6", "poweroff", "halt", // Additional patterns to catch encoding/obfuscation attempts "eval ", // eval can execute arbitrary strings "exec ", // exec can replace process "$(", // command substitution "`", // backtick command substitution "${", // variable expansion (can be used for obfuscation) "|bash", // piping to bash "|sh", // piping to sh "|dash", // piping to dash "|zsh", // piping to zsh "base64", // base64 encoding/decoding (common for obfuscation) "python -c", // inline python execution "perl -e", // inline perl execution "ruby -e", // inline ruby execution "node -e", // inline node execution "/dev/tcp", // bash reverse shell "nc -e", // netcat reverse shell "ncat", // netcat alternative "socat", // socket relay ]; /// Additional regex-like patterns that indicate dangerous constructs. /// These are checked with simple string matching for complexity reasons. const DANGEROUS_PREFIXES: &[&str] = &[ "rm -rf /", // rm -rf with absolute path "rm -rf ~", // rm -rf with home directory "rm -rf .", // rm -rf with relative path (current dir) "rm -rf *", // rm -rf with wildcard ]; /// Maximum hook script size (64KB). const MAX_HOOK_SIZE: usize = 65536; /// Validate a custom hook script content for safety. pub fn validate_hook_content(content: &str) -> GitResult<()> { if content.is_empty() { return Err(GitError::InvalidArgument( "hook content cannot be empty".into(), )); } if content.len() > MAX_HOOK_SIZE { return Err(GitError::InvalidArgument(format!( "hook content too large (max {} bytes): {} bytes", MAX_HOOK_SIZE, content.len() ))); } if content.contains('\0') { return Err(GitError::InvalidArgument( "hook content cannot contain null bytes".into(), )); } // Check for forbidden patterns (case-insensitive where appropriate) let content_lower = content.to_lowercase(); for pattern in FORBIDDEN_PATTERNS { if content_lower.contains(&pattern.to_lowercase()) { return Err(GitError::InvalidArgument(format!( "hook content contains forbidden pattern: '{pattern}'" ))); } } // Check for dangerous prefixes (exact case) for prefix in DANGEROUS_PREFIXES { if content.contains(prefix) { return Err(GitError::InvalidArgument(format!( "hook content contains dangerous command: '{prefix}'" ))); } } // Check for obfuscation techniques check_obfuscation_attempts(content)?; Ok(()) } /// Check for common obfuscation attempts. fn check_obfuscation_attempts(content: &str) -> GitResult<()> { // Check for excessive use of special characters that might indicate obfuscation let special_char_count = content.chars().filter(|c| matches!(c, '$' | '`' | '\\' | '|' | ';' | '&' | '(' | ')' | '{' | '}' | '[' | ']')).count(); let total_chars = content.chars().count(); // If more than 30% of content is special characters, it's suspicious if total_chars > 0 && (special_char_count * 100 / total_chars) > 30 { return Err(GitError::InvalidArgument( "hook content appears obfuscated (too many special characters)".into(), )); } // Check for hex encoding attempts (e.g., \x41\x42) if content.contains("\\x") { let hex_count = content.matches("\\x").count(); if hex_count > 5 { return Err(GitError::InvalidArgument( "hook content contains hex encoding (potential obfuscation)".into(), )); } } // Check for unicode escape sequences if content.contains("\\u") { let unicode_count = content.matches("\\u").count(); if unicode_count > 5 { return Err(GitError::InvalidArgument( "hook content contains unicode escapes (potential obfuscation)".into(), )); } } Ok(()) } /// Validate a hook name (must be a recognized git hook name). pub fn validate_hook_name(name: &str) -> GitResult<()> { const VALID_HOOK_NAMES: &[&str] = &[ "pre-receive", "update", "post-receive", "pre-applypatch", "applypatch-msg", "post-applypatch", "pre-commit", "prepare-commit-msg", "commit-msg", "post-commit", "pre-auto-gc", ]; if !VALID_HOOK_NAMES.contains(&name) { return Err(GitError::InvalidArgument(format!( "invalid hook name: '{name}'. Must be one of: {}", VALID_HOOK_NAMES.join(", ") ))); } Ok(()) }