From 78c4d4426158694bead3953210af85e6bc4369d1 Mon Sep 17 00:00:00 2001 From: Ben Kalsky Date: Wed, 10 Jun 2026 01:19:38 +0300 Subject: [PATCH] security: warn on plaintext WP URLs, disclose audit capability, declare permissions (v3.8.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Safe-additive hardening for ClawHub audit findings. No breaking changes — http:// now warns (opt-in WP_REQUIRE_HTTPS=1 to refuse), description discloses no-auth site audit, permissions declared. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 8 +++ package.json | 2 +- tests/test_security.py | 67 +++++++++++++++++++ wordpress-api-pro/SKILL.md | 14 +++- wordpress-api-pro/scripts/acf_fields.py | 10 +-- wordpress-api-pro/scripts/batch_update.py | 9 ++- wordpress-api-pro/scripts/create_post.py | 2 + wordpress-api-pro/scripts/detect_plugins.py | 10 +-- .../scripts/elementor_content.py | 2 + wordpress-api-pro/scripts/get_post.py | 2 + wordpress-api-pro/scripts/jetengine_fields.py | 10 +-- wordpress-api-pro/scripts/list_posts.py | 2 + wordpress-api-pro/scripts/security.py | 25 +++++++ wordpress-api-pro/scripts/seed_content.py | 2 + wordpress-api-pro/scripts/seo_meta.py | 10 +-- wordpress-api-pro/scripts/update_post.py | 3 +- wordpress-api-pro/scripts/upload_media.py | 3 +- wordpress-api-pro/scripts/woo_products.py | 2 + wordpress-api-pro/scripts/wp_cli.py | 5 +- 19 files changed, 166 insertions(+), 22 deletions(-) create mode 100644 tests/test_security.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a34fa7..d767829 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 3.8.0 — 2026-06-10 + +Security hardening (ClawHub audit, safe-additive — no breaking changes): + +- Warn on plaintext http:// WordPress URLs (Basic-Auth credentials would be sent in cleartext); set WP_REQUIRE_HTTPS=1 to refuse instead. Localhost/dev hosts exempt. +- SKILL.md description now discloses the no-auth site-audit / fingerprinting capability. +- Added an explicit permissions declaration (env / network / filesystem / shell). + ## 3.7.1 - 2026-06-04 - ClawHub listing now publishes under the display name **WordPress API Pro** (`--name`) with a pinned slug (`--slug wordpress-api-pro`), instead of an auto-title-cased "Wordpress Api Pro". diff --git a/package.json b/package.json index a0bf4fe..746f81d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wordpress-api-pro", - "version": "3.7.1", + "version": "3.8.0", "description": "WordPress REST API integration skill for OpenClaw - manage posts, pages, media, WooCommerce, Elementor, and metadata with explicit safety boundaries", "private": true, "main": "wordpress-api-pro/SKILL.md", diff --git a/tests/test_security.py b/tests/test_security.py new file mode 100644 index 0000000..5098146 --- /dev/null +++ b/tests/test_security.py @@ -0,0 +1,67 @@ +import os, sys, unittest + +SCRIPTS = os.path.join(os.path.dirname(__file__), "..", "wordpress-api-pro", "scripts") +sys.path.insert(0, os.path.abspath(SCRIPTS)) + +from security import SafetyError, warn_insecure_wp_url # noqa: E402 + + +class WarnInsecureWpUrlTest(unittest.TestCase): + def test_warns_on_http_nonlocal(self): + """http:// on a public host prints a SECURITY WARNING to stderr.""" + import io, contextlib + buf = io.StringIO() + with contextlib.redirect_stderr(buf): + result = warn_insecure_wp_url("http://example.com", env={}) + self.assertIn("SECURITY WARNING", buf.getvalue()) + self.assertEqual(result, "http://example.com") # url returned unchanged + + def test_silent_on_https(self): + """https:// never triggers a warning.""" + import io, contextlib + buf = io.StringIO() + with contextlib.redirect_stderr(buf): + warn_insecure_wp_url("https://example.com", env={}) + self.assertEqual(buf.getvalue(), "") + + def test_silent_on_localhost_http(self): + """http:// on localhost/dev hosts is exempt — no warning.""" + import io, contextlib + buf = io.StringIO() + with contextlib.redirect_stderr(buf): + warn_insecure_wp_url("http://localhost:8080", env={}) + warn_insecure_wp_url("http://site.local", env={}) + self.assertEqual(buf.getvalue(), "") + + def test_raises_when_wp_require_https_set(self): + """WP_REQUIRE_HTTPS=1 upgrades the warning to a SafetyError.""" + with self.assertRaises(SafetyError): + warn_insecure_wp_url("http://example.com", env={"WP_REQUIRE_HTTPS": "1"}) + + def test_silent_on_dot_test_host(self): + """http://*.test hosts are treated as local dev — no warning.""" + import io, contextlib + buf = io.StringIO() + with contextlib.redirect_stderr(buf): + warn_insecure_wp_url("http://mysite.test", env={}) + self.assertEqual(buf.getvalue(), "") + + def test_silent_on_dot_localhost_host(self): + """http://*.localhost hosts are treated as local dev — no warning.""" + import io, contextlib + buf = io.StringIO() + with contextlib.redirect_stderr(buf): + warn_insecure_wp_url("http://app.localhost", env={}) + self.assertEqual(buf.getvalue(), "") + + def test_wp_require_https_not_triggered_for_local(self): + """WP_REQUIRE_HTTPS=1 does NOT raise for localhost — local is always exempt.""" + import io, contextlib + buf = io.StringIO() + with contextlib.redirect_stderr(buf): + warn_insecure_wp_url("http://localhost", env={"WP_REQUIRE_HTTPS": "1"}) + self.assertEqual(buf.getvalue(), "") + + +if __name__ == "__main__": + unittest.main() diff --git a/wordpress-api-pro/SKILL.md b/wordpress-api-pro/SKILL.md index 8a92018..6ca48c5 100644 --- a/wordpress-api-pro/SKILL.md +++ b/wordpress-api-pro/SKILL.md @@ -1,12 +1,24 @@ --- name: wordpress-api-pro -version: 3.7.1 +version: 3.8.0 license: MIT-0 description: | Production-grade WordPress REST API integration for managing posts, pages, media, WooCommerce products, Elementor content, SEO meta, ACF, and JetEngine fields. Use when you need to retrieve, draft, create, or update WordPress content programmatically on sites where the user has provided explicit credentials. For any operation that writes to a live site, get explicit user approval for the target site, post/product IDs, and final action before executing. Prefer drafts first. Run batch operations in dry-run mode first; use --execute only after review. Remote URL media downloads and local file reads are restricted by safety boundaries. + Also includes a no-auth Tier-1 site audit (PageSpeed, SSL, security headers, CMS/PHP fingerprint, SEO basics) for cold pre-sale checks, and authenticated plugin/SEO-stack discovery. +permissions: + env: + - "WP_URL / WP_SITE_URL, WP_USERNAME / WP_USER, WP_APP_PASSWORD (auth)" + - "WP_CONFIG (optional sites.json path), WP_ALLOWED_FILE_ROOTS (file-read scope)" + - "WP_ALLOW_REMOTE_URLS, WP_REQUIRE_HTTPS, PAGESPEED_API_KEY" + network: + - "Outbound HTTPS to the configured WordPress site(s) /wp-json/ REST API" + - "https://www.googleapis.com/pagespeedonline (site_audit only)" + filesystem: + - "Read-only, scoped to WP_ALLOWED_FILE_ROOTS (default: cwd)" + shell: "none (Python only; no shell-out)" --- # WordPress API Pro diff --git a/wordpress-api-pro/scripts/acf_fields.py b/wordpress-api-pro/scripts/acf_fields.py index 9fc2f2e..24388f0 100755 --- a/wordpress-api-pro/scripts/acf_fields.py +++ b/wordpress-api-pro/scripts/acf_fields.py @@ -27,6 +27,7 @@ import sys import requests from base64 import b64encode +from security import warn_insecure_wp_url def get_acf_fields(url, username, password, post_id, field_name=None): """Get ACF fields via REST API (with postmeta fallback)""" @@ -131,18 +132,19 @@ def main(): # Validate required args if not args.url: - print(json.dumps({"error": "WordPress URL required (--url or WP_SITE_URL/WP_URL env var)"}), + print(json.dumps({"error": "WordPress URL required (--url or WP_SITE_URL/WP_URL env var)"}), file=sys.stderr) sys.exit(1) if not args.username: - print(json.dumps({"error": "Username required (--username or WP_USER/WP_USERNAME env var)"}), + print(json.dumps({"error": "Username required (--username or WP_USER/WP_USERNAME env var)"}), file=sys.stderr) sys.exit(1) if not args.app_password: - print(json.dumps({"error": "App password required (--app-password or WP_APP_PASSWORD env var)"}), + print(json.dumps({"error": "App password required (--app-password or WP_APP_PASSWORD env var)"}), file=sys.stderr) sys.exit(1) - + warn_insecure_wp_url(args.url) + try: # Set operation if args.set_json: diff --git a/wordpress-api-pro/scripts/batch_update.py b/wordpress-api-pro/scripts/batch_update.py index 0b1af65..770b8f5 100755 --- a/wordpress-api-pro/scripts/batch_update.py +++ b/wordpress-api-pro/scripts/batch_update.py @@ -22,6 +22,12 @@ import urllib.error from base64 import b64encode +# security.py lives alongside this script; insert its directory on the path if needed. +import importlib.util as _ilu, pathlib as _pl +if not _ilu.find_spec("security"): + sys.path.insert(0, str(_pl.Path(__file__).parent)) +from security import warn_insecure_wp_url + def load_config(config_path=None): """Load sites configuration""" if config_path is None: @@ -44,7 +50,8 @@ def update_post(site, post_id, updates, dry_run=False): if dry_run: print(f" [DRY RUN] Would update post {post_id}: {updates}") return True - + + warn_insecure_wp_url(site['url']) credentials = f"{site['username']}:{site['app_password']}".encode('utf-8') auth_header = b64encode(credentials).decode('ascii') diff --git a/wordpress-api-pro/scripts/create_post.py b/wordpress-api-pro/scripts/create_post.py index 1eae334..7054542 100755 --- a/wordpress-api-pro/scripts/create_post.py +++ b/wordpress-api-pro/scripts/create_post.py @@ -2,6 +2,7 @@ """Create a WordPress post or CPT entry via REST API (with taxonomy support).""" import argparse, json, os, sys, urllib.request, urllib.parse from base64 import b64encode +from security import warn_insecure_wp_url def _auth(username, password): @@ -93,6 +94,7 @@ def main(): if not all([a.url, a.username, a.app_password]): print(json.dumps({"error": "Missing required credentials"}), file=sys.stderr) sys.exit(1) + warn_insecure_wp_url(a.url) try: result = create_post(a.url, a.username, a.app_password, a.title, a.content, a.status, post_type=a.post_type, diff --git a/wordpress-api-pro/scripts/detect_plugins.py b/wordpress-api-pro/scripts/detect_plugins.py index 9191256..d85e6ae 100755 --- a/wordpress-api-pro/scripts/detect_plugins.py +++ b/wordpress-api-pro/scripts/detect_plugins.py @@ -18,6 +18,7 @@ import sys import requests from base64 import b64encode +from security import warn_insecure_wp_url def detect_plugins(url, username, password, verbose=False): """Detect WordPress plugins via REST API""" @@ -127,18 +128,19 @@ def main(): # Validate required args if not args.url: - print(json.dumps({"error": "WordPress URL required (--url or WP_SITE_URL/WP_URL env var)"}), + print(json.dumps({"error": "WordPress URL required (--url or WP_SITE_URL/WP_URL env var)"}), file=sys.stderr) sys.exit(1) if not args.username: - print(json.dumps({"error": "Username required (--username or WP_USER/WP_USERNAME env var)"}), + print(json.dumps({"error": "Username required (--username or WP_USER/WP_USERNAME env var)"}), file=sys.stderr) sys.exit(1) if not args.app_password: - print(json.dumps({"error": "App password required (--app-password or WP_APP_PASSWORD env var)"}), + print(json.dumps({"error": "App password required (--app-password or WP_APP_PASSWORD env var)"}), file=sys.stderr) sys.exit(1) - + warn_insecure_wp_url(args.url) + try: plugins = detect_plugins(args.url, args.username, args.app_password, args.verbose) print(json.dumps(plugins, indent=2)) diff --git a/wordpress-api-pro/scripts/elementor_content.py b/wordpress-api-pro/scripts/elementor_content.py index 1d6cc7d..a439927 100755 --- a/wordpress-api-pro/scripts/elementor_content.py +++ b/wordpress-api-pro/scripts/elementor_content.py @@ -2,6 +2,7 @@ """Manage Elementor page content via REST API""" import argparse, json, os, sys, urllib.request from base64 import b64encode +from security import warn_insecure_wp_url def get_elementor_data(url, username, password, post_id): """Get Elementor data for a page""" @@ -127,6 +128,7 @@ def update_widget_content(elements, widget_id, content): if not all([args.url, args.username, args.app_password]): print(json.dumps({"error": "Missing credentials"}), file=sys.stderr) sys.exit(1) +warn_insecure_wp_url(args.url) if args.action == 'get': result = get_elementor_data(args.url, args.username, args.app_password, args.post_id) diff --git a/wordpress-api-pro/scripts/get_post.py b/wordpress-api-pro/scripts/get_post.py index 3924169..aac8e5f 100755 --- a/wordpress-api-pro/scripts/get_post.py +++ b/wordpress-api-pro/scripts/get_post.py @@ -2,6 +2,7 @@ """Get WordPress post via REST API""" import argparse, json, os, sys, urllib.request from base64 import b64encode +from security import warn_insecure_wp_url parser = argparse.ArgumentParser(description='Get WordPress post') parser.add_argument('--url', default=os.getenv('WP_URL')) @@ -13,6 +14,7 @@ if not all([args.url, args.username, args.app_password]): print(json.dumps({"error": "Missing credentials"}), file=sys.stderr) sys.exit(1) +warn_insecure_wp_url(args.url) api_url = f"{args.url.rstrip('/')}/wp-json/wp/v2/posts/{args.post_id}" credentials = f"{args.username}:{args.app_password}".encode('utf-8') diff --git a/wordpress-api-pro/scripts/jetengine_fields.py b/wordpress-api-pro/scripts/jetengine_fields.py index af0983f..ccebb5e 100755 --- a/wordpress-api-pro/scripts/jetengine_fields.py +++ b/wordpress-api-pro/scripts/jetengine_fields.py @@ -30,6 +30,7 @@ import sys import requests from base64 import b64encode +from security import warn_insecure_wp_url def get_jetengine_fields(url, username, password, post_id, field_name=None): """Get JetEngine fields (stored as postmeta)""" @@ -111,18 +112,19 @@ def main(): # Validate required args if not args.url: - print(json.dumps({"error": "WordPress URL required (--url or WP_SITE_URL/WP_URL env var)"}), + print(json.dumps({"error": "WordPress URL required (--url or WP_SITE_URL/WP_URL env var)"}), file=sys.stderr) sys.exit(1) if not args.username: - print(json.dumps({"error": "Username required (--username or WP_USER/WP_USERNAME env var)"}), + print(json.dumps({"error": "Username required (--username or WP_USER/WP_USERNAME env var)"}), file=sys.stderr) sys.exit(1) if not args.app_password: - print(json.dumps({"error": "App password required (--app-password or WP_APP_PASSWORD env var)"}), + print(json.dumps({"error": "App password required (--app-password or WP_APP_PASSWORD env var)"}), file=sys.stderr) sys.exit(1) - + warn_insecure_wp_url(args.url) + try: # Set operation if args.set_json: diff --git a/wordpress-api-pro/scripts/list_posts.py b/wordpress-api-pro/scripts/list_posts.py index 39525d1..86a20ab 100755 --- a/wordpress-api-pro/scripts/list_posts.py +++ b/wordpress-api-pro/scripts/list_posts.py @@ -2,6 +2,7 @@ """List WordPress posts via REST API""" import argparse, json, os, sys, urllib.request, urllib.parse from base64 import b64encode +from security import warn_insecure_wp_url parser = argparse.ArgumentParser(description='List WordPress posts') parser.add_argument('--url', default=os.getenv('WP_URL')) @@ -16,6 +17,7 @@ if not all([args.url, args.username, args.app_password]): print(json.dumps({"error": "Missing credentials"}), file=sys.stderr) sys.exit(1) +warn_insecure_wp_url(args.url) params = {'per_page': args.per_page, 'page': args.page, 'status': args.status} if args.author: diff --git a/wordpress-api-pro/scripts/security.py b/wordpress-api-pro/scripts/security.py index dc1254a..9a9f22d 100644 --- a/wordpress-api-pro/scripts/security.py +++ b/wordpress-api-pro/scripts/security.py @@ -141,6 +141,31 @@ def fetch_https_media(url: str, *, timeout: int = 20, max_bytes: int = DEFAULT_M return response, body +def warn_insecure_wp_url(url, env=None): + """Warn when a WordPress API URL is plaintext http:// on a non-local host. + Basic-Auth credentials would travel unencrypted. Localhost/dev hosts are exempt. + With WP_REQUIRE_HTTPS=1 this raises SafetyError instead of warning. + Returns the url unchanged (never mutates it).""" + env = env if env is not None else os.environ + parsed = urllib.parse.urlparse(url if "://" in str(url) else "https://" + str(url)) + host = (parsed.hostname or "").lower() + is_local = ( + host in ("localhost", "127.0.0.1", "0.0.0.0", "::1") + or host.endswith(".local") + or host.endswith(".test") + or host.endswith(".localhost") + ) + if parsed.scheme == "http" and not is_local: + msg = ( + "SECURITY WARNING: WordPress URL '%s' uses plaintext http:// — " + "Basic-Auth credentials will be sent unencrypted. Use https:// in production." % url + ) + if env.get("WP_REQUIRE_HTTPS") == "1": + raise SafetyError(msg + " (WP_REQUIRE_HTTPS=1 set — refusing.)") + print(msg, file=sys.stderr) + return url + + def die_safety(error: Exception) -> None: print(f"Safety error: {error}", file=sys.stderr) sys.exit(2) diff --git a/wordpress-api-pro/scripts/seed_content.py b/wordpress-api-pro/scripts/seed_content.py index de0210e..354dac3 100644 --- a/wordpress-api-pro/scripts/seed_content.py +++ b/wordpress-api-pro/scripts/seed_content.py @@ -11,6 +11,7 @@ Env: WP_URL/WP_SITE_URL, WP_USERNAME/WP_USER, WP_APP_PASSWORD """ import argparse, json, os, sys +from security import warn_insecure_wp_url # NB: the write-path modules (acf_fields/jetengine_fields) import `requests`, and # the image path needs upload_media. They are imported lazily inside seed() so the @@ -98,6 +99,7 @@ def main(): if not all([a.url, a.username, a.app_password]): print(json.dumps({"error": "Missing required credentials"}), file=sys.stderr); sys.exit(1) + warn_insecure_wp_url(a.url) result = seed(a.url, a.username, a.app_password, dataset, allow_remote=a.allow_remote_url) print(json.dumps(result, indent=2)) if result['failed']: diff --git a/wordpress-api-pro/scripts/seo_meta.py b/wordpress-api-pro/scripts/seo_meta.py index 7031a8a..ca5e054 100755 --- a/wordpress-api-pro/scripts/seo_meta.py +++ b/wordpress-api-pro/scripts/seo_meta.py @@ -28,6 +28,7 @@ import sys import requests from base64 import b64encode +from security import warn_insecure_wp_url # Meta key mappings RANKMATH_KEYS = { @@ -194,18 +195,19 @@ def main(): # Validate required args if not args.url: - print(json.dumps({"error": "WordPress URL required (--url or WP_SITE_URL/WP_URL env var)"}), + print(json.dumps({"error": "WordPress URL required (--url or WP_SITE_URL/WP_URL env var)"}), file=sys.stderr) sys.exit(1) if not args.username: - print(json.dumps({"error": "Username required (--username or WP_USER/WP_USERNAME env var)"}), + print(json.dumps({"error": "Username required (--username or WP_USER/WP_USERNAME env var)"}), file=sys.stderr) sys.exit(1) if not args.app_password: - print(json.dumps({"error": "App password required (--app-password or WP_APP_PASSWORD env var)"}), + print(json.dumps({"error": "App password required (--app-password or WP_APP_PASSWORD env var)"}), file=sys.stderr) sys.exit(1) - + warn_insecure_wp_url(args.url) + try: # Detect only if args.detect: diff --git a/wordpress-api-pro/scripts/update_post.py b/wordpress-api-pro/scripts/update_post.py index 691afc1..64e41ac 100755 --- a/wordpress-api-pro/scripts/update_post.py +++ b/wordpress-api-pro/scripts/update_post.py @@ -19,7 +19,7 @@ import urllib.error from base64 import b64encode -from security import SafetyError, TEXT_MAX_BYTES, die_safety, validate_local_file +from security import SafetyError, TEXT_MAX_BYTES, die_safety, validate_local_file, warn_insecure_wp_url def update_post(url, username, app_credential, post_id, **updates): """Update WordPress post via REST API""" @@ -92,6 +92,7 @@ def main(): if not args.url: print(json.dumps({"error": "WordPress URL required (--url or WP_URL)"}), file=sys.stderr) sys.exit(1) + warn_insecure_wp_url(args.url) if not args.username: print(json.dumps({"error": "Username required (--username or WP_USERNAME)"}), file=sys.stderr) sys.exit(1) diff --git a/wordpress-api-pro/scripts/upload_media.py b/wordpress-api-pro/scripts/upload_media.py index bc59a03..62d51b0 100755 --- a/wordpress-api-pro/scripts/upload_media.py +++ b/wordpress-api-pro/scripts/upload_media.py @@ -3,7 +3,7 @@ import argparse, json, os, sys, urllib.request, urllib.parse, urllib.error, mimetypes from base64 import b64encode -from security import SafetyError, die_safety, fetch_https_media, validate_local_file +from security import SafetyError, die_safety, fetch_https_media, validate_local_file, warn_insecure_wp_url def upload_media(url, username, app_credential, file_path, title=None, alt_text=None, caption=None, allow_remote_url=False): """Upload a media file to WordPress""" @@ -137,6 +137,7 @@ def main(): if not all([args.url, args.username, args.app_password]): print(json.dumps({"error": "Missing credentials"}), file=sys.stderr) sys.exit(1) + warn_insecure_wp_url(args.url) if args.set_featured and not args.post_id: print(json.dumps({"error": "--post-id required when using --set-featured"}), file=sys.stderr) diff --git a/wordpress-api-pro/scripts/woo_products.py b/wordpress-api-pro/scripts/woo_products.py index b3a984c..3a0bcce 100755 --- a/wordpress-api-pro/scripts/woo_products.py +++ b/wordpress-api-pro/scripts/woo_products.py @@ -2,6 +2,7 @@ """Manage WooCommerce products via REST API""" import argparse, json, os, sys, urllib.request, urllib.parse from base64 import b64encode +from security import warn_insecure_wp_url def make_wc_request(url, consumer_key, consumer_secret, endpoint, method='GET', data=None): """Make a WooCommerce REST API request""" @@ -103,6 +104,7 @@ def update_product(url, consumer_key, consumer_secret, product_id, **kwargs): if not all([args.url, args.consumer_key, args.consumer_secret]): print(json.dumps({"error": "Missing WooCommerce credentials (--consumer-key, --consumer-secret)"}), file=sys.stderr) sys.exit(1) +warn_insecure_wp_url(args.url) if args.action == 'list': result = list_products(args.url, args.consumer_key, args.consumer_secret, args.per_page, args.page) diff --git a/wordpress-api-pro/scripts/wp_cli.py b/wordpress-api-pro/scripts/wp_cli.py index 2a2b7d6..d893a24 100755 --- a/wordpress-api-pro/scripts/wp_cli.py +++ b/wordpress-api-pro/scripts/wp_cli.py @@ -26,6 +26,7 @@ import sys import subprocess from pathlib import Path +from security import warn_insecure_wp_url def load_config(config_path=None): """Load sites configuration (optional fallback)""" @@ -115,7 +116,9 @@ def run_command(site_config, command, args): if not all([env.get('WP_URL'), env.get('WP_USERNAME'), env.get('WP_APP_PASSWORD')]): print("Error: Missing credentials. Set WP_URL, WP_USERNAME, WP_APP_PASSWORD or use config file", file=sys.stderr) sys.exit(1) - + + warn_insecure_wp_url(env['WP_URL']) + # Run with modified environment result = subprocess.run(cmd, env=env) return result.returncode