This repository has been archived on 2026-09-11. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files

359 lines
12 KiB
Python

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 = """
<?php
// Standard default style
define( 'DB_NAME', 'database_name_here' );
// Custom plugin directory style
define( 'WP_PLUGIN_DIR', dirname(__FILE__) . '/custom/location/plugins' );
// Hardcoded absolute path style
define( 'WPMU_PLUGIN_DIR', '/usr/local/share/wordpress/mu-plugins' );
?>
"""
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()