#!/usr/bin/env tsx "use strict"; /** * WordPress Plugin Validator Tool * * Validates WordPress plugins for security, coding standards, and best practices. * Token-efficient: returns minimal output on success, detailed output on failure. */ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.validateWordPress = validateWordPress; const fs = __importStar(require("fs")); const path = __importStar(require("path")); const FORBIDDEN_FUNCTIONS = [ { pattern: /\beval\s*\(/, name: "eval", reason: "Arbitrary code execution risk" }, { pattern: /\bexec\s*\(/, name: "exec", reason: "Command execution" }, { pattern: /\bpassthru\s*\(/, name: "passthru", reason: "Command execution" }, { pattern: /\bshell_exec\s*\(/, name: "shell_exec", reason: "Command execution" }, { pattern: /\bsystem\s*\(/, name: "system", reason: "Command execution" }, { pattern: /\bproc_open\s*\(/, name: "proc_open", reason: "Command execution" }, { pattern: /\bpopen\s*\(/, name: "popen", reason: "Command execution" }, { pattern: /\bcurl_exec\s*\(/, name: "curl_exec", reason: "Use wp_remote_get() instead" }, { pattern: /\bfile_get_contents\s*\(/, name: "file_get_contents", reason: "Use wp_remote_get() for URLs" }, { pattern: /\bbase64_decode\s*\(/, name: "base64_decode", reason: "Suspicious obfuscation risk" }, { pattern: /\bcreate_function\s*\(/, name: "create_function", reason: "Deprecated and dangerous" }, { pattern: /\bextract\s*\(/, name: "extract", reason: "Variable pollution risk" }, { pattern: /\bassert\s*\(/, name: "assert", reason: "Deprecated in PHP 8" }, { pattern: /\bpreg_replace\s*\([^)]*\/e/, name: "preg_replace /e", reason: "Use callback instead" }, ]; const DEPRECATED_WP_FUNCTIONS = [ { pattern: /\bwp_get_http\b/, name: "wp_get_http", replacement: "wp_remote_get()" }, { pattern: /\bwp_specialchars\b/, name: "wp_specialchars", replacement: "esc_html()" }, { pattern: /\bget_the_author_email\b/, name: "get_the_author_email", replacement: "get_the_author_meta('email')" }, { pattern: /\bget_the_author_icq\b/, name: "get_the_author_icq", replacement: "Removed" }, { pattern: /\bget_the_author_yim\b/, name: "get_the_author_yim", replacement: "Removed" }, { pattern: /\bget_the_author_msn\b/, name: "get_the_author_msn", replacement: "Removed" }, { pattern: /\bget_the_author_aim\b/, name: "get_the_author_aim", replacement: "Removed" }, { pattern: /\bthe_editor\b/, name: "the_editor", replacement: "wp_editor()" }, ]; const TOO_FEW_ARGUMENTS_PATTERNS = [ { pattern: /array_key_exists\s*\(\s*['"][^'"]+['"]\s*\)(?!\s*,)/, name: "array_key_exists", required: "2 arguments (key, array)" }, { pattern: /str_replace\s*\(\s*[^)]+,\s*[^)]+\s*\)(?!,)/, name: "str_replace", required: "3 arguments (search, replace, subject)" }, { pattern: /array_slice\s*\(\s*\$[a-zA-Z_]+\s*\)(?!,)/, name: "array_slice", required: "2+ arguments (array, offset)" }, { pattern: /in_array\s*\(\s*\$[a-zA-Z_]+\s*\)(?!,)/, name: "in_array", required: "2 arguments (needle, haystack)" }, { pattern: /explode\s*\(\s*['"][^'"]+['"]\s*\)(?!,)/, name: "explode", required: "2 arguments (delimiter, string)" }, ]; function findPhpFiles(dir) { const files = []; const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { if (!["vendor", "node_modules"].includes(entry.name)) { files.push(...findPhpFiles(fullPath)); } } else if (entry.name.endsWith(".php")) { files.push(fullPath); } } return files; } function findCssFiles(dir) { const files = []; const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { if (!["vendor", "node_modules"].includes(entry.name)) { files.push(...findCssFiles(fullPath)); } } else if (entry.name.endsWith(".css")) { files.push(fullPath); } } return files; } function getLineNumber(content, index) { return content.substring(0, index).split("\n").length; } function checkForbiddenFunctions(filePath, content) { const issues = []; for (const { pattern, name, reason } of FORBIDDEN_FUNCTIONS) { let match; while ((match = pattern.exec(content)) !== null) { issues.push({ check: "Forbidden Functions", file: filePath, line: getLineNumber(content, match.index), issue: `Found forbidden function '${name}'`, severity: "critical", suggestion: reason, }); } } return issues; } function checkSqlInjection(filePath, content) { const issues = []; const sqlPattern = /\$wpdb->(query|get_results|get_row|get_var|get_col)\s*\(\s*["'].*\$[a-zA-Z].*["']/g; let match; while ((match = sqlPattern.exec(content)) !== null) { issues.push({ check: "SQL Injection", file: filePath, line: getLineNumber(content, match.index), issue: "Variable directly interpolated into SQL string", severity: "critical", suggestion: "Use $wpdb->prepare()", }); } return issues; } function checkXssVulnerabilities(filePath, content) { const issues = []; // Direct echo of superglobals const superglobalEchoPattern = /echo\s+.*(\$_GET|\$_POST|\$_REQUEST|\$_SERVER)/g; let match; while ((match = superglobalEchoPattern.exec(content)) !== null) { issues.push({ check: "XSS Vulnerability", file: filePath, line: getLineNumber(content, match.index), issue: "Direct echo of superglobal detected", severity: "critical", suggestion: "Use esc_html() or esc_attr()", }); } // Unsanitized input assignment const unsanitizedPattern = /\$[a-zA-Z0-9_]+\s*=\s*(\$_POST|\$_GET|\$_REQUEST)(?!.*sanitize_|.*intval|.*esc_)/g; while ((match = unsanitizedPattern.exec(content)) !== null) { issues.push({ check: "Unsanitized Input", file: filePath, line: getLineNumber(content, match.index), issue: "Raw input assigned without sanitization", severity: "warning", suggestion: "Use sanitize_text_field() or similar", }); } return issues; } function checkNoncesAndCapabilities(filePath, content) { const issues = []; if (content.includes("$_POST")) { const hasNonce = /wp_verify_nonce|check_admin_referer|check_ajax_referer/.test(content); const hasCapability = /current_user_can/.test(content); if (!hasNonce) { issues.push({ check: "CSRF Protection", file: filePath, issue: "File processes $_POST but no nonce verification found", severity: "critical", suggestion: "Add wp_verify_nonce() or check_admin_referer()", }); } if (!hasCapability) { issues.push({ check: "Capability Check", file: filePath, issue: "File processes $_POST but no capability check found", severity: "warning", suggestion: "Add current_user_can() check", }); } } return issues; } function checkUndefinedArrayKeys(filePath, content) { const issues = []; const superglobalPattern = /\$_(POST|GET|REQUEST|COOKIE)\[/g; const lines = content.split("\n"); for (let i = 0; i < lines.length; i++) { const line = lines[i]; let match; while ((match = superglobalPattern.exec(line)) !== null) { // Check if previous line has isset or array_key_exists if (i === 0 || (!lines[i - 1].includes("isset") && !lines[i - 1].includes("array_key_exists"))) { issues.push({ check: "Undefined Array Keys", file: filePath, line: i + 1, issue: "Superglobal array access without validation", severity: "critical", suggestion: "Use isset() or array_key_exists()", }); } } } return issues; } function checkDirectFileAccess(filePath, content) { const issues = []; const firstLines = content.split("\n").slice(0, 20).join("\n"); if (!/defined\s*\(\s*['"]ABSPATH['"]\s*\)|defined\s*\(\s*['"]WPINC['"]\s*\)/.test(firstLines)) { issues.push({ check: "Direct Access Protection", file: filePath, issue: "Missing ABSPATH check at top of file", severity: "warning", suggestion: "Add 'defined(ABSPATH) || exit;'", }); } return issues; } function checkDeprecatedFunctions(filePath, content) { const issues = []; for (const { pattern, name, replacement } of DEPRECATED_WP_FUNCTIONS) { let match; while ((match = pattern.exec(content)) !== null) { issues.push({ check: "Deprecated WordPress Function", file: filePath, line: getLineNumber(content, match.index), issue: `Found deprecated function '${name}'`, severity: "warning", suggestion: `Use ${replacement}`, }); } } return issues; } function checkAjaxSecurity(filePath, content) { const issues = []; if (/add_action.*wp_ajax_/.test(content)) { if (!/check_ajax_referer|wp_verify_nonce/.test(content)) { issues.push({ check: "AJAX Security", file: filePath, issue: "AJAX handler missing nonce verification", severity: "critical", suggestion: "Add check_ajax_referer()", }); } } return issues; } function checkRestApiSecurity(filePath, content) { const issues = []; if (/register_rest_route/.test(content)) { if (!/permission_callback/.test(content)) { issues.push({ check: "REST API Security", file: filePath, issue: "REST route missing permission_callback", severity: "critical", suggestion: "Add permission_callback to register_rest_route()", }); } } return issues; } function checkTooFewArguments(filePath, content) { const issues = []; for (const { pattern, name, required } of TOO_FEW_ARGUMENTS_PATTERNS) { let match; while ((match = pattern.exec(content)) !== null) { issues.push({ check: "Too Few Arguments", file: filePath, line: getLineNumber(content, match.index), issue: `${name}() called with insufficient arguments`, severity: "critical", suggestion: `Requires ${required}`, }); } } return issues; } function checkCssOverlap(filePath, content) { const issues = []; // Negative margins const negativeMarginPattern = /margin-(top|bottom|left|right)\s*:\s*-[0-9]/g; let match; while ((match = negativeMarginPattern.exec(content)) !== null) { issues.push({ check: "CSS Overlap", file: filePath, line: getLineNumber(content, match.index), issue: "Negative margins detected", severity: "warning", suggestion: "May cause element overlap", }); } // Absolute positioning without z-index if (/position\s*:\s*absolute/.test(content) && !/z-index\s*:\s*[1-9]/.test(content)) { issues.push({ check: "CSS Overlap", file: filePath, issue: "Absolute positioning without z-index", severity: "warning", suggestion: "Add z-index to prevent overlap", }); } return issues; } function checkWpLoadInclusion(filePath, content) { const issues = []; if (/wp-load\.php/.test(content)) { issues.push({ check: "Architecture", file: filePath, issue: "Direct load of wp-load.php detected", severity: "critical", suggestion: "Plugins must hook into WP, not load it manually", }); } return issues; } async function validateWordPress(pluginPath) { const result = { status: "pass", summary: "", pluginPath, checksRun: 11, errors: [], warnings: [], stats: { filesScanned: 0, criticalCount: 0, warningCount: 0, }, }; if (!fs.existsSync(pluginPath)) { result.status = "fail"; result.summary = "Plugin directory not found"; return result; } const phpFiles = findPhpFiles(pluginPath); const cssFiles = findCssFiles(pluginPath); result.stats.filesScanned = phpFiles.length + cssFiles.length; if (phpFiles.length === 0) { result.status = "fail"; result.summary = "No PHP files found"; return result; } // Run all checks for (const file of phpFiles) { const content = fs.readFileSync(file, "utf-8"); const relativePath = path.relative(pluginPath, file); result.errors.push(...checkForbiddenFunctions(relativePath, content)); result.errors.push(...checkSqlInjection(relativePath, content)); result.errors.push(...checkXssVulnerabilities(relativePath, content)); result.errors.push(...checkNoncesAndCapabilities(relativePath, content)); result.errors.push(...checkUndefinedArrayKeys(relativePath, content)); result.errors.push(...checkDirectFileAccess(relativePath, content)); result.warnings.push(...checkDeprecatedFunctions(relativePath, content)); result.errors.push(...checkAjaxSecurity(relativePath, content)); result.errors.push(...checkRestApiSecurity(relativePath, content)); result.errors.push(...checkTooFewArguments(relativePath, content)); result.errors.push(...checkWpLoadInclusion(relativePath, content)); } // CSS checks for (const file of cssFiles) { const content = fs.readFileSync(file, "utf-8"); const relativePath = path.relative(pluginPath, file); result.warnings.push(...checkCssOverlap(relativePath, content)); } // Update stats result.stats.criticalCount = result.errors.length; result.stats.warningCount = result.warnings.length; // Determine status if (result.errors.length > 0) { result.status = "fail"; result.summary = `${result.errors.length} critical issues, ${result.warnings.length} warnings`; } else if (result.warnings.length > 0) { result.status = "pass"; result.summary = `All checks passed with ${result.warnings.length} warnings`; } else { result.status = "pass"; result.summary = "All 11 checks passed"; } return result; } // CLI entry point if (require.main === module) { const pluginPath = process.argv[2]; if (!pluginPath) { console.error("Usage: wordpress-validate "); process.exit(1); } validateWordPress(pluginPath).then(result => { console.log(JSON.stringify(result, null, 2)); process.exit(result.status === "fail" ? 1 : 0); }).catch(err => { console.error("Validation error:", err); process.exit(1); }); }