wrapper-guard: remove quotes instead of splitting on them; last name consumer
ci/woodpecker/pr/ci Pipeline was canceled
ci/woodpecker/pr/ci Pipeline was canceled
Round-five remediation of both blockers gate-ultron-01 raised ondf83a9ee. Measured against that head first; all eight returned rc=0 and each executes the program the check exists to recognize: cu"rl" --config /tmp/w.cfg -> allowed cu'rl' --config /tmp/w.cfg -> allowed /usr/bin/cu\rl --config /tmp/w.cfg -> allowed g"h" api -X POST repos/a/b/issues … -> allowed /usr/bin/g\h api -X POST repos/a/b/… … -> allowed curl --con"fig" /tmp/w.cfg -> allowed /usr/bin/gh api -X POST repos/a/b/$EP … -> allowed g"h" api -X POST repos/a/b/$EP … -> allowed BLOCKER 1. The previous commit said names are recognized "after quote removal" and did not do that. It replaced quote characters with whitespace, which is token SEPARATION: a shell removes a quote WITHOUT splitting the word around it, so `cu"rl"` is one word naming curl, while whitespace made it two words naming neither. `"/usr/bin/curl"` blocked under that version only because the inserted space happened to land after a slash — a passing case that established nothing about quote removal, and I read it as confirmation. The characters are now DELETED, which is what quote removal is. Backslashes go with them, because escaping is ordinary word formation too. Deletion still handles substitution: `$(which curl)` becomes `which curl`, where the name is a word on its own. The flag is read from the same normalized copy for the same reason — `--con"fig"` is one word spelling --config. No review raised that; the name was simply the easier half to reach, and reading both halves the same way is the entire point of having one normalization. BLOCKER 2. A THIRD name consumer never went through the shared site: the unreadable-endpoint arm kept a private bare-name copy of the scope gate's regex against raw $CMD. A caller could be admitted by the repaired gate and then go unrecognized by the fail-closed refinement — a gate and its own refinement disagreeing about who the caller is, which is the defect one layer downstream. Fixing that surfaced the same mistake a third time inside this very edit: my first version left the NAME in the refinement's tail regex, so the name gate recognized `g"h" api` while the tail still demanded the undressed spelling, and the two halves disagreed exactly as before. Caught by the fixture, not by reading. Each half now asks one question: the name gate asks WHO, from the normalized copy; the tail asks whether the ENDPOINT is readable, from the raw text — deliberately raw, because the expansion markers that make an endpoint unreadable are the characters the normalized copy removes, and reading the tail from it would erase the evidence. Controls: the 8 positive fixtures FAIL atdf83a9eeand pass here; the negatives — mycurl, curl-wrapper, mygh, mygh with an assembled endpoint, an absolute-path read, and -K on a non-curl — pass at BOTH heads. Suite 166/166. Unchanged and still stated in the comment rather than this message: a name ABSENT from the text, assembled from variables or reached through a wrapper script that execs the program, is invisible to any of this.
This commit is contained in:
@@ -314,6 +314,29 @@ FIXTURES="$TMP/fixtures.tsv"
|
|||||||
printf '0\t{"tool_input":{"command":"mygh api -X POST repos/a/b/issues -f title=x"}}\tmygh is not gh, and the scope gate must not over-admit either\n'
|
printf '0\t{"tool_input":{"command":"mygh api -X POST repos/a/b/issues -f title=x"}}\tmygh is not gh, and the scope gate must not over-admit either\n'
|
||||||
printf '0\t{"tool_input":{"command":"/usr/bin/gh api repos/a/b/issues"}}\ta read through an absolute path is still a read\n'
|
printf '0\t{"tool_input":{"command":"/usr/bin/gh api repos/a/b/issues"}}\ta read through an absolute path is still a read\n'
|
||||||
|
|
||||||
|
# Quotes and backslashes INSIDE the word. The previous repair replaced quote
|
||||||
|
# characters with whitespace, which is token separation and not quote removal:
|
||||||
|
# a shell removes a quote without splitting the word around it, so `cu"rl"` is
|
||||||
|
# one word naming curl while whitespace made it two words naming neither.
|
||||||
|
# `"/usr/bin/curl"` passed under that version only because the inserted space
|
||||||
|
# happened to land after a slash, which established nothing.
|
||||||
|
printf '2\t{"tool_input":{"command":"cu\\"rl\\" --config /tmp/provider-write.cfg"}}\ta quote inside the word does not make it another program\t--config/-K\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"cu'"'"'rl'"'"' --config /tmp/provider-write.cfg"}}\tand a single quote inside it is the same word again\t--config/-K\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"/usr/bin/cu\\\\rl --config /tmp/provider-write.cfg"}}\tescaping is ordinary word formation, not a disguise\t--config/-K\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"g\\"h\\" api -X POST repos/a/b/issues -f title=x"}}\tthe CLI name is a word on the same terms\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"/usr/bin/g\\\\h api -X POST repos/a/b/issues -f title=x"}}\tincluding when it is escaped behind a path\n'
|
||||||
|
# The FLAG is the same recognition problem as the name, and is read the same
|
||||||
|
# way. No review raised this one; the name was simply the easier half to reach.
|
||||||
|
printf '2\t{"tool_input":{"command":"curl --con\\"fig\\" /tmp/provider-write.cfg"}}\tone word spelling --config is still --config\t--config/-K\n'
|
||||||
|
|
||||||
|
# The unreadable-endpoint arm is the THIRD name consumer. It kept a private
|
||||||
|
# bare-name copy of the scope gate's regex, so a caller could be admitted by
|
||||||
|
# the repaired gate and then go unrecognized by the fail-closed refinement —
|
||||||
|
# a gate and its own refinement disagreeing about who the caller is.
|
||||||
|
printf '2\t{"tool_input":{"command":"/usr/bin/gh api -X POST repos/a/b/$EP -f title=x"}}\ta path-qualified CLI with an assembled endpoint is still unreadable\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"g\\"h\\" api -X POST repos/a/b/$EP -f title=x"}}\tand so is a quoted one, which is where the two halves disagreed\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"mygh api -X POST repos/a/b/$EP -f title=x"}}\tmygh is still not gh, in the refinement as well as the gate\n'
|
||||||
|
|
||||||
# Percent-encoded endpoints. Not hypothetical: /issues/1174 and /iss%%75es/1174
|
# 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
|
# both returned HTTP 200 with the same object from the live forge, so the
|
||||||
# encoded spelling IS the wrapped endpoint and the literal comparison below it
|
# encoded spelling IS the wrapped endpoint and the literal comparison below it
|
||||||
|
|||||||
@@ -48,25 +48,35 @@ CMD="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null |
|
|||||||
# joined form, because that is the command.
|
# joined form, because that is the command.
|
||||||
CMD="$(printf '%s' "$CMD" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\\\n//g')"
|
CMD="$(printf '%s' "$CMD" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\\\n//g')"
|
||||||
|
|
||||||
# A second reading of the SAME command, used only by the two checks below that
|
# A second reading of the SAME command, used by every check below that has to
|
||||||
# have to recognize a program by name. Quote characters and the punctuation of
|
# recognize a program by NAME. Quote characters, backslashes and the punctuation
|
||||||
# command substitution become whitespace, so a name that the shell will resolve
|
# of command substitution are DELETED, so a name the shell will resolve is a word
|
||||||
# is a word here regardless of how it was dressed:
|
# here regardless of how it was dressed:
|
||||||
#
|
#
|
||||||
# "/usr/bin/curl" --config /tmp/req quoted absolute path
|
# "/usr/bin/curl" --config /tmp/req quoted absolute path
|
||||||
# './curl' --config /tmp/req quoted relative path
|
# './curl' --config /tmp/req quoted relative path
|
||||||
# $(which curl) --config /tmp/req command substitution
|
# $(which curl) --config /tmp/req command substitution
|
||||||
# `which curl` --config /tmp/req the older spelling of the same thing
|
# `which curl` --config /tmp/req the older spelling of the same thing
|
||||||
# /usr/bin/gh api -X POST repos/a/b/... absolute path to a provider CLI
|
# /usr/bin/gh api -X POST repos/a/b/... absolute path to a provider CLI
|
||||||
|
# cu"rl" --config /tmp/req quotes INSIDE the word
|
||||||
|
# /usr/bin/cu\rl --config /tmp/req a backslash inside the word
|
||||||
|
# g"h" api -X POST repos/a/b/... the same, in the provider-CLI name
|
||||||
#
|
#
|
||||||
# All five executed the real program and all five returned ALLOW from the two
|
# Every one of them executed the real program and every one returned ALLOW, at
|
||||||
# name checks, at this file's previous head and the one before it. The first
|
# each of this file's three previous heads. The repairs went bare word, then
|
||||||
# repair of this class matched curl as a basename and stopped there, which fixed
|
# unquoted basename, then quotes-as-separators; each fixed a PRESENTATION and
|
||||||
# the unquoted spelling only: the check still modelled ONE TEXTUAL PRESENTATION
|
# left the class, and the third is worth spelling out because it is the subtlest
|
||||||
# of a shell word rather than the word itself, so adding two quote characters
|
# and it was mine: replacing quote characters with whitespace is token
|
||||||
# restored the bypass, and the same defect sat untouched in the provider-CLI arm
|
# SEPARATION, not quote removal. A shell removes a quote WITHOUT splitting the
|
||||||
# the whole time. Recognizing a name is either done after quote removal or it is
|
# word around it, so `cu"rl"` is one word naming curl, while whitespace made it
|
||||||
# caller-name parsing wearing a longer regex.
|
# two words naming neither. `"/usr/bin/curl"` blocked under that version only
|
||||||
|
# because the whitespace happened to land after a slash — a passing case that
|
||||||
|
# established nothing.
|
||||||
|
#
|
||||||
|
# So the characters are DELETED rather than replaced, which is what quote removal
|
||||||
|
# is, and backslashes go with them because escaping is ordinary word formation
|
||||||
|
# too. Deletion also handles substitution: `$(which curl)` becomes `which curl`,
|
||||||
|
# where the name is a word on its own.
|
||||||
#
|
#
|
||||||
# This does NOT try to be a shell. It cannot see a name that is absent from the
|
# This does NOT try to be a shell. It cannot see a name that is absent from the
|
||||||
# text — assembled from variables, or reached through a wrapper script that
|
# text — assembled from variables, or reached through a wrapper script that
|
||||||
@@ -79,8 +89,7 @@ CMD="$(printf '%s' "$CMD" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\\\n//g')"
|
|||||||
# these commands inside quotes on a Bash line is refused too. Applying that same
|
# these commands inside quotes on a Bash line is refused too. Applying that same
|
||||||
# rule here keeps the file coherent — the alternative is a guard where the
|
# rule here keeps the file coherent — the alternative is a guard where the
|
||||||
# payload arm treats quotes as text and the name arms treat them as armour.
|
# payload arm treats quotes as text and the name arms treat them as armour.
|
||||||
CMD_NAMES="$(printf '%s' "$CMD" | tr '"'"'"'\`()' ' ')"
|
CMD_NAMES="$(printf '%s' "$CMD" | tr -d '"'"'"'\`()$\\')"
|
||||||
CMD_NAMES="$(printf '%s' "$CMD_NAMES" | sed 's/\$/ /g')"
|
|
||||||
|
|
||||||
# The one place the shape of a program NAME is written down. Both callers below
|
# The one place the shape of a program NAME is written down. Both callers below
|
||||||
# use it, so the next fix to this class lands in a single location instead of
|
# use it, so the next fix to this class lands in a single location instead of
|
||||||
@@ -340,8 +349,12 @@ fi
|
|||||||
# command text at all. That is a limit of inspecting a command string rather
|
# command text at all. That is a limit of inspecting a command string rather
|
||||||
# than a defect this pattern can close, and it is stated rather than left for
|
# than a defect this pattern can close, and it is stated rather than left for
|
||||||
# the next reader to find.
|
# the next reader to find.
|
||||||
|
# The FLAG is read from $CMD_NAMES too. It is the same recognition problem as
|
||||||
|
# the name — `--con"fig"` is one word spelling --config — and no review has
|
||||||
|
# raised it yet only because the name was the easier half to reach. Reading both
|
||||||
|
# halves the same way is the point of having one normalization.
|
||||||
if printf '%s' "$CMD_NAMES" | grep -Eq "${NAME_PREFIX}curl([[:space:]]|$)" \
|
if printf '%s' "$CMD_NAMES" | grep -Eq "${NAME_PREFIX}curl([[:space:]]|$)" \
|
||||||
&& printf '%s' "$CMD" | grep -Eq -- '(^|[[:space:]])(-[A-Za-z]*K([[:space:]=]|$|[^[:space:]])|--config([[:space:]=]|$))'; then
|
&& printf '%s' "$CMD_NAMES" | grep -Eq -- '(^|[[:space:]])(-[A-Za-z]*K([[:space:]=]|$|[^[:space:]])|--config([[:space:]=]|$))'; then
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
BLOCKED: curl invocation whose request is supplied from a --config/-K file.
|
BLOCKED: curl invocation whose request is supplied from a --config/-K file.
|
||||||
|
|
||||||
@@ -648,9 +661,31 @@ edit — that one is a real wrapper gap, and the override exists for it." ;;
|
|||||||
&& printf '%s' "$CMD" | grep -Eq "$FORGE"; then unreadable=1; fi
|
&& printf '%s' "$CMD" | grep -Eq "$FORGE"; then unreadable=1; fi
|
||||||
printf '%s' "$CMD" | grep -Eq \
|
printf '%s' "$CMD" | grep -Eq \
|
||||||
"$URLTOK($FORGE$URLTOK"'[$`]'"|"'[$`]'"$URLTOK$FORGE)" && unreadable=1
|
"$URLTOK($FORGE$URLTOK"'[$`]'"|"'[$`]'"$URLTOK$FORGE)" && unreadable=1
|
||||||
printf '%s' "$CMD" | grep -Eq \
|
# THIRD name consumer, and the one that proves the point about a single
|
||||||
'(^|[[:space:]|;&(])(gh|tea|glab|hub)[[:space:]]+api([[:space:]]+(--|--?[A-Za-z][A-Za-z-]*)([[:space:]]+[^-[:space:]][^[:space:]]*)?)*[[:space:]]+[^-[:space:]][^[:space:]]*[$`]' \
|
# site: while the scope gate above was repaired for path- and quote-dressed
|
||||||
&& unreadable=1
|
# provider CLIs, this fail-closed refinement kept its own bare-name copy of
|
||||||
|
# the same regex against raw $CMD. A caller could therefore enter the scope
|
||||||
|
# gate through the fixed check and then fail to be recognized by the arm
|
||||||
|
# that refuses unreadable endpoints — name recognition differing between a
|
||||||
|
# gate and its own refinement, which is the defect one layer downstream.
|
||||||
|
# It reads $CMD_NAMES through $NAME_PREFIX like every other name check.
|
||||||
|
#
|
||||||
|
# The endpoint tail still asks $CMD, deliberately: this arm fires on an
|
||||||
|
# endpoint the guard CANNOT READ, and the expansion markers that make it
|
||||||
|
# unreadable are exactly the characters $CMD_NAMES removes. Reading the tail
|
||||||
|
# from the normalized copy would erase the evidence the check exists to find.
|
||||||
|
#
|
||||||
|
# The tail carries NO name of its own. Leaving one there was the same defect
|
||||||
|
# a third time in the same edit — the name gate would recognize `g"h" api`
|
||||||
|
# while the tail still demanded the undressed spelling, so the two halves
|
||||||
|
# disagreed about the same caller and the refinement failed open. Each half
|
||||||
|
# now asks exactly one question: the name gate asks WHO, from the normalized
|
||||||
|
# copy; the tail asks whether the ENDPOINT is readable, from the raw text.
|
||||||
|
if printf '%s' "$CMD_NAMES" | grep -Eq "${NAME_PREFIX}(gh|tea|glab|hub)[[:space:]]+api([[:space:]]|$)" \
|
||||||
|
&& printf '%s' "$CMD" | grep -Eq \
|
||||||
|
'(^|[[:space:]])api([[:space:]]+(--|--?[A-Za-z][A-Za-z-]*)([[:space:]]+[^-[:space:]][^[:space:]]*)?)*[[:space:]]+[^-[:space:]][^[:space:]]*[$`]'; then
|
||||||
|
unreadable=1
|
||||||
|
fi
|
||||||
|
|
||||||
if [ -z "$endpoint" ] && [ "$unreadable" -eq 1 ]; then
|
if [ -z "$endpoint" ] && [ "$unreadable" -eq 1 ]; then
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
|
|||||||
Reference in New Issue
Block a user