wrapper-guard: judge the payload, not the caller — delete the code/data parser
ci/woodpecker/pr/ci Pipeline was canceled
ci/woodpecker/pr/ci Pipeline was canceled
Round-five review found command substitution executing inside the very quoted spans the skeleton was discarding as prose: echo "$(curl -d@b .../issues/1/comments)" msg="$(curl -d@b .../issues/1/comments)" The unquoted and process-substitution forms already blocked, so the same call was refused or allowed depending on a quote character. That makes it a classification defect rather than another spelling, and it is the nineteenth write to reach execution through this file by the same route: the client was ABSENT from the skeleton, so the guard allowed. The reviewer's judgement, which I asked for and accept: this is fitting to the test set. Answering "code or data" from shell text with sed and awk is not a hard problem, it is the wrong problem. It was also not portable. CI has been red at `sanitization` since round four, and the log says why: under the image's busybox awk the octal escape in the quote-stripping regex does not bite, every quoted span survives into the skeleton, and the guard began refusing ordinary prose. Five allow-direction fixtures failed in CI that pass under GNU awk. A control that reverses its verdict with the awk on the host is not a control. So the client detection is gone — the skeleton, the invoker list, the prefix list, the option-value skipping, all of it. What remains asks two questions of the text: is this a write, and does it name an endpoint a wrapper owns. It cannot fail open by hiding the caller because it never looks for one, and it now catches clients it was never taught: `python -c ... requests.post(...)` and `wget --post-data` are both fixtures. The cost is stated in the file and pinned in both directions: QUOTING one of these calls on a Bash command line is refused as well. Ten fixtures that used to assert "discussing a call is not making one" now assert the opposite, and the boundary that stops this becoming block-everything is asserted just as hard — a quoted READ, an endpoint named without a body flag, a quoted write to an UNWRAPPED endpoint, and the wrapper's own body flag all still pass. The 18-command ordinary-work sweep blocks none. The rule an agent can hold without a parser: do not put a raw write to a wrapped forge endpoint on a Bash command line, not even inside quotes. Write the example with a file-writing tool. 60/60 fixtures, verified inside the CI image (busybox) as well as locally. Gates: sanitization, resident budget, test enumeration, tools-index (self-test 4/4, git suite 100%), issue-close, prettier.
This commit is contained in:
@@ -59,58 +59,84 @@ FIXTURES="$TMP/fixtures.tsv"
|
||||
printf '2\t{"tool_input":{"command":"a=/api/v1/repos/a/b/iss; b=ues/1/comments; curl -d@body https://git.example.invalid${a}${b}"}}\tan endpoint token split across variables is unreadable, not absent\n'
|
||||
printf '2\t{"tool_input":{"command":"a=/api/v1/repos/a/b/pu; b=lls/1/reviews; curl -d@body https://git.example.invalid${a}${b}"}}\tsame split, review endpoint\n'
|
||||
printf '0\t{"tool_input":{"command":"curl -X POST -d @payload https://hooks.example.invalid/services/${WEBHOOK_ID}"}}\tan opaque URL that is not forge-shaped stays allowed\n'
|
||||
# And the other direction, which is the failure mode that gets a hook deleted:
|
||||
# discussing a call is not making one. In each of these the client sits behind
|
||||
# a quote, never at command position.
|
||||
printf '0\t{"tool_input":{"command":"grep -R \\"curl -d https://git.example.invalid/api/v1/repos/a/b/issues\\" docs/"}}\tgrepping for an example is not calling it\n'
|
||||
printf '0\t{"tool_input":{"command":"echo \\"curl -d https://git.example.invalid/api/v1/repos/a/b/pulls\\" > note.txt"}}\twriting an example into a file is not calling it\n'
|
||||
printf '0\t{"tool_input":{"command":"python3 -c '"'"'print(\\"curl -d https://git.example.invalid/api/v1/repos/a/b/issues\\")'"'"'"}}\tprinting an example is not calling it\n'
|
||||
# --- round six changed the contract in this direction, and these fixtures are
|
||||
# where it shows. They used to assert that discussing a call is not making one.
|
||||
# Five rounds proved there is no textual way to tell a quoted example from a
|
||||
# quoted command, so the guard stopped trying: it judges the payload, and a
|
||||
# payload inside quotes is still a payload. Quoting one of these on a Bash
|
||||
# command line is now refused, and the way to write the example is a
|
||||
# file-writing tool. This is the deliberate cost of the mechanism change.
|
||||
printf '2\t{"tool_input":{"command":"grep -R \\"curl -d https://git.example.invalid/api/v1/repos/a/b/issues\\" docs/"}}\tquoting a wrapped write is refused even in a grep\n'
|
||||
printf '2\t{"tool_input":{"command":"echo \\"curl -d https://git.example.invalid/api/v1/repos/a/b/pulls\\" > note.txt"}}\t...and when written into a file\n'
|
||||
printf '2\t{"tool_input":{"command":"python3 -c '"'"'print(\\"curl -d https://git.example.invalid/api/v1/repos/a/b/issues\\")'"'"'"}}\t...and when printed from another language\n'
|
||||
# The boundary that keeps this from being "block everything": what is refused
|
||||
# is a WRITE to a WRAPPED endpoint. Mentioning either alone still passes, and
|
||||
# these are asserted as hard as the blocks above.
|
||||
printf '0\t{"tool_input":{"command":"grep -R \\"curl -s https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\" docs/"}}\tquoting a READ example is untouched\n'
|
||||
printf '0\t{"tool_input":{"command":"echo \\"the wrapped endpoint is https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\" >> notes.md"}}\tnaming the endpoint without a body flag is untouched\n'
|
||||
printf '0\t{"tool_input":{"command":"grep -R \\"curl -d@b https://git.example.invalid/api/v1/repos/a/b/releases\\" docs/"}}\tquoting a write to an UNWRAPPED endpoint is untouched\n'
|
||||
printf '0\t{"tool_input":{"command":"issue-comment.sh --repo a/b --issue 1 --body @msg.md"}}\tthe wrapper itself carries a body flag and must never trip its own guard\n'
|
||||
# Command position must still catch the real thing behind operators and env.
|
||||
printf '2\t{"tool_input":{"command":"cd /tmp && GITEA_TOKEN=$T curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/merge"}}\ta real call behind && and an assignment is still a call\n'
|
||||
# --- and the case the AUTHOR hit, one level in from the reported one: an
|
||||
# operator INSIDE a quoted string is not an operator. This blocked a message
|
||||
# that merely quoted the fixture above. Position is judged on the skeleton.
|
||||
printf '0\t{"tool_input":{"command":"send.sh -m \\"repro was: cd /tmp && curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/merge\\""}}\tan operator inside a quoted string is not an operator\n'
|
||||
printf '0\t{"tool_input":{"command":"cat >> notes.md <<EOF\\nwe ran: curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues\\nEOF"}}\ta heredoc body is data, not code\n'
|
||||
# --- the case the AUTHOR hit twice while chasing the above: sending a message
|
||||
# that QUOTED one of these fixtures. Under the old contract that was a defect
|
||||
# to be parsed away; under this one it is the documented cost, and the message
|
||||
# gets composed with a file-writing tool instead.
|
||||
printf '2\t{"tool_input":{"command":"send.sh -m \\"repro was: cd /tmp && curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/merge\\""}}\tquoting the repro in a message is refused too\n'
|
||||
printf '2\t{"tool_input":{"command":"cat >> notes.md <<EOF\\nwe ran: curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues\\nEOF"}}\ta heredoc body carrying the payload is refused with it\n'
|
||||
# ...but quotes stop being data the moment something executes them.
|
||||
printf '2\t{"tool_input":{"command":"bash -c \\"curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/merge\\""}}\tbash -c makes the quoted text code again\n'
|
||||
# --- round three. The skeleton was right about position and wrong about which
|
||||
# words hold it. A word in front of a command does not displace the command:
|
||||
# each of these four is a real write that the bare-name match could not see.
|
||||
# --- round three. Each of these four is a real write that a bare-name match
|
||||
# for the client could not see, because an ordinary word sat in front of it.
|
||||
# They are kept as fixtures after the mechanism change even though the guard no
|
||||
# longer looks for a client at all: they are the evidence for WHY it stopped,
|
||||
# and a future re-narrowing that reintroduced position would fail here first.
|
||||
printf '2\t{"tool_input":{"command":"env GITEA_TOKEN=$T curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tenv VAR=... in front of the client is still the client\n'
|
||||
printf '2\t{"tool_input":{"command":"command curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tcommand in front of the client is still the client\n'
|
||||
printf '2\t{"tool_input":{"command":"timeout 10 curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\ttimeout N in front of the client is still the client\n'
|
||||
printf '2\t{"tool_input":{"command":"/usr/bin/curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tan absolute path to the client is still the client\n'
|
||||
# ...and the prefix list must stay a list of prefixes. `echo` is not one.
|
||||
printf '0\t{"tool_input":{"command":"echo timeout 10 curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments >> notes.md"}}\tnaming a command after echo is not running it\n'
|
||||
printf '2\t{"tool_input":{"command":"echo timeout 10 curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments >> notes.md"}}\tnaming the call after echo carries the payload, so it is refused\n'
|
||||
# A shell standing between quoted data and execution makes that data code,
|
||||
# and the pipe is the form agents actually use. Filing it as data allowed the
|
||||
# call to vanish from the skeleton while still running.
|
||||
printf '2\t{"tool_input":{"command":"printf '"'"'%%s\\\\n'"'"' '"'"'curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments'"'"' | sh"}}\tquoted code piped to a shell is code\n'
|
||||
printf '2\t{"tool_input":{"command":"cat <<EOF | sh\\ncurl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\nEOF"}}\ta heredoc piped to a shell is code\n'
|
||||
printf '2\t{"tool_input":{"command":"sh -s <<EOF\\ncurl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\nEOF"}}\tsh -s reads its script from the heredoc\n'
|
||||
# ...but a pipe to anything that is not a shell leaves the data as data.
|
||||
printf '0\t{"tool_input":{"command":"grep -R \\"curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\" docs/ | wc -l"}}\tpiping an example to wc is not executing it\n'
|
||||
# ...and a shell on ONE line does not execute a string on another. Switching
|
||||
# the whole command into code because it contains an unrelated `sh -c` is how
|
||||
# this blocked its author a second time.
|
||||
printf '0\t{"tool_input":{"command":"docker run --rm alpine sh -c '"'"'echo hi'"'"'\\necho \\"example: curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\" >> notes.md"}}\tan unrelated shell on another line does not promote quoted prose to code\n'
|
||||
# ...and the questions that used to follow — is the pipe target a shell, does a
|
||||
# shell on one line execute a string on another — no longer have to be answered
|
||||
# at all. Both of these carry the payload, both are refused, and neither
|
||||
# outcome depends on parsing what the pipe or the other line does.
|
||||
printf '2\t{"tool_input":{"command":"grep -R \\"curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\" docs/ | wc -l"}}\tpiping the payload to wc is refused without asking what wc is\n'
|
||||
printf '2\t{"tool_input":{"command":"docker run --rm alpine sh -c '"'"'echo hi'"'"'\\necho \\"example: curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\" >> notes.md"}}\tan unrelated shell on another line no longer changes the answer either way\n'
|
||||
# --- round four. The guard was still reading the command as typed rather than
|
||||
# as the shell will run it: a backslash before a newline is removed before
|
||||
# anything else happens, so the endpoint token can be split across the join.
|
||||
printf '2\t{"tool_input":{"command":"curl -d@b https://git.example.invalid/api/v1/repos/a/b/iss\\\\\\nues/1/comments"}}\ta line continuation inside the endpoint token is still that endpoint\n'
|
||||
printf '2\t{"tool_input":{"command":"curl -d@b https://git.example.invalid/api/v1/repos/a/b/pu\\\\\\nlls/1/reviews"}}\tsame join, review endpoint\n'
|
||||
printf '0\t{"tool_input":{"command":"cat >> notes.md <<EOF\\nwe ran: curl -d@b https://git.example.invalid/api/v1/repos/a/b/iss\\\\\\nues/1/comments\\nEOF"}}\tjoining lines does not turn a document into a command\n'
|
||||
printf '2\t{"tool_input":{"command":"cat >> notes.md <<EOF\\nwe ran: curl -d@b https://git.example.invalid/api/v1/repos/a/b/iss\\\\\\nues/1/comments\\nEOF"}}\tthe join still runs first, and the joined payload is refused in a document too\n'
|
||||
# Transparent prefixes take option VALUES, and the value was a word the list
|
||||
# did not know — so the client went missing again behind an ordinary `sudo -u`.
|
||||
printf '2\t{"tool_input":{"command":"sudo -u root curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tan option value after a prefix does not hide the client\n'
|
||||
printf '2\t{"tool_input":{"command":"timeout --signal TERM 10 curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tan option pair plus a duration does not hide the client\n'
|
||||
# ...but only an OPTION and its value are skipped, never any word: in
|
||||
# `xargs echo curl ...` the command is echo and the client is its argument.
|
||||
printf '0\t{"tool_input":{"command":"xargs echo curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\ta client passed as an argument to echo is not a call\n'
|
||||
printf '2\t{"tool_input":{"command":"xargs echo curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tthe payload behind xargs echo is refused rather than adjudicated\n'
|
||||
printf '0\t{"tool_input":{"command":"sudo apt-get install curl"}}\tinstalling the client is not calling it\n'
|
||||
# Execution through another command still opens command position.
|
||||
# Execution through another command needed its own case under the old design.
|
||||
printf '2\t{"tool_input":{"command":"find . -maxdepth 0 -exec curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments ;"}}\tfind -exec runs the client\n'
|
||||
# --- round five, and the finding that ended the parser. Command substitution
|
||||
# inside a double-quoted span EXECUTES, while the skeleton was discarding that
|
||||
# span as inert prose. The unquoted and process-substitution forms already
|
||||
# blocked, which is what made it a classification defect rather than a spelling
|
||||
# one: the same call was refused or allowed depending on a quote character.
|
||||
printf '2\t{"tool_input":{"command":"echo \\"$(curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments)\\""}}\tcommand substitution inside double quotes executes\n'
|
||||
printf '2\t{"tool_input":{"command":"echo \\"`curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments`\\""}}\tso does the backtick form\n'
|
||||
printf '2\t{"tool_input":{"command":"msg=\\"$(curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments)\\""}}\tand an assignment RHS is not data either\n'
|
||||
printf '2\t{"tool_input":{"command":"echo $(curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments)"}}\tthe unquoted form, which blocked before and must keep blocking\n'
|
||||
printf '2\t{"tool_input":{"command":"cat <(curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments)"}}\tprocess substitution, same\n'
|
||||
printf '2\t{"tool_input":{"command":"bash --command \\"curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\""}}\tthe long-option spelling of bash -c needs no entry in any list now\n'
|
||||
# A client the guard was never taught is the point of dropping client
|
||||
# detection: neither of these names curl at all.
|
||||
printf '2\t{"tool_input":{"command":"python3 -c '"'"'import requests; requests.post(\\"https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\", json={})'"'"'"}}\ta library call is a write with no flag and no curl\n'
|
||||
printf '2\t{"tool_input":{"command":"wget --post-data=x https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\twget spells its body differently and is still a write\n'
|
||||
} > "$FIXTURES"
|
||||
|
||||
fail=0 n=0
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
# curl for reads, for registry/manifest calls, and for endpoints with no
|
||||
# wrapper (there are many) all pass untouched.
|
||||
#
|
||||
# One consequence is worth knowing before it surprises you: it judges the
|
||||
# payload, not the caller, so a command that merely QUOTES such a write is
|
||||
# refused as well. See the long note at section 2 for why that trade was made.
|
||||
#
|
||||
# Break-glass, for a genuine gap where no wrapper can express the call:
|
||||
# MOSAIC_WRAPPER_OVERRIDE=1 <command>
|
||||
# Using it means "no wrapper covers this" — if that is wrong, the fix is to
|
||||
@@ -83,103 +87,73 @@ EOF
|
||||
fi
|
||||
|
||||
# ---- 2/3. provider API writes ---------------------------------------------
|
||||
# A raw provider write is four things at once: an HTTP client, a URL, a mutating
|
||||
# verb or a request body, and a path fragment naming an endpoint a wrapper
|
||||
# already owns. All four are required, which is what keeps reads and unwrapped
|
||||
# endpoints flowing.
|
||||
# A raw provider write this guard cares about is two things: a WRITE, and a URL
|
||||
# naming an endpoint a Mosaic wrapper already owns. Reads are untouched — they
|
||||
# are how you gather evidence — and the many endpoints with no wrapper flow
|
||||
# through.
|
||||
#
|
||||
# Deliberately NOT gated on the literal "/api/v1/repos/". An independent reviewer
|
||||
# broke that version in one line: build the path in shell variables
|
||||
# p=/api/v1/repo; q=s/a/b/pulls/1/reviews; curl -d@body "https://host${p}${q}"
|
||||
# and the host-anchored literal never appears, so the check read clean while the
|
||||
# write went through. The endpoint fragments below survive it, because the
|
||||
# fragment has to appear somewhere for the URL to be constructible at all.
|
||||
# The client must be at COMMAND POSITION, and that has to be judged against the
|
||||
# CODE in the command, not against its text. Review caught the text version
|
||||
# blocking ordinary work:
|
||||
# grep -R "curl -d https://host/api/v1/repos/a/b/issues" docs/
|
||||
# echo "curl -d https://host/api/v1/repos/a/b/pulls" > note.txt
|
||||
# Talking about a call is not making one, and over-blocking is not the safe
|
||||
# direction: a guard that blocks ordinary work gets switched off, and a guard
|
||||
# that is off permits everything.
|
||||
# It deliberately does NOT ask which program makes the call, or whether that
|
||||
# program sits at shell command position. It used to, and that is the whole
|
||||
# history of this file. Answering "is this code or is this data" from the text
|
||||
# of a shell command required a skeleton with quoted spans and heredoc bodies
|
||||
# removed, an invoker list for the forms where a shell executes quoted text, a
|
||||
# prefix list for `env`/`sudo`/`timeout`, option-value skipping, and
|
||||
# backslash-newline joining. Five rounds of adversarial review put nineteen
|
||||
# writes straight through it, and every one had the same shape: the client was
|
||||
# ABSENT from the skeleton, so the guard allowed. Variables, line continuations,
|
||||
# command prefixes, option values, pipes into a shell, and finally command
|
||||
# substitution inside the very quotes the skeleton was discarding:
|
||||
# echo "$(curl -d@b .../issues/1/comments)"
|
||||
# msg="$(curl -d@b .../issues/1/comments)"
|
||||
# Classifying code against data in shell text with sed and awk is not a hard
|
||||
# problem, it is the wrong problem. It was not even portable: under CI's busybox
|
||||
# awk the quote-stripping silently failed, the skeleton kept every quoted span,
|
||||
# and the guard started refusing ordinary prose instead — which is the other way
|
||||
# a control like this dies.
|
||||
#
|
||||
# A first fix required the client to follow a shell operator. That lasted until
|
||||
# the author sent a message quoting one of these fixtures — the quoted text
|
||||
# contained `... && GITEA_TOKEN=$T curl -d@b .../merge`, so an operator appeared
|
||||
# INSIDE the quotes and the guard blocked the message. Same defect, one level
|
||||
# in: an operator inside a string is not an operator.
|
||||
# So the client detection is gone, and with it that entire failure class: what
|
||||
# is left cannot fail open by hiding the caller, because it never looks for one.
|
||||
# It looks for the payload. Something that names a wrapped endpoint and carries
|
||||
# a body is refused however it is spelled — curl, wget, `python -c`, or a form
|
||||
# nobody has thought of yet.
|
||||
#
|
||||
# So the position test runs against a SKELETON — the command with its data spans
|
||||
# (quoted strings, heredoc bodies) removed. Endpoint, URL and body detection all
|
||||
# still run against the FULL text, because real calls quote their URLs and a
|
||||
# skeleton would be blind to them.
|
||||
# The cost is real and belongs in the open, because over-blocking is how a hook
|
||||
# gets switched off: QUOTING one of these calls on a Bash command line now
|
||||
# blocks too. `grep -R "curl -d .../issues" docs/` is refused, and so is echoing
|
||||
# an example into a file. There is no textual way to tell a quoted example from
|
||||
# a quoted command — that is exactly the finding above — so the rule is the one
|
||||
# an agent can hold in mind without a parser:
|
||||
#
|
||||
# The exception is the reason quotes are data at all: if something is about to
|
||||
# EXECUTE the quoted text, then the quotes hold code and the skeleton is the
|
||||
# full text again. The first version of this list named only `bash -c`, `sh <<`
|
||||
# and `eval`, and review immediately produced the spellings it did not know:
|
||||
# printf '%s\n' 'curl -d@b .../comments' | sh
|
||||
# cat <<EOF | sh
|
||||
# sh -s <<EOF
|
||||
# Each one executes; each one had its client filed away as data. So the test is
|
||||
# not "which invocation form did I think of" but "does a shell stand between
|
||||
# this data and execution" — including the pipe, which is the common form.
|
||||
SHELL_EXECUTES_DATA='(^|[[:space:]|;&(])((ba|z)?sh|dash)([[:space:]]+-[a-z]*[cs]([[:space:]]|$)|[[:space:]]*<<)'
|
||||
SHELL_EXECUTES_DATA="$SHELL_EXECUTES_DATA"'|(^|[[:space:]|;&(])eval([[:space:]]|$)'
|
||||
SHELL_EXECUTES_DATA="$SHELL_EXECUTES_DATA"'|\|[[:space:]]*((ba|z)?sh|dash)([[:space:]]|$)'
|
||||
# do not put a raw write to a wrapped forge endpoint on a Bash command line,
|
||||
# not even inside quotes.
|
||||
#
|
||||
# Write the example with a file-writing tool, or leave the body flag out of it.
|
||||
# That is a deliberate narrowing of scope, not an oversight. This hook stops
|
||||
# mistakes; it is not a sandbox, and pretending otherwise is how you get a
|
||||
# control nobody can trust the boundaries of.
|
||||
#
|
||||
# Scoped to commands carrying a URL, so nothing without one is even considered.
|
||||
if printf '%s' "$CMD" | grep -Eq 'https?://'; then
|
||||
|
||||
# That decision is made PER LINE, not for the whole command. The first version
|
||||
# switched globally, and it blocked its author again within the hour: a script
|
||||
# whose only shell invocation was an unrelated `docker run ... sh -c 'echo hi'`
|
||||
# had every OTHER quoted span on every other line promoted to code with it.
|
||||
# A shell on one line does not execute a string on another, and a guard that
|
||||
# says otherwise is back to blocking ordinary work.
|
||||
SKEL="$(printf '%s' "$CMD" | awk -v inv="$SHELL_EXECUTES_DATA" '
|
||||
function ascode(s) { gsub(/["\047]/, ";", s); return s } # quotes separate
|
||||
function asdata(s) { gsub(/\047[^\047]*\047/, "", s); gsub(/"[^"]*"/, "", s); return s }
|
||||
{
|
||||
# Inside a heredoc: the body is code only if the line that OPENED it fed a
|
||||
# shell (`cat <<EOF | sh`, `sh -s <<EOF`). Otherwise it is a document.
|
||||
if (hd != "") { if ($0 == hd) { hd=""; next }
|
||||
if (hdcode) print ascode($0); next }
|
||||
if (match($0, /<<-?[[:space:]]*\047?"?[A-Za-z_][A-Za-z0-9_]*/)) {
|
||||
t = substr($0, RSTART, RLENGTH); sub(/^<<-?[[:space:]]*[\047"]?/, "", t)
|
||||
hd = t; hdcode = ($0 ~ inv)
|
||||
}
|
||||
print ($0 ~ inv) ? ascode($0) : asdata($0)
|
||||
}')"
|
||||
|
||||
# Command position is not "the first word is literally `curl`". A word can sit
|
||||
# in front of a command without displacing it, and review found four ordinary
|
||||
# ones hiding a real write: `env GITEA_TOKEN=$T curl`, `command curl`,
|
||||
# `timeout 10 curl`, `/usr/bin/curl`. The `env` form matters most, because it is
|
||||
# precisely what an agent reaches for to keep a credential out of the global
|
||||
# environment — the careful spelling was the invisible one.
|
||||
CMD_PREFIX='([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*|env|command|builtin|exec|nohup|setsid|stdbuf|nice|ionice|sudo|doas|xargs|time|timeout|watch)[[:space:]]+'
|
||||
# Their options take values: `sudo -u root curl` hid a live write because `root`
|
||||
# was a word the list did not know. What is skipped is an OPTION and at most one
|
||||
# value for it — not any word — so `xargs echo curl ...` stays allowed, because
|
||||
# there `echo` is the command and curl is its argument. Plus a bare duration,
|
||||
# which is `timeout`'s operand.
|
||||
PREFIX_OPT='-[^[:space:]]+[[:space:]]+([^-][^[:space:]|;&(){}<>]*[[:space:]]+)?'
|
||||
PREFIX_ARG='('"$PREFIX_OPT"'|[0-9]+[smhd]?[[:space:]]+)'
|
||||
# A path in front of the client is still the client.
|
||||
CLIENT='([^[:space:]]*/)?(curl|wget|httpie|http)'
|
||||
# `find -exec` opens command position the same way an operator does.
|
||||
CLIENT_AT_CMD_POS='(^|[;&|(){}]|`|\$\(|-execdir|-exec)[[:space:]]*('"$CMD_PREFIX"'('"$CMD_PREFIX"'|'"$PREFIX_ARG"')*)?'"$CLIENT"'([[:space:]]|$)'
|
||||
|
||||
if printf '%s' "$SKEL" | grep -Eq "$CLIENT_AT_CMD_POS" \
|
||||
&& printf '%s' "$CMD" | grep -Eq 'https?://'; then
|
||||
|
||||
# Write detection. Every spelling curl accepts, because the guard is defeated
|
||||
# by the one spelling it does not know: `-d@body` (no space) and
|
||||
# `--request=POST` (equals form) both slipped past the first version.
|
||||
# Write detection, now client-agnostic. Every spelling curl accepts, because
|
||||
# the guard is defeated by the one spelling it does not know: `-d@body` (no
|
||||
# space) and `--request=POST` (equals form) both slipped past the first
|
||||
# version. Plus wget's forms and a library call, which a client-shaped test
|
||||
# could not have seen at all.
|
||||
#
|
||||
# A body flag is read as a body flag wherever it appears. `ls -d */ && curl -s
|
||||
# .../issues/1/comments` is therefore refused, which is a read wearing a
|
||||
# write's flag. That direction is the acceptable one: it costs an override on
|
||||
# a rare command, where the reverse costs a silent raw write.
|
||||
is_write=0
|
||||
printf '%s' "$CMD" | grep -Eq -- \
|
||||
'-X[[:space:]]*(POST|PATCH|PUT|DELETE)|--request[[:space:]=]*(POST|PATCH|PUT|DELETE)' && is_write=1
|
||||
'-X[[:space:]]*(POST|PATCH|PUT|DELETE)|--(request|method)[[:space:]=]*(POST|PATCH|PUT|DELETE)' && is_write=1
|
||||
# curl sends POST implicitly when handed a body, in any of these forms.
|
||||
printf '%s' "$CMD" | grep -Eq -- \
|
||||
'(^|[[:space:]])(-d|-F|-T)|--data([-a-z]*)?[[:space:]=]|--json[[:space:]=]|--form|--upload-file' && is_write=1
|
||||
'(^|[[:space:]])(-d|-F|-T)|--data([-a-z]*)?[[:space:]=]|--json[[:space:]=]|--form|--upload-file|--post-(data|file)[[:space:]=]' && is_write=1
|
||||
# ...and a library call is a write without any flag at all.
|
||||
printf '%s' "$CMD" | grep -Eq -- \
|
||||
'\.(post|put|patch|delete)\(' && is_write=1
|
||||
|
||||
if [ "$is_write" -eq 1 ]; then
|
||||
endpoint=""; wrapper=""
|
||||
|
||||
Reference in New Issue
Block a user