fix(check-jsonschema): set nullglob and fail on empty glob expansion#165
Conversation
The Validate step left $FILES unquoted so documented shell globs expand, but never set nullglob, so a pattern matching nothing reached the tool as a literal filename and crashed it with OSError. nullglob alone would turn that crash into a zero-file invocation that exits 0, so the total expansion is captured into an array and guarded: an all-empty expansion fails with its own message, distinct from the pre-existing empty-input guard. Closes #162 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 1m 52s —— View job PR Review
Overall: LGTM. The fix is correct, the two halves are correctly coupled (nullglob alone would introduce the false-green the PR documents), and the verification section is unusually thorough. A few observations below. Correctness
Empty-expansion guard (lines 80–82) echoes Call site (line 83) correctly uses SecurityNo regressions introduced. The concern with The quoted One minor observationThe PR correctly notes:
There is a narrow edge case not mentioned: a filename that contains glob metacharacters ( CLAUDE.md alignment
No blocking issues. The fix is sound, well-tested, and safe to merge. |
|
Claude finished @kyle-sexton's task in 2m 16s —— View job Security Review
Scope: Overall verdictThe changes are net-positive from a security standpoint. The false-green path (nullglob alone, zero-file invocation exiting 0) that was explicitly guarded against is the most security-relevant behavior here — allowing validation to silently pass with no files checked is a subtle supply-chain risk, and this PR closes it. No critical or important security issues were found. One suggestion-level finding follows. SUGGESTION — Workflow command injection via echoed user input | Confidence: LowLocation: echo "::error::files expanded to no existing paths: ${FILES}"; exit 1
If a caller supplied a Practical exploitability is very low:
The pre-existing error path ( A hardened alternative that removes the surface entirely: echo "::error::files expanded to no existing paths:" >&2
echo "${FILES}" >&2
exit 1Or, since only the first line carries the printf '::error::files expanded to no existing paths:\n%s\n' "${FILES}"; exit 1Either form is safe regardless of newlines in No other security issues found. The use of |
No related issue: rollout of the decision recorded in melodic-software/ci-workflows#163, plus the pin bump that makes melodic-software/ci-workflows#162 live here. ## Summary Two changes to `ci.yml`, both about consuming `ci-workflows` composites. **1. Adopt the `ci-status` composite.** The nine-line lane-aggregation loop was duplicated byte-identically across seven repos. It now lives in `ci-workflows` (melodic-software/ci-workflows#164) and this is one of the six adopters. **2. Bump the four `check-jsonschema` pins** to the same SHA, which carries the `nullglob` fix from melodic-software/ci-workflows#165. ##⚠️ The composite is NOT a like-for-like copy — it fixes two false greens Worth reviewing deliberately rather than as a mechanical extraction. The inline block being replaced had two defects, both found by Codex review on melodic-software/ci-workflows#164: - `read -ra results <<< "$RESULTS"` consumed **only the first line**. A multi-line results string left every lane after the first unchecked — the gate reported success while a lane had failed. - The empty guard trimmed spaces only, so newline-only or tab-only input passed it, produced an empty array, skipped the loop, and reported success. Both were unreachable while the block was fed a single-line `${{ }}` interpolation, and both become reachable once the input is an action input. Reproduced against the old logic: ``` OLD MULTILINE failure 2nd EXIT=0 All lanes passed or were skipped. OLD MULTILINE failure last EXIT=0 All lanes passed or were skipped. OLD newline only EXIT=0 All lanes passed or were skipped. OLD tab only EXIT=0 All lanes passed or were skipped. ``` All four now exit 1. **Empty input also now fails closed** where it previously passed — a deliberate behavior change: a gate with nothing to aggregate is a miswired `needs` list, not a pass. ## Why the pin bump matters here specifically #29 replaced this repo's hand-enumerated workflow list with `.github/workflows/*.yml`. The `nullglob` fix guards a fully-empty expansion so it fails loudly instead of validating nothing — that guard is what keeps the glob safe if a future rename ever leaves it matching zero files. ## Test plan - [x] `actionlint` — clean - [x] `check-jsonschema` `vendor.github-workflows` against all 5 workflows — `ok`, exit 0 - [x] Composite behavior verified across 13 cases before adoption (single-line, multi-line, tab-separated, cancelled, skipped, empty, whitespace-only) - [x] CI on this PR exercises the composite against this repo's real 8-lane `needs` list ## Related - melodic-software/ci-workflows#164 — the composite, and the dogfooding adoption in `ci-workflows` itself - melodic-software/ci-workflows#165 — the `nullglob` fix now pinned here - melodic-software/ci-workflows#167 — composite `run:` blocks are not ShellCheck-covered, which is why both bugs above reached review rather than CI - Other `ci-workflows` composite pins deliberately stay at `1d3762c`; Dependabot owns those bumps, and moving all of them here would pull ten days of unrelated change into a targeted fix. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
.github/actions/check-jsonschemadocumentsfilesas accepting shell globs anddeliberately leaves
$FILESunquoted so they expand — but never setnullglob, so apattern matching nothing was passed through literally and crashed the tool with
OSError. That makes the documented glob support unsafe to use defensively (the*.yml+*.yamlcase from melodic-software/.github#29).Both halves of the fix land together, because
nullglobalone would convert a loudcrash into
check-jsonschemabeing invoked with zero files — which exits 0 and becomes afalse-green CI lane, strictly worse than the bug:
shopt -s nullglob, scoped to the expansion only (shopt -u nullglobimmediatelyafter), capturing into a
targetsarray.targetsis empty the step fails with its ownmessage.
The call site now passes
"${targets[@]}"(already expanded) rather than re-expanding$FILES, which also clears the pre-existing SC2086 on that line. Thefilesinputdescription gained one sentence stating the new contract.
Two distinct failure paths
filesempty / whitespace-only (pre-existing guard, unchanged)::error::files is required.filesnon-empty but expands to nothing (new)::error::files expanded to no existing paths: <patterns>Behavior deliberately not changed: a literal filename with no glob metacharacters is
untouched by
nullglob, so a non-existent literal still reaches the tool and errors thereexactly as before.
Verification
1. Before/after against the real tool, in this repo
The pre-fix block reproduces the issue's crash on the defensive pattern; the fixed block
passes:
Live regression cases — the three real
ci.ymlcall sites, run with the actualcheck-jsonschema@0.37.4against this worktree:2. Matrix over the modified shell logic
A throwaway harness extracts the Validate step's
run:block verbatim fromaction.yml(byte-diffed against the file to prove it is not a paraphrase), runs it underthe real
set -euo pipefail, and stubsuvxwith an arg-counter so the number of filearguments actually handed to the tool is observable. Fixture tree: two
.ymlworkflows,zero
.yaml.3. The false-green is demonstrated, not asserted
Same harness, with only the empty-expansion guard stripped out (
nullglobretained) —i.e. what half 1 without half 2 would ship:
4. Static analysis
ShellCheck 0.11.0 (the version this repo pins) against the extracted block, using the
repo's
.shellcheckrc:SC2086(info) on the unquoted$FILESat the call site, plus twoSC2154warnings.
SC2086, noSC2206. Only the same twoSC2154warnings(
NO_CACHE,VERSION), which are artifacts of extracting the block out of itsenv:block — present identically before the change.
The
# shellcheck disable=SC2206directive was proven load-bearing by stripping it andre-linting:
Also clean:
editorconfig-checker(exit 0),typos(exit 0), and this repo's owncheck-jsonschemavendor.github-actionsvalidation over.github/actions/*/action.ymlincluding the edited file (exit 0).
Blast radius
The composite is called by every repo in the constellation. Existing callers pass plain
space-separated literal file lists and globs; both paths are covered above and behave
identically to before. The only new failure mode is one that previously either crashed
with
OSErroror (after half 1 alone) would have silently passed.Not verified here
actionlintdoes not lint compositeaction.ymlfiles — passing the file explicitly makesit parse it as a workflow and emit
"jobs" section is missing/"on" section is missing.This composite's
run:block therefore has never been covered by the repo'sactionlintjob; the standalone ShellCheck run above is the equivalent check. Not changed here — out of
scope for #162.
Closes #162
Related
files: .github/workflows/*.yml .github/workflows/*.yaml, which this makes safe..github/workflows/ci.yml— the three in-repo call sites (one literal, two globs) thatserve as the live regression cases above.
🤖 Generated with Claude Code
https://claude.ai/code/session_01UmDSbhvfsnpL1DuTMjjJtP