From wordpress-expert
Audits WordPress file permissions via SSH for security misconfigurations in wp-config.php, .htaccess, wp-content/uploads/, and debug.log. SSH sources only.
npx claudepluginhub dr-robert-li/cowork-wordpress-expertThis skill uses the workspace's default tool permissions.
You audit WordPress file permissions via SSH to detect insecure configurations that could expose sensitive data or allow unauthorized code execution.
Guides Next.js Cache Components and Partial Prerendering (PPR) with cacheComponents enabled. Implements 'use cache', cacheLife(), cacheTag(), revalidateTag(), static/dynamic optimization, and cache debugging.
Guides building MCP servers enabling LLMs to interact with external services via tools. Covers best practices, TypeScript/Node (MCP SDK), Python (FastMCP).
Generates original PNG/PDF visual art via design philosophy manifestos for posters, graphics, and static designs on user request.
You audit WordPress file permissions via SSH to detect insecure configurations that could expose sensitive data or allow unauthorized code execution.
WordPress sites on shared and managed hosting frequently have overly permissive file permissions set by automated installers or misconfigured deployment scripts:
This skill checks permissions REMOTELY via SSH using Linux stat -c %a syntax. It cannot use local synced file copies because rsync normalizes permissions during the sync process — local copies do not reflect the actual server permissions.
This skill is NOT in the WP_CLI_SKILLS array — it manages its own SSH-only gating internally.
SITE_NAME="${1:-default-site}"
PROFILE=$(jq -r ".sites[\"$SITE_NAME\"]" sites.json)
HOST=$(echo "$PROFILE" | jq -r '.host // empty')
USER=$(echo "$PROFILE" | jq -r '.user // empty')
WP_PATH=$(echo "$PROFILE" | jq -r '.wp_path // empty')
SOURCE_TYPE=$(echo "$PROFILE" | jq -r '.source_type // "ssh"')
WP_CLI_AVAILABLE=$(echo "$PROFILE" | jq -r '.wp_cli_available // "false"')
WP_CLI_PREFIX=$(echo "$PROFILE" | jq -r '.wp_cli_prefix // empty')
SSH_OPTS="-o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new"
This skill runs only for SSH source types. Local, Docker, and Git sources do not have accessible server file permissions that reflect production reality.
SOURCE_TYPE=$(jq -r ".sites[\"$SITE_NAME\"].source_type // \"ssh\"" sites.json)
if [ "$SOURCE_TYPE" != "ssh" ]; then
# Return skip finding — permissions not checkable for non-SSH sources
exit 0
fi
For non-SSH sources, return a single Info finding INFR-PERM-SKP and exit:
{
"id": "INFR-PERM-SKP",
"severity": "Info",
"category": "Infrastructure",
"title": "File permission check skipped — non-SSH source",
"summary": "File permission checks require SSH access to the production server and cannot run for local, Docker, or Git sources",
"detail": "This site profile uses source_type '{SOURCE_TYPE}', which does not provide SSH access to the actual server file system. File permission checks work by running `stat -c %a` commands remotely via SSH. For non-SSH sources: (1) Local sources — permissions on developer machines differ from production servers and are not diagnostically meaningful, (2) Docker sources — container file permissions are controlled by Dockerfile/entrypoint configuration, not the OS, (3) Git sources — git checkouts do not preserve server file permissions (rsync also normalizes permissions during sync). To check file permissions, connect this site using SSH source type.",
"location": "sites.json source_type: {SOURCE_TYPE}",
"fix": "If you need to check file permissions on a live server, add an SSH connection profile for this site in sites.json with source_type: 'ssh', host, user, and wp_path fields. Then re-run the file permissions diagnostic."
}
For SSH sources, run permission checks using Linux stat -c %a (octal permissions). All commands run over SSH — do NOT use local files.
Helper function pattern:
get_perms() {
local FILE="$1"
ssh $SSH_OPTS "${USER}@${HOST}" "stat -c %a '${FILE}' 2>/dev/null || echo 'NOT_FOUND'" 2>/dev/null
}
Risk: Critical — If world-readable, any user on shared hosting can read database credentials.
World-readable detection: A file is world-readable if the "others" permission set includes read bit. In octal, the others read bit is 4. To detect: $((8#$PERMS & 4)) -gt 0 means world-readable.
CONFIG_PERMS=$(get_perms "${WP_PATH}/wp-config.php")
# Also check one directory up (common security pattern)
if [ "$CONFIG_PERMS" == "NOT_FOUND" ]; then
CONFIG_PERMS=$(get_perms "${WP_PATH}/../wp-config.php")
fi
Threshold logic:
| Permissions | Action |
|---|---|
| NOT_FOUND | Skip silently — no finding |
| 600, 640 | OK — Info finding only if all pass |
644 or world-readable (others read bit set) | Critical finding INFR-PERM-CFG |
World-readable detection:
WORLD_READ=$((8#$CONFIG_PERMS & 4))
if [ "$WORLD_READ" -gt 0 ]; then
# Generate Critical finding
fi
Finding:
{
"id": "INFR-PERM-CFG",
"severity": "Critical",
"category": "Infrastructure",
"title": "wp-config.php is world-readable — database credentials exposed",
"summary": "wp-config.php has permissions {CONFIG_PERMS}, making it readable by all users on the server, exposing database credentials and authentication keys",
"detail": "The file wp-config.php has permissions {CONFIG_PERMS} (octal). This file contains the WordPress database name, username, password, and host, plus the authentication keys and salts used to secure session cookies. On shared hosting environments, world-readable permissions (any permission where the 'others' read bit is set, e.g., 644, 664, 666, 755, 777) mean any other hosting account on the same server can read the file contents and obtain your database credentials. This is a Critical security risk: attackers with any server foothold can immediately pivot to database access.",
"location": "wp-config.php (permissions: {CONFIG_PERMS})",
"fix": "Restrict permissions immediately via SSH: `chmod 640 wp-config.php` This allows the file owner to read/write and the group to read, but blocks all other users. If your web server runs as a different user than the file owner, use 640 with the appropriate group, or 600 if the web server user owns the file. Run `ls -la wp-config.php` to verify the new permissions after applying the fix."
}
Risk: Warning — World-writable .htaccess allows any server process to inject malicious rewrite rules.
HTACCESS_PERMS=$(get_perms "${WP_PATH}/.htaccess")
Threshold logic:
| Permissions | Action |
|---|---|
| NOT_FOUND | Skip silently — .htaccess may not exist |
| 644 | OK |
666, 777, or any world-writable (others write bit 8#$PERMS & 2) | Warning finding INFR-PERM-HTA |
World-writable detection:
WORLD_WRITE=$((8#$HTACCESS_PERMS & 2))
if [ "$WORLD_WRITE" -gt 0 ]; then
# Generate Warning finding
fi
Finding:
{
"id": "INFR-PERM-HTA",
"severity": "Warning",
"category": "Infrastructure",
"title": ".htaccess is world-writable — redirect injection risk",
"summary": ".htaccess has permissions {HTACCESS_PERMS}, allowing any server process to modify Apache configuration and inject malicious redirects or disable security rules",
"detail": ".htaccess has permissions {HTACCESS_PERMS} (octal). The .htaccess file controls Apache's URL rewriting (WordPress permalinks), security headers, and access controls. World-writable permissions (others write bit set, e.g., 666, 777) mean any compromised web application or server process can modify this file to: (1) Redirect visitors to malicious sites, (2) Expose sensitive directories, (3) Disable existing security restrictions, (4) Add PHP execution to upload directories. WordPress only needs to write to .htaccess during settings changes — most of the time it should be read-only.",
"location": ".htaccess (permissions: {HTACCESS_PERMS})",
"fix": "Set correct permissions: `chmod 644 .htaccess` This allows the owner to read and write, and all others to read only. WordPress can update .htaccess when needed (e.g., when you change permalink settings), and web server can read it, but other users cannot write to it."
}
Risk: Warning — World-writable uploads directory allows arbitrary file uploads by any server process.
UPLOADS_PERMS=$(get_perms "${WP_PATH}/wp-content/uploads")
Threshold logic:
| Permissions | Action |
|---|---|
| NOT_FOUND | Skip silently — uploads dir may not exist on all sites |
| 755 | OK |
| 777 (world-writable: others write bit set) | Warning finding INFR-PERM-UPL |
World-writable detection:
WORLD_WRITE_UPL=$((8#$UPLOADS_PERMS & 2))
if [ "$WORLD_WRITE_UPL" -gt 0 ]; then
# Generate Warning finding
fi
Finding:
{
"id": "INFR-PERM-UPL",
"severity": "Warning",
"category": "Infrastructure",
"title": "wp-content/uploads/ is world-writable — arbitrary file upload risk",
"summary": "The uploads directory has permissions {UPLOADS_PERMS}, allowing any server user or process to write files into it",
"detail": "wp-content/uploads/ has permissions {UPLOADS_PERMS} (octal). The uploads directory stores user-uploaded media and attachments. World-writable permissions (others write bit set, e.g., 777) mean any user account or compromised process on the shared server can: (1) Plant malicious PHP scripts in the uploads directory, (2) Overwrite or delete legitimate uploaded files, (3) Potentially execute uploaded scripts if PHP execution is not restricted in this directory. WordPress only needs write access for the web server user — other server users should not be able to write here.",
"location": "wp-content/uploads/ (permissions: {UPLOADS_PERMS})",
"fix": "Set correct permissions: `chmod 755 wp-content/uploads` This allows the owner to read/write/execute (traverse) and all others to read and execute (traverse) but not write. The web server user (which owns the directory) retains write access for media uploads, but other server users cannot write. Also verify that PHP execution is disabled in uploads/ via .htaccess or server config to prevent execution of any uploaded scripts."
}
Risk: Warning — Debug logs exposed to the web or other server users reveal internal architecture.
This check is conditional: debug.log is only flagged if WP_DEBUG is enabled. A debug.log from an old debug session while WP_DEBUG is now off is not an active risk.
Step 1: Determine WP_DEBUG status
WP_DEBUG_STATUS="unknown"
if [ "$WP_CLI_AVAILABLE" == "true" ]; then
WP_DEBUG_RAW=$($WP_CLI_PREFIX config get WP_DEBUG 2>/dev/null | tr -d '[:space:]')
if [ "$WP_DEBUG_RAW" == "true" ] || [ "$WP_DEBUG_RAW" == "1" ]; then
WP_DEBUG_STATUS="enabled"
elif [ "$WP_DEBUG_RAW" == "false" ] || [ "$WP_DEBUG_RAW" == "0" ] || [ -n "$WP_DEBUG_RAW" ]; then
WP_DEBUG_STATUS="disabled"
fi
else
# Fall back to SSH grep on wp-config.php
WP_DEBUG_GREP=$(ssh $SSH_OPTS "${USER}@${HOST}" \
"grep -E \"define\s*\(\s*['\\\"]WP_DEBUG['\\\"].*true\" ${WP_PATH}/wp-config.php 2>/dev/null" 2>/dev/null)
if [ -n "$WP_DEBUG_GREP" ]; then
WP_DEBUG_STATUS="enabled"
else
WP_DEBUG_STATUS="disabled"
fi
fi
Step 2: Check debug.log only when WP_DEBUG is enabled
if [ "$WP_DEBUG_STATUS" == "enabled" ]; then
DEBUG_LOG_PERMS=$(get_perms "${WP_PATH}/wp-content/debug.log")
if [ "$DEBUG_LOG_PERMS" != "NOT_FOUND" ]; then
WORLD_READ_DBG=$((8#$DEBUG_LOG_PERMS & 4))
if [ "$WORLD_READ_DBG" -gt 0 ]; then
# Generate Warning finding INFR-PERM-DBG
fi
fi
fi
Threshold logic (only evaluated when WP_DEBUG=enabled):
| debug.log state | Action |
|---|---|
| File not found | Skip silently — debug.log not existing is normal |
| Found + 640 or stricter | OK (if all checks pass, emit INFR-PERM-OK) |
| Found + 644 or world-readable | Warning finding INFR-PERM-DBG |
Finding:
{
"id": "INFR-PERM-DBG",
"severity": "Warning",
"category": "Infrastructure",
"title": "wp-content/debug.log is world-readable while WP_DEBUG is enabled",
"summary": "debug.log has permissions {DEBUG_LOG_PERMS} and WP_DEBUG is enabled — PHP error traces with internal paths and queries are readable by other server users",
"detail": "wp-content/debug.log has permissions {DEBUG_LOG_PERMS} (octal), making it readable by all server users. WP_DEBUG is currently enabled, meaning this file is being actively written to with PHP error information. The debug log can contain: (1) Full file system paths (revealing server configuration), (2) Database query details (revealing table structure), (3) Plugin/theme errors that expose internal architecture, (4) Stack traces showing code execution paths. On shared hosting, other users can read this file. Additionally, if the file is web-accessible (no protection in .htaccess), the contents may be publicly visible at /wp-content/debug.log.",
"location": "wp-content/debug.log (permissions: {DEBUG_LOG_PERMS}, WP_DEBUG: enabled)",
"fix": "Two options: (1) Restrict permissions: `chmod 640 wp-content/debug.log` — this prevents other server users from reading it. Also add to .htaccess to block web access: `<Files debug.log>\\n Require all denied\\n</Files>`. (2) Disable WP_DEBUG in production (recommended): Change `define('WP_DEBUG', true)` to `define('WP_DEBUG', false)` in wp-config.php. In production, errors should be caught by monitoring tools, not logged to a file. If error logging is needed, use: `define('WP_DEBUG', true); define('WP_DEBUG_LOG', true); define('WP_DEBUG_DISPLAY', false);` and protect the log file."
}
If no issues were found across all checks (wp-config.php is 640 or stricter, .htaccess is 644 or stricter, uploads is 755 or stricter, debug.log is either not present, or WP_DEBUG is disabled, or debug.log is 640 or stricter):
Finding:
{
"id": "INFR-PERM-OK",
"severity": "Info",
"category": "Infrastructure",
"title": "File permissions within recommended ranges",
"summary": "All checked WordPress files have permissions within recommended security ranges",
"detail": "File permission checks passed for all accessible files: wp-config.php is not world-readable, .htaccess (if present) is not world-writable, wp-content/uploads/ (if present) is not world-writable, and debug.log (if present and WP_DEBUG enabled) is not world-readable. Note: These checks cover the four most critical WordPress permission issues; a comprehensive permissions audit should also check wp-admin/, wp-includes/, and theme/plugin directories.",
"location": "WordPress installation at {WP_PATH}",
"fix": "No action required — file permissions are within recommended ranges."
}
| ID | Severity | Trigger |
|---|---|---|
| INFR-PERM-CFG | Critical | wp-config.php world-readable (644 or looser) |
| INFR-PERM-HTA | Warning | .htaccess world-writable (666/777) |
| INFR-PERM-UPL | Warning | wp-content/uploads/ world-writable (777) |
| INFR-PERM-DBG | Warning | debug.log world-readable while WP_DEBUG enabled |
| INFR-PERM-SKP | Info | Non-SSH source type — permissions check skipped |
| INFR-PERM-OK | Info | All checks pass — permissions within recommended ranges |
Return a JSON array of findings. Files that do not exist are silently skipped. If all accessible files have acceptable permissions, return [INFR-PERM-OK] finding.
Example output (wp-config.php world-readable):
[
{
"id": "INFR-PERM-CFG",
"severity": "Critical",
"category": "Infrastructure",
"title": "wp-config.php is world-readable — database credentials exposed",
"summary": "wp-config.php has permissions 644, making it readable by all users on the server, exposing database credentials and authentication keys",
"detail": "The file wp-config.php has permissions 644 (octal). This file contains the WordPress database name, username, password...",
"location": "wp-config.php (permissions: 644)",
"fix": "Restrict permissions immediately via SSH: `chmod 640 wp-config.php`"
}
]
Example output (non-SSH source):
[
{
"id": "INFR-PERM-SKP",
"severity": "Info",
"category": "Infrastructure",
"title": "File permission check skipped — non-SSH source",
"summary": "File permission checks require SSH access to the production server and cannot run for local, Docker, or Git sources",
"detail": "This site profile uses source_type 'local'...",
"location": "sites.json source_type: local",
"fix": "Add an SSH connection profile for this site to check file permissions."
}
]
Example output (all checks pass):
[
{
"id": "INFR-PERM-OK",
"severity": "Info",
"category": "Infrastructure",
"title": "File permissions within recommended ranges",
"summary": "All checked WordPress files have permissions within recommended security ranges",
"detail": "File permission checks passed for all accessible files...",
"location": "WordPress installation at /var/www/html",
"fix": "No action required."
}
]
stat -c %a syntax — SSH targets are Linux servers; do NOT use macOS stat -f %OLpWP_DEBUG=enabled AND file exists AND is world-readable$((8#$PERMS & 4)) to test the others-read bit directly, not by matching exact octal values — this correctly flags 644, 664, 666, 755, 775, 777, etc.WP_CLI_SKILLS array