From ee834ad9b9f926166e6058ed0cf043591d384544 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Dec 2025 00:08:50 +0000 Subject: [PATCH] feat: Integrate php-aegis and sanctify-php security patterns This commit enhances the plugin's security posture by integrating patterns from the php-aegis and sanctify-php security libraries. Changes: - Add WPCM_Security class (PHP 7.4+ compatible security layer) - Add declare(strict_types=1) to all PHP files - Add SPDX license headers to all PHP files - Fix REST API limit parameter sanitization - Add security-analysis.yml GitHub workflow - Add docs/SECURITY-INTEGRATION.md with integration report - Update composer.json with security scripts and suggestions The integration report documents: - PHP version incompatibility (php-aegis requires 8.1+, plugin needs 7.4+) - sanctify-php is a Haskell build tool, not a runtime PHP library - Recommendations for upstream improvements to both libraries Version bumped to 1.1.0 to reflect security enhancements. --- .github/workflows/security-analysis.yml | 180 ++++++++ admin/class-wpcm-admin.php | 5 + admin/class-wpcm-ajax.php | 5 + admin/class-wpcm-settings.php | 5 + admin/views/dashboard.php | 5 + admin/views/rankings.php | 5 + admin/views/reports.php | 5 + admin/views/settings.php | 5 + composer.json | 12 +- docs/SECURITY-INTEGRATION.md | 195 ++++++++ includes/class-wpcm-cache.php | 5 + includes/class-wpcm-cli.php | 5 + includes/class-wpcm-conflict-detector.php | 5 + includes/class-wpcm-database.php | 5 + includes/class-wpcm-installer.php | 5 + includes/class-wpcm-overlap-analyzer.php | 5 + includes/class-wpcm-performance-analyzer.php | 5 + includes/class-wpcm-plugin-scanner.php | 5 + includes/class-wpcm-ranking-engine.php | 5 + includes/class-wpcm-rest-api.php | 12 +- includes/class-wpcm-security-scanner.php | 5 + includes/class-wpcm-security.php | 439 +++++++++++++++++++ uninstall.php | 5 + wp-plugin-conflict-mapper.php | 14 +- 24 files changed, 936 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/security-analysis.yml create mode 100644 docs/SECURITY-INTEGRATION.md create mode 100644 includes/class-wpcm-security.php diff --git a/.github/workflows/security-analysis.yml b/.github/workflows/security-analysis.yml new file mode 100644 index 0000000..f1c4cfb --- /dev/null +++ b/.github/workflows/security-analysis.yml @@ -0,0 +1,180 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2025 Jonathan + +name: Security Analysis + +on: + push: + branches: [ "main", "develop" ] + pull_request: + branches: [ "main" ] + +permissions: + contents: read + security-events: write + +jobs: + phpstan: + name: PHPStan Static Analysis + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + extensions: mbstring, intl + tools: composer, phpstan + + - name: Cache Composer packages + uses: actions/cache@v4 + with: + path: vendor + key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run PHPStan + run: vendor/bin/phpstan analyse --level=5 --error-format=github includes/ admin/ wp-plugin-conflict-mapper.php || true + + wpcs: + name: WordPress Coding Standards + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + extensions: mbstring + tools: composer, cs2pr + + - name: Cache Composer packages + uses: actions/cache@v4 + with: + path: vendor + key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run PHPCS + run: | + vendor/bin/phpcs --standard=WordPress --extensions=php \ + --ignore=*/vendor/*,*/node_modules/*,*/tests/* \ + --report=checkstyle \ + includes/ admin/ wp-plugin-conflict-mapper.php uninstall.php | cs2pr || true + + security-patterns: + name: Security Pattern Check + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Check for dangerous patterns + run: | + echo "Checking for dangerous PHP patterns..." + + # Check for eval usage + if grep -r --include="*.php" "\beval\s*(" includes/ admin/ wp-plugin-conflict-mapper.php 2>/dev/null; then + echo "::warning::Found eval() usage - review required" + fi + + # Check for unescaped output + if grep -r --include="*.php" "echo\s*\\\$_" includes/ admin/ 2>/dev/null; then + echo "::error::Found unescaped superglobal in echo" + exit 1 + fi + + # Check for SQL without prepare + if grep -r --include="*.php" '\$wpdb->query\s*(\s*"' includes/ admin/ 2>/dev/null | grep -v "prepare"; then + echo "::warning::Found potential SQL without prepare()" + fi + + # Check for missing ABSPATH + for file in $(find includes/ admin/ -name "*.php" -type f); do + if ! head -20 "$file" | grep -q "defined.*ABSPATH"; then + echo "::error::Missing ABSPATH check in $file" + exit 1 + fi + done + + echo "Security pattern check passed" + + strict-types: + name: Strict Types Check + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Check for strict_types declaration + run: | + echo "Checking for declare(strict_types=1)..." + missing=0 + + for file in $(find includes/ admin/ -name "*.php" -type f); do + if ! head -20 "$file" | grep -q "declare(strict_types=1)"; then + echo "::warning::Missing strict_types in $file" + missing=$((missing + 1)) + fi + done + + if [ $missing -gt 0 ]; then + echo "::warning::$missing files missing strict_types declaration" + else + echo "All PHP files have strict_types declaration" + fi + + # sanctify-php analysis (when available) + # Uncomment when sanctify-php binaries are available + # + # sanctify: + # name: Sanctify-PHP Analysis + # runs-on: ubuntu-latest + # + # steps: + # - name: Checkout code + # uses: actions/checkout@v4 + # + # - name: Setup Haskell + # uses: haskell-actions/setup@v2 + # with: + # ghc-version: '9.4' + # cabal-version: '3.8' + # + # - name: Cache Cabal packages + # uses: actions/cache@v4 + # with: + # path: ~/.cabal + # key: ${{ runner.os }}-cabal-${{ hashFiles('**/sanctify-php.cabal') }} + # + # - name: Install sanctify-php + # run: | + # git clone https://github.com/hyperpolymath/sanctify-php.git /tmp/sanctify-php + # cd /tmp/sanctify-php + # cabal update + # cabal install + # + # - name: Run sanctify-php analysis + # run: | + # sanctify analyze --format sarif ./includes/ ./admin/ ./wp-plugin-conflict-mapper.php > sanctify-results.sarif + # + # - name: Upload SARIF results + # uses: github/codeql-action/upload-sarif@v3 + # with: + # sarif_file: sanctify-results.sarif diff --git a/admin/class-wpcm-admin.php b/admin/class-wpcm-admin.php index 60a6011..eb3a785 100644 --- a/admin/class-wpcm-admin.php +++ b/admin/class-wpcm-admin.php @@ -1,5 +1,8 @@ security-report.sarif +``` + +### Continuous Integration + +Added `.github/workflows/php-security.yml` for automated security checks: +- PHPStan static analysis +- WordPress Coding Standards +- sanctify-php analysis (when available) + +--- + +## Recommendations Summary + +### For This Project + +1. Consider raising minimum PHP to 8.0+ in future major version +2. Run sanctify-php analysis before releases +3. Keep WPCM_Security class updated with php-aegis patterns + +### For php-aegis Upstream + +1. **Priority High**: Add PHP 7.4 compatibility or separate package +2. **Priority Medium**: Add WordPress adapter +3. **Priority Low**: Consider runtime-only minimal build + +### For sanctify-php Upstream + +1. **Priority High**: Pre-built binaries / easy installation +2. **Priority Medium**: GitHub Action for CI integration +3. **Priority Low**: PHP Composer plugin wrapper + +--- + +## Files Modified + +- `includes/class-wpcm-security.php` - New security layer +- `includes/class-wpcm-rest-api.php` - Sanitized limit parameter +- `wp-plugin-conflict-mapper.php` - Added strict_types, loads security class +- All PHP files - Added declare(strict_types=1) +- `.github/workflows/php-security.yml` - Security CI workflow +- `composer.json` - Added php-aegis as dev suggestion + +--- + +## Version History + +| Date | Change | +|------|--------| +| 2025-12-27 | Initial integration analysis and implementation | diff --git a/includes/class-wpcm-cache.php b/includes/class-wpcm-cache.php index 1ae12c8..93f4ebf 100644 --- a/includes/class-wpcm-cache.php +++ b/includes/class-wpcm-cache.php @@ -1,5 +1,8 @@ get_param('limit') ?: 10; + public function get_scans($request): WP_REST_Response { + $limit_param = $request->get_param('limit'); + $limit = $limit_param ? absint($limit_param) : 10; + $limit = min($limit, 100); // Cap at 100 for performance + $database = new WPCM_Database(); $scans = $database->get_recent_scans($limit); diff --git a/includes/class-wpcm-security-scanner.php b/includes/class-wpcm-security-scanner.php index 75567d1..a98079d 100644 --- a/includes/class-wpcm-security-scanner.php +++ b/includes/class-wpcm-security-scanner.php @@ -1,5 +1,8 @@ $options); + + return filter_var($value, FILTER_VALIDATE_INT, $filter_options) !== false; + } + + /** + * Validate boolean value + * + * @param mixed $value Value to validate. + * @return bool True if valid boolean representation. + */ + public static function validate_bool($value): bool { + return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== null; + } + + /** + * Validate IP address + * + * @param string $ip IP address to validate. + * @param int $type FILTER_FLAG_IPV4 or FILTER_FLAG_IPV6 (optional). + * @return bool True if valid IP address. + */ + public static function validate_ip(string $ip, ?int $type = null): bool { + return filter_var($ip, FILTER_VALIDATE_IP, $type) !== false; + } + + /** + * Validate slug format + * + * Checks if string contains only lowercase alphanumeric and hyphens. + * + * @param string $slug Slug to validate. + * @return bool True if valid slug format. + */ + public static function validate_slug(string $slug): bool { + return (bool) preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slug); + } + + /** + * Sanitize HTML for safe output + * + * Prevents XSS by encoding special characters. + * + * @param string $input Input to sanitize. + * @return string Sanitized output safe for HTML. + */ + public static function sanitize_html(string $input): string { + return htmlspecialchars($input, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } + + /** + * Sanitize text field (WordPress wrapper) + * + * @param string $input Input to sanitize. + * @return string Sanitized text. + */ + public static function sanitize_text(string $input): string { + return sanitize_text_field($input); + } + + /** + * Sanitize key (WordPress wrapper) + * + * @param string $input Input to sanitize. + * @return string Sanitized key. + */ + public static function sanitize_key(string $input): string { + return sanitize_key($input); + } + + /** + * Sanitize textarea field (WordPress wrapper) + * + * @param string $input Input to sanitize. + * @return string Sanitized textarea content. + */ + public static function sanitize_textarea(string $input): string { + return sanitize_textarea_field($input); + } + + /** + * Sanitize file name (WordPress wrapper) + * + * @param string $input Input to sanitize. + * @return string Sanitized file name. + */ + public static function sanitize_filename(string $input): string { + return sanitize_file_name($input); + } + + /** + * Sanitize and validate integer + * + * Returns absolute integer value, always positive. + * + * @param mixed $input Input to sanitize. + * @return int Absolute integer value. + */ + public static function sanitize_int($input): int { + return absint($input); + } + + /** + * Sanitize integer allowing negative values + * + * @param mixed $input Input to sanitize. + * @return int Integer value. + */ + public static function sanitize_int_signed($input): int { + return (int) $input; + } + + /** + * Strip all HTML and PHP tags + * + * @param string $input Input to strip. + * @return string Content without tags. + */ + public static function strip_tags(string $input): string { + return strip_tags($input); + } + + /** + * Verify WordPress nonce + * + * @param string $nonce Nonce to verify. + * @param string $action Action name. + * @return bool True if valid nonce. + */ + public static function verify_nonce(string $nonce, string $action): bool { + return wp_verify_nonce($nonce, $action) !== false; + } + + /** + * Check AJAX referer (nonce) + * + * @param string $action Action name. + * @param string $query_arg Query argument containing nonce. + * @param bool $die Whether to die on failure. + * @return bool|int False on failure, 1 or 2 on success. + */ + public static function check_ajax_nonce(string $action, string $query_arg = '_wpnonce', bool $die = false) { + return check_ajax_referer($action, $query_arg, $die); + } + + /** + * Check user capability + * + * @param string $capability Capability to check. + * @return bool True if user has capability. + */ + public static function check_capability(string $capability = 'manage_options'): bool { + return current_user_can($capability); + } + + /** + * Verify AJAX request security + * + * Combines nonce verification and capability check. + * + * @param string $nonce_action Nonce action name. + * @param string $nonce_key POST key containing nonce. + * @param string $capability Required capability. + * @return bool True if request is valid. + */ + public static function verify_ajax_request( + string $nonce_action, + string $nonce_key = 'nonce', + string $capability = 'manage_options' + ): bool { + if (!self::check_ajax_nonce($nonce_action, $nonce_key, false)) { + return false; + } + + if (!self::check_capability($capability)) { + return false; + } + + return true; + } + + /** + * Send unauthorized JSON response and die + * + * @param string $message Error message. + * @return void + */ + public static function send_unauthorized(string $message = ''): void { + if (empty($message)) { + $message = __('Security check failed', 'wp-plugin-conflict-mapper'); + } + + wp_send_json_error( + array('message' => $message), + 403 + ); + } + + /** + * Send forbidden JSON response and die + * + * @param string $message Error message. + * @return void + */ + public static function send_forbidden(string $message = ''): void { + if (empty($message)) { + $message = __('Insufficient permissions', 'wp-plugin-conflict-mapper'); + } + + wp_send_json_error( + array('message' => $message), + 403 + ); + } + + /** + * Get and sanitize POST parameter + * + * @param string $key Parameter key. + * @param string $type Sanitization type: text, int, email, url, key, textarea. + * @param mixed $default Default value if not set. + * @return mixed Sanitized value. + */ + public static function get_post_param(string $key, string $type = 'text', $default = '') { + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce should be verified separately + if (!isset($_POST[$key])) { + return $default; + } + + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce should be verified separately + $value = $_POST[$key]; + + return self::sanitize_by_type($value, $type); + } + + /** + * Get and sanitize GET parameter + * + * @param string $key Parameter key. + * @param string $type Sanitization type: text, int, email, url, key, textarea. + * @param mixed $default Default value if not set. + * @return mixed Sanitized value. + */ + public static function get_get_param(string $key, string $type = 'text', $default = '') { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce should be verified separately + if (!isset($_GET[$key])) { + return $default; + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce should be verified separately + $value = $_GET[$key]; + + return self::sanitize_by_type($value, $type); + } + + /** + * Sanitize value by type + * + * @param mixed $value Value to sanitize. + * @param string $type Sanitization type. + * @return mixed Sanitized value. + */ + private static function sanitize_by_type($value, string $type) { + switch ($type) { + case 'int': + return self::sanitize_int($value); + + case 'int_signed': + return self::sanitize_int_signed($value); + + case 'email': + return sanitize_email($value); + + case 'url': + return esc_url_raw($value); + + case 'key': + return self::sanitize_key($value); + + case 'textarea': + return self::sanitize_textarea($value); + + case 'filename': + return self::sanitize_filename($value); + + case 'html': + return wp_kses_post($value); + + case 'text': + default: + return self::sanitize_text($value); + } + } + + /** + * Escape output for HTML context (WordPress wrapper) + * + * @param string $output Output to escape. + * @return string Escaped output. + */ + public static function esc_html(string $output): string { + return esc_html($output); + } + + /** + * Escape output for HTML attribute context (WordPress wrapper) + * + * @param string $output Output to escape. + * @return string Escaped output. + */ + public static function esc_attr(string $output): string { + return esc_attr($output); + } + + /** + * Escape output for URL context (WordPress wrapper) + * + * @param string $output Output to escape. + * @return string Escaped output. + */ + public static function esc_url(string $output): string { + return esc_url($output); + } + + /** + * Escape output for JavaScript context (WordPress wrapper) + * + * @param string $output Output to escape. + * @return string Escaped output. + */ + public static function esc_js(string $output): string { + return esc_js($output); + } + + /** + * Check for potentially dangerous patterns in content + * + * @param string $content Content to check. + * @return array Array of detected issues. + */ + public static function detect_dangerous_patterns(string $content): array { + $issues = array(); + + $dangerous_patterns = array( + 'eval' => '/\beval\s*\(/i', + 'base64_decode' => '/\bbase64_decode\s*\(/i', + 'shell_exec' => '/\bshell_exec\s*\(/i', + 'exec' => '/\bexec\s*\(/i', + 'system' => '/\bsystem\s*\(/i', + 'passthru' => '/\bpassthru\s*\(/i', + 'popen' => '/\bpopen\s*\(/i', + 'proc_open' => '/\bproc_open\s*\(/i', + 'unserialize' => '/\bunserialize\s*\(/i', + 'create_function' => '/\bcreate_function\s*\(/i', + 'preg_replace_e' => '/preg_replace\s*\(\s*[\'"][^\'"]*\/[a-zA-Z]*e/i', + 'extract' => '/\bextract\s*\(\s*\$_(GET|POST|REQUEST)/i', + 'sql_injection' => '/\$wpdb->(query|get_results)\s*\(\s*["\'].*?\$[a-zA-Z_]/', + ); + + foreach ($dangerous_patterns as $name => $pattern) { + if (preg_match($pattern, $content)) { + $issues[] = array( + 'type' => $name, + 'message' => sprintf('Potentially dangerous pattern detected: %s', $name), + ); + } + } + + return $issues; + } +} diff --git a/uninstall.php b/uninstall.php index 365ad65..420470c 100644 --- a/uninstall.php +++ b/uninstall.php @@ -1,5 +1,8 @@