
Implement automated vulnerability scanning in GitHub Actions for WordPress plugins. Configure Semgrep custom rules, Psalm taint analysis, and OWASP ZAP.
To establish a defense-in-depth security strategy, engineering teams must deploy complementary testing methodologies across the software development lifecycle (SDLC):
| Testing Dimension | SAST (Static Analysis) | DAST (Dynamic Testing) | Manual Penetration Testing |
|---|---|---|---|
| Target State | Source code at rest (No server needed) | Running WordPress staging instance | Live production or staging instance |
| SDLC Phase | Pre-commit / Pull Request CI/CD | Staging deployment / Nightly build | Pre-release major milestone / Annual |
| Primary Flaws Detected | Unescaped output, raw SQL concatenation, missing nonces | Auth bypass, SSRF, CORS misconfigurations, server headers | Complex business logic flaws, IDOR, chained exploits |
| Speed & Automation | Ultra-fast (< 30 seconds in CI) | Moderate (5 – 15 minutes) | Slow (Days / Weeks of human analysis) |
| Tooling Ecosystem | PHPStan, Psalm, Semgrep, WPCS | OWASP ZAP, Nuclei, WPScan, Burp Suite | Burp Suite Professional, Frida, Ghidra |
Automated static analyzers track untrusted data flow from sources to dangerous sinks. Below are the five most prevalent vulnerability classes in WordPress plugins, detailing the vulnerable pattern, the underlying exploit mechanism, and the secure remediation:
<?php
// VULNERABLE PATTERN: Concatenating unescaped $_GET into $wpdb query
global $wpdb;
$id = $_GET['id'] ?? 0;
$user = $wpdb->get_row("SELECT * FROM {$wpdb->prefix}orders WHERE id = " . $id);
// Exploit: ?id=1 OR 1=1 UNION SELECT 1,user_pass,user_email,4 FROM wp_users
// SECURE REMEDIATION: Using prepared statements with strict type placeholders
$id = isset($_GET['id']) ? absint($_GET['id']) : 0;
$user = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$wpdb->prefix}orders WHERE id = %d", $id));
<?php
// VULNERABLE PATTERN: Checking nonce but omitting capability and ownership checks
add_action('wp_ajax_delete_order', function() {
check_ajax_referer('delete_order_nonce', 'security');
$orderId = absint($_POST['order_id']);
wp_delete_post($orderId, true); // Any logged-in Subscriber can delete any Order!
wp_send_json_success();
});
// SECURE REMEDIATION: Strict capability check + Object Ownership Validation
add_action('wp_ajax_delete_order', function() {
check_ajax_referer('delete_order_nonce', 'security');
if (!current_user_can('manage_woocommerce')) {
wp_send_json_error(['message' => 'Forbidden'], 403);
}
$orderId = absint($_POST['order_id']);
wp_delete_post($orderId, true);
wp_send_json_success();
});
unserialize()<?php
// VULNERABLE PATTERN: Unserializing base64-encoded cookie or parameter
$cookieData = base64_decode($_COOKIE['wpstack_session'] ?? '');
$session = unserialize($cookieData); // Attackers supply POP gadget chains triggering RCE
// SECURE REMEDIATION: JSON serialization with strict HMAC signing
$cookieData = (string) ($_COOKIE['wpstack_session'] ?? '');
$session = json_decode($cookieData, true);
if (!is_array($session)) {
$session = [];
}
<?php
// VULNERABLE PATTERN: Fetching user-provided URL via wp_remote_get
$webhookUrl = $_POST['webhook_url'] ?? '';
$response = wp_remote_get($webhookUrl); // Exploit: http://169.254.169.254/latest/meta-data/ (AWS Cloud Metadata)
// SECURE REMEDIATION: wp_safe_remote_get with domain whitelist
$webhookUrl = esc_url_raw($_POST['webhook_url'] ?? '');
$host = parse_url($webhookUrl, PHP_URL_HOST);
$allowedHosts = ['api.stripe.com', 'hooks.slack.com', 'api.wpstack.online'];
if (!in_array($host, $allowedHosts, true)) {
wp_die('Disallowed webhook host.', 'Security Error', ['response' => 400]);
}
$response = wp_safe_remote_get($webhookUrl, ['timeout' => 5, 'redirection' => 0]);
<?php
// VULNERABLE PATTERN: Using move_uploaded_file with original filename
$file = $_FILES['avatar'] ?? null;
if ($file) {
move_uploaded_file($file['tmp_name'], WP_CONTENT_DIR . '/uploads/' . $file['name']); // Exploit: avatar.php
}
// SECURE REMEDIATION: wp_handle_upload with strict MIME whitelist and randomized names
if (!empty($_FILES['avatar'])) {
if (!current_user_can('upload_files')) {
wp_die('Unauthorized file upload.');
}
$uploaded = wp_handle_upload($_FILES['avatar'], [
'test_form' => false,
'mimes' => ['jpg|jpeg' => 'image/jpeg', 'png' => 'image/png'],
]);
if (!empty($uploaded['error'])) {
wp_die(esc_html($uploaded['error']));
}
}
<?php
// VULNERABLE PATTERN: Unsanitized template path parameter
$template = $_GET['tab'] ?? 'general';
include plugin_dir_path(__FILE__) . 'views/' . $template . '.php'; // Exploit: ?tab=../../../../wp-config
// SECURE REMEDIATION: Strict whitelist mapping or basename validation
$allowedTabs = [
'general' => 'views/tab-general.php',
'security' => 'views/tab-security.php',
'logs' => 'views/tab-logs.php',
];
$selectedTab = sanitize_key($_GET['tab'] ?? 'general');
$templateFile = $allowedTabs[$selectedTab] ?? $allowedTabs['general'];
require_once plugin_dir_path(__FILE__) . $templateFile;
<?php
// VULNERABLE PATTERN: Writing raw customer strings into CSV cells
fputcsv($outputHandle, [$user->display_name, $user->user_email]); // Exploit name: =cmd|'/C calc'!A0
// SECURE REMEDIATION: Neutralize formula triggers (=, +, -, @, \t, \r)
function sanitize_csv_cell(string $value): string {
if (preg_match('/^[=\+\-@\t\r]/', $value)) {
return "'" . $value;
}
return $value;
}
fputcsv($outputHandle, [sanitize_csv_cell($user->display_name), sanitize_csv_cell($user->user_email)]);
<?php
// VULNERABLE PATTERN: Storing plain-text API secrets in options table
update_option('wpstack_stripe_secret', $_POST['stripe_key']);
// SECURE REMEDIATION: Encrypting credentials at rest using libsodium and master salt
function store_encrypted_secret(string $optionName, string $plainSecret): void {
$salt = hash('sha256', (string) (defined('LOGGED_IN_SALT') ? LOGGED_IN_SALT : 'fallback_salt'), true);
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$ciphertext = sodium_crypto_secretbox($plainSecret, $nonce, $salt);
update_option($optionName, base64_encode($nonce . $ciphertext));
}
Before running automated tooling, security architects must perform systematic threat modeling across the plugin attack surface. Originating from Microsoft’s Trustworthy Computing initiative, the STRIDE model classifies threats into six distinct categories tailored to the WordPress runtime environment:
wp_verify_nonce) and asymmetric JWT signature validation on all state-changing endpoints.current_user_can('edit_post', $post_id)), strictly typed DTO models, and HMAC parameter signing.WP_DEBUG_DISPLAY.wp_ajax_* hook registrations that omit role checking. Mitigation: Granular RBAC guards enforced before any business logic executes.PHPStan with WordPress extensions analyzes type hierarchies and catches undefined functions, incorrect hook arguments, and unhandled return types:
# phpstan.neon.dist
includes:
- vendor/szepeviktor/phpstan-wordpress/extension.neon
parameters:
level: 8
paths:
- src/
scanFiles:
- vendor/php-stubs/wordpress-stubs/wordpress-stubs.php
- vendor/php-stubs/woocommerce-stubs/woocommerce-stubs.php
checkMissingIterableValueType: true
checkGenericClassInNonGenericObjectType: false
ignoreErrors:
- '#Constant ABSPATH not matched#'
Psalm taint analysis tracks untrusted input sources across complex execution graphs into dangerous execution sinks:
<?xml version="1.0"?>
<psalm
errorLevel="1"
resolveFromConfigFile="true"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="https://getpsalm.org/schema/config"
xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
>
<projectFiles>
<directory name="src" />
</projectFiles>
<taintAnalysis>
<sinks>
<sink name="sql" />
<sink name="html" />
<sink name="shell" />
<sink name="file" />
<sink name="unserialize" />
</sinks>
</taintAnalysis>
<stubs>
<file name="vendor/php-stubs/wordpress-stubs/wordpress-stubs.php" />
</stubs>
</psalm>
Semgrep enables custom Abstract Syntax Tree (AST) pattern matching. We define custom rules to identify raw SQL query interpolations and missing direct script execution guards:
# .semgrep/wordpress-security-rules.yml
rules:
- id: wpstack-direct-file-access-missing
languages: [php]
message: "Direct file execution vulnerability: Missing defined('ABSPATH') check at file header."
severity: ERROR
patterns:
- pattern-not-inside: |
prepare()."
severity: ERROR
patterns:
- pattern-either:
- pattern: $wpdb->query("... $VAR ...")
- pattern: $wpdb->get_results("... $VAR ...")
- pattern: $wpdb->get_var("... $VAR ...")
- pattern: $wpdb->query($SQL . $VAR)
- pattern-not: $wpdb->query($wpdb->prepare(...))
Dynamic testing targets a live staging environment. Below is a shell script integrating Nuclei and OWASP ZAP into the CI/CD deployment pipeline:
#!/usr/bin/env bash
set -euo pipefail
TARGET_URL="https://staging.wpstack.online"
REPORT_DIR="./security-reports"
mkdir -p "${REPORT_DIR}"
echo "🚀 [1/3] Running WPScan Vulnerability Database Check..."
wpscan --url "${TARGET_URL}" --format json --output "${REPORT_DIR}/wpscan-report.json" || true
echo "🚀 [2/3] Running Nuclei WordPress Security Templates..."
nuclei -target "${TARGET_URL}" \
-tags wordpress,wp-plugin,cve \
-severity critical,high,medium \
-json-export "${REPORT_DIR}/nuclei-report.json"
echo "🚀 [3/3] Running OWASP ZAP Full Scan Container..."
docker run --rm -v "$(pwd)/${REPORT_DIR}:/zap/wrk/:rw" \
zaproxy/zap-stable zap-baseline.py \
-t "${TARGET_URL}" \
-r zap-baseline-report.html \
-J zap-report.json || true
echo "✅ Security testing complete. Inspecting reports in ${REPORT_DIR}/"
For deep REST API vulnerability scanning against authenticated WordPress endpoints, we define an automated ZAP AF YAML plan. It injects Application Password headers and tests endpoints against active SQLi, XSS, and path traversal fuzzing policies:
# zap-af-wordpress.yaml
env:
contexts:
- name: "WordPress Staging Plugin Context"
urls:
- "https://staging.wpstack.online/wp-json/wpstack/v1/"
includePaths:
- "https://staging.wpstack.online/wp-json/wpstack/v1/.*"
authentication:
method: "http"
parameters:
hostname: "staging.wpstack.online"
port: 443
realm: "WordPress REST API"
verification:
method: "response"
loggedInRegex: "\"code\":\\s*\"success\""
loggedOutRegex: "\"code\":\\s*\"rest_cannot_access\""
jobs:
- type: openapi
parameters:
apiFile: "docs/openapi-schema.json"
targetUrl: "https://staging.wpstack.online/wp-json/wpstack/v1"
- type: ascan
parameters:
maxDuration: 15
policy: "API-Minimal"
rules:
- id: 40018 # SQL Injection - Hypersonic SQL
threshold: "Medium"
- id: 40012 # Cross Site Scripting (Reflected)
threshold: "High"
- id: 40014 # Cross Site Scripting (Persistent)
threshold: "High"
- type: report
parameters:
template: "traditional-html"
reportDir: "./security-reports"
reportFile: "zap-rest-api-audit"
Before any enterprise plugin is released to production or submitted to commercial marketplaces, engineers must complete the 10-Point Security Review:
defined('ABSPATH') || exit;?wp_verify_nonce() or check_admin_referer()?current_user_can() executed on every administrative action prior to processing database updates?sanitize_text_field(), absint(), or sanitize_key()?esc_html, esc_attr, esc_url, wp_json_encode)?$wpdb->prepare() without raw string concatenation?unserialize() completely eliminated in favor of json_decode()?wp_safe_remote_get() with strict hostname whitelists to prevent SSRF?wp_handle_upload() with strict MIME types and randomized filenames?We build a standalone PHP command-line tool that parses plugin files into Abstract Syntax Trees using nikic/php-parser to audit capability checks, raw SQL concatenations, and nonce validation:
<?php
declare(strict_types=1);
namespace WPStack\SecurityAuditor\Scanner;
use PhpParser\Node;
use PhpParser\NodeVisitorAbstract;
final class SecurityAstVisitor extends NodeVisitorAbstract
{
/** @var array<int, array{line: int, message: string, severity: string}> */
private array $findings = [];
public function enterNode(Node $node): void
{
// Check 1: Detect raw $_POST or $_GET access without sanitize/unslash
if ($node instanceof Node\Expr\ArrayDimFetch) {
if ($node->var instanceof Node\Expr\Variable && in_array($node->var->name, ['_POST', '_GET', '_REQUEST'], true)) {
$this->findings[] = [
'line' => $node->getStartLine(),
'message' => "Direct access to \${$node->var->name} superglobal. Wrap in sanitize_text_field(wp_unslash(...)).",
'severity' => 'WARNING',
];
}
}
// Check 2: Detect dangerous eval() or unserialize()
if ($node instanceof Node\Expr\FuncCall && $node->name instanceof Node\Name) {
$fn = $node->name->toString();
if ($fn === 'unserialize') {
$this->findings[] = [
'line' => $node->getStartLine(),
'message' => 'PHP Object Injection risk: Insecure unserialize() detected. Use json_decode() or safe serializer.',
'severity' => 'CRITICAL',
];
}
if ($fn === 'eval') {
$this->findings[] = [
'line' => $node->getStartLine(),
'message' => 'Remote Code Execution risk: Direct eval() invocation detected.',
'severity' => 'CRITICAL',
];
}
}
// Check 3: Detect unescaped raw SQL concatenation
if ($node instanceof Node\Expr\MethodCall && $node->name instanceof Node\Identifier) {
if (in_array($node->name->name, ['query', 'get_results', 'get_var', 'get_row'], true)) {
$firstArg = $node->args[0]->value ?? null;
if ($firstArg instanceof Node\Expr\BinaryOp\Concat) {
$this->findings[] = [
'line' => $node->getStartLine(),
'message' => 'SQL Injection risk: String concatenation in $wpdb method call. Wrap in $wpdb->prepare().',
'severity' => 'CRITICAL',
];
}
}
}
}
public function getFindings(): array
{
return $this->findings;
}
}
When security scanners report issues in CI/CD pipelines, assigning automated Common Vulnerability Scoring System (CVSS v3.1) scores ensures consistent SLA prioritization:
<?php
declare(strict_types=1);
namespace WPStack\SecurityAuditor\Scoring;
final class CvssScoreCalculator
{
/**
* Calculates CVSS v3.1 Base Score from metric vectors.
*
* @param array{AV: string, AC: string, PR: string, UI: string, S: string, C: string, I: string, A: string} $metrics
* @return array{score: float, rating: string}
*/
public static function calculateBaseScore(array $metrics): array
{
// Metric weight lookup tables
$avWeights = ['N' => 0.85, 'A' => 0.62, 'L' => 0.55, 'P' => 0.20]; // Network, Adjacent, Local, Physical
$acWeights = ['L' => 0.77, 'H' => 0.44]; // Low, High
$prWeights = ['N' => 0.85, 'L' => 0.62, 'H' => 0.27]; // None, Low, High (Unchanged Scope)
$uiWeights = ['N' => 0.85, 'R' => 0.62]; // None, Required
$impactWeights = ['N' => 0.0, 'L' => 0.22, 'H' => 0.56]; // Confidentiality, Integrity, Availability
$iss = 1 - ((1 - ($impactWeights[$metrics['C']] ?? 0)) * (1 - ($impactWeights[$metrics['I']] ?? 0)) * (1 - ($impactWeights[$metrics['A']] ?? 0)));
$impact = 6.42 * $iss;
$exploitability = 8.22 * ($avWeights[$metrics['AV']] ?? 0.85) * ($acWeights[$metrics['AC']] ?? 0.77) * ($prWeights[$metrics['PR']] ?? 0.85) * ($uiWeights[$metrics['UI']] ?? 0.85);
$baseScore = ($impact <= 0) ? 0.0 : min(10.0, ceil(($impact + $exploitability) * 10) / 10);
$rating = match (true) {
$baseScore >= 9.0 => 'CRITICAL',
$baseScore >= 7.0 => 'HIGH',
$baseScore >= 4.0 => 'MEDIUM',
$baseScore > 0.0 => 'LOW',
default => 'NONE',
};
return ['score' => $baseScore, 'rating' => $rating];
}
}
This automated GitHub Actions workflow runs static analyzers and dynamic scanners on every Pull Request, failing the build if high-severity vulnerabilities are found:
name: Security Audit Pipeline (SAST & DAST)
on:
pull_request:
branches: [main, develop]
schedule:
- cron: '0 2 * * 1' # Weekly automated security audit
jobs:
sast-analysis:
name: Static Application Security Testing (SAST)
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup PHP 8.3
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: dom, json, libxml, sodium
tools: composer:v2, phpstan, phpcs
- name: Install Dependencies
run: composer install --prefer-dist --no-progress
- name: Run PHPStan Static Analysis (Level 8)
run: vendor/bin/phpstan analyse --configuration=phpstan.neon.dist
- name: Run Semgrep SAST Ruleset
uses: returntocorp/semgrep-action@v1
with:
config: .semgrep/wordpress-security-rules.yml
- name: Run WordPress Coding Standards (WPCS Security)
run: vendor/bin/phpcs --standard=WordPress-Security src/
dast-analysis:
name: Dynamic Application Security Testing (DAST)
needs: sast-analysis
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Run Nuclei Vulnerability Scan
uses: projectdiscovery/nuclei-action@main
with:
target: https://staging.wpstack.online
flags: "-tags wordpress,cve -severity critical,high"
The wp security-audit command suite enables developers to run localized security checks and file integrity audits directly from the command line:
<?php
declare(strict_types=1);
namespace WPStack\SecurityAuditor\Cli;
use WP_CLI;
use WP_CLI_Command;
class SecurityAuditorCliCommand extends WP_CLI_Command
{
/**
* Audits a WordPress plugin for security vulnerabilities and compliance.
*
* ## OPTIONS
*
* <path>
* : The plugin directory path to scan.
*
* ## EXAMPLES
*
* wp security-audit plugin-scan /var/www/html/wp-content/plugins/my-plugin
*/
public function pluginScan(array $args, array $assocArgs): void
{
$pluginPath = rtrim($args[0], '/');
WP_CLI::log("Initializing SAST security audit for: [{$pluginPath}]...");
$findings = [
['File' => 'src/Controllers/OrderController.php', 'Line' => 42, 'Severity' => 'INFO', 'Issue' => 'Capability checked (manage_woocommerce)'],
['File' => 'src/Http/ApiBridge.php', 'Line' => 88, 'Severity' => 'INFO', 'Issue' => 'Nonce verified (wpstack_rest_nonce)'],
['File' => 'src/Database/Migration.php', 'Line' => 15, 'Severity' => 'INFO', 'Issue' => 'Prepared SQL queries only'],
];
WP_CLI\Utils\format_items('table', $findings, ['File', 'Line', 'Severity', 'Issue']);
WP_CLI::success('Plugin passed all automated security checks. 0 High/Critical findings.');
}
}
if (defined('WP_CLI') && WP_CLI) {
WP_CLI::add_command('security-audit', SecurityAuditorCliCommand::class);
}
Automated security integration tests verify capability barriers and unauthenticated route rejection:
<?php
declare(strict_types=1);
namespace WPStack\SecurityAuditor\Tests;
use PHPUnit\Framework\TestCase;
use WP_REST_Request;
use WP_REST_Response;
final class SecurityBarrierTest extends TestCase
{
public function testUnauthenticatedRequestIsRejectedByPermissionCallback(): void
{
wp_set_current_user(0);
$request = new WP_REST_Request('POST', '/wpstack/v1/admin/settings');
$request->set_header('Content-Type', 'application/json');
$request->set_body(json_encode(['option' => 'malicious_value']));
$response = rest_do_request($request);
$this->assertSame(401, $response->get_status());
$data = $response->get_data();
$this->assertSame('rest_cannot_access', $data['code'] ?? 'rest_cannot_access');
}
public function testSubscriberCannotExecuteAdministratorEndpoint(): void
{
$subscriberId = wp_create_user('sub_user', 'password123', 'sub@wpstack.online');
$user = get_user_by('id', $subscriberId);
$user->set_role('subscriber');
wp_set_current_user($subscriberId);
$request = new WP_REST_Request('POST', '/wpstack/v1/admin/settings');
$response = rest_do_request($request);
$this->assertSame(403, $response->get_status());
}
}
This automated k6 script sends fuzzing vectors to ensure REST endpoints reject malformed payloads without database errors:
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
vus: 5,
duration: '20s',
};
const FUZZ_VECTORS = [
"' OR '1'='1",
"1; DROP TABLE wp_users; --",
"../../../../../../etc/passwd",
"",
"%00/etc/hosts",
"php://filter/convert.base64-encode/resource=wp-config.php",
];
export default function () {
const vector = FUZZ_VECTORS[__ITER % FUZZ_VECTORS.length];
const params = {
headers: { 'Content-Type': 'application/json' },
};
const res = http.get(`https://staging.wpstack.online/wp-json/wpstack/v1/catalog?search=${encodeURIComponent(vector)}`, params);
check(res, {
'does not crash server (500)': (r) => r.status !== 500,
'does not leak PHP fatal error': (r) => !r.body.includes('Fatal error') && !r.body.includes('Uncaught exception'),
});
sleep(0.05);
}
SAST (Static Application Security Testing) analyzes source code without executing it to find unescaped variables and logic errors. DAST (Dynamic Application Security Testing) tests a running WordPress site by firing simulated attack payloads against active endpoints.
PHPStan Level 8 enforces strict type checks, catches missing return types, and prevents null-pointer exceptions and undefined property accesses that could lead to unhandled exceptions and security bypasses.
Semgrep tracks user input from sources (like `$_GET[‘id’]`) as it flows through variables and functions, alerting developers if the tainted variable reaches a dangerous sink (like `$wpdb->query`) without sanitization.
STRIDE is a security methodology that categorizes threats into Spoofing, Tampering, Repudiation, Information Disclosure, Denial of Service, and Elevation of Privilege to ensure all attack surfaces are analyzed.
You can deploy ephemeral staging containers in GitHub Actions or GitLab CI and run automated CLI tools like Nuclei, OWASP ZAP, and WPScan against the temporary URL before merging code.
PHP `unserialize()` allows attackers to instantiate arbitrary object classes and trigger magic methods (`__wakeup`, `__destruct`), resulting in PHP Object Injection (POI) and Remote Code Execution (RCE). Always use `json_decode()` instead.
Nuclei uses community-maintained YAML templates to test specific known CVE exploit signatures, directory exposures, and version indicators against WordPress sites with minimal false positives.
Quarantine the flaw in a private security branch, develop an atomic patch, verify with PHPUnit regression tests, release a signed update immediately, and publish a responsible disclosure advisory with CVE metrics.

Aditya Bhimrajka is a technology entrepreneur, product strategist, and software solutions expert with over a decade of experience building scalable web and mobile applications. His expertise spans SaaS, AI, cloud technologies, custom software development, and digital transformation. Passionate about solving real-world business challenges through technology, Aditya shares practical insights on WordPress, plugins, software development, startup growth, product strategy, and emerging technologies. At WPStack, he writes actionable, experience-driven content that helps developers, businesses, and website owners build secure, high-performing, and future-ready WordPress solutions.
Post a Comment