WP Plugin CVE Vulnerability Scanner
Lists all installed plugins and themes and lets an admin manually scan each (single or bulk) against live vulnerability databases (WPScan, Patchstack, WordPress.org). Computes a documented risk score, shows results inline, and keeps a scan history. 100% admin-initiated — no cron, no background jobs.
Install
This is the repository archive. It installs, but the folder name will carry the branch suffix and updates will not flow.
Install straight from GitHub with WP-CLI:
wp plugin install https://github.com/krunalzwt/plugin-vuln-scanner/archive/refs/heads/main.zipA WordPress plugin that lists every installed plugin and theme in a single admin dashboard and lets an administrator manually scan each item (individually or in bulk) against live vulnerability databases. Results are appended inline to the item's row in an expandable accordion, with a computed risk score and severity badge.
Scanning is 100% admin-initiated — there is no WP Cron, no background jobs, and no unattended daily scanning. This is a deliberate design decision (see Rate-limit / quota-aware UX): the free-tier vulnerability APIs have very small daily quotas, and an admin in control of exactly when and what gets checked is better UX than a cron job silently burning the quota.
Portfolio/resume-grade security tooling that mirrors commercial products (Wordfence, Patchstack, Jetpack Protect) but built from scratch to demonstrate external API integration, quota-aware UX design, caching strategy, and risk-scoring design.
Features
- Combined inventory of installed plugins (
get_plugins()) and themes (wp_get_themes()), one row per item with type filter (All / Plugins / Themes). - Per-row "Scan for CVEs" — AJAX scan that expands the row into an inline accordion (no page reload, no modal).
- Bulk "Scan All Visible" / "Scan Selected" — client-side priority queue that stops cleanly when the daily quota is exhausted and reports which items were scanned vs skipped.
- Sticky quota header — always shows
N / 25 API checks remaining todaywith a progress bar and the UTC reset time. - Cache-first — an item checked within the last 24h returns instantly and spends zero quota.
- Risk scoring engine with a documented, filterable formula and severity bands.
- Abandonment detection via the wordpress.org API (unlimited, unauthenticated) — surfaces outdated-but-not-yet-CVE'd plugins that naive scanners miss.
- Historical scan table (custom DB table) powering a trend chart.
- Optional alerting — email (
wp_mail) and/or Slack webhook on new Critical findings, deduplicated.
Data sources
| Source | Purpose | Auth | Limit |
|---|---|---|---|
| WPScan API v3 | Primary CVE source (plugins + themes) | Authorization: Token token=… |
25 req/day (free tier) |
| Patchstack API | Secondary / cross-reference + fallback | Bearer key | Varies (not hardcoded) |
| wordpress.org Plugin/Theme Info API | Version + last_updated + active_installs (abandonment) |
none | none for our volume |
All external calls go through wp_remote_get() / wp_remote_post() — never raw cURL or file_get_contents().
Rate-limit / quota-aware UX design
WPScan's free tier allows 25 requests/day. Because scanning is admin-initiated, the engineering problem is making the quota constraint legible so the admin spends it well. The implementation follows six rules (spec §5):
- Always show remaining quota. A
/statuscall runs at most once per page load (guarded by a short transient), andN / 25 remainingis pinned in the header. Seeclass-quota-tracker.php. - Cache-first, always. A per-item transient keyed
pvs_<md5(vuln_scan_{type}_{slug}_{version})>with a 24h TTL means a re-click on a cached item costs 0 quota. Seeclass-cache.php. - Cost is visible before the click. Buttons read
Scan for CVEs (1 check)when uncached,View Results(free) +Rescanwhen cached, and are disabled with a tooltip when quota is 0. - Bulk scan uses a priority queue, not FIFO:
active + prior CVE→active→popular (>100k installs)→inactive. It stops the instant the API reports exhaustion and marks the remainder as skipped. SeePVS_Admin_Dashboard::priority()andbulkScan()inassets/js/dashboard.js. - Graceful degradation. If WPScan quota is gone, the unlimited wordpress.org abandonment check still runs so a row is never blank, and the row is labeled "CVE check unavailable — daily quota reached, resets at [UTC time]."
- HTTP 429 is handled explicitly, distinct from network errors, and surfaced as a specific human-readable notice. See the
429branch inclass-api-wpscan.php.
Worked example: a site with 40 plugins + 5 themes = 45 items. On a fresh day, the first bulk scan can check at most 25 (uncached) items before hitting the wall; the priority queue guarantees those 25 are the active, historically-vulnerable ones. The remaining 20 get their free abandonment signal and are clearly marked as pending quota reset. The next day, cached items cost nothing, so quota is spent only on newly-added or changed items.
Risk scoring engine
Documented, justified formula (spec §6). Weights are tunable at runtime via apply_filters( 'pvs_risk_weights', $defaults ) and in the Settings page.
risk_score =
(max_cvss_score * 10) // 0–100, base severity from worst known CVE
+ (is_active ? 20 : 5) // active plugin = live attack surface
+ (months_since_last_update > 12 ? 15 : 0) // abandonment penalty
+ (active_installs > 100000 ? 10 : 0) // popular plugin = more attractive target
+ (no_fixed_in_version_available ? 20 : 0) // unpatched is materially worse
| Score | Label |
|---|---|
| 80–100+ | Critical |
| 60–79 | High |
| 30–59 | Medium |
| 1–29 | Low |
| 0, no CVE, recently updated | Clean |
Design detail: a plugin with zero known CVEs but severely outdated still surfaces as at least Medium via an abandonment floor (min_abandoned_score). Most naive scanners only flag known-CVE plugins and miss the quieter abandonment-risk category. Since the free WPScan tier rarely exposes a numeric CVSS, a coarse severity is derived from vuln_type (e.g. SQLI → 9.0, XSS → 6.1) as a fallback — see PVS_Risk_Scorer::cvss_from_type().
When results come from multiple sources, CVEs are de-duplicated by CVE ID (falling back to normalized title), keeping the highest CVSS per match and merging source labels.
Database schema
A dedicated custom table {$wpdb->prefix}plugin_vuln_scans is used instead of wp_options to avoid autoload bloat. Each scan is stored as a history row; the latest row per (item_type, plugin_slug) carries is_latest = 1 for cheap dashboard reads, while older rows remain for the trend chart. A triggered_by column records the admin user ID for an audit trail. Old history is pruned via a manual "Clear old history" button in Settings (no cron). See class-db.php.
File structure
plugin-vuln-scanner/
├── plugin-vuln-scanner.php # bootstrap, headers, activation/deactivation
├── uninstall.php # drop table + delete options/transients
├── includes/
│ ├── class-inventory.php # get_plugins() + wp_get_themes()
│ ├── class-api-wpscan.php # WPScan client (plugin + theme methods, /status, 429)
│ ├── class-api-patchstack.php # Patchstack fallback client
│ ├── class-api-wporg.php # wordpress.org info 1.2 client
│ ├── class-cache.php # transient TTL wrapper
│ ├── class-risk-scorer.php # scoring formula + bands + cross-source merge
│ ├── class-db.php # dbDelta schema + upsert-latest + history CRUD
│ ├── class-quota-tracker.php # remaining daily quota + reset time (NO cron)
│ ├── class-alerts.php # optional email/Slack on new Critical, deduped
│ ├── class-admin-dashboard.php # menu, render, AJAX (single + bulk), settings
│ └── views/
│ ├── dashboard.php # dashboard markup
│ └── settings.php # settings markup
├── assets/
│ ├── css/dashboard.css # accordion rows, badges, empty/loading states
│ └── js/dashboard.js # per-row AJAX, accordion, bulk queue, filters, chart
└── README.md
Installation
- Copy this folder into
wp-content/plugins/. - Activate WP Plugin CVE Vulnerability Scanner in Plugins.
- Open Vuln Scanner in the admin menu.
- (Optional but recommended) In Vuln Scanner → Settings, add a free WPScan API token. Without a token the plugin still runs, using wordpress.org abandonment data only.
Security notes
Security tooling that ships its own vulnerabilities would be an obviously bad look, so (spec §11):
- All custom SQL uses
$wpdb->prepare(); the only unprepared statements are DDL and identifier-only queries built from the trusted table prefix. - All output is escaped (
esc_html(),esc_attr(),esc_url()). - Every AJAX and admin-post action is nonce-verified (
check_ajax_referer/check_admin_referer) and capability-gated (manage_options). - All HTTP uses the WordPress HTTP API, never raw cURL /
file_get_contents(). - Written to the WordPress Coding Standards (PHPCS
WordPressruleset).
Roadmap / future upgrade paths
- WPScan enterprise
/batchendpoint would eliminate the per-item quota problem entirely. - NVD / CVE.org cross-referencing (spec §2.4) for broader, non-WP-specific CVE records.
- Optional opt-in scheduled scan using lightweight WP Cron only (not Action Scheduler) for a single daily trigger.