Archived
361 lines
12 KiB
Python
361 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__":
|
|
SCANDIR = "/scan"
|
|
OUTFILE = "/scripts/plugins.json"
|
|
output_recursive_plugins(SCANDIR, OUTFILE)
|