Skip to content

fix(hooks): continuation-line reserved word and fully \u-encoded command bypasses - #3

Merged
roadhero merged 3 commits into
mainfrom
fix/continuation-and-unicode
Sep 7, 2026
Merged

roadhero merged 3 commits into
mainfrom
fix/continuation-and-unicode

Conversation

@roadhero

@roadhero roadhero commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Summary

Two force-push / --no-verify / committer bypasses that v1.0.7 shipped, both verified end-to-end against a git argv shim under bash 4/5. They live in the same construct the guard exists to model, so this is a patch release.

  • A reserved word on a backslash-continuation line. A backslash-newline joins the next line onto the previous word with no gap, so a case (or a split ca+se) at column 1 of a continuation line inside $(...) is the reserved word case. The walker skipped scanning a word at column 1 of a continuation line, so case was never counted into casec, its pattern ) was read as the substitution's closing paren, the $( frame popped early, and everything after the pattern, a git push --force, a git commit --no-verify, or a git config user.name, was swallowed as string data and never checked. The walker now scans that word, carries a word split across the backslash-newline, and resolves a carried fragment the moment a continuation line does not continue it (so foo\+; \+case no longer glues into foocase and hides the push). A no-gap join that bash never executes (foo\+\+case, which is foocase) stays allowed, confirmed against bash 5.
  • A fully \u-encoded command. The fast path routed a command to the walker on a JSON \u escape only when a literal git/commit/push word still remained after dequoting, so a command whose every such word was \u-encoded slipped through unread. jq decodes a \u escape, so the escape alone now routes the command to the walker. An ANSI-C numeric escape is decoded by the shell at run time, not jq, so it still routes on a residual word; a command whose every such word is ANSI-C-encoded stays the documented splice limit.

The reserved-word / command-position tracking is factored into one classify() helper used by both the inline word scan and the fragment resolve.

  • Tests: 597 guard cases, up from 585, green on macOS awk, mawk, and busybox awk. The new cases pin the continuation, split-word, stacked-continuation, and \u-encoded forms, each verified to run (or not run) the dangerous command under bash 5 before being pinned.

Deferred / out of scope

  • The accepted-limit list is unchanged: variable indirection, a word/flag spliced into another by an expansion, a fully ANSI-C-numerically-encoded git word, a script run by name, another interpreter, config set in a prior call.
  • A command carrying a JSON \u00XX escape now reaches jq even when it is not a git command, so it needs jq (a documented hard dependency); a fully \u-encoded git command has no literal word to match, so routing on the escape alone is the only way to close the bypass, and failing closed on a missing jq is the safe direction. Noted in the fast-path comment.
  • Nothing outside the hook, its tests, and the §19.4 version pointer changed.

Test plan

  • Failing first: both bypasses reproduced (hook exit 0 and the dangerous argv reaching a stderr git shim under docker bash:5) before the fix; blocked after
  • Local quality gate green: shellcheck, bash tests/hooks/test-guard-commit.sh (597 passed), bash tests/hooks/test-format.sh (4 passed)
  • Three awks via docker; the no-gap foocase join confirmed not to execute under bash 5, so it stays allowed
  • Closing adversarial review and security audit (~130 payloads through a bash-5 execution oracle); every command bash runs is blocked, every allow is a form bash does not run
  • CI green on ubuntu-latest

… fully \u-encoded command to the walker

Two bypasses that v1.0.7 shipped, both verified end-to-end against a git
argv shim under bash 4+.

A backslash-newline joins the next line onto the previous word, so a
`case` (or a split `ca`+`se`) at column 1 of a continuation line inside
`$(...)` is the reserved word `case`. The walker skipped scanning a word
at column 1 of a continuation line, so `case` was never counted, its
pattern `)` was read as the substitution's closing paren, and everything
after it, a force-push, a `--no-verify`, or a `git config user.name`,
was discarded from the stripped text. The walker now scans that word and
carries a word split across the backslash-newline, so the reserved word
is seen and the command after the pattern stays visible.

The fast path routed a command to the walker on a JSON `\u` escape only
when a literal git/commit/push word still remained after dequoting, so a
command whose every such word was `\u`-encoded slipped through unread.
jq decodes a `\u` escape, so the presence of the escape alone now routes
the command to the walker. An ANSI-C numeric escape is decoded by the
shell at run time, not jq, so it still routes on a residual word; a
command whose every such word is ANSI-C-encoded stays the documented
splice limit.

Guard tests 585 to 594.
…does not continue it

The first cut of the continuation fix carried a word fragment across a
backslash-newline but cleared it only at end of line. A fragment could
survive an intervening continuation that neither continued nor ended it
(`foo\` then `; \` then `case`), and was then wrongly glued onto a later
column-1 `case`, making `foocase`, so the reserved word was not counted
and the case-pattern `)` popped the substitution frame again. A
`git push --force`, `--no-verify`, or committer write after the pattern
was discarded and ran under bash 4+.

A carried fragment now continues only when the next joined line begins
with a word character. Otherwise the deferred word was complete, so it
is classified and dropped rather than glued onto a later word. The
keyword and command-position logic is factored into one `classify`
helper used by both the inline word scan and this resolve.

A bare `\` intermediate line (`foo\` then `\` then `case`) joins with no
gap into `foocase`, so bash never runs the command after it; that stays
allowed, confirmed against bash 5.
@roadhero
roadhero merged commit ee8d4a1 into main Sep 7, 2026
1 check passed
@roadhero
roadhero deleted the fix/continuation-and-unicode branch September 7, 2026 15:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant