wp-security-audit — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited wp-security-audit (Agent Skill) and scored it 96/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 1 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 1 flagged
A bulleted imperative like {match} tells the agent to never reveal, disclose, or mention something to the user. Used adversarially it can instruct the agent to hide its tool calls or lie about what it did — stripping the transparency a user relies on to trust the agent.
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.
A defensive checklist-driven review for WP plugin and theme PHP. Goal: catch the boring, repeatable mistakes that ship to production because no one ran through the basics. This is not a substitute for a real security review of cryptography, business logic, or third-party deps.
Trigger this skill when ANY of the following is true:
$_GET, $_POST,$_REQUEST, $_COOKIE, $_FILES, $_SERVER, wp_unslash, wp_verify_nonce, check_admin_referer, current_user_can, add_action( 'wp_ajax, add_action( 'admin_post, register_rest_route, $wpdb->, update_option, update_user_meta, wp_redirect, wp_safe_redirect, file_get_contents, move_uploaded_file.
Work through the Critical checks below in order. For each finding:
(e.g. "missing nonce", "unescaped output", "broken access control").
conditions), LOW (hardening / best practice).
Do NOT silently rewrite the file. Produce a report first; only edit if the user asks you to apply fixes.
Any handler that writes (saves option, updates meta, deletes a post, sends an email, mutates anything) must verify a nonce.
wp_nonce_field( 'action_name', '_wpnonce' ) →check_admin_referer( 'action_name' ) in handler.
wp_create_nonce( 'action_name' ) → check_ajax_referer( 'action_name', 'nonce' )._wpnonce (wp-api nonce) forlogged-in routes; for permission_callback use a real capability check.
Common mistake: verifying the nonce inside an if whose else branch still does the write. The nonce check must short-circuit.
Authentication ≠ authorization. A logged-in subscriber is still a user.
current_user_can( 'manage_options' ) or a morespecific capability (edit_posts, edit_post with object ID, manage_woocommerce etc.).
current_user_can( 'edit_post', $post_id ) — the ID-less form is wrong.
permission_callback must NEVER be __return_true forstate-changing routes. Returning true unconditionally is the #1 plugin vulnerability pattern on wp.org.
WordPress magically slashes superglobals. The pipeline is fixed:
$raw = isset( $_POST['email'] ) ? wp_unslash( $_POST['email'] ) : '';
$email = sanitize_email( $raw );
if ( ! is_email( $email ) ) { /* reject */ }wp_unslash before sanitization → escaped quotes leak through.sanitize_text_fieldsanitize_textarea_fieldsanitize_emailesc_url_raw (storage) / esc_url (output)sanitize_key, sanitize_titleabsint or (int) with range checkwp_kses_post or wp_kses with explicit allowlistsanitize_file_name + realpath containment check$_SERVER['HTTP_*'] headers without sanitizing; they'reattacker-controlled.
Escape at the point of output, in the right context:
esc_html( $x )esc_attr( $x )href/src: esc_url( $x )<script> JSON: wp_json_encode( $x ), never raw concatenationthe substituted value separately. esc_html__() only escapes the static template; printf( esc_html__( '%s', 'td' ), $name ) is XSS if $name contains markup. Correct form: printf( esc_html__( 'Hello, %s', 'td' ), esc_html( $name ) ).
wp_kses_post( $x )echo $foo; where $foo came from input or DB without escaping → XSS. This is the most common finding in plugin audits.
// WRONG
$wpdb->get_results( "SELECT * FROM x WHERE id = $id" );
// RIGHT
$wpdb->get_results( $wpdb->prepare( "SELECT * FROM x WHERE id = %d", $id ) );%d integers, %f floats, %s strings. Table/column names CANNOT beparameterized — validate against an allowlist before interpolating.
LIKE needs $wpdb->esc_like() BEFORE prepare():$like = '%' . $wpdb->esc_like( $term ) . '%';
Two hooks, two meanings — confuse them and you ship a vulnerability:
wp_ajax_{action} — fires only for logged-in users.wp_ajax_nopriv_{action} — fires for logged-out users.Rules:
nopriv ONLY if the feature is genuinely meant for guests(e.g. public search, login form). Never copy-paste both registrations "to be safe".
check_ajax_referer().nopriv handler must NOT perform actions that only logged-in usersshould do (saving prefs, accessing other users' data, etc.).
wp_send_json_success / wp_send_json_error, not echo + die.admin_post_{action} and admin_post_nopriv_{action} follow the same rules as AJAX. Plus: redirect with wp_safe_redirect() + exit;. Never redirect with wp_redirect( $_GET['redirect_to'] ) without validating against an allowlist — that's an open-redirect.
register_rest_route( 'myplugin/v1', '/save', [
'methods' => 'POST',
'callback' => 'myplugin_save',
'permission_callback' => function () {
return current_user_can( 'manage_options' );
},
'args' => [
'id' => [
'required' => true,
'validate_callback' => 'is_numeric',
'sanitize_callback' => 'absint',
],
],
] );Findings to flag:
permission_callback missing, or __return_true on a non-public route.args schema — input is unsanitized.user_pass,user_activation_key, private meta).
wp_check_filetype_and_ext(), store viawp_handle_upload(), never trust the client-provided extension or MIME.
realpath() and check theresult starts with the intended base dir. Otherwise: path traversal.
include / require a path containing user input.constants in wp-config.php.
WP_DEBUG_DISPLAY should be off in prod; flag any var_dump,print_r, error_log( $sensitive ) left in handlers.
messages ("user not found" vs "wrong password" — pick one).
wp_safe_redirect() for any URL that may be influenced by input.exit; after a redirect — execution continues otherwise.wp_schedule_event callbacks run with no current user. If the jobperforms privileged work, do not trust any "stored intent" without re-validating; treat persisted user input as untrusted.
composer audit separately.mail/zip injection, timing comparison, TOCTOU races — these are covered by `wp-security-deep`. Run it after this one.
storage, cookie flags, secrets in logs — covered by `wp-security-secrets`. Run it whenever auth or third-party integrations are in scope.
State this scope explicitly in the report. If the reviewed code clearly falls into one of the deeper categories above, recommend the appropriate skill in the report's footer.
# Security audit: <plugin name>
Scope: <files reviewed>
Date: <YYYY-MM-DD>
## HIGH
1. <file>:<line> — <issue>
<code>
Fix: <code>
## MEDIUM
...
## LOW / Hardening
...
## Out of scope
- <thing not checked>reference.mdexamples/~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.