Files
nolimit-discounts/ci/static.sh
T
kristianwindandClaude Opus 5 d121005c0a Replace the two growing | grep -q checks with case
Under `set -o pipefail` a grep that MATCHES can make the pipeline exit 141:
grep closes the pipe on its first hit, the producer takes SIGPIPE, and pipefail
reports that as failure. A match becomes an error. Security found the shape;
measured here before believing it, because the earlier pipefail lesson went the
other way and I did not want to carry a second claim I had not seen:

  yes hittet | grep -q hittet   under pipefail  -> exit 141
  printf hittet | grep -q hittet                -> exit 0

So it needs the producer to outrun the 64 KB pipe buffer, which is why it is
invisible until it is not.

Two of this repo's sites can get there, and both were converted. Neither is
reachable today — that is the reason to do it now rather than an argument
against:

ci/publish-release.sh checked the release feed for a download URL. The feed is
3.5 KB in the new repo and 13.5 KB in the old one across seven releases, so
about 1.9 KB each: it reaches 64 KB around release 34. That failure would land
in the publishing path and say the feed has no zip attached while it plainly
has one, and nothing about the commit that tripped it would have anything to do
with it. A bug on a timer is worse than a bug.

ci/static.sh checked whether each plugin-relative include is in the shipped
list, once per include. Same growth, plus a fork per iteration.

Both now use `case`, which has no pipe, no fork and no producer to signal, and
cannot be reversed by a shell option. The static.sh one wraps both sides in
newlines to keep the exact-line match `grep -qx` gave. Proven both ways:
thirteen includes checked on a clean tree, and excluding one from the zip still
names it.

The remaining `| grep -q` sites were measured rather than assumed safe:
zip_entries is 1.3 KB, the shipped list 478 bytes, static.sh's own output
1.6 KB, and site-test.sh and update-smoke.sh grep a single line of wp-cli
output. Left alone; converting those would be churn against producers that
cannot plausibly reach the buffer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 10:28:52 +02:00

357 lines
18 KiB
Bash
Executable File

#!/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 <?php, not short open tags."
fi
# -z: git quotes non-ASCII paths, and the quoted form is not a path that
# exists, so this loop would silently skip the file it was meant to check.
while IFS= read -r -d '' f; do
[ -n "$f" ] || continue
[ "$(tail -c 3 "$f" | tr -d '[:space:]')" = "?>" ] \
&& { 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:-<none>}"
[ "$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:-<missing>}"
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:-<missing>}, current release is ${current_wp:-<unknown>}"
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:-<missing>}, current release is ${current_wc:-<unknown>}"
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:-<missing>}"
fi
echo
if [ "$fails" != 0 ]; then
echo "$fails check(s) failed."
exit 1
fi
echo "Static checks: green."