Dependency & supply-chain security
When to use this skill
Use this skill whenever code consumes third-party code or assets:
- Adding a Composer package, or vendoring a library copy into
vendor/orassets/lib/. - Enqueueing a script or stylesheet whose
srcis a fullhttps://CDN URL. - Loading PHP at runtime from the network, including "remote updater" patterns.
- Preparing the distributable zip and deciding what ships inside
vendor/. - Merging a snippet taken from Stack Overflow, a blog post, or an AI answer.
Do not reach for this skill for flaws in first-party code: use the
output-escaping skill for XSS in your own markup, the
capability-permission-checks skill for authorization, and the
cron-background-job-security skill for cron callbacks.
A plugin is only as secure as the oldest library inside it. A large share of WordPress plugin CVEs are vulnerable bundled dependencies or compromised third-party code loaded at runtime, not bugs in the plugin's own logic.
Related: see the http-api-ssrf-prevention skill for validating URLs you fetch
data from, the filesystem-security skill for confining local includes, and
the secure-plugin-development skill for overall plugin hardening.
Core principles (and why they matter)
- Prefer core over bundling. WordPress registers jQuery, Underscore,
Backbone, media libraries, and the block-editor React build under documented
handles.
wp_enqueue_script()the core handle instead of shipping your own copy: bundled duplicates drift out of date and conflict with other plugins and themes that use the core version. Core patches its bundled libraries; your vendored copy of the same library gets no patches. - Vet before adding. Confirm the project shipped a release in the last
~12 months, scan its CVE history, and check the license is compatible with
GPL where required.
composer audit(Composer 2.4+) checks installed packages against the Packagist advisory database; theroave/security-advisoriesdev dependency refuses installs of known-vulnerable versions outright. - Lock and pin. Commit
composer.lockand build withcomposer install, nevercomposer update. Avoidminimum-stability: devand unbounded constraints likedev-main: they make builds unreproducible and unauditable, so a compromise upstream is indistinguishable from your own release. - Ship a minimal artifact. Distributable zips must not contain dev
dependencies, tests, CI configs, or demo data.
.gitattributesexport-ignorelines keep them out ofgit archive/composer archivebuilds, so attackers cannot read your tests to map untested code paths. - Never load code from the network at runtime.
eval(), stringassert(),create_function()(removed in PHP 8), the preg/emodifier (removed in PHP 7), orinclude/requireof a fetched file, includingwp_remote_get()bodies andphar://deserialization tricks, is RCE by design. Updates ship through an update channel; fetched payloads are not an update channel. - Integrity-check front-end assets you do not host. Self-host when
possible. If a CDN is required, pin an exact version URL and add Subresource
Integrity (
integrity+crossoriginattributes) via thescript_loader_tag/style_loader_tagfilters. Never enqueue a URL whose content can change, such aslatest.js. - Keep update latency low for what you ship. Do not ship code that blocks
the auto-updates users rely on (
auto_update_plugin,auto_update_theme,WP_AUTO_UPDATE_CORE,AUTOMATIC_UPDATER_DISABLED). Guard your own PHP and WordPress minimums withversion_compare()and release patched builds fast when a bundled dependency announces a CVE. - Treat pasted snippets as untrusted dependencies. Rewrite them against
current WordPress APIs and check for removed functions (
mysql_*,create_function()) before merging. A snippet copied from 2011 carries the threat model of 2011.
Step-by-step implementation
-
Before vendoring, check whether WordPress core already registers the asset. The
wp_register_script()documentation lists the default handles (jquery,jquery-ui-*,underscore,backbone,wp-api, and more). Enqueue the handle; do not print your own copy of the same file. -
Vet each new dependency before the first
composer require:- Last tagged release within roughly the last 12 months.
- No open high-severity advisories: run
composer auditafter adding. - License compatible with the plugin's distribution (GPL-compatible for WordPress.org hosting).
- For non-Composer front-end libraries, check advisories with OWASP Dependency-Check or the vendor's security page.
-
Configure Composer for reproducible builds and commit the lock file:
json{ "name": "acme/my-plugin", "minimum-stability": "stable", "require": { "php": ">=7.4" }, "require-dev": { "roave/security-advisories": "dev-latest" }, "config": { "sort-packages": true } }Build with
composer install --no-dev --optimize-autoloader. Onlycomposer updateintentionally bumps the lock file, and the diff gets reviewed like any other code change. -
Keep tests, CI configs, and demo data out of the shipped zip via
.gitattributes:/.github export-ignore /tests export-ignore /demo export-ignore /phpcs.xml.dist export-ignore /phpunit.xml.dist export-ignore -
Enforce your minimums at load time with
version_compare()and refuse to run on stacks below them (see the reference file for the guard). -
For CDN assets: self-host if possible. Otherwise pin an exact version URL, store an integrity hash per asset, and add
integrity+crossoriginthrough thescript_loader_tag/style_loader_tagfilters (full example in the reference file). -
Ship no auto-update blockers. Never distribute
add_filter( 'auto_update_plugin', '__return_false' );or adefine( 'AUTOMATIC_UPDATER_DISABLED', true );inside plugin code. If you must influence updates, filter only your own plugin:php// Keep this plugin on the auto-update train; never silence the whole site. add_filter( 'auto_update_plugin', 'my_plugin_auto_update_self', 10, 2 ); function my_plugin_auto_update_self( $update, $item ) { if ( isset( $item->slug ) && 'my-plugin' === $item->slug ) { return true; } return $update; } -
Grep the codebase before every release for runtime code loading:
eval(,assert(,create_function(,preg_replace(with/e,include/requirefed bywp_remote_get(), andphar://stream usage. -
Rewrite pasted snippets against current APIs before merging. Anything calling
mysql_*orcreate_function()was written for PHP that no longer runs your code.
Supporting references
| Reference | Load when |
|---|---|
| Dependency & supply-chain cheatsheet | Choosing the applicable WordPress API or control for dependency and supply-chain security. |
| Dependency & supply-chain checklist | Before final verification of the dependency & supply-chain controls. |
| Secure dependency management | Implementing version-gated dependency loading, core asset reuse, pinned CDN assets, and confined local includes. |
Common AI mistakes / anti-patterns
Mistake 1 - Vendoring a stale copy of a library core already ships
php// ❌ Insecure: bundles an old PHPMailer 5.x copy "because it worked". // Old 5.x releases carry known remote-command-execution CVEs, and this copy // never receives patches. Core's copy does. require_once __DIR__ . '/vendor/phpmailer/phpmailer/PHPMailerAutoload.php';
php// ✅ Secure: wp_mail() uses the PHPMailer version core ships and patches. wp_mail( $to, $subject, $message, $headers );
Mistake 2 - CDN script with no version pin and no integrity
php// ❌ Insecure: "chart.js" can change content at any time; the CDN operator // (or anyone compromising it) controls the code that runs on every site. wp_enqueue_script( 'my-charts', 'https://cdn.example.com/chart.js', array(), null );
php// ✅ Secure: exact version URL + Subresource Integrity, or self-host the file. wp_enqueue_script( 'my-charts', 'https://cdn.jsdelivr.net/npm/chart.js@4.1.2/dist/chart.umd.min.js', array(), '4.1.2', // Matches the version pinned in the URL path. true // Load in the footer. ); // integrity/crossorigin attributes are added in the script_loader_tag filter.
Mistake 3 - Runtime updater that fetches and executes code
php// ❌ Insecure: RCE by design, and the plugin bricks itself when the host dies. // $response = wp_remote_get( 'https://example.com/updater.php' ); // $body = wp_remote_retrieve_body( $response ); // eval( $body ); // executes whatever the remote server returns // include $tmp_path; // same result if $tmp_path holds the fetched payload
php// ✅ Secure: updates arrive through the WordPress.org update channel, or a // signed self-hosted channel. Metadata travels over the network; code does not. // Plugins on WordPress.org need no custom updater code at all.
Mistake 4 - Committing vendor/ with dev dependencies and CI configs
php// ❌ Insecure: the distributable zip ships phpunit, phpcs configs, and test // fixtures. Attackers read tests to map code paths nothing verifies. // Built with: composer update (whatever was newest that Tuesday).
php// ✅ Secure: export-ignore keeps dev trees out of archives; builds are pinned. // .gitattributes: // /tests export-ignore // /.github export-ignore // Build command: // composer install --no-dev --optimize-autoloader
Mistake 5 - minimum-stability: dev plus unbounded constraints
json// ❌ Insecure: every build resolves differently; you cannot audit what ships. { "minimum-stability": "dev", "require": { "monolog/monolog": "dev-main" } }
json// ✅ Secure: stable channel, bounded constraints, committed lock file. { "minimum-stability": "stable", "require": { "monolog/monolog": "^3.0" } }
Mistake 6 - Shipping removed dynamic-code constructs
php// ❌ Insecure: fatals on PHP 8 (create_function removed) and on PHP 7 (/e // modifier removed). Both execute strings as code, which is the point. // $double = create_function( '$a', 'return $a * 2;' ); // $bold = preg_replace( '/<b>(.*?)<\/b>/e', 'strtoupper("$1")', $html );
php// ✅ Secure: closures and callbacks; no string-as-code anywhere. $double = function ( $a ) { return $a * 2; }; $bold = preg_replace_callback( '/<b>(.*?)<\/b>/', function ( $m ) { return strtoupper( $m[1] ); }, $html );
Mistake 7 - CI that hands release secrets to untrusted code
yaml# ❌ Insecure: pull_request_target runs with repository secrets and checks out # the fork's code, so a pull request can read SVN_PASSWORD. Actions pinned to a # moving tag can also change underneath the workflow. on: pull_request_target jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha }} - uses: some-org/deploy-action@main env: SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }}
yaml# ✅ Secure: untrusted PRs run without secrets; deploys run only on tags from # the main repository, with least-privilege tokens and SHA-pinned actions. on: push: tags: [ 'v*' ] permissions: contents: read jobs: deploy: if: github.repository == 'my-org/my-plugin' runs-on: ubuntu-latest environment: wordpress-org steps: - uses: actions/checkout@<full-commit-sha> # v4.x - uses: some-org/deploy-action@<full-commit-sha> env: SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }}
Also check custom update servers: updates must come over HTTPS from an endpoint you control, with the package verified (signature or checksum from a trusted channel) before install, and the release zip built from the tagged commit.
Correct code examples
A complete reference module covering core-handle-first enqueuing, pinned CDN
assets with Subresource Integrity via the script_loader_tag filter, a
version_compare() minimum-version gate, and a guarded local include confined
to the plugin directory is in
references/secure-dependency-management.php.
A before-adding / before-shipping checklist is in
references/checklist.md, and a
situation-to-practice lookup table is in
references/cheatsheet.md.
Checklist
- Core handles are used for assets WordPress registers (
jquery,underscore, editor builds). - Every bundled dependency is maintained (release within ~12 months) and audited (
composer audit). -
roave/security-advisoriesis inrequire-devfor Composer projects. -
composer.lockis committed; builds runcomposer install --no-dev. -
minimum-stabilityisstable; no unboundeddev-*constraints. -
.gitattributesexport-ignorekeeps tests, CI configs, and demo data out of the zip. - No
eval(), stringassert(),create_function(), preg/e, or remoteinclude/requireanywhere in shipped code. - CDN assets are self-hosted, or pinned to an exact version with
integrity+crossoriginattributes. - No enqueued URL resolves to changeable content such as
latest.js. - Shipped code contains no
auto_update_plugin/auto_update_theme/AUTOMATIC_UPDATER_DISABLEDblockers. - PHP and WordPress minimums are enforced with
version_compare()before the plugin loads. - Pasted snippets were rewritten against current APIs before merging.
- CI never runs untrusted pull-request code with secrets (
pull_request_target+ PR checkout, orworkflow_runon fork artifacts). - Third-party GitHub Actions are pinned to full commit SHAs; workflow
permissionsare least-privilege; deploy secrets live in a protected environment. - Custom update endpoints use HTTPS and verify the package before install; release zips are built from the tagged commit.
Official references
wp_enqueue_script()wp_enqueue_style()wp_register_script()(default handle list)wp_script_add_data()script_loader_tagfilterstyle_loader_tagfilterWP_HTML_Tag_Processorclasswp_remote_get()wp_is_auto_update_enabled_for_type()auto_update_{$type}filter (auto_update_plugin,auto_update_theme)AUTOMATIC_UPDATER_DISABLEDconstantWP_AUTO_UPDATE_COREconstantvalidate_file()- Composer
auditcommand - Composer
composer.lock - Composer
minimum-stability - Roave SecurityAdvisories
- MDN: Subresource Integrity
- OWASP Dependency-Check
- OWASP Third-Party JavaScript Management Cheat Sheet
version_compare()create_function()(removed)- PCRE pattern modifiers (
/eremoved) export-ignorein.gitattributes


