wp-security-deep — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited wp-security-deep (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Run AFTER wp-security-audit covers the basics (sanitize, escape, nonce, capability, SQL prepare, AJAX nopriv, REST permission). This skill catches the second-tier issues that static-analysis tools miss and that a hurried review skips.
Trigger when the basic audit is clean but the code does any of:
unserialize, maybe_unserialize on stored or transmitted data.wp_remote_*, file_get_contents, curl_*, fopen with aURL that could be influenced by input.
?action= GET paramsthat perform writes.
$_POST / $_REQUEST keys into update_post_meta,update_user_meta, wp_update_user, wp_insert_post, or similar.
From, Cc, Bcc, or custom headers.ZipArchive, PharData, tar).== / === instead ofhash_equals.
get_option + update_option.// HIGH — attacker-controlled serialized payload → gadget chains
$data = unserialize( $_POST['payload'] );Even with [ 'allowed_classes' => false ], prefer json_decode for network/user input. maybe_unserialize on get_option is generally safe (admin wrote it), but flag it on user-meta keys that any user can write (custom registration forms, profile editors).
Phar deserialization: on PHP < 8.0, filesystem functions (file_exists, is_dir, filesize, fopen, etc.) on a path using the phar:// stream wrapper would auto-unserialize the archive's metadata, enabling object-injection gadget chains. PHP 8.0+ removed this auto-unserialization (RFC: phar_stop_autoloading_metadata) — the risk now requires an explicit Phar::getMetadata() call. So the finding severity depends on the deployment's minimum PHP version:
user-influenced paths. Strip phar:// from input.
Phar::getMetadata() overuser-controlled archives, plus any unserialize() of binary blobs.
Fix: JSON for transport, allowlist classes if unserialize is unavoidable, never accept phar:// from input on PHP 7.x.
// HIGH — internal network probe / cloud metadata exfil
$response = wp_remote_get( $_POST['webhook_url'] );Preferred defense — host allowlist. If the integration only ever talks to a known set of hosts (Stripe, Slack, an internal API), allowlist them:
$url = esc_url_raw( wp_unslash( $_POST['webhook_url'] ?? '' ) );
$parts = wp_parse_url( $url );
$allowed_hosts = [ 'api.stripe.com', 'hooks.slack.com' ];
if ( empty( $parts['host'] )
|| ! in_array( strtolower( $parts['host'] ), $allowed_hosts, true )
|| ! in_array( $parts['scheme'] ?? '', [ 'https' ], true ) ) {
wp_die( 'Forbidden host', 403 );
}
$response = wp_remote_post( $url, [
'timeout' => 5,
'redirection' => 2,
'reject_unsafe_urls' => true,
] );Fallback — generic URL with IP-range filtering. Only use this when allowlisting is impossible (e.g. user-submitted webhook URLs). The naive gethostbyname() check is not enough: it returns a single IPv4 A record, missing AAAA records (IPv6 ::1, fc00::/7), multi-record DNS responses, and post-redirect destinations. A correct generic check needs:
$host = strtolower( wp_parse_url( $url, PHP_URL_HOST ) );
// Reject literal IPs in private/reserved ranges before DNS
if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
if ( ! filter_var( $host, FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
wp_die( 'Forbidden host', 403 );
}
}
// Resolve ALL records, not just the first A
$records = array_merge(
dns_get_record( $host, DNS_A ) ?: [],
dns_get_record( $host, DNS_AAAA ) ?: []
);
foreach ( $records as $r ) {
$ip = $r['ip'] ?? $r['ipv6'] ?? '';
if ( ! filter_var( $ip, FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
wp_die( 'Forbidden host', 403 );
}
}
$response = wp_remote_get( $url, [
'timeout' => 5,
'redirection' => 2,
'reject_unsafe_urls' => true,
] );Even this is incomplete — DNS rebinding can return safe records to the resolver and unsafe records to the actual fetch. For full protection you need to resolve once, fetch by IP with a Host: header. WordPress's reject_unsafe_urls request arg does some filtering via the http_request_host_is_external / http_request_host_is_allowed filters, but is opt-in and not sufficient on its own.
Flag when:
gethostbyname()-only check (audit ourselves: this skill's earlierversions had this same bug — IPv4-only, single-record, no redirect reverification).
redirection not capped (default follows up to 5 — can chain intointernal services after passing the initial check).
timeout missing — DoS vector.reject_unsafe_urls not set on user-influenced URLs.WP plugins commonly wire admin pages with ?action=delete&id=42 links. These bypass check_admin_referer if the dev only added it to POST handlers.
// HIGH — GET with side effect, no nonce
if ( isset( $_GET['action'] ) && $_GET['action'] === 'delete' ) {
delete_post( (int) $_GET['id'] );
}Rule: any handler that writes MUST verify a nonce regardless of HTTP method. Action links must be built with wp_nonce_url( $url, 'delete_post_' . $id ) and verified with check_admin_referer( 'delete_post_' . $id ).
// HIGH — user can set role, status, meta_input, etc.
wp_update_user( $_POST );
wp_insert_post( $_POST );
foreach ( $_POST as $key => $value ) {
update_user_meta( $user_id, $key, $value );
}Fix: explicit allowlist of accepted keys. Never spread $_POST / $_REQUEST into a write function whose schema includes privileged fields (role, user_pass, post_status, post_author, meta_input, tax_input).
// HIGH — RCE
include $template_dir . '/' . $_GET['view'] . '.php';
locate_template( $_GET['t'] . '.php' );Fix: allowlist:
$allowed = [ 'list', 'edit', 'settings' ];
$view = isset( $_GET['view'] ) && in_array( $_GET['view'], $allowed, true )
? $_GET['view'] : 'list';
include $template_dir . '/' . $view . '.php';Even with sanitize_file_name, .. and null bytes can survive. Only allowlist is safe.
// HIGH — \r\n injection adds Bcc
wp_mail( '[email protected]', 'Hi', $body, "From: " . $_POST['email'] );Fix: sanitize email + reject CRLF:
$from = sanitize_email( wp_unslash( $_POST['email'] ?? '' ) );
if ( ! $from || preg_match( '/[\r\n]/', $from ) ) {
wp_die( 'Invalid sender', 400 );
}
wp_mail( '[email protected]', 'Hi', $body, [ 'From: ' . $from ] );Pass headers as an array, not a concatenated string.
// HIGH — extracted file can escape with ../
$zip->extractTo( $target_dir );Fix: reject suspicious entries explicitly, then containment-check the resolved path with a trailing separator:
$base_real = realpath( $target_dir );
if ( $base_real === false ) {
wp_die( 'Invalid target', 500 );
}
$base_with_sep = rtrim( $base_real, DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR;
for ( $i = 0; $i < $zip->numFiles; $i++ ) {
$name = $zip->getNameIndex( $i );
// Reject obvious traversal / absolute / drive prefixes
if ( $name === false
|| $name === ''
|| strpos( $name, "\0" ) !== false
|| preg_match( '#(^|[/\\\\])\.\.([/\\\\]|$)#', $name )
|| preg_match( '#^([a-zA-Z]:|/|\\\\)#', $name )
) {
wp_die( 'Malicious archive entry', 400 );
}
// Resolve intended target. Note: file does not exist yet, so
// realpath() returns false — we manually normalize and then
// require it to be inside $base_with_sep (with the trailing
// separator, so /base does not pass for /base-evil).
$candidate = $base_with_sep . $name;
$normalized = $base_with_sep
. ltrim( str_replace( '\\', '/', $name ), '/' );
// After normalization, prefix-check against base+sep.
if ( strncmp( $normalized, $base_with_sep, strlen( $base_with_sep ) ) !== 0 ) {
wp_die( 'Archive escape attempt', 400 );
}
}
// Optionally extract entries one at a time with stream filters that
// also reject symlinks (ZipArchive::extractTo does not honor symlinks
// portably; symlink-bearing archives are a separate audit).
$zip->extractTo( $target_dir );Also: never use `realpath()` for not-yet-existing paths — it returns false and the common fallback $base . '/' . $name is exactly the unvalidated string the attacker controls. Always normalize manually and prefix-check against base + DIRECTORY_SEPARATOR.
Reject symlinks, hardlinks, and entries whose archive metadata indicates non-regular file types if your archive format exposes them.
// MEDIUM — timing leak + type juggling
if ( $_GET['token'] == $stored_token ) { /* grant */ }
// '0e123...' == '0e456...' is true (scientific notation)Fix: if ( hash_equals( $stored_token, (string) $_GET['token'] ) ).
Use for: API keys, password reset tokens, signed URLs, any secret comparison. Not needed for IDs / user-visible values.
// MEDIUM — two simultaneous requests both pass the check
if ( ! get_option( 'myplugin_processing' ) ) {
update_option( 'myplugin_processing', 1 );
do_expensive_thing();
update_option( 'myplugin_processing', 0 );
}WP options have no atomic CAS. Mitigations:
better than the above.
$wpdb->query( "SELECT GET_LOCK('myplugin', 0)" )and RELEASE_LOCK. MySQL-level, atomic.
insert as the gate: insert fails → another worker is in flight.
Flag the pattern, propose the lock approach. Don't claim certainty about race windows without dynamic testing.
Top of every PHP file that has side effects on load:
if ( ! defined( 'ABSPATH' ) ) { exit; }Missing guard is LOW unless the file actually executes work at top-level (most class files are fine). Flag explicitly for files in includes/ or admin/ that do procedural work.
Same as wp-security-audit. Object injection, SSRF on internal network, RCE via include, ZipSlip → HIGH. CSRF on admin GET typically HIGH (any logged-in admin clicking a link). TOCTOU, timing → MEDIUM unless directly exploitable.
Reuse the format from wp-security-audit. If both skills run, merge findings into a single report grouped by severity, not by skill.
(sanitize, escape, nonce, capability, SQL prepare).
randomness in tokens, and password-storage issues — these are adjacent but distinct findings.
composer audit).https://developer.wordpress.org/plugins/security/
https://www.php.net/manual/en/function.unserialize.php
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.