#!/usr/bin/env bash # Checks that need no WordPress: syntax, plugin hygiene, version consistency, # and whether the plugin header still tells the truth about what it supports. # # Fast enough to run before every commit. `ci/site-test.sh` is the slow half. set -uo pipefail cd "$(dirname "$0")/.." . ci/lib.sh ci_load_conf fails=0 note_fail() { ci_fail "$1"; fails=$((fails + 1)); } # Only files that actually reach a server. A tool under ci/ has no business # carrying an ABSPATH guard, and a test file is not shipped at all. Same list # the release zip is built from, so the two cannot drift. shipped_files() { ./ci/dist-files.sh | grep '\.php$' || true } ci_step "PHP syntax" # The header promises a minimum PHP version; without it in the loop a piece of # newer syntax passes here and fatals on the customer's server instead. min_php=$(ci_plugin_header 'Requires PHP') tarball=$(mktemp -t wpci-lint-XXXXXX.tar) git ls-files -z '*.php' | tar --null -T - -cf "$tarball" for v in "${min_php:-7.4}" 8.4; do echo " php $v" # docker cp of a tarball, not -v "$PWD":/src — a Gitea Actions job runs # inside a container talking to the HOST's docker daemon, so a host path # here mounts an empty directory nobody created and the lint passes by # linting nothing. Copying a directory needs its parent to already exist in # the container, which /src does not; a single file into /tmp always works. cid=$(docker create "php:$v-cli" sh -c ' set -e mkdir -p /src && tar -xf /tmp/src.tar -C /src n=0 for f in $(find /src -name "*.php"); do php -l "$f" >/dev/null; n=$((n + 1)); done echo " $n files parse" # Guards against the failure this step had on its first run: an empty /src # makes the loop a no-op and the whole check reports success. [ "$n" -gt 0 ] || { echo " no PHP files arrived in the container" >&2; exit 1; }') docker cp "$tarball" "$cid:/tmp/src.tar" >/dev/null code=0 docker start -a "$cid" || code=$? docker rm -f "$cid" >/dev/null [ "$code" = 0 ] || note_fail "PHP $v cannot parse this plugin." done rm -f "$tarball" ci_step "protection against direct access" # Without the guard a PHP file can be requested straight from the browser, # outside WordPress and therefore outside every capability check the code # assumes has already happened. # # 🔴 Tokenised by ci/check-abspath.php, not grepped. The grep version asked # whether the words appeared anywhere in the file, so a COMMENT saying the # guard was handled elsewhere passed it. Measured with a planted file whose # only mention was in a comment: "15 shipped PHP files examined, every one # guarded" — green over a file with no guard at all. # # The count and the floor stay: "every shipped file is guarded" is also what # zero shipped files prints, and shipped_files() swallows a dist-files.sh # failure with `|| true`. The floor is above 1 because a bad .distignore # pattern can collapse the list without emptying it. shipped_php=$(shipped_files) if [ -z "$shipped_php" ]; then note_fail "No shipped PHP files were listed at all. The file list is wrong, so this check proved nothing." else # shellcheck disable=SC2086 ci_php ci/check-abspath.php "--min=$MIN_SHIPPED_PHP_FILES" $shipped_php || fails=$((fails + 1)) fi ci_step "no debug leftovers" if git ls-files -z '*.php' | xargs -0 grep -nE '\b(var_dump|print_r|dd)[[:space:]]*\(' ; then note_fail "Debug functions must not ship." else echo " none" fi ci_step "no short open tags, no closing ?>" before=$fails # With short_open_tag off the code is printed to the visitor as text instead of # being run; whitespace after a closing tag is sent to the browser and breaks # headers and redirects. if git ls-files -z '*.php' | xargs -0 grep -nP '<\?(?!php|=|xml)' ; then note_fail "Use " ] \ && { ci_annotate error "$f" 1 "Remove the trailing ?> at the end of the file."; fails=$((fails + 1)); } done < <(git ls-files -z '*.php') [ "$fails" = "$before" ] && echo " clean" ci_step "text domain" # --min is deliberately far below the real count. It is not an assertion about # how many strings this plugin has — a tight number goes red on ordinary churn # and gets raised until it means nothing. It asserts the scan still finds this # plugin's strings AT ALL, because "every translation call carries the text # domain" is also what this prints having found none. ci_php ci/check-textdomain.php "--min=$MIN_TRANSLATION_CALLS" "$TEXT_DOMAIN" . || fails=$((fails + 1)) ci_step "every file the plugin loads is a file the release contains" # The bug this exists for: a new class file is written, required from the main # file, works perfectly in every local test because it is sitting right there # in the working tree — and is not committed, so the release zip does not have # it. This plugin degrades instead of fataling on a missing include, which is # good for a half-finished upgrade and terrible here: the shop would just # quietly lose a feature. shipped=$(./ci/dist-files.sh) referenced=$(printf '%s\n' "$shipped" | grep '\.php$' | xargs grep -ohE "'[A-Za-z0-9_./-]+\.php'" 2>/dev/null | tr -d "'" | sort -u) checked=0 for path in $referenced; do # A plugin-relative include always names a directory of this plugin. That # rules out the look-alikes: "admin-ajax.php" and "admin.php" are admin URLs # and menu slugs, "woocommerce/woocommerce.php" is another plugin's basename, # and "wp-admin/includes/upgrade.php" is resolved against ABSPATH. case "$path" in */*) ;; *) continue ;; esac [ -d "${path%%/*}" ] || continue checked=$((checked + 1)) # `case` rather than piping the list into grep -qx: under pipefail a grep # that matches can exit 141 when the producer is signalled, so a HIT becomes # a failure. The list is small today and this loop runs once per include, so # this also drops a fork per iteration. Newlines on both sides make it an # exact line match, which is what -qx gave. case $'\n'"$shipped"$'\n' in *$'\n'"$path"$'\n'*) ;; *) note_fail "The code loads '$path', but the release zip does not contain it. Committed?" ;; esac done if [ "$checked" = 0 ]; then # A check that verifies nothing must say so rather than reporting success. # Not a failure: a single-file plugin, or one with an autoloader instead of # a list of requires, legitimately has nothing here. echo "::warning::No plugin-relative include was found to check. That is expected for a single-file plugin or an autoloader, and a broken scan otherwise." else echo " $checked plugin-relative include(s) checked" fi ci_step "one version, stated once" # WordPress compares the update feed against the header and nothing else, so a # constant or a readme that disagrees with it is how a release ends up either # offered forever or never offered at all. version=$(ci_plugin_version) echo " plugin header: $version" [ -n "$version" ] || note_fail "Could not read Version: from $PLUGIN_MAIN." while read -r line; do [ -n "$line" ] || continue const=${line%%=*} value=${line#*=} echo " $const: $value" [ "$value" = "$version" ] \ || note_fail "$const is $value but the plugin header says $version." done <<< "$(sed -n -E "s/^[[:space:]]*define\([[:space:]]*'([A-Z_]*VERSION)'[[:space:]]*,[[:space:]]*'([^']+)'.*/\1=\2/p" "$PLUGIN_MAIN")" for readme in readme.txt README.txt; do [ -f "$readme" ] || continue stable=$(sed -n -E 's/^Stable tag:[[:space:]]*([^[:space:]]+).*/\1/p' "$readme" | head -1) echo " $readme stable tag: ${stable:-}" [ "$stable" = "$version" ] \ || note_fail "$readme has 'Stable tag: $stable' but the plugin header says $version." done ci_step "the update feed and the release target name the same repository" # 🔴 The header is what every installed copy polls; RELEASE_REPO is where the # zip is actually published. If they disagree, the shop keeps asking an address # nothing is published to any more and quietly stops receiving updates — # latest_release() just returns false, and nothing says so. Same failure as a # privacy page that stands still while the code moves: the two must change in # one commit, and this is what makes that impossible to forget. update_uri=$(ci_plugin_header 'Update URI') echo " Update URI: ${update_uri:-}" echo " RELEASE_REPO: $RELEASE_REPO" case "$update_uri" in */"$RELEASE_REPO") echo " they agree" ;; *) note_fail "Update URI is '$update_uri' but releases are published to '$RELEASE_REPO'. Installed copies poll the header, so they would stop being offered updates without a word." ;; esac ci_step "the shared menu file is the family copy" # includes/shared-menu.php is byte-identical in all six plugins. Editing it # here would not make this plugin's version win — which copy a site loads is # decided by activation order, and PHP cannot un-declare a function — it would # make the menu differ between shops depending on which plugin booted first, # and change its mind when one is deactivated. So the file is frozen, and # taking a new version is something all six do in the same window. shared_menu=includes/shared-menu.php if [ ! -f "$shared_menu" ]; then echo "::warning::$shared_menu is not present, so this plugin registers no shared menu." else have=$(sha256sum "$shared_menu" | cut -d" " -f1) echo " sha256: $have" [ "$have" = "$WPMGR_MENU_SHA" ] \ && echo " matches the frozen family copy" \ || note_fail "$shared_menu does not match the frozen family copy. Expected $WPMGR_MENU_SHA, got $have. Do not edit it — take the new version the others took." fi ci_step "no secrets in tracked files" # Suggested by the AI-manager port, and it costs nothing: a pushed key cannot be # unpushed. It has to be rotated, and the window between the push and noticing # is the whole problem. # # Two rules, at two different confidence levels. # # The first is content-anchored: prefixes that mean one thing only. Broad # ENTROPY heuristics are still refused here — they fire on minified assets, # hashes and base64 images, and a check that cries wolf gets commented out, # which is worse than not having it. # # The second is name-anchored, and closes a hole the first cannot see. A prefix # list matches the shape of a VALUE, so it is blind to any credential that does # not carry a vendor's marker: an api_key assigned twenty opaque characters # walks straight past it. This is not an entropy heuristic — it fires only # where the code itself names the value a secret, which is why it does not # touch a minified asset. Obvious placeholders are excluded so a settings # default does not cry wolf. # # ⚠️ It DOES fire on a documentation example, and there is no bypass marker on # purpose — an unexercised escape hatch is its own hollow check. The first # version of this comment spelled the example out in full and the rule flagged # its own explanation, which is the one false positive measured so far. Write # such an example in prose, or break the literal up. # # 🔴 A count cannot tell you a pattern is not INERT. Proven the hard way in a # sibling plugin: it planted an AWS key, the file count rose, and the scan # passed green — that prefix was simply missing from its list. So every shape # claimed below is planted and run through this whole script, not grepped in # isolation, and a lookalike control is checked not to fire. # # 🔴 And it says how many files it read. A pushed key is compromised even after # the commit is deleted, so this scan silently examining nothing is not a weak # check — it is the absence of one, wearing a green tick. # # The floor here is 1 and is NOT plugin-configurable, unlike the shipped-file # one above: this list is every tracked file with no filter that could go # partially wrong, so the only way it misreports is by coming out empty, and # any higher number would just be a guess about the plugin's size. The count on # the success line is what catches a list that shrank without emptying. secret_value_pat='(sk-ant-[A-Za-z0-9_-]{16,}|sk-[A-Za-z0-9]{32,}|ghp_[A-Za-z0-9]{30,}|gho_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{50,}|AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]*PRIVATE KEY-----)' # The code calls it a secret and then hands it twenty-odd opaque characters. secret_name_pat="['\"](api[_-]?key|secret|password|passwd|token|auth[_-]?token|private[_-]?key)['\"][[:space:]]*(=>|=|:)[[:space:]]*['\"][A-Za-z0-9/+_-]{20,}['\"]|define\([[:space:]]*['\"][A-Z0-9_]*(SECRET|PASSWORD|TOKEN|API_KEY)['\"][[:space:]]*,[[:space:]]*['\"][A-Za-z0-9/+_-]{20,}['\"]" # A default nobody has filled in yet is not a leak. Without this the check goes # red on the one thing a settings screen is guaranteed to contain. placeholder_pat='REPLACE|YOUR[_-]|EXAMPLE|CHANGE[_-]?ME|TODO|FIXME|PLACEHOLDER|xxxxx|XXXXX|\*\*\*\*' before=$fails secrets_scanned=0 while IFS= read -r -d '' f; do [ -f "$f" ] || continue secrets_scanned=$((secrets_scanned + 1)) # No -I here, deliberately, and the two rules differ on this for a reason. # grep -I SILENTLY SKIPS a file it decides is binary, so a PEM header inside # one would be waved through without a word. Without -I the same file reports # "binary file matches" and fails, which is the right direction to be wrong # in. (Narrowed by accident when the name rule was added, and caught only by # asking what the flag actually does — the tree has no binary files today, so # nothing went red to announce it.) if LC_ALL=C grep -nE "$secret_value_pat" "$f"; then note_fail "$f looks like it contains a credential. It cannot be unpushed — rotate it." fi # -I is right HERE: this rule is heuristic and name-anchored, so a chance # match inside a compiled asset is noise, not a finding. if LC_ALL=C grep -nEI "$secret_name_pat" "$f" | LC_ALL=C grep -vE "$placeholder_pat"; then note_fail "$f assigns a long opaque value to something the code calls a secret. If it is one, it cannot be unpushed — rotate it." fi done < <(git ls-files -z) if [ "$secrets_scanned" -lt 1 ]; then note_fail "The credential scan examined no files at all. git tracks nothing here, so this check proved nothing." elif [ "$fails" = "$before" ]; then echo " $secrets_scanned tracked files read, nothing that looks like a key or a private key" fi ci_step "no placeholders left in the plugin header" # The header is the plugin's business card: wp-admin shows the author and the # links to whoever installs it, so a placeholder there survives for as long as # nobody happens to look at that screen. This plugin shipped with # "https://example.com/" as its Plugin URI for several releases. # # It catches the generic markers only. A made-up vendor name — this one went # out as "WEDSMP" — looks exactly like a real one to any check, so an empty # field is warned about but a wrong-but-plausible value cannot be. before=$fails for field in 'Plugin Name' 'Description' 'Author' 'Author URI' 'Plugin URI'; do value=$(ci_plugin_header "$field") if [ -z "$value" ]; then echo "::warning::The plugin header has no '$field:'." continue fi case "$value" in *example.com*|*example.org*|*yourname*|*YOUR_*|*TODO*|*FIXME*|*changeme*|*'<'*'>'*) note_fail "$field is still a placeholder: $value" ;; esac done [ "$fails" = "$before" ] && echo " name, description, author and links are all filled in" ci_step "the compatibility headers still tell the truth" # Advisory, never fatal. WordPress ships a new version whether or not anything # here is wrong, and a build that goes red on release day for a header nobody # has had a chance to update yet is a build that gets ignored. What this does # is make the staleness visible; compat.yml opens the PR that fixes it, but # only once the real tests have passed against the new version. # stable-check, not version-check: version-check's payload also carries old # offers, and a loose match on it returned "4.7.35" as the current release. current_wp=$(curl -fsS --max-time 20 'https://api.wordpress.org/core/stable-check/1.0/' 2>/dev/null \ | tr ',' '\n' | sed -n -E 's/.*"([0-9][^"]*)"[[:space:]]*:[[:space:]]*"latest".*/\1/p' | head -1) current_wc=$(curl -fsS --max-time 20 'https://api.wordpress.org/plugins/info/1.0/woocommerce.json' 2>/dev/null \ | sed -n -E 's/.*"version":"([^"]+)".*/\1/p' | head -1) behind() { # claimed current -> true when claimed is the older of the two [ -n "$1" ] && [ -n "$2" ] || return 1 [ "$1" != "$2" ] && [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -1)" = "$1" ] } # "Tested up to" is a readme.txt field — WordPress never reads it from the # plugin header, so checking the header for it reports "missing" on a plugin # that states it correctly. "WC tested up to" is the opposite: WooCommerce # reads that one from the plugin header and nowhere else. readme= for f in readme.txt README.txt; do [ -f "$f" ] && readme=$f && break; done tested_wp= [ -n "$readme" ] && tested_wp=$(sed -n -E 's/^Tested up to:[[:space:]]*([^[:space:]]+).*/\1/p' "$readme" | head -1) echo " WordPress: ${readme:-readme} says ${tested_wp:-}, current release is ${current_wp:-}" if [ -z "$tested_wp" ]; then echo "::warning::No 'Tested up to:' in ${readme:-the readme}. WordPress lists the plugin as untested." elif behind "$tested_wp" "$current_wp"; then echo "::warning::'Tested up to: $tested_wp' is behind WordPress $current_wp." fi if [ "$NEEDS_WOOCOMMERCE" = yes ]; then tested_wc=$(ci_plugin_header 'WC tested up to') echo " WooCommerce: plugin header says ${tested_wc:-}, current release is ${current_wc:-}" if [ -z "$tested_wc" ]; then echo "::warning::No 'WC tested up to:' in the plugin header. WooCommerce then shows this plugin as untested in wp-admin — that banner is exactly what a shop owner reads before deciding whether to press update." elif behind "$tested_wc" "$current_wc"; then echo "::warning::'WC tested up to: $tested_wc' is behind WooCommerce $current_wc." fi requires_wc=$(ci_plugin_header 'WC requires at least') echo " WooCommerce minimum: ${requires_wc:-}" fi echo if [ "$fails" != 0 ]; then echo "$fails check(s) failed." exit 1 fi echo "Static checks: green."