Stop reading a verdict out of xargs' exit status

`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>
This commit is contained in:
kristianwind
2026-09-28 21:14:10 +02:00
co-authored by Claude Opus 5
parent 5151204b12
commit 539abf946c
2 changed files with 115 additions and 16 deletions
+38 -4
View File
@@ -251,7 +251,11 @@ identity_row() { # $1 = RED|green (want), $2 = label, $3 = line to plant
return return
fi fi
row_out=$(./ci/static.sh 2>&1 || true) row_out=$(./ci/static.sh 2>&1 || true)
if printf '%s' "$row_out" | grep -q 'sets a git identity that outlives it'; then # The stable half of the message. The wider phrasing moved from "A ci/ script
# sets …" to "A tracked file writes …" when the scan was widened, and these
# rows all reported NOT FLAGGED while the check was working perfectly — an
# assertion on a log line is coupled to its wording.
if printf '%s' "$row_out" | grep -q 'git identity that outlives it'; then
got=RED got=RED
else else
got=green got=green
@@ -279,12 +283,42 @@ identity_row green 'other a non-identity key' "$gc core.autocrlf false"
identity_row green 'prose a commented-out mention' "# $gc user.name \"x\" is the fault" identity_row green 'prose a commented-out mention' "# $gc user.name \"x\" is the fault"
cp "$work/build-zip.orig" ci/build-zip.sh cp "$work/build-zip.orig" ci/build-zip.sh
# A violation OUTSIDE ci/*.sh. This is the row the old scope missed entirely:
# a workflow step writes an identity into the runner's clone just as
# permanently, and the glob the check started with could not see it.
cp "$work/build-zip.orig" ci/build-zip.sh
wf=.github/workflows/selftest-identity.yml
printf 'name: x\njobs:\n y:\n steps:\n - run: %s user.email "x@y"\n' "$gc" > "$wf"
git add -N "$wf" >/dev/null # untracked files are invisible to git ls-files
wf_out=$(./ci/static.sh 2>&1 || true)
printf '%s' "$wf_out" | grep -q 'writes a git identity that outlives it' \
&& ok "flagged a write outside ci/ (a workflow step)" \
|| bad "NOT FLAGGED: a workflow step writing an identity — the scan is still scoped to ci/"
rm -f "$wf"
git rm --cached -q --ignore-unmatch "$wf" >/dev/null 2>&1
# 🔴 A drift check on the FORM, and only that: `if … | xargs -0 grep …; then`
# has a false green in it, because xargs exits 123 when any batch found nothing
# and `if` reads 123 as no-match. It is invisible while the list fits one exec,
# which is why nothing here can measure it — this tree's 48 files are one batch.
# So this asserts the shape is gone rather than that the bug is absent, and it
# cannot tell you the replacement is correct. scan_tracked greps per file and
# reads its verdict from the output, which is what makes batching irrelevant.
# ^[^#]* — the first version of this matched the COMMENT in static.sh that
# quotes the bad form in order to explain it, which is the stale-docblock trap
# in its own right: the paragraph documenting a rule is text the rule applies to.
if grep -nE '^[^#]*(if|elif).*\| *xargs.*grep' ci/static.sh; then
bad "a check above consumes xargs' exit status — 123 on a partial match reads as clean"
else
ok "no check in ci/static.sh consumes an xargs exit status"
fi
# And the passing line must carry a number: "every script is clean" reads the # And the passing line must carry a number: "every script is clean" reads the
# same over zero files as over eleven. # same over zero files as over eleven.
clean_out=$(./ci/static.sh 2>&1 || true) clean_out=$(./ci/static.sh 2>&1 || true)
printf '%s' "$clean_out" | grep -qE "^ [0-9]+ scripts in ci/ leave the clone's git identity alone" \ printf '%s' "$clean_out" | grep -qE "^ [0-9]+ tracked files leave the clone's git identity alone" \
&& ok "the git-identity check reports how many scripts it read" \ && ok "the git-identity check reports how many files it read" \
|| bad "the git-identity check printed no count — it cannot tell 11 scripts from 0" || bad "the git-identity check printed no count — it cannot tell 48 files from 0"
# The fixtures are lines that SET an identity. If any of them executed rather # The fixtures are lines that SET an identity. If any of them executed rather
# than being read, this clone now has one — which would also mean every verdict # than being read, this clone now has one — which would also mean every verdict
+77 -12
View File
@@ -19,6 +19,51 @@ shipped_files() {
./ci/dist-files.sh | grep '\.php$' || true ./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" ci_step "PHP syntax"
# The header promises a minimum PHP version; without it in the loop a piece of # 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. # newer syntax passes here and fatals on the customer's server instead.
@@ -73,10 +118,14 @@ else
fi fi
ci_step "no debug leftovers" ci_step "no debug leftovers"
if git ls-files -z '*.php' | xargs -0 grep -nE '\b(var_dump|print_r|dd)[[:space:]]*\(' ; then 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." note_fail "Debug functions must not ship."
else else
echo " none" echo " none in $scan_files tracked PHP files"
fi fi
ci_step "no short open tags, no closing ?>" ci_step "no short open tags, no closing ?>"
@@ -84,9 +133,14 @@ before=$fails
# With short_open_tag off the code is printed to the visitor as text instead of # 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 # being run; whitespace after a closing tag is sent to the browser and breaks
# headers and redirects. # headers and redirects.
if git ls-files -z '*.php' | xargs -0 grep -nP '<\?(?!php|=|xml)' ; then 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." note_fail "Use <?php, not short open tags."
fi fi
open_tag_files=$scan_files
# -z: git quotes non-ASCII paths, and the quoted form is not a path that # -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. # exists, so this loop would silently skip the file it was meant to check.
while IFS= read -r -d '' f; do while IFS= read -r -d '' f; do
@@ -94,7 +148,7 @@ while IFS= read -r -d '' f; do
[ "$(tail -c 3 "$f" | tr -d '[:space:]')" = "?>" ] \ [ "$(tail -c 3 "$f" | tr -d '[:space:]')" = "?>" ] \
&& { ci_annotate error "$f" 1 "Remove the trailing ?> at the end of the file."; fails=$((fails + 1)); } && { ci_annotate error "$f" 1 "Remove the trailing ?> at the end of the file."; fails=$((fails + 1)); }
done < <(git ls-files -z '*.php') done < <(git ls-files -z '*.php')
[ "$fails" = "$before" ] && echo " clean" [ "$fails" = "$before" ] && echo " $open_tag_files tracked PHP files, no short tags and no trailing ?>"
ci_step "text domain" ci_step "text domain"
# --min is deliberately far below the real count. It is not an assertion about # --min is deliberately far below the real count. It is not an assertion about
@@ -307,7 +361,7 @@ elif [ "$fails" = "$before" ]; then
echo " $secrets_scanned tracked files read, nothing that looks like a key or a private key" echo " $secrets_scanned tracked files read, nothing that looks like a key or a private key"
fi fi
ci_step "no ci/ script configures the clone it was handed" ci_step "no tracked file configures the clone it was handed"
# `git config user.name` writes .git/config and OUTLIVES the script. A helper # `git config user.name` writes .git/config and OUTLIVES the script. A helper
# that sets an identity before committing therefore renames every later commit # that sets an identity before committing therefore renames every later commit
# in that working copy, including a release made by a person days afterwards # in that working copy, including a release made by a person days afterwards
@@ -352,14 +406,25 @@ ci_step "no ci/ script configures the clone it was handed"
# #
# The rule: a script may configure ITSELF, never the working copy it was # The rule: a script may configure ITSELF, never the working copy it was
# handed. Reported by the Nolimit Security session 2026-09-25. # handed. Reported by the Nolimit Security session 2026-09-25.
scripts_scanned=$(git ls-files 'ci/*.sh' | wc -l) #
if [ "$scripts_scanned" -lt 1 ]; then # Writing the fault in prose inside a tracked file will trip this, by design.
note_fail "The git-identity scan found no ci/*.sh files at all, so it proved nothing." # To name it in documentation without going red, write the key as user.<key> —
elif git ls-files -z 'ci/*.sh' \ # the pattern needs [a-zA-Z] after the dot, so an angle-bracket placeholder is
| xargs -0 grep -nE '^[^#]*git config([[:space:]]+--(local|global|system))*[[:space:]]+user\.[a-zA-Z]+[[:space:]]+[^[:space:])]'; then # prose and a real key is a violation.
note_fail "A ci/ script sets a git identity that outlives it. Scope it to the one commit with 'git -c user.name=… commit', which does not write .git/config." # 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 else
echo " $scripts_scanned scripts in ci/ leave the clone's git identity alone" echo " $scan_files tracked files leave the clone's git identity alone"
fi fi
ci_step "no placeholders left in the plugin header" ci_step "no placeholders left in the plugin header"