ss Site Integrity Verification Framework
Requires: WP-CLI, jq, openssl
set -euo pipefail
Configuration
REPORT_DIR="./audit-reports"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
Ensure report directory exists
mkdir -p "$REPORT_DIR"
Helper: Log result to JSON
log_result() {
local site="$1"
local check="$2"
local status="$3"
local detail="$4"
echo "{\"site\": \"$site\", \"check\": \"$check\", \"status\": \"$status\", \"detail\": \"$detail\", \"timestamp\": \"$TIMESTAMP\"}" >> "${REPORT_DIR}/${site}_${TIMESTAMP}.json"
}
Check 1: Core and Plugin Currency
check_updates() {
local site="$1"
local core_update
local plugin_updates
core_update=$(wp core update-check --path="$site" --format=json 2>/dev/null)
if [[ $(echo "$core_update" | jq -r '.updates | length') -gt 0 ]]; then
log_result "$site" "core_update" "FAIL" "Core update available"
else
log_result "$site" "core_update" "PASS" "Core is current"
fi
plugin_updates=$(wp plugin list --update=available --path="$site" --format=csv 2>/dev/null)
if [[ -n "$plugin_updates" ]]; then
log_result "$site" "plugin_updates" "FAIL" "Plugins require updates"
else
log_result "$site" "plugin_updates" "PASS" "All plugins current"
fi
}
Check 2: Default Admin Username Detection
check_default_user() {
local site="$1"
local admin_exists
admin_exists=$(wp user list --role=administrator --field=user_login --path="$site" --format=csv 2>/dev/null | grep -c "^admin$" || true)
if [[ "$admin_exists" -gt 0 ]]; then
log_result "$site" "default_admin" "FAIL" "Default 'admin' user detected"
else
log_result "$site" "default_admin" "PASS" "No default admin user"
fi
}
Check 3: wp-config.php Permissions
check_config_perms() {
local site="$1"
local config_file="${site}/wp-config.php"
local perms
if [[ -f "$config_file" ]]; then
perms=$(stat -c "%a" "$config_file")
if [[ "$perms" != "600" && "$perms" != "640" ]]; then
log_result "$site" "config_perms" "FAIL" "Insecure permissions: $perms"
else
log_result "$site" "config_perms" "PASS" "Permissions secure: $perms"
fi
else
log_result "$site" "config_perms" "FAIL" "wp-config.php not found"
fi
}
Check 4: Debug Log Exposure
check_debug_log() {
local site="$1"
local debug_log="${site}/wp-content/debug.log"
local debug_enabled
if [[ -f "$debug_log" ]]; then
log_result "$site" "debug_log" "FAIL" "debug.log exists and may be exposed"
else
log_result "$site" "debug_log" "PASS" "No debug.log found"
fi
debug_enabled=$(wp config get WP_DEBUG_LOG --path="$site" --format=json 2>/dev/null || echo "false")
if [[ "$debug_enabled" == "true" ]]; then
log_result "$site" "debug_enabled" "FAIL" "WP_DEBUG_LOG is enabled in production"
else
log_result "$site" "debug_enabled" "PASS" "Debug logging disabled"
fi
}
Check 5: File Integrity Checksums
check_checksums() {
local site="$1"
local core_result
local plugin_result
core_result=$(wp core verify-checksums --path="$site" 2>&1)
if [[ "$core_result" == *"Success"* ]]; then
log_result "$site" "core_checksums" "PASS" "Core files verified"
else
log_result "$site" "core_checksums" "FAIL" "Core file modification detected"
fi
plugin_result=$(wp plugin verify-checksums --all --path="$site" 2>&1)
if [[ "$plugin_result" == *"Success"* ]]; then
log_result "$site" "plugin_checksums" "PASS" "Plugin files verified"
else
log_result "$site" "plugin_checksums" "FAIL" "Plugin file modification detected"
fi
}
Check 6: SSL Certificate Expiry
check_ssl_expiry() {
local site="$1"
local domain
domain=$(wp option get siteurl --path="$site" --format=json | tr -d '"')
domain=$(echo "$domain" | sed -E 's/https?:////')
local expiry_date
expiry_date=$(echo | openssl s_client -servername "$domain" -connect "${domain}:443" 2>/dev/null | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
if [[ -n "$expiry_date" ]]; then
local expiry_epoch
expiry_epoch=$(date -d "$expiry_date" +%s)
local current_epoch
current_epoch=$(date +%s)
local days_left=$(( (expiry_epoch - current_epoch) / 86400 ))
if [[ "$days_left" -lt 30 ]]; then
log_result "$site" "ssl_expiry" "FAIL" "SSL expires in $days_left days"
else
log_result "$site" "ssl_expiry" "PASS" "SSL valid for $days_left days"
fi
else
log_result "$site" "ssl_expiry" "FAIL" "Unable to retrieve SSL certificate"
fi
}
Main Execution Loop
run_audit() {
local site_alias="$1"
local site_path
# Resolve alias to path
site_path=$(wp --alias="$site_alias" --path=. eval 'echo ABSPATH;' 2>/dev/null)
if [[ -z "$site_path" ]]; then
echo "Error: Could not resolve path for alias $site_alias"
return 1
fi
echo "Auditing site: $site_alias ($site_path)"
check_updates "$site_path"
check_default_user "$site_path"
check_config_perms "$site_path"
check_debug_log "$site_path"
check_checksums "$site_path"
check_ssl_expiry "$site_path"
echo "Audit complete for $site_alias. Report saved to ${REPORT_DIR}/"
}
Entry point
if [[ $# -eq 0 ]]; then
echo "Usage: $0 <site-alias>"
exit 1
fi
run_audit "$1"
### Rationale for Choices
* **Modular Functions:** Each check is isolated, making it easy to add new verifications or disable specific checks without breaking the script.
* **Alias Resolution:** The script resolves WP-CLI aliases dynamically, ensuring it works across diverse environments (local, staging, production) defined in `wp-cli.yml`.
* **Safe SSL Check:** The `openssl` command uses `-servername` for SNI support, which is critical for modern hosting environments hosting multiple domains.
* **JSON Aggregation:** Results are appended to a JSON file per site per run. This structure supports downstream analysis, such as tracking drift over time or feeding data into a dashboard.
## Pitfall Guide
Even with a robust script, operational mistakes can undermine security efforts. The following pitfalls are common in production environments.
### 1. Root Execution Risk
**Explanation:** Running WP-CLI commands as the `root` user can create files owned by `root`, causing permission denied errors for the web server process (e.g., `www-data`). This breaks updates and file uploads.
**Fix:** Always execute WP-CLI as the web server user or a dedicated management user. Use `sudo -u www-data wp ...` or configure SSH keys for the appropriate user. Never use `--allow-root` in production scripts.
### 2. The "Inactive Plugin" Trap
**Explanation:** Developers often deactivate plugins they no longer use, assuming they pose no risk. However, inactive plugins remain on the filesystem and can be included by malicious code or vulnerable themes. They also consume disk space and may contain unpatched vulnerabilities.
**Fix:** Delete inactive plugins immediately. The script should include a check for inactive plugins and flag them for removal. Use `wp plugin list --status=inactive` to identify candidates.
### 3. Checksum Blindness on Custom Code
**Explanation:** `wp core verify-checksums` and `wp plugin verify-checksums` only validate files against the official WordPress repository. They do not check custom themes or proprietary plugins. An attacker can inject code into a custom theme without triggering checksum failures.
**Fix:** Maintain a separate hash baseline for custom themes and plugins. Implement a pre-deployment check that compares file hashes against a known-good state in version control.
### 4. Backup Illusion
**Explanation:** A backup plugin may report "Success" even if the backup file is corrupted, empty, or fails to upload to the off-site destination. Relying solely on the plugin's status indicator is insufficient.
**Fix:** Verify backup integrity by checking file size and hash periodically. Ensure backups are stored off-server (e.g., S3, Google Drive). Test restoration procedures quarterly to validate recoverability.
### 5. SSL Automation Gaps
**Explanation:** Automated SSL renewal via Certbot can fail silently due to DNS changes, port conflicts, or web server configuration errors. An expired certificate breaks functionality and erodes user trust.
**Fix:** Monitor Certbot logs for errors. The script's SSL check should alert when expiry is within 30 days. Implement a secondary monitoring service that checks SSL status externally to catch renewal failures.
### 6. Shared Hosting Permission Leakage
**Explanation:** Setting `wp-config.php` to `640` is generally secure, but on shared hosting, the group might be "users," allowing other accounts on the server to read the file.
**Fix:** Ensure the file group is specific to the web server user (e.g., `www-data` or `nginx`). Use `chown` to set ownership correctly. Verify group permissions with `ls -l`.
### 7. Login Anomaly Noise
**Explanation:** Flagging every login from a new IP address as malicious leads to alert fatigue. Legitimate users often travel or use dynamic IPs.
**Fix:** Focus on high-fidelity signals: logins from impossible travel distances, multiple failed attempts followed by success, or logins at unusual hours for the user's role. Use tools like WP Last Login to track patterns, but filter alerts based on risk heuristics.
## Production Bundle
### Action Checklist
- [ ] **Define Site Aliases:** Configure `wp-cli.yml` with aliases for all managed sites, including SSH and path details.
- [ ] **Deploy Verification Script:** Install `wp-verify.sh` on a management host with SSH access to all target servers.
- [ ] **Schedule Execution:** Set up a cron job to run the audit monthly for all sites, e.g., `0 9 1 * * /path/to/wp-verify.sh --all`.
- [ ] **Configure Alerting:** Integrate JSON output with a webhook or email service to notify on `FAIL` status.
- [ ] **Review Stale Plugins:** Establish a policy to delete plugins inactive for >30 days. Update the script to flag these.
- [ ] **Test Restore Procedure:** Quarterly, restore a site from backup to a staging environment to verify data integrity.
- [ ] **Audit Admin Users:** Manually review admin accounts quarterly to remove former employees or contractors.
- [ ] **Monitor SSL Renewals:** Verify Certbot logs weekly and ensure automated renewal is functioning correctly.
### Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|----------|----------------------|-----|-------------|
| **< 5 Sites** | CLI Script + Cron | Low overhead, full control, no subscription fees. | Time investment for setup. |
| **5-20 Sites** | CLI Script + Central Dashboard | Scalable, structured reporting, integrates with existing tools. | Moderate dev time for dashboard integration. |
| **> 20 Sites** | Managed Platform (MainWP/InfiniteWP) | UI scalability, bulk operations, client reporting features. | Subscription cost per site. |
| **Enterprise/Compliance** | CI/CD Pipeline Integration | Audit trails, automated enforcement, version control alignment. | High dev effort, requires infrastructure. |
### Configuration Template
Use this `wp-cli.yml` template to define site aliases for the verification script.
```yaml
# wp-cli.yml
@production:
ssh: deploy@prod-server.example.com
path: /var/www/html
url: https://example.com
@staging:
ssh: deploy@staging-server.example.com
path: /var/www/staging
url: https://staging.example.com
@client-alpha:
ssh: deploy@client-alpha.example.com
path: /home/client-alpha/public_html
url: https://client-alpha.com
Quick Start Guide
- Install Dependencies: Ensure WP-CLI,
jq, and openssl are installed on your management host.
sudo apt-get install wp-cli jq openssl
- Configure Aliases: Create
wp-cli.yml in your project directory and define aliases for your sites.
- Deploy Script: Save the
wp-verify.sh script and make it executable.
chmod +x wp-verify.sh
- Run Audit: Execute the script against a specific site alias.
./wp-verify.sh @production
- Review Report: Check the generated JSON report in the
audit-reports directory.
cat audit-reports/production_*.json | jq .
This framework provides a repeatable, scalable method for maintaining WordPress site integrity. By automating verification and focusing on high-impact checks, teams can reduce risk exposure while minimizing operational overhead.