`if git ls-files -z … | xargs -0 grep -nE …; then` has a false green in it. xargs exits 123 when ANY of its invocations exited 1-125, and grep exits 1 on no-match, so a run where one batch matched and another did not exits 123 — which `if` reads as clean, after grep has already printed the hit. Measured: one batch, a match xargs exit 0 -> caught one batch, no match xargs exit 123 -> clean, correct two batches, one match xargs exit 123 -> CLEAN, and it printed the hit Three checks here used that form: debug leftovers, short open tags, and the git-identity scan added two days ago. All three are one batch at this tree's size, so none of them is wrong today — this is a latent false green that arrives with the file list, not a bug anyone would see. Nothing in the output distinguishes the cases. scan_tracked() replaces it: one grep per file, verdict from the OUTPUT rather than any exit status, and a grep failure (exit > 1) reported instead of swallowed. Batching stops existing. The loop also yields the file count, which is the other thing all three were missing — "none" over zero files and "none" over nineteen were the same green. They now read "none in 19 tracked PHP files" and fail below a floor of one. The identity scan is also widened from ci/*.sh to every tracked file. A workflow step writes an identity into the runner's clone just as permanently, and ci/*.sh was scope chosen from where the first instance happened to live — the same "what are your checks pointed at" question as the contrast check. 13 files to 48. The new selftest row plants a write into a workflow file, which the old scope could not see at all. Reported by the Nolimit Security session, who hit the xargs case after widening their own scan from 13 files to 102. Three of my own probes were wrong before they were right, all the same class: the assertions grep for the check's message text, and widening the scope changed the wording from "A ci/ script sets …" to "A tracked file writes …". Five rows reported NOT FLAGGED while the check was working perfectly. They now match the stable half of the message, with the reason written beside them. And the form check matched the comment in static.sh that quotes the bad form in order to explain it, so it is anchored with ^[^#]* — the paragraph documenting a rule is text the rule applies to. That form check is a drift check on the shape and says so: it asserts the pattern is gone, not that the replacement is correct, because this tree's 48 files are one batch and nothing here can measure the fault. 46 selftest assertions. static=0 selftest=0 site=0; site still 69 assertions on WordPress 7.1.2 with WooCommerce 11.1.2. ci/ ships nothing, so no version bump. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
507 lines
26 KiB
Bash
Executable File
507 lines
26 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
|
|
}
|
|
|
|
# 🔴 scan_tracked: the only way this file greps a set of tracked files.
|
|
#
|
|
# The form it replaces — `if git ls-files -z … | xargs -0 grep -nE …; then` —
|
|
# has a false GREEN in it. `xargs` exits 123 when ANY of its invocations exited
|
|
# 1-125, and `grep` exits 1 on no-match, so a run where one batch matched and
|
|
# another did not exits 123 and `if` reads that as clean. Measured:
|
|
#
|
|
# one batch, a match xargs exit 0 -> caught
|
|
# one batch, no match xargs exit 123 -> clean, correct
|
|
# two batches, one match xargs exit 123 -> CLEAN, and it printed the hit
|
|
#
|
|
# Harmless only while the whole list fits in one exec. git ls-files hands over
|
|
# enough paths to split the moment a tree grows or a path gets long, and then
|
|
# the check passes over a violation it has already printed. Nothing about the
|
|
# output says which case you are in. Reported by the Nolimit Security session
|
|
# 2026-09-28, who hit it after widening their own scan from 13 files to 102.
|
|
#
|
|
# So: no xargs, and the verdict is the OUTPUT rather than any exit status. The
|
|
# loop also yields the count for free, which is the other thing every one of
|
|
# these checks was missing — "none" over zero files and "none" over nineteen
|
|
# are the same green.
|
|
#
|
|
# Sets: scan_hits (matches, one "file:line:text" per line), scan_files (how
|
|
# many files were read). A grep failure (exit > 1) is reported, never swallowed.
|
|
scan_tracked() { # $1 = -E|-P, $2 = pattern, rest = git pathspecs
|
|
local mode=$1 pattern=$2 f m rc
|
|
shift 2
|
|
scan_hits=''
|
|
scan_files=0
|
|
# -z: git QUOTES a non-ASCII path, and the quoted form is not a path that
|
|
# exists, so a line-based read silently skips the file it meant to check.
|
|
while IFS= read -r -d '' f; do
|
|
[ -n "$f" ] || continue
|
|
scan_files=$((scan_files + 1))
|
|
m=$(grep -n "$mode" -e "$pattern" -- "$f")
|
|
rc=$?
|
|
if [ "$rc" -gt 1 ]; then
|
|
note_fail "grep failed on $f (exit $rc) — this scan did not examine it."
|
|
elif [ "$rc" = 0 ]; then
|
|
scan_hits="${scan_hits}$(printf '%s\n' "$m" | sed "s|^|$f:|")
|
|
"
|
|
fi
|
|
done < <(git ls-files -z "$@")
|
|
}
|
|
|
|
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"
|
|
scan_tracked -E '\b(var_dump|print_r|dd)[[:space:]]*\(' '*.php'
|
|
if [ "$scan_files" -lt 1 ]; then
|
|
note_fail "The debug-leftover scan read no PHP files at all, so it proved nothing."
|
|
elif [ -n "$scan_hits" ]; then
|
|
printf '%s' "$scan_hits"
|
|
note_fail "Debug functions must not ship."
|
|
else
|
|
echo " none in $scan_files tracked PHP files"
|
|
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.
|
|
scan_tracked -P '<\?(?!php|=|xml)' '*.php'
|
|
if [ "$scan_files" -lt 1 ]; then
|
|
note_fail "The open-tag scan read no PHP files at all, so it proved nothing."
|
|
elif [ -n "$scan_hits" ]; then
|
|
printf '%s' "$scan_hits"
|
|
note_fail "Use <?php, not short open tags."
|
|
fi
|
|
open_tag_files=$scan_files
|
|
# -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 " $open_tag_files tracked PHP files, no short tags and no trailing ?>"
|
|
|
|
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
|
|
|
|
readme=
|
|
for f in readme.txt README.txt; do [ -f "$f" ] && readme=$f && break; done
|
|
|
|
ci_step "the plugin is called the same thing everywhere"
|
|
# 🔴 The rename moved the code and left the text behind: the readme still told
|
|
# people to look under WooCommerce for a page that had moved to the Nolimit
|
|
# menu, and three notices the PLUGIN SHOWS THE USER still used the old name.
|
|
# Kristian found it by reading the readme, which is the only reason it was
|
|
# found at all — nothing fails when a sentence goes stale.
|
|
#
|
|
# Same failure as a privacy page standing still while the code moves. The name
|
|
# in the header is the one WordPress shows, so it is the one everything else
|
|
# has to agree with.
|
|
header_name=$(ci_plugin_header 'Plugin Name')
|
|
echo " header: $header_name"
|
|
readme_name=$(sed -n -E 's/^=== (.+) ===$/\1/p' "$readme" 2>/dev/null | head -1)
|
|
echo " $readme: $readme_name"
|
|
[ "$header_name" = "$readme_name" ] \
|
|
|| note_fail "The plugin header says '$header_name' but $readme says '$readme_name'."
|
|
|
|
# Old names in text a user reads. The changelog is exempt: an entry describes
|
|
# what was true for that release, and rewriting it would be a different lie.
|
|
stale=$(./ci/dist-files.sh | grep -E '\.php$' \
|
|
| xargs grep -nF "$OLD_PLUGIN_NAME" 2>/dev/null || true)
|
|
if [ -n "$stale" ]; then
|
|
printf '%s\n' "$stale" | sed 's/^/ /'
|
|
note_fail "The old plugin name '$OLD_PLUGIN_NAME' is still in shipped code."
|
|
else
|
|
echo " no shipped file still says '$OLD_PLUGIN_NAME'"
|
|
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 tracked file configures the clone it was handed"
|
|
# `git config user.name` writes .git/config and OUTLIVES the script. A helper
|
|
# that sets an identity before committing therefore renames every later commit
|
|
# in that working copy, including a release made by a person days afterwards
|
|
# and with no Co-Authored-By trailer. git announces an identity it cannot FIND
|
|
# and says nothing about one that is wrong, so the missing case is loud and
|
|
# this one is silent: nothing fails, nothing warns, and the author is only
|
|
# visible to somebody reading `git log` for another reason.
|
|
#
|
|
# ci/bump-tested-up-to.sh did this until it was scoped to `git -c … commit`,
|
|
# which still signs its own commit as the robot — correct, that one is not a
|
|
# person's — without touching the clone. Measured across the family: six
|
|
# misattributed hand commits in one sibling, two in another, including
|
|
# releases. This tree was clean and one run away.
|
|
#
|
|
# The pattern has to separate a WRITE from a read and from the repair, and the
|
|
# obvious widening gets all three wrong. Measured row by row:
|
|
#
|
|
# git config user.name "compatibility check" WRITE caught
|
|
# git config --local user.name "x" WRITE caught
|
|
# git config --global user.email "x@y" WRITE caught <- --local only: missed
|
|
# git config --system user.name "x" WRITE caught
|
|
# git config --local --unset user.name REPAIR ignored <- --[a-z-]+: flagged
|
|
# git config --unset user.email REPAIR ignored <- --[a-z-]+: flagged
|
|
# echo "$(git config user.name)" READ ignored <- --[a-z-]+: flagged
|
|
# git -c user.name="x" … commit -m y FIX ignored
|
|
# git config core.autocrlf false other ignored
|
|
# git config --global core.pager cat other ignored
|
|
# # git config user.name "x" (a comment) prose ignored
|
|
# (nothing planted) clean ignored
|
|
#
|
|
# ⚠️ Do NOT widen the scope group to `--[a-z-]+`. It reads `--unset` as a scope
|
|
# and then finds `user.` right after it, so the check goes red on the very
|
|
# repair this message recommends — and a build that fails on the remedy is how
|
|
# a correct check gets deleted: the failure accuses the check, and the next
|
|
# person believes it. Three sessions reached for `--[a-z-]+` independently on
|
|
# 2026-09-25, which is why it is written here rather than re-derived.
|
|
#
|
|
# Enumerating the three real scopes is one half. The other is requiring a VALUE
|
|
# after the key — `user\.[a-zA-Z]+[[:space:]]+[^[:space:])]` — which is what
|
|
# makes a read not a violation: `$(git config user.name)` has `)` where a write
|
|
# has a value. Found by nolimit-seo, whose first version failed on it.
|
|
#
|
|
# The rule: a script may configure ITSELF, never the working copy it was
|
|
# handed. Reported by the Nolimit Security session 2026-09-25.
|
|
#
|
|
# Writing the fault in prose inside a tracked file will trip this, by design.
|
|
# To name it in documentation without going red, write the key as user.<key> —
|
|
# the pattern needs [a-zA-Z] after the dot, so an angle-bracket placeholder is
|
|
# prose and a real key is a violation.
|
|
# Every tracked file, not just ci/*.sh. A workflow step, a hook, a Makefile or
|
|
# a docs snippet somebody pastes can write an identity just as permanently, and
|
|
# `ci/*.sh` was scope chosen from where the first instance happened to live —
|
|
# the same "what are your checks pointed at" question as the contrast check.
|
|
# Nolimit Security widened theirs from 13 files to 102 after the thing that
|
|
# actually ran on this box turned out to be outside that glob.
|
|
scan_tracked -E '^[^#]*git config([[:space:]]+--(local|global|system))*[[:space:]]+user\.[a-zA-Z]+[[:space:]]+[^[:space:])]' .
|
|
if [ "$scan_files" -lt 1 ]; then
|
|
note_fail "The git-identity scan read no tracked files at all, so it proved nothing."
|
|
elif [ -n "$scan_hits" ]; then
|
|
printf '%s' "$scan_hits"
|
|
note_fail "A tracked file writes a git identity that outlives it. Scope it to the one command with 'git -c user.name=… commit', which does not write .git/config."
|
|
else
|
|
echo " $scan_files tracked files leave the clone's git identity alone"
|
|
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.
|
|
|
|
|
|
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."
|