mirror of
https://github.com/ivuorinen/actions.git
synced 2026-03-02 04:53:44 +00:00
* fix(deps): replace step-security/retry with nick-fields/retry * chore(deps): update github action sha pins via pinact * refactor: remove common-retry references from tests and validators * chore: simplify description fallback and update action count * docs: remove hardcoded test counts from memory and docs Replace exact "769 tests" references with qualitative language so these files don't go stale as test count grows.
908 lines
34 KiB
Python
Executable File
908 lines
34 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Shared validation core module for GitHub Actions.
|
|
|
|
This module consolidates all validation logic to eliminate duplication between
|
|
the framework validation and the centralized validator. It provides:
|
|
|
|
1. Standardized token patterns (resolved GitHub documentation discrepancies)
|
|
2. Common validation functions
|
|
3. Unified security validation
|
|
4. Centralized YAML parsing utilities
|
|
5. Command-line interface for ShellSpec test integration
|
|
|
|
This replaces inline Python code in ShellSpec tests and duplicate functions
|
|
across multiple files.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from pathlib import Path
|
|
import re
|
|
import sys
|
|
from typing import Any
|
|
|
|
import yaml # pylint: disable=import-error
|
|
|
|
# Default value for unknown items (used by ActionFileParser)
|
|
DEFAULT_UNKNOWN = "Unknown"
|
|
|
|
|
|
class ValidationCore:
|
|
"""Core validation functionality with standardized patterns and functions."""
|
|
|
|
# Standardized token patterns - resolved based on GitHub documentation
|
|
# Fine-grained tokens are 50-255 characters with underscores
|
|
TOKEN_PATTERNS = {
|
|
"classic": r"^gh[efpousr]_[a-zA-Z0-9]{36}$",
|
|
"fine_grained": r"^github_pat_[A-Za-z0-9_]{50,255}$", # 50-255 chars with underscores
|
|
"installation": r"^ghs_[a-zA-Z0-9]{36}$",
|
|
"npm_classic": r"^npm_[a-zA-Z0-9]{40,}$", # NPM classic tokens
|
|
}
|
|
|
|
# Injection detection pattern - characters commonly used in command injection
|
|
INJECTION_CHARS_PATTERN = r"[;&|`$()]"
|
|
|
|
# Security injection patterns
|
|
SECURITY_PATTERNS = [
|
|
r";\s*(rm|del|format|shutdown|reboot)",
|
|
r"&&\s*(rm|del|format|shutdown|reboot)",
|
|
r"\|\s*(rm|del|format|shutdown|reboot)",
|
|
r"`[^`]*`", # Command substitution
|
|
r"\$\([^)]*\)", # Command substitution
|
|
# Path traversal only dangerous when combined with commands
|
|
r"\.\./.*;\s*(rm|del|format|shutdown|reboot)",
|
|
r"\.\.\\+.*;\s*(rm|del|format|shutdown|reboot)", # Windows: ..\ or ..\\ patterns
|
|
]
|
|
|
|
def __init__(self):
|
|
"""Initialize the validation core."""
|
|
|
|
def validate_github_token(self, token: str, *, required: bool = False) -> tuple[bool, str]:
|
|
"""
|
|
Validate GitHub token format using standardized PCRE patterns.
|
|
|
|
Args:
|
|
token: The token to validate
|
|
required: Whether the token is required
|
|
|
|
Returns:
|
|
Tuple of (is_valid, error_message)
|
|
"""
|
|
if not token or token.strip() == "":
|
|
if required:
|
|
return False, "Token is required but not provided"
|
|
return True, ""
|
|
|
|
# Allow GitHub Actions expressions
|
|
if token == "${{ github.token }}" or (token.startswith("${{") and token.endswith("}}")):
|
|
return True, ""
|
|
|
|
# Allow environment variable references (e.g., $GITHUB_TOKEN)
|
|
if re.match(r"^\$[A-Za-z_][\w]*$", token):
|
|
return True, ""
|
|
|
|
# Check against standardized token patterns
|
|
for _token_type, pattern in self.TOKEN_PATTERNS.items():
|
|
if re.match(pattern, token):
|
|
return True, ""
|
|
|
|
return (
|
|
False,
|
|
"Invalid token format. Expected: gh[efpousr]_* (36 chars), "
|
|
"github_pat_[A-Za-z0-9_]* (50-255 chars), ghs_* (36 chars), or npm_* (40+ chars)",
|
|
)
|
|
|
|
def validate_namespace_with_lookahead(self, namespace: str) -> tuple[bool, str]:
|
|
"""
|
|
Validate namespace using lookahead pattern for .NET namespaces.
|
|
|
|
Args:
|
|
namespace: The namespace to validate
|
|
|
|
Returns:
|
|
Tuple of (is_valid, error_message)
|
|
"""
|
|
if not namespace or namespace.strip() == "":
|
|
return False, "Namespace cannot be empty"
|
|
|
|
# Pattern with lookahead ensures hyphens are only allowed when followed by alphanumeric
|
|
pattern = r"^[a-zA-Z0-9]([a-zA-Z0-9]|-(?=[a-zA-Z0-9])){0,38}$"
|
|
|
|
if re.match(pattern, namespace):
|
|
return True, ""
|
|
return (
|
|
False,
|
|
"Invalid namespace format. Must be 1-39 characters, "
|
|
"alphanumeric and hyphens, no trailing hyphens",
|
|
)
|
|
|
|
def validate_security_patterns(
|
|
self,
|
|
input_value: str,
|
|
input_name: str = "",
|
|
) -> tuple[bool, str]:
|
|
"""
|
|
Check for common security injection patterns.
|
|
|
|
Args:
|
|
input_value: The value to validate
|
|
input_name: Name of the input (for context)
|
|
|
|
Returns:
|
|
Tuple of (is_valid, error_message)
|
|
"""
|
|
# Allow empty values for most inputs (they're often optional)
|
|
if not input_value or input_value.strip() == "":
|
|
return True, ""
|
|
|
|
for pattern in self.SECURITY_PATTERNS:
|
|
if re.search(pattern, input_value, re.IGNORECASE):
|
|
return (
|
|
False,
|
|
f"Potential security injection pattern detected in {input_name or 'input'}",
|
|
)
|
|
|
|
return True, ""
|
|
|
|
def validate_boolean(self, value: str, input_name: str) -> tuple[bool, str]:
|
|
"""Validate boolean input with intelligent fallback for misclassified inputs."""
|
|
# Handle empty values
|
|
if not value:
|
|
return True, ""
|
|
|
|
# Standard boolean values
|
|
if value.lower() in ["true", "false"]:
|
|
return True, ""
|
|
|
|
# Intelligent fallback for misclassified inputs
|
|
# If input name suggests it should accept paths/directories, validate as such
|
|
if any(
|
|
keyword in input_name.lower()
|
|
for keyword in ["directories", "directory", "path", "file"]
|
|
):
|
|
return self.validate_cache_directories(value)
|
|
|
|
return False, f"Input '{input_name}' must be 'true' or 'false'"
|
|
|
|
def validate_version_format(
|
|
self,
|
|
value: str,
|
|
*,
|
|
allow_v_prefix: bool = False,
|
|
) -> tuple[bool, str]:
|
|
"""Validate semantic version format."""
|
|
if value.lower() == "latest":
|
|
return True, ""
|
|
if not allow_v_prefix and value.startswith("v"):
|
|
return False, f"Version should not start with 'v': {value}"
|
|
value = value.removeprefix("v") # Remove v prefix for validation
|
|
# Split validation to reduce complexity
|
|
# Base version: major.minor.patch (or simpler forms)
|
|
base_pattern = r"^[\d]+(\.[\d]+)?(\.[\d]+)?$"
|
|
# Version with prerelease/build: major.minor.patch-prerelease+build
|
|
extended_pattern = r"^[\d]+(\.[\d]+)?(\.[\d]+)?[-+][0-9A-Za-z.-]+$"
|
|
|
|
if re.match(base_pattern, value) or re.match(extended_pattern, value):
|
|
return True, ""
|
|
return False, f"Invalid version format: {value}"
|
|
|
|
def validate_file_path(self, value: str, *, allow_traversal: bool = False) -> tuple[bool, str]:
|
|
"""Validate file path format."""
|
|
if not value:
|
|
return True, ""
|
|
|
|
# Check for injection patterns
|
|
if re.search(self.INJECTION_CHARS_PATTERN, value):
|
|
return False, f"Potential injection detected in file path: {value}"
|
|
|
|
# Check for path traversal (unless explicitly allowed)
|
|
if not allow_traversal and ("../" in value or "..\\" in value):
|
|
return False, f"Path traversal not allowed: {value}"
|
|
|
|
# Check for absolute paths (often not allowed)
|
|
if value.startswith("/") or (len(value) > 1 and value[1] == ":"):
|
|
return False, f"Absolute paths not allowed: {value}"
|
|
|
|
return True, ""
|
|
|
|
def validate_docker_image_name(self, value: str) -> tuple[bool, str]:
|
|
"""Validate docker image name format."""
|
|
if not value:
|
|
return True, ""
|
|
# Split validation into parts to reduce regex complexity
|
|
# Valid format: lowercase alphanumeric with separators (., _, __, -) and optional namespace
|
|
if not re.match(r"^[a-z0-9]", value):
|
|
return False, f"Invalid docker image name format: {value}"
|
|
if not re.match(r"^[a-z0-9._/-]+$", value):
|
|
return False, f"Invalid docker image name format: {value}"
|
|
# Check for invalid patterns
|
|
if value.endswith((".", "_", "-", "/")):
|
|
return False, f"Invalid docker image name format: {value}"
|
|
if "//" in value or ".." in value:
|
|
return False, f"Invalid docker image name format: {value}"
|
|
return True, ""
|
|
|
|
def validate_docker_tag(self, value: str) -> tuple[bool, str]:
|
|
"""Validate Docker tag format."""
|
|
if not value:
|
|
return True, ""
|
|
# Docker tags must be valid ASCII and may contain lowercase and uppercase letters,
|
|
# digits, underscores, periods and dashes. Cannot start with period or dash.
|
|
# Max length is 128 characters.
|
|
if len(value) > 128:
|
|
return False, f"Docker tag too long (max 128 characters): {value}"
|
|
if not re.match(r"^[a-zA-Z0-9_][a-zA-Z0-9._-]*$", value):
|
|
return False, f"Invalid docker tag format: {value}"
|
|
return True, ""
|
|
|
|
def validate_php_extensions(self, value: str) -> tuple[bool, str]:
|
|
"""Validate PHP extensions format."""
|
|
if not value:
|
|
return True, ""
|
|
if re.search(r"[;&|`$()@#]", value):
|
|
return False, f"Potential injection detected in PHP extensions: {value}"
|
|
if not re.match(r"^[a-zA-Z0-9_,\s]+$", value):
|
|
return False, f"Invalid PHP extensions format: {value}"
|
|
return True, ""
|
|
|
|
def validate_coverage_driver(self, value: str) -> tuple[bool, str]:
|
|
"""Validate coverage driver."""
|
|
if value not in ["none", "xdebug", "pcov", "xdebug3"]:
|
|
return False, "Invalid coverage driver. Must be 'none', 'xdebug', 'pcov', or 'xdebug3'"
|
|
return True, ""
|
|
|
|
def validate_numeric_range(self, value: str, min_val: int, max_val: int) -> tuple[bool, str]:
|
|
"""Validate numeric value within range."""
|
|
try:
|
|
num = int(value)
|
|
if min_val <= num <= max_val:
|
|
return True, ""
|
|
return False, f"Value must be between {min_val} and {max_val}, got {num}"
|
|
except ValueError:
|
|
return False, f"Invalid numeric value: {value}"
|
|
|
|
def validate_php_version(self, value: str) -> tuple[bool, str]:
|
|
"""Validate PHP version format (allows X.Y and X.Y.Z)."""
|
|
if not value:
|
|
return True, ""
|
|
# PHP versions can be X.Y or X.Y.Z format
|
|
if re.match(r"^[\d]+\.[\d]+(\.[\d]+)?$", value):
|
|
return True, ""
|
|
return False, f"Invalid PHP version format: {value}"
|
|
|
|
def validate_composer_version(self, value: str) -> tuple[bool, str]:
|
|
"""Validate Composer version (1 or 2)."""
|
|
if value in ["1", "2"]:
|
|
return True, ""
|
|
return False, f"Invalid Composer version. Must be '1' or '2', got '{value}'"
|
|
|
|
def validate_stability(self, value: str) -> tuple[bool, str]:
|
|
"""Validate Composer stability."""
|
|
valid_stabilities = ["stable", "RC", "beta", "alpha", "dev"]
|
|
if value in valid_stabilities:
|
|
return True, ""
|
|
return False, f"Invalid stability. Must be one of: {', '.join(valid_stabilities)}"
|
|
|
|
def validate_cache_directories(self, value: str) -> tuple[bool, str]:
|
|
"""Validate cache directories (comma-separated paths)."""
|
|
if not value:
|
|
return True, ""
|
|
|
|
# Split by comma and validate each directory
|
|
directories = [d.strip() for d in value.split(",")]
|
|
for directory in directories:
|
|
if not directory:
|
|
continue
|
|
|
|
# Basic path validation
|
|
if re.search(self.INJECTION_CHARS_PATTERN, directory):
|
|
return False, f"Potential injection detected in directory path: {directory}"
|
|
|
|
# Check for path traversal (both Unix and Windows)
|
|
if re.search(r"\.\.[/\\]", directory):
|
|
return False, f"Path traversal not allowed in directory: {directory}"
|
|
|
|
# Check for absolute paths
|
|
if directory.startswith("/") or (len(directory) > 1 and directory[1] == ":"):
|
|
return False, f"Absolute paths not allowed in directory: {directory}"
|
|
|
|
return True, ""
|
|
|
|
def validate_tools(self, value: str) -> tuple[bool, str]:
|
|
"""Validate Composer tools format (allows @ for stability flags like dev-master@dev)."""
|
|
if not value:
|
|
return True, ""
|
|
|
|
# Check for injection patterns (@ removed to allow Composer stability flags)
|
|
if re.search(self.INJECTION_CHARS_PATTERN, value):
|
|
return False, f"Potential injection detected in tools: {value}"
|
|
|
|
return True, ""
|
|
|
|
def validate_numeric_range_1_10(self, value: str) -> tuple[bool, str]:
|
|
"""Validate numeric value between 1 and 10."""
|
|
return self.validate_numeric_range(value, 1, 10)
|
|
|
|
def validate_enhanced_business_logic(
|
|
self,
|
|
action_name: str,
|
|
input_name: str,
|
|
value: str,
|
|
) -> tuple[bool | None, str]:
|
|
"""
|
|
Enhanced business logic validation for specific action/input combinations.
|
|
Returns (None, "") if no enhanced validation applies, otherwise returns validation result.
|
|
"""
|
|
if not value: # Empty values are generally allowed, except for specific cases
|
|
# Some inputs should not be empty even if they're optional
|
|
if action_name == "php-composer" and input_name == "composer-version":
|
|
return False, f"Empty {input_name} is not allowed"
|
|
return None, ""
|
|
|
|
# PHP Composer specific validations
|
|
if action_name == "php-composer":
|
|
return self._validate_php_composer_business_logic(input_name, value)
|
|
|
|
# Prettier-check specific validations
|
|
if action_name == "prettier-check":
|
|
return self._validate_prettier_check_business_logic(input_name, value)
|
|
|
|
# Add more action-specific validations here as needed
|
|
|
|
return None, "" # No enhanced validation applies
|
|
|
|
def _validate_composer_version(self, value: str) -> tuple[bool, str]:
|
|
"""Validate composer version input."""
|
|
if value not in ["1", "2"]:
|
|
return False, f"Composer version must be '1' or '2', got '{value}'"
|
|
return True, ""
|
|
|
|
def _validate_stability(self, value: str) -> tuple[bool, str]:
|
|
"""Validate stability input."""
|
|
valid_stabilities = ["stable", "RC", "beta", "alpha", "dev"]
|
|
if value not in valid_stabilities:
|
|
return (
|
|
False,
|
|
f"Invalid stability '{value}'. Must be one of: {', '.join(valid_stabilities)}",
|
|
)
|
|
return True, ""
|
|
|
|
def _validate_php_version(self, value: str) -> tuple[bool, str]:
|
|
"""Validate PHP version input."""
|
|
if not re.match(r"^[\d]+\.[\d]+(\.[\d]+)?$", value):
|
|
return False, f"Invalid PHP version format: {value}"
|
|
|
|
try:
|
|
major, minor = value.split(".")[:2]
|
|
major_num, minor_num = int(major), int(minor)
|
|
|
|
if major_num < 7:
|
|
return False, f"PHP version {value} is too old (minimum 7.0)"
|
|
|
|
if major_num > 20:
|
|
return False, f"Invalid PHP version: {value}"
|
|
|
|
if minor_num < 0 or minor_num > 99:
|
|
return False, f"Invalid PHP version: {value}"
|
|
|
|
except (ValueError, IndexError):
|
|
return False, f"Invalid PHP version format: {value}"
|
|
return True, ""
|
|
|
|
def _validate_extensions(self, value: str) -> tuple[bool, str]:
|
|
"""Validate PHP extensions input."""
|
|
if re.search(r"[@#$&*(){}\[\]|\\]", value):
|
|
return False, f"Invalid characters in PHP extensions: {value}"
|
|
return True, ""
|
|
|
|
def _validate_tools(self, value: str) -> tuple[bool, str]:
|
|
"""Validate tools input (@ allowed for Composer stability flags like dev-master@dev)."""
|
|
if re.search(r"[#$&*(){}\[\]|\\]", value):
|
|
return False, f"Invalid characters in tools specification: {value}"
|
|
return True, ""
|
|
|
|
def _validate_args(self, value: str) -> tuple[bool, str]:
|
|
"""Validate args input."""
|
|
if re.search(self.INJECTION_CHARS_PATTERN, value):
|
|
return False, f"Potentially dangerous characters in args: {value}"
|
|
return True, ""
|
|
|
|
def _validate_php_composer_business_logic(
|
|
self,
|
|
input_name: str,
|
|
value: str,
|
|
) -> tuple[bool | None, str]:
|
|
"""Business logic validation specific to php-composer action."""
|
|
validators = {
|
|
"composer-version": self._validate_composer_version,
|
|
"stability": self._validate_stability,
|
|
"php": self._validate_php_version,
|
|
"extensions": self._validate_extensions,
|
|
"tools": self._validate_tools,
|
|
"args": self._validate_args,
|
|
}
|
|
|
|
if input_name in validators:
|
|
is_valid, error_msg = validators[input_name](value)
|
|
return is_valid, error_msg
|
|
|
|
return None, "" # No specific validation for this input
|
|
|
|
def _validate_file_pattern_security(self, value: str) -> tuple[bool, str]:
|
|
"""Validate file-pattern for security issues."""
|
|
if ".." in value:
|
|
return False, "Path traversal detected in file-pattern"
|
|
if value.startswith("/"):
|
|
return False, "Absolute path not allowed in file-pattern"
|
|
if "$" in value:
|
|
return False, "Shell expansion not allowed in file-pattern"
|
|
return True, ""
|
|
|
|
def _validate_plugins_security(self, value: str) -> tuple[bool, str]:
|
|
"""Validate plugins for security issues."""
|
|
if re.search(self.INJECTION_CHARS_PATTERN, value):
|
|
return False, "Potentially dangerous characters in plugins"
|
|
if re.search(r"\$\{.*\}", value):
|
|
return False, "Variable expansion not allowed in plugins"
|
|
if re.search(r"\$\(.*\)", value):
|
|
return False, "Command substitution not allowed in plugins"
|
|
return True, ""
|
|
|
|
def _validate_prettier_check_business_logic(
|
|
self,
|
|
input_name: str,
|
|
value: str,
|
|
) -> tuple[bool | None, str]:
|
|
"""Business logic validation specific to prettier-check action."""
|
|
# Handle prettier-version specially (accepts "latest" or semantic version)
|
|
if input_name == "prettier-version":
|
|
if value == "latest":
|
|
return True, ""
|
|
# Otherwise validate as semantic version
|
|
return None, "" # Let standard semantic version validation handle it
|
|
|
|
# Validate file-pattern for security issues
|
|
if input_name == "file-pattern":
|
|
return self._validate_file_pattern_security(value)
|
|
|
|
# Validate report-format enum
|
|
if input_name == "report-format":
|
|
if value == "":
|
|
return False, "report-format cannot be empty"
|
|
if value not in ["json", "sarif"]:
|
|
return False, f"Invalid report-format: {value}"
|
|
return True, ""
|
|
|
|
# Validate plugins for security issues
|
|
if input_name == "plugins":
|
|
return self._validate_plugins_security(value)
|
|
|
|
return None, "" # No specific validation for this input
|
|
|
|
|
|
class ActionFileParser:
|
|
"""Parser for GitHub Action YAML files."""
|
|
|
|
@staticmethod
|
|
def load_action_file(action_file: str) -> dict[str, Any]:
|
|
"""Load and parse an action.yml file."""
|
|
try:
|
|
with Path(action_file).open(encoding="utf-8") as f:
|
|
return yaml.safe_load(f)
|
|
except (OSError, yaml.YAMLError) as e:
|
|
msg = f"Failed to load action file {action_file}: {e}"
|
|
raise ValueError(msg) from e
|
|
|
|
@staticmethod
|
|
def get_action_name(action_file: str) -> str:
|
|
"""Get the action name from an action.yml file."""
|
|
try:
|
|
data = ActionFileParser.load_action_file(action_file)
|
|
return data.get("name", DEFAULT_UNKNOWN)
|
|
except (OSError, ValueError, yaml.YAMLError, AttributeError):
|
|
return DEFAULT_UNKNOWN
|
|
|
|
@staticmethod
|
|
def get_action_inputs(action_file: str) -> list[str]:
|
|
"""Get all input names from an action.yml file."""
|
|
try:
|
|
data = ActionFileParser.load_action_file(action_file)
|
|
inputs = data.get("inputs", {})
|
|
return list(inputs.keys())
|
|
except (OSError, ValueError, yaml.YAMLError, AttributeError):
|
|
return []
|
|
|
|
@staticmethod
|
|
def get_action_outputs(action_file: str) -> list[str]:
|
|
"""Get all output names from an action.yml file."""
|
|
try:
|
|
data = ActionFileParser.load_action_file(action_file)
|
|
outputs = data.get("outputs", {})
|
|
return list(outputs.keys())
|
|
except (OSError, ValueError, yaml.YAMLError, AttributeError):
|
|
return []
|
|
|
|
@staticmethod
|
|
def get_action_runs_using(action_file: str) -> str:
|
|
"""Get the runs.using value from an action.yml file."""
|
|
try:
|
|
data = ActionFileParser.load_action_file(action_file)
|
|
runs = data.get("runs", {})
|
|
return runs.get("using", "unknown")
|
|
except (OSError, ValueError, yaml.YAMLError, AttributeError):
|
|
return "unknown"
|
|
|
|
@staticmethod
|
|
def _get_required_property(input_data: dict, property_name: str) -> str:
|
|
"""Get the required/optional property."""
|
|
is_required = input_data.get("required") in [True, "true"]
|
|
if property_name == "required":
|
|
return "required" if is_required else "optional"
|
|
return "optional" if not is_required else "required"
|
|
|
|
@staticmethod
|
|
def _get_default_property(input_data: dict) -> str:
|
|
"""Get the default property."""
|
|
default_value = input_data.get("default", "")
|
|
return str(default_value) if default_value else "no-default"
|
|
|
|
@staticmethod
|
|
def _get_description_property(input_data: dict) -> str:
|
|
"""Get the description property."""
|
|
description = input_data.get("description", "")
|
|
return description or "no-description"
|
|
|
|
@staticmethod
|
|
def _get_all_optional_property(inputs: dict) -> str:
|
|
"""Get the all_optional property (list of required inputs)."""
|
|
required_inputs = [k for k, v in inputs.items() if v.get("required") in [True, "true"]]
|
|
return "none" if not required_inputs else ",".join(required_inputs)
|
|
|
|
@staticmethod
|
|
def get_input_property(action_file: str, input_name: str, property_name: str) -> str:
|
|
"""
|
|
Get a property of an input from an action.yml file.
|
|
|
|
Args:
|
|
action_file: Path to the action.yml file
|
|
input_name: Name of the input to check
|
|
property_name: Property to check (required, optional, default, description,
|
|
all_optional)
|
|
|
|
Returns:
|
|
- For 'required': 'required' or 'optional'
|
|
- For 'optional': 'optional' or 'required'
|
|
- For 'default': the default value or 'no-default'
|
|
- For 'description': the description or 'no-description'
|
|
- For 'all_optional': 'none' if no required inputs, else comma-separated list
|
|
"""
|
|
try:
|
|
data = ActionFileParser.load_action_file(action_file)
|
|
inputs = data.get("inputs", {})
|
|
input_data = inputs.get(input_name, {})
|
|
|
|
property_handlers = {
|
|
"required": lambda: ActionFileParser._get_required_property(
|
|
input_data, property_name
|
|
),
|
|
"optional": lambda: ActionFileParser._get_required_property(
|
|
input_data, property_name
|
|
),
|
|
"default": lambda: ActionFileParser._get_default_property(input_data),
|
|
"description": lambda: ActionFileParser._get_description_property(input_data),
|
|
"all_optional": lambda: ActionFileParser._get_all_optional_property(inputs),
|
|
}
|
|
|
|
if property_name in property_handlers:
|
|
return property_handlers[property_name]()
|
|
|
|
return f"unknown-property-{property_name}"
|
|
|
|
except (OSError, ValueError, yaml.YAMLError, AttributeError, KeyError) as e:
|
|
return f"error: {e}"
|
|
|
|
|
|
def resolve_action_file_path(action_dir: str) -> str:
|
|
"""Resolve the path to the action.yml file."""
|
|
action_dir_path = Path(action_dir)
|
|
if not action_dir_path.is_absolute():
|
|
# If relative, assume we're in _tests/shared and actions are at ../../
|
|
script_dir = Path(__file__).resolve().parent
|
|
project_root = script_dir.parent.parent
|
|
return str(project_root / action_dir / "action.yml")
|
|
return f"{action_dir}/action.yml"
|
|
|
|
|
|
def _apply_validation_by_type(
|
|
validator: ValidationCore,
|
|
validation_type: str,
|
|
input_value: str,
|
|
input_name: str,
|
|
required_inputs: list,
|
|
) -> tuple[bool, str]:
|
|
"""Apply validation based on the validation type."""
|
|
validation_map = {
|
|
"github_token": lambda: validator.validate_github_token(
|
|
input_value, required=input_name in required_inputs
|
|
),
|
|
"namespace_with_lookahead": lambda: validator.validate_namespace_with_lookahead(
|
|
input_value,
|
|
),
|
|
"boolean": lambda: validator.validate_boolean(input_value, input_name),
|
|
"file_path": lambda: validator.validate_file_path(input_value),
|
|
"docker_image_name": lambda: validator.validate_docker_image_name(input_value),
|
|
"docker_tag": lambda: validator.validate_docker_tag(input_value),
|
|
"php_extensions": lambda: validator.validate_php_extensions(input_value),
|
|
"coverage_driver": lambda: validator.validate_coverage_driver(input_value),
|
|
"php_version": lambda: validator.validate_php_version(input_value),
|
|
"composer_version": lambda: validator.validate_composer_version(input_value),
|
|
"stability": lambda: validator.validate_stability(input_value),
|
|
"cache_directories": lambda: validator.validate_cache_directories(input_value),
|
|
"tools": lambda: validator.validate_tools(input_value),
|
|
"numeric_range_1_10": lambda: validator.validate_numeric_range_1_10(input_value),
|
|
}
|
|
|
|
# Handle version formats
|
|
if validation_type in ["semantic_version", "calver_version", "flexible_version"]:
|
|
return validator.validate_version_format(input_value)
|
|
|
|
if validation_type == "terraform_version":
|
|
return validator.validate_version_format(input_value, allow_v_prefix=True)
|
|
|
|
# Use validation map for other types
|
|
if validation_type in validation_map:
|
|
return validation_map[validation_type]()
|
|
|
|
return True, "" # Unknown validation type, assume valid
|
|
|
|
|
|
def _load_and_validate_rules(
|
|
rules_file: Path,
|
|
input_name: str,
|
|
input_value: str,
|
|
) -> tuple[str | None, dict, list]:
|
|
"""Load validation rules and perform basic validation."""
|
|
try:
|
|
with Path(rules_file).open(encoding="utf-8") as f:
|
|
rules_data = yaml.safe_load(f)
|
|
|
|
conventions = rules_data.get("conventions", {})
|
|
overrides = rules_data.get("overrides", {})
|
|
required_inputs = rules_data.get("required_inputs", [])
|
|
|
|
# Check if input is required and empty
|
|
if input_name in required_inputs and (not input_value or input_value.strip() == ""):
|
|
return None, {}, [] # Will cause error in caller
|
|
|
|
# Get validation type
|
|
validation_type = overrides.get(input_name, conventions.get(input_name))
|
|
return validation_type, rules_data, required_inputs
|
|
|
|
except (OSError, yaml.YAMLError, KeyError, AttributeError):
|
|
return None, {}, []
|
|
|
|
|
|
def validate_input(action_dir: str, input_name: str, input_value: str) -> tuple[bool | None, str]:
|
|
"""
|
|
Validate an input value for a specific action.
|
|
|
|
This is the main validation entry point that replaces the complex
|
|
validation logic in the original framework.
|
|
"""
|
|
validator = ValidationCore()
|
|
|
|
# Always perform security validation first
|
|
security_valid, security_error = validator.validate_security_patterns(input_value, input_name)
|
|
if not security_valid:
|
|
return False, security_error
|
|
|
|
# Get action name for business logic and rules
|
|
action_name = Path(action_dir).name
|
|
|
|
# Check enhanced business logic first (takes precedence over general rules)
|
|
enhanced_validation = validator.validate_enhanced_business_logic(
|
|
action_name,
|
|
input_name,
|
|
input_value,
|
|
)
|
|
if enhanced_validation[0] is not None: # If enhanced validation has an opinion
|
|
return enhanced_validation
|
|
|
|
# Load validation rules from action folder
|
|
script_dir = Path(__file__).resolve().parent
|
|
project_root = script_dir.parent.parent
|
|
rules_file = project_root / action_name / "rules.yml"
|
|
|
|
if rules_file.exists():
|
|
validation_type, _rules_data, required_inputs = _load_and_validate_rules(
|
|
rules_file,
|
|
input_name,
|
|
input_value,
|
|
)
|
|
|
|
# Check for required input error
|
|
if input_name in required_inputs and (not input_value or input_value.strip() == ""):
|
|
return False, f"Required input '{input_name}' cannot be empty"
|
|
|
|
if validation_type:
|
|
try:
|
|
return _apply_validation_by_type(
|
|
validator,
|
|
validation_type,
|
|
input_value,
|
|
input_name,
|
|
required_inputs,
|
|
)
|
|
except (ValueError, AttributeError, KeyError, TypeError) as e:
|
|
print(
|
|
f"Warning: Could not apply validation for {action_name}: {e}",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
# If no specific validation found, the security check is sufficient
|
|
return True, ""
|
|
|
|
|
|
def _handle_legacy_interface():
|
|
"""Handle legacy CLI interface for backward compatibility."""
|
|
if len(sys.argv) == 5 and all(not arg.startswith("-") for arg in sys.argv[1:]):
|
|
action_dir, input_name, input_value, expected_result = sys.argv[1:5]
|
|
is_valid, error_msg = validate_input(action_dir, input_name, input_value)
|
|
|
|
actual_result = "success" if is_valid else "failure"
|
|
if actual_result == expected_result:
|
|
sys.exit(0)
|
|
else:
|
|
print(f"Expected {expected_result}, got {actual_result}: {error_msg}", file=sys.stderr)
|
|
sys.exit(1)
|
|
return False # Not legacy interface
|
|
|
|
|
|
def _create_argument_parser():
|
|
"""Create and configure the argument parser."""
|
|
parser = argparse.ArgumentParser(
|
|
description="Shared validation core for GitHub Actions",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="""
|
|
Examples:
|
|
# Validate an input value
|
|
python3 validation_core.py --validate action-dir input-name input-value
|
|
|
|
# Get input property
|
|
python3 validation_core.py --property action.yml input-name required
|
|
|
|
# List inputs
|
|
python3 validation_core.py --inputs action.yml
|
|
|
|
# List outputs
|
|
python3 validation_core.py --outputs action.yml
|
|
|
|
# Get action name
|
|
python3 validation_core.py --name action.yml
|
|
""",
|
|
)
|
|
|
|
mode_group = parser.add_mutually_exclusive_group(required=True)
|
|
mode_group.add_argument(
|
|
"--validate",
|
|
nargs=3,
|
|
metavar=("ACTION_DIR", "INPUT_NAME", "INPUT_VALUE"),
|
|
help="Validate an input value",
|
|
)
|
|
mode_group.add_argument(
|
|
"--property",
|
|
nargs=3,
|
|
metavar=("ACTION_FILE", "INPUT_NAME", "PROPERTY"),
|
|
help="Get input property",
|
|
)
|
|
mode_group.add_argument("--inputs", metavar="ACTION_FILE", help="List action inputs")
|
|
mode_group.add_argument("--outputs", metavar="ACTION_FILE", help="List action outputs")
|
|
mode_group.add_argument("--name", metavar="ACTION_FILE", help="Get action name")
|
|
mode_group.add_argument(
|
|
"--runs-using",
|
|
metavar="ACTION_FILE",
|
|
help="Get action runs.using value",
|
|
)
|
|
mode_group.add_argument(
|
|
"--validate-yaml",
|
|
metavar="YAML_FILE",
|
|
help="Validate YAML file syntax",
|
|
)
|
|
|
|
return parser
|
|
|
|
|
|
def _handle_validate_command(args):
|
|
"""Handle the validate command."""
|
|
action_dir, input_name, input_value = args.validate
|
|
is_valid, error_msg = validate_input(action_dir, input_name, input_value)
|
|
if is_valid:
|
|
sys.exit(0)
|
|
else:
|
|
print(f"INVALID: {error_msg}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def _handle_property_command(args):
|
|
"""Handle the property command."""
|
|
action_file, input_name, property_name = args.property
|
|
result = ActionFileParser.get_input_property(action_file, input_name, property_name)
|
|
print(result)
|
|
|
|
|
|
def _handle_inputs_command(args):
|
|
"""Handle the inputs command."""
|
|
inputs = ActionFileParser.get_action_inputs(args.inputs)
|
|
for input_name in inputs:
|
|
print(input_name)
|
|
|
|
|
|
def _handle_outputs_command(args):
|
|
"""Handle the outputs command."""
|
|
outputs = ActionFileParser.get_action_outputs(args.outputs)
|
|
for output_name in outputs:
|
|
print(output_name)
|
|
|
|
|
|
def _handle_name_command(args):
|
|
"""Handle the name command."""
|
|
name = ActionFileParser.get_action_name(args.name)
|
|
print(name)
|
|
|
|
|
|
def _handle_runs_using_command(args):
|
|
"""Handle the runs-using command."""
|
|
runs_using = ActionFileParser.get_action_runs_using(args.runs_using)
|
|
print(runs_using)
|
|
|
|
|
|
def _handle_validate_yaml_command(args):
|
|
"""Handle the validate-yaml command."""
|
|
try:
|
|
with Path(args.validate_yaml).open(encoding="utf-8") as f:
|
|
yaml.safe_load(f)
|
|
sys.exit(0)
|
|
except (OSError, yaml.YAMLError) as e:
|
|
print(f"Invalid YAML: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def _execute_command(args):
|
|
"""Execute the appropriate command based on arguments."""
|
|
command_handlers = {
|
|
"validate": _handle_validate_command,
|
|
"property": _handle_property_command,
|
|
"inputs": _handle_inputs_command,
|
|
"outputs": _handle_outputs_command,
|
|
"name": _handle_name_command,
|
|
"runs_using": _handle_runs_using_command,
|
|
"validate_yaml": _handle_validate_yaml_command,
|
|
}
|
|
|
|
for command, handler in command_handlers.items():
|
|
if getattr(args, command, None):
|
|
handler(args)
|
|
return
|
|
|
|
|
|
def main():
|
|
"""Command-line interface for validation core."""
|
|
# Handle legacy interface first
|
|
_handle_legacy_interface()
|
|
|
|
# Parse arguments and execute command
|
|
parser = _create_argument_parser()
|
|
args = parser.parse_args()
|
|
|
|
try:
|
|
_execute_command(args)
|
|
except (ValueError, OSError, AttributeError) as e:
|
|
print(f"Error: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|