DEV Community

Cover image for The Obvious Way to Detect WordPress Plugins Counts WooCommerce Six Times
Muhammad Zeeshan Sardar
Muhammad Zeeshan Sardar

Posted on

The Obvious Way to Detect WordPress Plugins Counts WooCommerce Six Times

TL;DR: Asset paths, script handles and REST namespaces all reveal WordPress plugins from outside, and each one produces false positives if you trust it on its own. Deny core handles, map handle families to their parent plugin, require corroboration, and never trust a ?ver= that matches the core version.

Point a naive plugin detector at a WooCommerce store and it will tell you the site runs wc, wc-admin, wc-analytics, wc-telemetry, wccom-site and wc-admin-email. Six plugins. It is one plugin, WooCommerce, and the real woocommerce slug may not appear in the list at all.

The same detector will report wp-block-editor and wp-site-health as plugins. Both are WordPress core.

I build WordPress plugins, and I recently built a free scanner that reads a public site and lists the plugins it runs, with the maintenance facts WordPress.org publishes about each one. The use case is quoting: before a client hands over a login, you want to know whether you are walking into twelve current plugins or four that have not shipped a release since 2022.

Getting that list right turned out to be the whole job. Detection from outside is mostly an exercise in not believing your own evidence too quickly. Here are the three signals that work, the trap in each one, and the rules that keep the list honest.

Signal 1: asset paths (the one to trust most)

Plugins live in /wp-content/plugins/<slug>/, and when a plugin loads a stylesheet, script or image on a page, that path shows up in the HTML.

const PLUGIN_PATH = /\/wp-content\/plugins\/([a-z0-9-]+)\//gi;

function slugsFromPaths(html: string): Set<string> {
  const slugs = new Set<string>();
  for (const match of html.matchAll(PLUGIN_PATH)) {
    slugs.add(match[1].toLowerCase());
  }
  return slugs;
}
Enter fullscreen mode Exit fullscreen mode

When a slug appears in an asset path, the plugin is installed and active on that page. That is about as strong as outside evidence gets, and the directory name matches the WordPress.org slug for anything installed from the directory.

The trap: absence proves nothing. Three things hide plugins from this signal:

  • Optimisation and caching plugins that combine every stylesheet and script into one bundle served from their own cache folder. The individual plugin paths disappear from the HTML.
  • Plugins with no front-end assets. Anything that works entirely in wp-admin or on the server leaves no trace in the page.
  • Security plugins that rewrite or hide plugin paths on purpose.

So a short list from this signal is evidence of what is visible, not evidence of a small install. That distinction has to reach the person reading the report, or they will draw the wrong conclusion.

Signal 2: script and style handles (useful, and full of traps)

When WordPress prints an enqueued script or stylesheet, it adds an ID built from the handle the code registered:

<link rel="stylesheet" id="contact-form-7-css" href="...">
<script id="wc-add-to-cart-js" src="..."></script>
Enter fullscreen mode Exit fullscreen mode

Strip the -js or -css suffix and you have the handle. This signal matters because it survives some of the situations that hide asset paths, including assets served from a CDN under a different URL structure.

const HANDLE_ID = /<(?:script|link)\b[^>]*\bid=["']([a-z0-9_-]+)-(js|css)["']/gi;

function handlesFromIds(html: string): Set<string> {
  const handles = new Set<string>();
  for (const match of html.matchAll(HANDLE_ID)) {
    handles.add(match[1].toLowerCase());
  }
  return handles;
}
Enter fullscreen mode Exit fullscreen mode

The regex requires the closing quote straight after -js, which quietly skips inline companions such as wc-add-to-cart-js-extra and -js-after. Those belong to a handle you have already counted.

This is where the six WooCommerce rows come from. A handle is not a slug. It is whatever string the developer chose, and three kinds of handle break the naive approach. (Core packages also start with wp-, so a blanket "anything beginning with wp-" rule would throw away real plugins such as wp-mail-smtp. The deny list has to be explicit.)

Core handles. WordPress registers dozens of its own scripts: wp-block-editor, wp-polyfill, jquery-core, wp-emoji-release, wp-block-library, global-styles, classic-theme-styles and more. None of them is a plugin. You need an explicit deny list, and it needs a comment saying where it came from, because it will need extending every few releases.

Handle families. One plugin often registers many handles under a shared prefix. WooCommerce uses wc-* and wccom-*. Yoast uses yoast-* and wpseo-*, and its directory slug is wordpress-seo, which no handle contains. You need a maintained map from prefix to parent slug, and you emit one row for the parent.

Theme handles. Themes enqueue assets too. A theme stylesheet with the ID acme-siteheader-css produces the handle acme-siteheader, which looks exactly like a plugin that does not exist.

The rule that fixes all three is corroboration. A slug derived only from a handle is accepted when it resolves in the WordPress.org directory, or when the same slug also appears as a real asset path on the site. Anything else is dropped silently.

// Core script and style handles. Extend this as WordPress adds packages.
const CORE_HANDLES = new Set([
  'jquery', 'jquery-core', 'jquery-migrate', 'wp-polyfill', 'wp-emoji-release',
  'wp-block-library', 'wp-block-editor', 'wp-site-health', 'global-styles',
  'classic-theme-styles', 'admin-bar', 'regenerator-runtime', 'lodash', 'moment',
]);

// Prefix to parent slug. Order matters: more specific prefixes first.
const HANDLE_FAMILIES: Array<[string, string]> = [
  ['wccom-', 'woocommerce'],
  ['wc-', 'woocommerce'],
  ['wpseo-', 'wordpress-seo'],
  ['yoast-', 'wordpress-seo'],
  ['elementor-', 'elementor'],
];

function familyParent(handle: string): string | null {
  if (handle === 'wc') return 'woocommerce';
  const match = HANDLE_FAMILIES.find(([prefix]) => handle.startsWith(prefix));
  return match ? match[1] : null;
}

function acceptHandle(
  handle: string,
  pathSlugs: Set<string>,
  directorySlugs: Set<string>,
): string | null {
  if (CORE_HANDLES.has(handle)) return null;

  const parent = familyParent(handle) ?? handle;
  if (pathSlugs.has(parent) || directorySlugs.has(parent)) return parent;

  return null;
}
Enter fullscreen mode Exit fullscreen mode

Dropping silently is the important part. The tempting alternative is to show unmatched handles as "not in directory". That turns a theme stylesheet into a finding, and a WordPress developer will spot it in the first ten seconds and stop trusting everything else on the page.

Signal 3: REST API namespaces

Most WordPress sites expose an index at /wp-json/, and its namespaces array lists the route prefixes every active plugin has registered:

{
  "namespaces": ["oembed/1.0", "wc/v3", "yoast/v1", "contact-form-7/v1", "wp/v2"]
}
Enter fullscreen mode Exit fullscreen mode

This catches plugins that load nothing on the front end but still register routes, which fills part of the gap Signal 1 leaves.

The trap: namespaces are not slugs either. wp/v2 and oembed/1.0 are core. yoast/v1 belongs to wordpress-seo. wc/v3 belongs to woocommerce. The same family map from Signal 2 does most of the work here, and the same corroboration rule applies.

It is also the weakest of the three signals. A namespace proves the plugin registered routes. It does not prove the plugin does anything visible, and some sites disable or restrict the REST index. When you report a plugin, record which signal found it (path, handle, namespace, or several), so you can tell later how much weight each row deserves.

Getting the installed version

Knowing a plugin is installed is half of it. Knowing the site runs 4.2 while the current release is 6.1 is the part that matters for a quote.

The best source is the plugin's own readme.txt, fetched from /wp-content/plugins/<slug>/readme.txt, which carries a Stable tag line. Many sites block direct requests to plugin folders, and larger ones block them almost always.

The fallback is the ver query string on asset URLs:

function versionFromAsset(
  src: string,
  pageUrl: string,
  coreVersion: string | null,
): string | null {
  const ver = new URL(src, pageUrl).searchParams.get('ver');
  if (!ver) return null;
  if (coreVersion && ver === coreVersion) return null;
  return ver;
}
Enter fullscreen mode Exit fullscreen mode

The core version check exists because of how wp_register_script behaves. When a plugin registers an asset without passing its own version, WordPress appends the core version instead. Read that at face value and you will report that a plugin is on version 6.8, when 6.8 is simply the WordPress version.

Two more rules make this usable:

  • Label it. A version from a readme is confirmed. A version from ?ver= is inferred, because it is a cache-busting parameter and a site can put anything in it. The report should say which is which.
  • Find the core version from core assets, such as the ver on files under /wp-includes/. The generator meta tag is often removed, so it cannot be the only source.

What the directory can tell you, and what it cannot

Once you have a clean list of slugs, the WordPress.org plugin API gives you the maintenance facts for each one: when it last shipped a release, which WordPress version its author last tested against, and whether the directory still lists it.

What it does not give you is vulnerability data. I made a deliberate decision not to guess at it. Good vulnerability data for WordPress plugins sits behind commercial APIs, and a free tool that fakes it has two options. It can guess from version numbers, which flags plugins that are fine. Or it can flag a few famous cases and stay quiet on the rest, which implies everything unflagged has been checked. Both are worse than saying clearly that no security check was run.

The same principle applies to the score. If a site loads 11 plugins and 8 of them are premium plugins sold outside the directory, there is no public release history to judge those 8 on. A score built on the remaining 3 should say so, rather than printing "Healthy" next to it.

The standard I hold a scanner to

Fewer, truer findings beat a longer list. Every rule above removes rows rather than adding them: the deny list, the family map, the corroboration check and the core version check. Each one exists because a plausible-looking false positive costs more trust than a missing row.

If you want to see the result on a real site, the scanner is free and needs no signup: WordPress Plugin Checker. It marks which installed versions are inferred rather than confirmed, and states plainly what it could not see. If you only care about one plugin, the plugin maintenance check covers widely installed plugins one at a time.

If you build plugins yourself, I wrote about what maintaining one actually involves, including why "tested up to" goes stale on its own.

I would like to hear from anyone who has built detection like this. Which handle families or namespaces have caught you out? The family map only gets better with more eyes on it.

Top comments (1)

Collapse
 
zeeshansardar08 profile image
Muhammad Zeeshan Sardar •

One detail I left out of the post: namespaces come in families too, just like handles. Jetpack registers routes under wpcom/v2 as well as jetpack/v4, so a map that only looks for "jetpack" misses half of it. WooCommerce spreads across wc/v3, wc/store/v1, wc-admin and wc-analytics.

If you've built anything that reads WordPress sites from the outside, which plugin was the hardest to identify? I'm collecting awkward cases for the family map.