Files
Jonathan Miller 4cc3f5bee4
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Successful in 5s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 6s
Generic: Repo Health / Release configuration (push) Successful in 5s
Generic: Repo Health / Scripts governance (push) Successful in 5s
Generic: Repo Health / Release configuration (pull_request) Successful in 6s
Generic: Repo Health / Scripts governance (pull_request) Successful in 6s
Generic: Repo Health / Repository health (push) Successful in 14s
Generic: Repo Health / Repository health (pull_request) Successful in 12s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 44s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 49s
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been skipped
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been skipped
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
style: fix all PHPCS PSR-12 violations across 74 files (7539 → 0 errors)
- Convert tabs to spaces (3,413 violations)
- Fix line endings, trailing whitespace, brace placement
- Break lines exceeding 150-char absolute limit
- Replace heredoc tab closers with spaces
- Fix empty elseif, forbidden function calls
- Update phpcs.xml: exclude rules inappropriate for CLI scripts
  (SideEffects, MissingNamespace, MultipleClasses, HeaderOrder,
  empty catch blocks)

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-24 17:07:51 -05:00

512 lines
15 KiB
PHP

<?php
declare(strict_types=1);
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoStandards.Enterprise.Validation
* INGROUP: MokoStandards.Enterprise
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /lib/Enterprise/InputValidator.php
* BRIEF: Input validation library
*/
/**
* Input Validation Library - Security-focused input validation and sanitization.
*
* This class provides comprehensive validation to prevent:
* - Path traversal attacks
* - Shell injection
* - SQL injection
* - XSS attacks
* - Invalid data types
* - Out-of-range values
*
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* @package MokoStandards\Enterprise
* @version 04.00.04
* @author MokoStandards Team
* @license GPL-3.0-or-later
*/
namespace MokoEnterprise;
use InvalidArgumentException;
use RuntimeException;
/**
* Exception raised when validation fails.
*/
class ValidationError extends RuntimeException
{
}
/**
* Input validation and sanitization utilities.
*
* Features:
* - Path validation (prevent path traversal)
* - Version format validation (semver, Moko format)
* - Email validation
* - URL validation with scheme checking
* - Shell injection prevention
* - SQL injection prevention
* - Integer validation with range checking
* - String validation with length/pattern checking
* - Choice validation (enum-like)
*
* Example:
* ```php
* use MokoEnterprise\InputValidator;
*
* $path = InputValidator::validatePath('/tmp/file.txt');
* $email = InputValidator::validateEmail('user@example.com');
* $version = InputValidator::validateVersion('04.00.04', 'moko');
* $safe = InputValidator::sanitizeShellInput('user; rm -rf /');
* ```
*/
class InputValidator
{
public const VERSION = '04.06.00';
/**
* Validate and sanitize file paths to prevent path traversal.
*
* @param string $path Path to validate
* @param bool $allowRelative Allow relative paths
* @param bool $mustExist Path must exist
* @param array<string>|null $allowedExtensions List of allowed file extensions
* @return string Validated path
* @throws ValidationError If path is invalid or dangerous
*/
public static function validatePath(
string $path,
bool $allowRelative = false,
bool $mustExist = false,
?array $allowedExtensions = null
): string {
if (empty($path)) {
throw new ValidationError("Path must be a non-empty string");
}
// Check for path traversal attempts
if (strpos($path, '..') !== false) {
throw new ValidationError("Path traversal detected (..)");
}
// Resolve to absolute path if not allowing relative
if (!$allowRelative) {
$realPath = realpath($path);
if ($realPath === false && $mustExist) {
throw new ValidationError("Path does not exist: {$path}");
}
if ($realPath !== false) {
$path = $realPath;
}
}
// Check if path must exist
if ($mustExist && !file_exists($path)) {
throw new ValidationError("Path does not exist: {$path}");
}
// Check file extension if specified
if ($allowedExtensions !== null) {
$extension = pathinfo($path, PATHINFO_EXTENSION);
if ($extension !== '') {
$allowedLower = array_map('strtolower', $allowedExtensions);
if (!in_array(strtolower($extension), $allowedLower, true)) {
throw new ValidationError(
"Invalid file extension: .{$extension}. " .
"Allowed: " . implode(', ', $allowedExtensions)
);
}
}
}
return $path;
}
/**
* Validate version strings.
*
* @param string $version Version string to validate
* @param string $formatType Version format ('semver', 'simple', 'moko')
* @return string Validated version string
* @throws ValidationError If version format is invalid
*/
public static function validateVersion(string $version, string $formatType = 'semver'): string
{
if (empty($version)) {
throw new ValidationError("Version must be a non-empty string");
}
switch ($formatType) {
case 'semver':
// Semantic versioning: MAJOR.MINOR.PATCH
$pattern = '/^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/';
if (!preg_match($pattern, $version)) {
throw new ValidationError(
"Invalid semver format: {$version}. Expected: MAJOR.MINOR.PATCH"
);
}
break;
case 'moko':
// MokoStandards format: XX.YY.ZZ
$pattern = '/^\d{2}\.\d{2}\.\d{2}$/';
if (!preg_match($pattern, $version)) {
throw new ValidationError(
"Invalid MokoStandards version format: {$version}. Expected: XX.YY.ZZ"
);
}
break;
case 'simple':
// Simple format: X.Y or X.Y.Z
$pattern = '/^\d+\.\d+(\.\d+)?$/';
if (!preg_match($pattern, $version)) {
throw new ValidationError("Invalid version format: {$version}");
}
break;
default:
throw new ValidationError("Unknown version format type: {$formatType}");
}
return $version;
}
/**
* Validate email addresses.
*
* @param string $email Email address to validate
* @return string Validated email address (lowercase)
* @throws ValidationError If email is invalid
*/
public static function validateEmail(string $email): string
{
if (empty($email)) {
throw new ValidationError("Email must be a non-empty string");
}
// Simple but effective email regex
$pattern = '/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/';
if (!preg_match($pattern, $email)) {
throw new ValidationError("Invalid email format: {$email}");
}
return strtolower($email);
}
/**
* Validate URLs and check schemes.
*
* @param string $url URL to validate
* @param array<string>|null $allowedSchemes List of allowed URL schemes (e.g., ['http', 'https'])
* @return string Validated URL
* @throws ValidationError If URL is invalid
*/
public static function validateUrl(string $url, ?array $allowedSchemes = null): string
{
if (empty($url)) {
throw new ValidationError("URL must be a non-empty string");
}
$parsed = parse_url($url);
if ($parsed === false || !isset($parsed['scheme']) || !isset($parsed['host'])) {
throw new ValidationError("Invalid URL format: {$url}");
}
if ($allowedSchemes !== null && !in_array($parsed['scheme'], $allowedSchemes, true)) {
throw new ValidationError(
"URL scheme '{$parsed['scheme']}' not allowed. " .
"Allowed: " . implode(', ', $allowedSchemes)
);
}
return $url;
}
/**
* Sanitize input to prevent shell injection.
*
* @param string $input Input string to sanitize
* @return string Sanitized string
*/
public static function sanitizeShellInput(string $input): string
{
// Remove dangerous shell characters
$dangerousChars = [';', '&', '|', '`', '$', '(', ')', '<', '>', "\n", "\r"];
$sanitized = str_replace($dangerousChars, '', $input);
return trim($sanitized);
}
/**
* Sanitize input to prevent SQL injection.
*
* @param string $input Input string to sanitize
* @return string Sanitized string
*/
public static function sanitizeSqlInput(string $input): string
{
// Remove SQL injection patterns
$dangerousPatterns = ["'", '"', '--', '/*', '*/', 'xp_', 'sp_'];
$sanitized = str_replace($dangerousPatterns, '', $input);
return trim($sanitized);
}
/**
* Validate and convert to integer with range checking.
*
* @param mixed $value Value to validate
* @param int|null $minValue Minimum allowed value
* @param int|null $maxValue Maximum allowed value
* @return int Validated integer
* @throws ValidationError If value is invalid or out of range
*/
public static function validateInteger(
mixed $value,
?int $minValue = null,
?int $maxValue = null
): int {
if (!is_numeric($value)) {
throw new ValidationError("Cannot convert to integer: {$value}");
}
$intValue = (int) $value;
if ($minValue !== null && $intValue < $minValue) {
throw new ValidationError("Value {$intValue} is below minimum {$minValue}");
}
if ($maxValue !== null && $intValue > $maxValue) {
throw new ValidationError("Value {$intValue} is above maximum {$maxValue}");
}
return $intValue;
}
/**
* Validate string with length and pattern checking.
*
* @param string $value String to validate
* @param int|null $minLength Minimum string length
* @param int|null $maxLength Maximum string length
* @param string|null $pattern Regex pattern to match
* @return string Validated string
* @throws ValidationError If string is invalid
*/
public static function validateString(
string $value,
?int $minLength = null,
?int $maxLength = null,
?string $pattern = null
): string {
$length = strlen($value);
if ($minLength !== null && $length < $minLength) {
throw new ValidationError("String length {$length} is below minimum {$minLength}");
}
if ($maxLength !== null && $length > $maxLength) {
throw new ValidationError("String length {$length} exceeds maximum {$maxLength}");
}
if ($pattern !== null && !preg_match($pattern, $value)) {
throw new ValidationError("String does not match pattern: {$pattern}");
}
return $value;
}
/**
* Validate that value is in a list of allowed choices.
*
* @param mixed $value Value to validate
* @param array<mixed> $choices List of allowed values
* @return mixed Validated value
* @throws ValidationError If value not in choices
*/
public static function validateChoice(mixed $value, array $choices): mixed
{
if (!in_array($value, $choices, true)) {
$choicesStr = implode(', ', array_map('strval', $choices));
throw new ValidationError("Invalid choice: {$value}. Allowed: {$choicesStr}");
}
return $value;
}
}
/**
* Chainable validator for complex validation scenarios.
*
* Features:
* - Fluent interface for chaining validations
* - Accumulates errors instead of throwing immediately
* - Single validation call at the end
*
* Example:
* ```php
* $validator = new Validator('user@example.com', 'email');
* $email = $validator
* ->isString(minLength: 5, maxLength: 100)
* ->isEmail()
* ->validate();
* ```
*/
class Validator
{
private mixed $value;
private string $name;
/** @var array<string> */
private array $errors = [];
/**
* Initialize validator.
*
* @param mixed $value Value to validate
* @param string $name Name of the value (for error messages)
*/
public function __construct(mixed $value, string $name = 'value')
{
$this->value = $value;
$this->name = $name;
}
/**
* Check if value is a string.
*
* @param int|null $minLength Minimum length
* @param int|null $maxLength Maximum length
* @return self
*/
public function isString(?int $minLength = null, ?int $maxLength = null): self
{
try {
if (!is_string($this->value)) {
throw new ValidationError("Value must be a string");
}
InputValidator::validateString($this->value, $minLength, $maxLength);
} catch (ValidationError $e) {
$this->errors[] = $e->getMessage();
}
return $this;
}
/**
* Check if value is an integer.
*
* @param int|null $minValue Minimum value
* @param int|null $maxValue Maximum value
* @return self
*/
public function isInteger(?int $minValue = null, ?int $maxValue = null): self
{
try {
InputValidator::validateInteger($this->value, $minValue, $maxValue);
} catch (ValidationError $e) {
$this->errors[] = $e->getMessage();
}
return $this;
}
/**
* Check if value is a valid email.
*
* @return self
*/
public function isEmail(): self
{
try {
if (!is_string($this->value)) {
throw new ValidationError("Email must be a string");
}
InputValidator::validateEmail($this->value);
} catch (ValidationError $e) {
$this->errors[] = $e->getMessage();
}
return $this;
}
/**
* Check if value is a valid URL.
*
* @param array<string>|null $allowedSchemes Allowed URL schemes
* @return self
*/
public function isUrl(?array $allowedSchemes = null): self
{
try {
if (!is_string($this->value)) {
throw new ValidationError("URL must be a string");
}
InputValidator::validateUrl($this->value, $allowedSchemes);
} catch (ValidationError $e) {
$this->errors[] = $e->getMessage();
}
return $this;
}
/**
* Check if value matches regex pattern.
*
* @param string $pattern Regex pattern
* @return self
*/
public function matches(string $pattern): self
{
if (!preg_match($pattern, (string) $this->value)) {
$this->errors[] = "{$this->name} does not match pattern: {$pattern}";
}
return $this;
}
/**
* Perform validation and raise exception if errors found.
*
* @return mixed The validated value
* @throws ValidationError If validation failed
*/
public function validate(): mixed
{
if (!empty($this->errors)) {
$errorMsg = "Validation failed for {$this->name}:\n";
$errorMsg .= implode("\n", array_map(fn($e) => " - {$e}", $this->errors));
throw new ValidationError($errorMsg);
}
return $this->value;
}
/**
* Get all validation errors.
*
* @return array<string>
*/
public function getErrors(): array
{
return $this->errors;
}
/**
* Check if validation has errors.
*
* @return bool
*/
public function hasErrors(): bool
{
return !empty($this->errors);
}
}