"""Base validator class for GitHub Actions input validation. Provides the foundation for all validators with common functionality. """ from __future__ import annotations from abc import ABC, abstractmethod from pathlib import Path from typing import Any class BaseValidator(ABC): """Abstract base class for all validators. Provides common validation interface and error handling. """ def __init__(self, action_type: str = "") -> None: """Initialize the base validator. Args: action_type: The type of GitHub Action being validated """ self.action_type = action_type self.errors: list[str] = [] self._rules: dict[str, Any] = {} def add_error(self, message: str) -> None: """Add a validation error message. Args: message: The error message to add """ self.errors.append(message) def clear_errors(self) -> None: """Clear all validation errors.""" self.errors = [] def has_errors(self) -> bool: """Check if there are any validation errors. Returns: True if there are errors, False otherwise """ return len(self.errors) > 0 @abstractmethod def validate_inputs(self, inputs: dict[str, str]) -> bool: """Validate the provided inputs. Args: inputs: Dictionary of input names to values Returns: True if all inputs are valid, False otherwise """ @abstractmethod def get_required_inputs(self) -> list[str]: """Get the list of required input names. Returns: List of required input names """ @abstractmethod def get_validation_rules(self) -> dict[str, Any]: """Get the validation rules for this validator. Returns: Dictionary of validation rules """ def validate_required_inputs(self, inputs: dict[str, str]) -> bool: """Validate that all required inputs are present and non-empty. Args: inputs: Dictionary of input names to values Returns: True if all required inputs are present, False otherwise """ valid = True required = self.get_required_inputs() for req_input in required: if not inputs.get(req_input, "").strip(): self.add_error(f"Required input '{req_input}' is missing or empty") valid = False return valid def validate_security_patterns(self, value: str, name: str = "input") -> bool: """Check for common security injection patterns. Args: value: The value to check name: The name of the input for error messages Returns: True if no injection patterns found, False otherwise """ if not value or value.strip() == "": return True # Common injection patterns to check dangerous_patterns = [ (";", "command separator"), ("&&", "command chaining"), ("||", "command chaining"), ("|", "pipe operator"), ("`", "command substitution"), ("$(", "command substitution"), ("${", "variable expansion"), ("../", "path traversal"), ("..\\", "path traversal"), ] for pattern, description in dangerous_patterns: if pattern in value: self.add_error( f"Potential security issue in {name}: contains {description} '{pattern}'", ) return False return True def validate_path_security(self, path: str, name: str = "path") -> bool: """Validate file paths for security issues. Args: path: The file path to validate name: The name of the input for error messages Returns: True if path is secure, False otherwise """ if not path or path.strip() == "": return True # Check for absolute paths if path.startswith("/") or (len(path) > 1 and path[1] == ":"): self.add_error(f"Invalid {name}: '{path}'. Absolute path not allowed") return False # Check for path traversal if ".." in path: self.add_error(f"Invalid {name}: '{path}'. Path traversal detected") return False # Check for home directory expansion if path.startswith("~"): self.add_error(f"Invalid {name}: '{path}'. Home directory expansion not allowed") return False return True def validate_empty_allowed(self, value: str, name: str) -> bool: """Validate that a value is provided (not empty). Args: value: The value to check name: The name of the input for error messages Returns: True if value is not empty, False otherwise """ if not value or value.strip() == "": self.add_error(f"Input '{name}' cannot be empty") return False return True def load_rules(self, rules_path: Path | None = None) -> dict[str, Any]: """Load validation rules from YAML file. Args: rules_path: Path to the rules YAML file (must be a Path object) Returns: Dictionary containing validation rules """ if not rules_path: # Default to action folder's rules.yml file action_dir = Path(__file__).parent.parent.parent / self.action_type.replace("_", "-") rules_path = action_dir / "rules.yml" # Ensure rules_path is a Path object if not isinstance(rules_path, Path): msg = f"rules_path must be a Path object, got {type(rules_path)}" raise TypeError(msg) if not rules_path.exists(): return {} try: import yaml # pylint: disable=import-error,import-outside-toplevel with rules_path.open(encoding="utf-8") as f: self._rules = yaml.safe_load(f) or {} return self._rules except Exception as e: # pylint: disable=broad-exception-caught self.add_error(f"Failed to load rules from {rules_path}: {e}") return {} def get_github_actions_output(self) -> dict[str, str]: """Get output formatted for GitHub Actions. Returns: Dictionary with status and error keys for GitHub Actions """ if self.has_errors(): return { "status": "failure", "error": "; ".join(self.errors), } return { "status": "success", "error": "", } def is_github_expression(self, value: str) -> bool: """Check if the value is a GitHub expression.""" return ( value.lower() == "${{ github.token }}" or ("${{" in value and "}}" in value) or (value.strip().startswith("${{") and value.strip().endswith("}}")) )