import os import re def _get_plugin_dirs(directory: str) -> tuple[str, str]: """Return (WP_PLUGIN_DIR, WPMU_PLUGIN_DIR) for a WordPress install. If `wp-config.php` exists, this will try to resolve custom constants such as WP_PLUGIN_DIR and WPMU_PLUGIN_DIR. Otherwise it returns the default `wp-content/plugins` and `wp-content/mu-plugins` paths under `directory`. """ config_path = os.path.join(directory, "wp-config.php") # Default standard WordPress locations plugin_path = os.path.join(directory, "wp-content", "plugins") mu_plugin_path = os.path.join(directory, "wp-content", "mu-plugins") if not os.path.exists(config_path): print(f"Notice: {config_path} not found. Returning default paths.") return plugin_path, mu_plugin_path # READ mode ('r') is critical. 'w' would wipe the file. with open(config_path, "r", encoding="utf-8", errors="ignore") as config: content = config.read() def resolve_php_path(php_value_str: str, root_dir: str) -> str: """Resolve simple PHP path expressions used in wp-config.php. Args: php_value_str: Right-hand value extracted from the define() call. root_dir: Filesystem path to the WordPress root (dirname of wp-config.php). Returns: Normalized filesystem path produced from the PHP expression. """ # 1. Handle dirname(__FILE__) # In wp-config, __FILE__ is the config file itself, so dirname is the root_dir # We replace it with the actual root_dir enclosed in quotes to make it a string literal for parsing simulated_val = php_value_str.replace("dirname(__FILE__)", f"'{root_dir}'") # 2. Remove PHP comments if present (//) simulated_val = simulated_val.split("//")[0] # 3. Split by concatenation operator '.' parts = simulated_val.split(".") clean_parts = [] for part in parts: part = part.strip() # Strip quotes (' or ") from string literals if (part.startswith("'") and part.endswith("'")) or ( part.startswith('"') and part.endswith('"') ): clean_parts.append(part[1:-1]) else: # If we encounter a variable we didn't substitute, likely can't resolve statically pass # 4. Join to get full path full_path = "".join(clean_parts) return os.path.normpath(full_path) # Regex looks for: define( 'CONSTANT_NAME', value ); # It is case-insensitive and allows for various spacing/quoting styles plugin_regex = re.compile( r"define\s*\(\s*['\"]WP_PLUGIN_DIR['\"]\s*,\s*(.*?)\s*\)\s*;", re.IGNORECASE ) mu_regex = re.compile( r"define\s*\(\s*['\"]WPMU_PLUGIN_DIR['\"]\s*,\s*(.*?)\s*\)\s*;", re.IGNORECASE ) plugin_match = plugin_regex.search(content) if plugin_match: # Extract the value part (e.g., dirname(__FILE__) . '/plugins') raw_value = plugin_match.group(1) plugin_path = resolve_php_path(raw_value, directory) mu_match = mu_regex.search(content) if mu_match: raw_value = mu_match.group(1) mu_plugin_path = resolve_php_path(raw_value, directory) return plugin_path, mu_plugin_path def _get_plugins_from_dirs(plugin_dirs: list[str]) -> dict[str, str]: """Scan plugin directories and return mapping of slug -> main PHP path. Args: plugin_dirs: List of plugin directories to scan. Returns: Mapping from plugin slug to absolute path of the plugin's main PHP file. Notes: Only files containing a WordPress plugin header (e.g. "Plugin Name:") are considered; this mirrors how WordPress discovers admin-visible plugins. """ plugins: dict[str, str] = {} # Regex to detect the Plugin Name header used by WordPress plugin main files header_re = re.compile(r"Plugin\s+Name\s*:\s*(.+)", re.IGNORECASE) def has_plugin_header(file_path: str) -> bool: try: with open(file_path, "r", encoding="utf-8", errors="ignore") as fh: # Read a limited amount of the file; headers are near the top sample = fh.read(8192) except OSError: return False return bool(header_re.search(sample)) for p_dir in plugin_dirs: if not p_dir: continue if not os.path.isdir(p_dir): # skip non-existent plugin dirs silently continue try: entries = sorted(os.listdir(p_dir)) except OSError: continue for entry in entries: # Skip hidden files/dirs if entry.startswith("."): continue entry_path = os.path.join(p_dir, entry) # Single-file plugin (foo.php) directly in plugins dir if os.path.isfile(entry_path) and entry.lower().endswith(".php"): if has_plugin_header(entry_path): slug = os.path.splitext(entry)[0] # Keep first discovered instance (do not override) plugins.setdefault(slug, entry_path) continue # Directory-based plugin if os.path.isdir(entry_path): # Candidate files to check (prefer directory-named php file) candidates: list[str] = [] dir_named = os.path.join(entry_path, f"{entry}.php") if os.path.isfile(dir_named): candidates.append(dir_named) # Add any top-level php files in the plugin directory try: for f in os.listdir(entry_path): if f.lower().endswith(".php"): candidates.append(os.path.join(entry_path, f)) except OSError: # unreadable directory; skip continue found = None for c in candidates: if has_plugin_header(c): found = c break if found: slug = entry plugins.setdefault(slug, found) return plugins def get_plugins(directory: str) -> dict[str, str]: """Return admin-visible plugins for the WordPress install at ``directory``. Args: directory: Path to the WordPress installation root (where wp-config.php lives). Returns: Mapping of plugin slug to the absolute path of the plugin main file. """ p_dir, _mu_dir = _get_plugin_dirs(directory) return _get_plugins_from_dirs([p_dir]) def _get_obfuscated_plugins(plugin_dirs: list[str]) -> dict[str, str]: """Return plugins that may lack a plugin header (e.g. mu-plugins). Args: plugin_dirs: List of directories to scan for obfuscated/mu plugins. Returns: Mapping of slug to absolute filesystem path. Includes top-level .php files and one representative .php file from subdirectories. """ obf_plugins: dict[str, str] = {} for p_dir in plugin_dirs: if not p_dir: continue if not os.path.isdir(p_dir): continue try: entries = sorted(os.listdir(p_dir)) except OSError: continue for entry in entries: if entry.startswith("."): continue entry_path = os.path.join(p_dir, entry) # Top-level php files are executed as mu-plugins if os.path.isfile(entry_path) and entry.lower().endswith(".php"): slug = os.path.splitext(entry)[0] obf_plugins.setdefault(slug, entry_path) continue # Directory: pick a representative php file if present if os.path.isdir(entry_path): candidates: list[str] = [] dir_named = os.path.join(entry_path, f"{entry}.php") if os.path.isfile(dir_named): candidates.append(dir_named) try: for f in os.listdir(entry_path): if f.lower().endswith(".php"): candidates.append(os.path.join(entry_path, f)) except OSError: continue if candidates: chosen = candidates[0] slug = entry obf_plugins.setdefault(slug, chosen) return obf_plugins def get_mu_plugins(directory: str) -> dict[str, str]: """Return mu-plugins (files executed without standard headers). Args: directory: Path to the WordPress installation root. Returns: Mapping of slug to absolute path for mu-plugins (may lack headers). """ _p_dir, mu_dir = _get_plugin_dirs(directory) return _get_obfuscated_plugins([mu_dir]) def output_recursive_plugins(scan_directory: str, outfile: str): """Writes mu-plugins and normal admin-visible plugins to outfile Outputs a format like: ```json { 'user1' : { 'plugin1' : '/abs/path/to/plugin1', 'plugin2' : '/abs/path/to/plugin2', }, 'user2' : { 'plugin1' : ... } } ``` Args: scan_directory: Path to directory containing WordPress installation subdirectories. outfile: Path to output JSON file. Returns: Writes a mapping of directory in scan_directory to dict of plugin slug to absolute path for all plugins (may lack headers). """ import json result = {} if not os.path.isdir(scan_directory): print(f"Error: {scan_directory} is not a directory") return try: subdirs = sorted(os.listdir(scan_directory)) except OSError as e: print(f"Error reading {scan_directory}: {e}") return for subdir in subdirs: # Skip hidden directories if subdir.startswith("."): continue subdir_path = os.path.join(scan_directory, subdir) # Only process directories if not os.path.isdir(subdir_path): continue # Get plugin directories for this WordPress install p_dir, mu_dir = _get_plugin_dirs(subdir_path) # Collect both normal and mu-plugins all_plugins = {} # Get normal plugins (with headers) normal_plugins = _get_plugins_from_dirs([p_dir]) all_plugins.update(normal_plugins) # Get mu-plugins (may lack headers) mu_plugins = _get_obfuscated_plugins([mu_dir]) all_plugins.update(mu_plugins) # Only add to result if we found any plugins if all_plugins: result[subdir] = all_plugins # Write to output file with open(outfile, "w", encoding="utf-8") as f: json.dump(result, f, ensure_ascii=False, indent=2) print(f"Wrote plugin data for {len(result)} directories to {outfile}") def _test(): # Change this to test against a real directory on your machine target_dir = "/var/www/html" # For demonstration, we'll create a fake dummy file in the current folder # to show how the parser works without needing a real WP installation. dummy_config = """ """ with open("wp-config.php", "w") as f: f.write(dummy_config) print(f"Testing with dummy config in: {os.getcwd()}") p_dir, mu_dir = _get_plugin_dirs(os.getcwd()) print(f"Detected Plugin Dir: {p_dir}") print(f"Detected MU Plugin Dir: {mu_dir}") # Clean up dummy file os.remove("wp-config.php") # --- Example Usage --- if __name__ == "__main__": _test()