diff --git a/.github/workflows/php-lint.yml b/.github/workflows/php-lint.yml new file mode 100644 index 0000000..0e83136 --- /dev/null +++ b/.github/workflows/php-lint.yml @@ -0,0 +1,359 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +name: PHP Lint & Analysis + +on: + push: + branches: [main, master] + paths: + - 'src/**' + - 'tests/**' + - 'composer.json' + - 'composer.lock' + - 'phpstan.neon' + - '.php-cs-fixer.dist.php' + - '.github/workflows/php-lint.yml' + pull_request: + paths: + - 'src/**' + - 'tests/**' + - 'composer.json' + - 'composer.lock' + - 'phpstan.neon' + - '.php-cs-fixer.dist.php' + - '.github/workflows/php-lint.yml' + +permissions: read-all + +env: + PHP_VERSION: '8.3' + +jobs: + # ============================================================ + # Syntax Check - Fast fail on parse errors + # ============================================================ + syntax: + name: PHP Syntax Check + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup PHP + uses: shivammathur/setup-php@9e72090525849c5e82e596468b86eb55e9cc5401 # v2.32.0 + with: + php-version: ${{ env.PHP_VERSION }} + tools: none + coverage: none + + - name: Check PHP syntax + run: | + echo "Checking PHP syntax..." + find src -name "*.php" -print0 | xargs -0 -n1 php -l + echo "✅ All PHP files have valid syntax" + + # ============================================================ + # Code Style - PSR-12 compliance via PHP-CS-Fixer + # ============================================================ + code-style: + name: Code Style (PHP-CS-Fixer) + runs-on: ubuntu-latest + needs: syntax + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup PHP + uses: shivammathur/setup-php@9e72090525849c5e82e596468b86eb55e9cc5401 # v2.32.0 + with: + php-version: ${{ env.PHP_VERSION }} + tools: php-cs-fixer:3 + coverage: none + + - name: Run PHP-CS-Fixer + run: | + php-cs-fixer fix --dry-run --diff --verbose --config=.php-cs-fixer.dist.php + + # ============================================================ + # Static Analysis - PHPStan at maximum strictness + # ============================================================ + phpstan: + name: Static Analysis (PHPStan Level 9) + runs-on: ubuntu-latest + needs: syntax + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup PHP + uses: shivammathur/setup-php@9e72090525849c5e82e596468b86eb55e9cc5401 # v2.32.0 + with: + php-version: ${{ env.PHP_VERSION }} + tools: phpstan:1 + coverage: none + + - name: Install Composer dependencies + run: composer install --no-progress --prefer-dist --no-interaction + + - name: Run PHPStan + run: | + phpstan analyse --configuration=phpstan.neon --error-format=github + + # ============================================================ + # SPDX License Headers - Ensure all files have headers + # ============================================================ + license-headers: + name: SPDX License Headers + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Check SPDX headers + run: | + MISSING="" + for file in $(find src -name "*.php"); do + if ! grep -q "SPDX-License-Identifier" "$file"; then + MISSING="$MISSING\n - $file" + fi + done + + if [ -n "$MISSING" ]; then + echo "::error::Missing SPDX-License-Identifier in:$MISSING" + exit 1 + fi + echo "✅ All PHP files have SPDX license headers" + + # ============================================================ + # Strict Types - Ensure all files use strict_types + # ============================================================ + strict-types: + name: Strict Types Declaration + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Check strict_types + run: | + MISSING="" + for file in $(find src -name "*.php"); do + if ! grep -q "declare(strict_types=1)" "$file"; then + MISSING="$MISSING\n - $file" + fi + done + + if [ -n "$MISSING" ]; then + echo "::error::Missing declare(strict_types=1) in:$MISSING" + exit 1 + fi + echo "✅ All PHP files declare strict_types=1" + + # ============================================================ + # Security Patterns - Check for dangerous code patterns + # ============================================================ + security-patterns: + name: Security Pattern Check + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Check dangerous functions + run: | + FOUND=0 + + # Dangerous execution functions + DANGEROUS=$(grep -rEn 'eval\s*\(|exec\s*\(|system\s*\(|passthru\s*\(|shell_exec\s*\(|proc_open\s*\(|popen\s*\(' \ + --include="*.php" src/ 2>/dev/null || true) + if [ -n "$DANGEROUS" ]; then + echo "::error::Dangerous execution functions found:" + echo "$DANGEROUS" + FOUND=1 + fi + + # Backtick execution + BACKTICKS=$(grep -rEn '`[^`]*\$' --include="*.php" src/ 2>/dev/null || true) + if [ -n "$BACKTICKS" ]; then + echo "::error::Backtick execution with variables found:" + echo "$BACKTICKS" + FOUND=1 + fi + + # preg_replace with /e modifier (deprecated but check anyway) + PREG_E=$(grep -rEn "preg_replace\s*\([^)]*'/[^']*e[^']*'" --include="*.php" src/ 2>/dev/null || true) + if [ -n "$PREG_E" ]; then + echo "::error::preg_replace with /e modifier found:" + echo "$PREG_E" + FOUND=1 + fi + + # assert() with string (can execute code) + ASSERT_STR=$(grep -rEn "assert\s*\(['\"]" --include="*.php" src/ 2>/dev/null || true) + if [ -n "$ASSERT_STR" ]; then + echo "::error::assert() with string argument found:" + echo "$ASSERT_STR" + FOUND=1 + fi + + # create_function (deprecated, can execute code) + CREATE_FUNC=$(grep -rEn 'create_function\s*\(' --include="*.php" src/ 2>/dev/null || true) + if [ -n "$CREATE_FUNC" ]; then + echo "::error::create_function() found (use closures instead):" + echo "$CREATE_FUNC" + FOUND=1 + fi + + if [ "$FOUND" -eq 0 ]; then + echo "✅ No dangerous function patterns found" + else + exit 1 + fi + + - name: Check weak cryptography + run: | + FOUND=0 + + # MD5 for security (allow md5_file for checksums) + MD5=$(grep -rEn 'md5\s*\(' --include="*.php" src/ 2>/dev/null | grep -v 'md5_file' || true) + if [ -n "$MD5" ]; then + echo "::warning::MD5 usage found (ensure not used for security):" + echo "$MD5" + fi + + # SHA1 for security + SHA1=$(grep -rEn 'sha1\s*\(' --include="*.php" src/ 2>/dev/null || true) + if [ -n "$SHA1" ]; then + echo "::warning::SHA1 usage found (ensure not used for security):" + echo "$SHA1" + fi + + # Insecure random functions + RAND=$(grep -rEn '\brand\s*\(|\bmt_rand\s*\(|\buniqid\s*\(' --include="*.php" src/ 2>/dev/null || true) + if [ -n "$RAND" ]; then + echo "::warning::Potentially insecure random functions found (use random_int/random_bytes):" + echo "$RAND" + fi + + echo "✅ Weak cryptography check completed" + + - name: Check SQL injection patterns + run: | + # Direct variable interpolation in queries + SQLI=$(grep -rEn '(mysql_query|mysqli_query|pg_query|->query)\s*\([^)]*\$' \ + --include="*.php" src/ 2>/dev/null || true) + if [ -n "$SQLI" ]; then + echo "::warning::Potential SQL injection pattern (use prepared statements):" + echo "$SQLI" + fi + echo "✅ SQL injection pattern check completed" + + # ============================================================ + # Composer Audit - Check for dependency vulnerabilities + # ============================================================ + composer-audit: + name: Dependency Audit + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup PHP + uses: shivammathur/setup-php@9e72090525849c5e82e596468b86eb55e9cc5401 # v2.32.0 + with: + php-version: ${{ env.PHP_VERSION }} + tools: composer:2 + coverage: none + + - name: Install dependencies + run: composer install --no-progress --prefer-dist --no-interaction + + - name: Run Composer audit + run: | + composer audit --format=plain || echo "::warning::Vulnerabilities found in dependencies" + + # ============================================================ + # Multi-version PHP Test - Ensure compatibility + # ============================================================ + php-compat: + name: PHP ${{ matrix.php }} Compatibility + runs-on: ubuntu-latest + needs: [syntax, code-style, phpstan] + permissions: + contents: read + strategy: + fail-fast: false + matrix: + php: ['8.1', '8.2', '8.3', '8.4'] + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup PHP ${{ matrix.php }} + uses: shivammathur/setup-php@9e72090525849c5e82e596468b86eb55e9cc5401 # v2.32.0 + with: + php-version: ${{ matrix.php }} + coverage: none + + - name: Install dependencies + run: composer install --no-progress --prefer-dist --no-interaction + + - name: Check syntax on PHP ${{ matrix.php }} + run: find src -name "*.php" -print0 | xargs -0 -n1 php -l + + - name: Run PHPStan on PHP ${{ matrix.php }} + run: vendor/bin/phpstan analyse --configuration=phpstan.neon --no-progress + continue-on-error: ${{ matrix.php == '8.4' }} # Allow failures on newest PHP + + # ============================================================ + # Summary - Aggregate all check results + # ============================================================ + lint-summary: + name: Lint Summary + runs-on: ubuntu-latest + needs: [syntax, code-style, phpstan, license-headers, strict-types, security-patterns, composer-audit, php-compat] + if: always() + permissions: + contents: read + steps: + - name: Check results + run: | + echo "## PHP Lint & Analysis Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Check | Status |" >> $GITHUB_STEP_SUMMARY + echo "|-------|--------|" >> $GITHUB_STEP_SUMMARY + echo "| Syntax | ${{ needs.syntax.result == 'success' && '✅ Pass' || '❌ Fail' }} |" >> $GITHUB_STEP_SUMMARY + echo "| Code Style | ${{ needs.code-style.result == 'success' && '✅ Pass' || '❌ Fail' }} |" >> $GITHUB_STEP_SUMMARY + echo "| PHPStan | ${{ needs.phpstan.result == 'success' && '✅ Pass' || '❌ Fail' }} |" >> $GITHUB_STEP_SUMMARY + echo "| License Headers | ${{ needs.license-headers.result == 'success' && '✅ Pass' || '❌ Fail' }} |" >> $GITHUB_STEP_SUMMARY + echo "| Strict Types | ${{ needs.strict-types.result == 'success' && '✅ Pass' || '❌ Fail' }} |" >> $GITHUB_STEP_SUMMARY + echo "| Security Patterns | ${{ needs.security-patterns.result == 'success' && '✅ Pass' || '❌ Fail' }} |" >> $GITHUB_STEP_SUMMARY + echo "| Composer Audit | ${{ needs.composer-audit.result == 'success' && '✅ Pass' || '❌ Fail' }} |" >> $GITHUB_STEP_SUMMARY + echo "| PHP Compatibility | ${{ needs.php-compat.result == 'success' && '✅ Pass' || '⚠️ Partial' }} |" >> $GITHUB_STEP_SUMMARY + + # Fail if any critical check failed + if [ "${{ needs.syntax.result }}" != "success" ] || \ + [ "${{ needs.code-style.result }}" != "success" ] || \ + [ "${{ needs.phpstan.result }}" != "success" ] || \ + [ "${{ needs.strict-types.result }}" != "success" ] || \ + [ "${{ needs.security-patterns.result }}" != "success" ]; then + echo "" + echo "::error::One or more critical checks failed" + exit 1 + fi + + echo "" + echo "✅ All critical checks passed!" diff --git a/.gitignore b/.gitignore index 0338461..b627ba3 100644 --- a/.gitignore +++ b/.gitignore @@ -73,6 +73,11 @@ htmlcov/ *.log /logs/ +# PHP +.php-cs-fixer.cache +.phpunit.cache/ +.phpstan.cache/ + # Temp /tmp/ *.tmp diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..016144c --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,222 @@ +in(__DIR__ . '/src') + ->in(__DIR__ . '/tests') + ->name('*.php') + ->ignoreDotFiles(true) + ->ignoreVCS(true) + ->exclude('vendor'); + +return (new PhpCsFixer\Config()) + ->setRiskyAllowed(true) + ->setRules([ + // PSR-12 as base + '@PSR12' => true, + '@PSR12:risky' => true, + + // PHP version features + '@PHP81Migration' => true, + '@PHP80Migration:risky' => true, + + // Strict mode + 'declare_strict_types' => true, + 'strict_param' => true, + 'strict_comparison' => true, + + // Array syntax + 'array_syntax' => ['syntax' => 'short'], + 'no_whitespace_before_comma_in_array' => true, + 'whitespace_after_comma_in_array' => true, + 'trim_array_spaces' => true, + 'normalize_index_brace' => true, + + // Imports/namespaces + 'no_unused_imports' => true, + 'ordered_imports' => ['sort_algorithm' => 'alpha'], + 'global_namespace_import' => [ + 'import_classes' => true, + 'import_constants' => false, + 'import_functions' => false, + ], + + // Type declarations + 'fully_qualified_strict_types' => true, + 'no_superfluous_phpdoc_tags' => [ + 'allow_mixed' => false, + 'remove_inheritdoc' => true, + ], + + // Operators + 'binary_operator_spaces' => [ + 'default' => 'single_space', + ], + 'concat_space' => ['spacing' => 'one'], + 'unary_operator_spaces' => true, + 'ternary_operator_spaces' => true, + 'ternary_to_null_coalescing' => true, + + // Control structures + 'no_unneeded_control_parentheses' => true, + 'no_unneeded_curly_braces' => true, + 'yoda_style' => [ + 'equal' => false, + 'identical' => false, + 'less_and_greater' => false, + ], + + // Braces and spacing + 'single_space_around_construct' => true, + 'control_structure_braces' => true, + 'control_structure_continuation_position' => true, + 'curly_braces_position' => [ + 'classes_opening_brace' => 'next_line_unless_newline_at_signature_end', + 'functions_opening_brace' => 'next_line_unless_newline_at_signature_end', + ], + + // Casting + 'cast_spaces' => ['space' => 'single'], + 'lowercase_cast' => true, + 'short_scalar_cast' => true, + 'modernize_types_casting' => true, + + // Functions + 'function_declaration' => ['closure_function_spacing' => 'one'], + 'lambda_not_used_import' => true, + 'method_argument_space' => [ + 'on_multiline' => 'ensure_fully_multiline', + ], + 'nullable_type_declaration_for_default_null_value' => true, + 'return_type_declaration' => ['space_before' => 'none'], + 'single_line_throw' => false, + + // Classes + 'class_attributes_separation' => [ + 'elements' => [ + 'const' => 'one', + 'method' => 'one', + 'property' => 'one', + ], + ], + 'class_definition' => [ + 'single_line' => true, + 'single_item_single_line' => true, + ], + 'no_null_property_initialization' => true, + 'ordered_class_elements' => [ + 'order' => [ + 'use_trait', + 'constant_public', + 'constant_protected', + 'constant_private', + 'property_public_static', + 'property_protected_static', + 'property_private_static', + 'property_public', + 'property_protected', + 'property_private', + 'construct', + 'destruct', + 'magic', + 'phpunit', + 'method_public_static', + 'method_protected_static', + 'method_private_static', + 'method_public', + 'method_protected', + 'method_private', + ], + ], + 'self_accessor' => true, + 'visibility_required' => [ + 'elements' => ['property', 'method', 'const'], + ], + + // Comments and PHPDoc + 'single_line_comment_style' => ['comment_types' => ['hash']], + 'no_empty_comment' => true, + 'phpdoc_align' => ['align' => 'left'], + 'phpdoc_annotation_without_dot' => true, + 'phpdoc_indent' => true, + 'phpdoc_inline_tag_normalizer' => true, + 'phpdoc_line_span' => [ + 'const' => 'single', + 'property' => 'single', + ], + 'phpdoc_no_empty_return' => true, + 'phpdoc_order' => true, + 'phpdoc_scalar' => true, + 'phpdoc_separation' => true, + 'phpdoc_summary' => true, + 'phpdoc_trim' => true, + 'phpdoc_trim_consecutive_blank_line_separation' => true, + 'phpdoc_types' => true, + 'phpdoc_types_order' => [ + 'null_adjustment' => 'always_last', + 'sort_algorithm' => 'none', + ], + 'phpdoc_var_without_name' => true, + + // Strings + 'single_quote' => true, + 'explicit_string_variable' => true, + 'simple_to_complex_string_variable' => true, + + // Semicolons + 'no_empty_statement' => true, + 'no_singleline_whitespace_before_semicolons' => true, + 'multiline_whitespace_before_semicolons' => [ + 'strategy' => 'no_multi_line', + ], + + // Whitespace + 'no_extra_blank_lines' => [ + 'tokens' => [ + 'extra', + 'throw', + 'use', + 'use_trait', + ], + ], + 'no_spaces_around_offset' => true, + 'no_trailing_whitespace' => true, + 'no_trailing_whitespace_in_comment' => true, + 'no_whitespace_in_blank_line' => true, + 'single_blank_line_at_eof' => true, + 'blank_line_before_statement' => [ + 'statements' => [ + 'return', + 'throw', + 'try', + ], + ], + + // Security-conscious rules + 'no_eval' => true, // Warn about eval() usage + 'random_api_migration' => true, // Use random_int/random_bytes + 'native_function_invocation' => [ + 'include' => ['@compiler_optimized'], + 'scope' => 'namespaced', + ], + + // Cleanup + 'no_unused_imports' => true, + 'no_useless_else' => true, + 'no_useless_return' => true, + 'simplified_null_return' => true, + ]) + ->setFinder($finder) + ->setCacheFile(__DIR__ . '/.php-cs-fixer.cache'); diff --git a/SECURE_DEFAULTS.md b/SECURE_DEFAULTS.md new file mode 100644 index 0000000..ce85e4d --- /dev/null +++ b/SECURE_DEFAULTS.md @@ -0,0 +1,487 @@ +# Secure Defaults Checklist + +This document provides a comprehensive checklist for secure PHP development using php-aegis. Follow these guidelines to ensure your application follows security best practices. + +## Table of Contents + +- [PHP Configuration](#php-configuration) +- [Input Validation](#input-validation) +- [Output Sanitization](#output-sanitization) +- [HTTP Security Headers](#http-security-headers) +- [Authentication & Sessions](#authentication--sessions) +- [Database Security](#database-security) +- [File Operations](#file-operations) +- [Cryptography](#cryptography) +- [Error Handling](#error-handling) +- [CI/CD Security](#cicd-security) +- [Dependency Management](#dependency-management) + +--- + +## PHP Configuration + +### Required Settings + +```ini +; Strict error reporting (development) +error_reporting = E_ALL +display_errors = Off +log_errors = On + +; Session security +session.cookie_httponly = 1 +session.cookie_secure = 1 +session.cookie_samesite = Strict +session.use_strict_mode = 1 +session.use_only_cookies = 1 + +; Disable dangerous functions +disable_functions = exec,passthru,shell_exec,system,proc_open,popen,curl_multi_exec,parse_ini_file,show_source,eval + +; File upload limits +upload_max_filesize = 10M +max_file_uploads = 5 + +; Exposure reduction +expose_php = Off +``` + +### Checklist + +- [ ] `declare(strict_types=1)` at top of every PHP file +- [ ] Error display disabled in production (`display_errors = Off`) +- [ ] Error logging enabled (`log_errors = On`) +- [ ] Dangerous functions disabled where not needed +- [ ] PHP version exposure disabled (`expose_php = Off`) +- [ ] Session cookies are HttpOnly and Secure +- [ ] Appropriate memory and execution limits set + +--- + +## Input Validation + +### Using php-aegis Validator + +```php +use PhpAegis\Validator; + +// Always validate before use +$email = Validator::email($_POST['email'] ?? '') ? $_POST['email'] : null; +$url = Validator::httpsUrl($_POST['website'] ?? '') ? $_POST['website'] : null; +$id = Validator::uuid($_GET['id'] ?? '') ? $_GET['id'] : null; +``` + +### Checklist + +- [ ] **Never trust user input** - validate ALL external data +- [ ] Use `Validator::email()` for email addresses +- [ ] Use `Validator::httpsUrl()` for URLs (enforce HTTPS) +- [ ] Use `Validator::uuid()` for identifiers +- [ ] Use `Validator::int()` with min/max bounds for integers +- [ ] Use `Validator::noNullBytes()` to prevent null byte injection +- [ ] Use `Validator::safeFilename()` for user-provided filenames +- [ ] Use `Validator::printable()` for text that should have no control chars +- [ ] Reject invalid input rather than attempting to "fix" it +- [ ] Validate data types, lengths, formats, and ranges +- [ ] Use allowlists over denylists where possible + +### Validation Priority + +| Input Source | Risk Level | Required Validation | +|-------------|------------|---------------------| +| `$_GET` | High | Always validate | +| `$_POST` | High | Always validate | +| `$_FILES` | Critical | Validate + scan | +| `$_COOKIE` | High | Always validate | +| `$_SERVER` | Medium | Validate if user-influenced | +| Database | Medium | Validate on retrieval | +| APIs | Medium | Validate responses | + +--- + +## Output Sanitization + +### Using php-aegis Sanitizer + +```php +use PhpAegis\Sanitizer; + +// HTML context +echo Sanitizer::html($userInput); + +// HTML attribute context +echo ''; + +// JavaScript context +echo ''; + +// URL context +echo 'Link'; + +// CSS context (limited support - prefer external stylesheets) +echo '