wrapper-guard: match curl by basename, not by bare word
ci/woodpecker/pr/ci Pipeline was successful

Round-three remediation of the single blocker gate-ultron-01 raised on
06046f76. Confirmed by measurement before being touched: all three spellings
returned rc=0 against that head.

    /usr/bin/curl --config /tmp/provider-write.cfg      -> allowed
    env /usr/bin/curl -K/tmp/provider-write.cfg         -> allowed
    ./curl --config /tmp/provider-write.cfg             -> allowed

The config file still owned the URL, method, body and headers in every one of
them, so each executed exactly the wrapped raw provider write the previous
commit was written to refuse, while the guard reported clean.

The mistake is worth naming precisely, because it is the one this file already
exists to refuse and I reintroduced it: recognizing the unqualified name only is
CALLER-NAME PARSING. `/usr/bin/curl` is not a different program from `curl`, and
a control that can be defeated by typing the absolute path is not a control. The
match is now on curl as a BASENAME — an optional prefix that must end at a
slash — so a path spelling costs the caller nothing and buys them nothing.

The prefix must end at a slash deliberately: `mycurl` and `curl-wrapper` are
different programs, and blocking them would be the over-block that gets a guard
routed around instead of fixed. Both are negative fixtures.

Still open and stated rather than left to be discovered: a wrapper script that
execs curl on the operator's behalf is invisible here, because neither the name
nor the request appears in the command text. That is a limit of inspecting a
command string, not something this regex can close, and it is now written in the
comment above the check.

Controls: the three bypass fixtures FAIL against 06046f76 and pass at this head;
the two over-block fixtures pass against both. Suite 148/148.
This commit is contained in:
Hermes Agent
2026-08-13 02:36:51 -05:00
parent 06046f7675
commit d99ff57e14
2 changed files with 24 additions and 1 deletions
@@ -282,6 +282,18 @@ FIXTURES="$TMP/fixtures.tsv"
printf '2\t{"tool_input":{"command":"curl -K/tmp/provider-write.cfg"}}\tcurl accepts the value attached to the short flag\t--config/-K\n'
printf '2\t{"tool_input":{"command":"curl -sK /tmp/provider-write.cfg"}}\tand inside a bundle, which a space-separated test does not see\t--config/-K\n'
printf '0\t{"tool_input":{"command":"tar -K /tmp/archive.tar"}}\t-K on a command that is not curl is not this hook'"'"'s business\n'
# curl by any ordinary path spelling. The first version of the config check
# matched the bare word only, so these three executed the same wrapped write
# while the guard reported clean. Recognizing only the unqualified name is
# caller-name parsing, and that is the class this file exists to refuse.
printf '2\t{"tool_input":{"command":"/usr/bin/curl --config /tmp/provider-write.cfg"}}\tan absolute path is the same invocation, not a different one\t--config/-K\n'
printf '2\t{"tool_input":{"command":"env /usr/bin/curl -K/tmp/provider-write.cfg"}}\tand it is still curl behind env, with the value attached\t--config/-K\n'
printf '2\t{"tool_input":{"command":"./curl --config /tmp/provider-write.cfg"}}\ta relative path costs two characters and used to be enough\t--config/-K\n'
# The prefix must end at a slash: a basename that merely ENDS in curl is a
# different program, and blocking it would be the over-block that gets a guard
# routed around rather than fixed.
printf '0\t{"tool_input":{"command":"mycurl --config /tmp/provider-write.cfg"}}\tmycurl is not curl, and over-blocking is its own failure\n'
printf '0\t{"tool_input":{"command":"/opt/x/curl-wrapper --config /tmp/provider-write.cfg"}}\tnor is curl-wrapper, whose name only starts the same way\n'
# Percent-encoded endpoints. Not hypothetical: /issues/1174 and /iss%%75es/1174
# both returned HTTP 200 with the same object from the live forge, so the
@@ -290,7 +290,18 @@ fi
# guard that recognizes only the space- and equals-separated forms is defeated by
# deleting one character. Scoped to curl so that `eslint --config .eslintrc.json`
# and every other tool with a --config flag are untouched.
if printf '%s' "$CMD" | grep -Eq '(^|[[:space:]|;&(])curl([[:space:]]|$)' \
#
# curl is matched as a BASENAME, not as a bare word: `/usr/bin/curl`, `./curl`
# and `env /usr/bin/curl` are ordinary spellings of the same invocation, and the
# first version of this check saw none of them. Recognizing only the unqualified
# name is caller-name parsing, which is the failure class this file removed
# elsewhere and which came straight back in with this control.
#
# A wrapper script that execs curl on the operator's behalf is still invisible
# here, because neither the name nor the request appears in the command text.
# That is a real limit of inspecting a command string rather than a defect this
# regex can close, and it is stated rather than left for the next reader to find.
if printf '%s' "$CMD" | grep -Eq '(^|[[:space:]|;&(])([^[:space:]|;&()]*/)?curl([[:space:]]|$)' \
&& printf '%s' "$CMD" | grep -Eq -- '(^|[[:space:]])(-[A-Za-z]*K([[:space:]=]|$|[^[:space:]])|--config([[:space:]=]|$))'; then
cat <<EOF
BLOCKED: curl invocation whose request is supplied from a --config/-K file.