From 38c6707970be5819889d2e6daeecaf66186c8b42 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 22:49:07 +0200 Subject: [PATCH] ci(security): hold API responses in a file instead of piping them onward MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Semgrep blocks on gha-curl-pipe-shell, and it is pointing at the publish step added an hour ago. The rule exists for `curl https://…/install.sh | bash`: remote content reaching an interpreter. What this actually did was pipe a JSON response into a python3 -c inline script -- the interpreter and its program both live in the workflow file, and the server only ever supplied data. A false positive, then, but the rule cannot see the difference between an interpreter reading a program from stdin and one reading data, and neither can the next person to read the step. Responses go to a file and are read from there. Same shape as the finding suggests, and worth having anyway: a response that is on disk can be looked at when a call misbehaves, instead of vanishing into a pipe. Verified by falsification rather than by a green run: the same ruleset against the previous revision of this file reports 1 blocking finding, against this one zero. Without that check a passing scan only proves the rule was not loaded. --- .gitea/workflows/release.yml | 47 ++++++++++++++++++++++++------------ 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index a905bad..33d2af0 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -174,28 +174,43 @@ jobs: api="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}" auth="Authorization: token ${GITEA_TOKEN}" - # Existing release for this tag, or create one. - rel_id="$(curl -sf -H "$auth" "$api/releases/tags/${TAG_NAME}" \ - | python3 -c 'import sys,json; print(json.load(sys.stdin).get("id",""))' 2>/dev/null || true)" + # Responses land in a file before anything reads them, rather than + # being piped straight into an interpreter. The interpreter is inline + # either way and the server only ever supplies data, but a scanner + # cannot tell those apart from curl-pipe-shell -- and holding the + # response makes it inspectable when a call misbehaves. + get_release_id() { + if curl -sf -H "$auth" "$api/releases/tags/${TAG_NAME}" -o release.json; then + python3 -c 'import json; print(json.load(open("release.json")).get("id",""))' + fi + } + + rel_id="$(get_release_id || true)" if [ -z "$rel_id" ]; then - payload="$(python3 -c ' - import json, os, sys + python3 - <<'PYCREATE' > create.json + import json, os body = open("release-body.md", encoding="utf-8").read() - json.dump({"tag_name": os.environ["TAG_NAME"], "name": os.environ["TAG_NAME"], - "body": body, "draft": False, "prerelease": False}, sys.stdout) - ')" - rel_id="$(printf '%s' "$payload" \ - | curl -sf -X POST -H "$auth" -H "Content-Type: application/json" -d @- "$api/releases" \ - | python3 -c 'import sys,json; print(json.load(sys.stdin)["id"])')" + print(json.dumps({ + "tag_name": os.environ["TAG_NAME"], + "name": os.environ["TAG_NAME"], + "body": body, + "draft": False, + "prerelease": False, + })) + PYCREATE + curl -sf -X POST -H "$auth" -H "Content-Type: application/json" \ + -d @create.json "$api/releases" -o created.json + rel_id="$(python3 -c 'import json; print(json.load(open("created.json"))["id"])')" echo "Created release $rel_id for ${TAG_NAME}" else echo "Reusing release $rel_id for ${TAG_NAME}" fi - # Drop a same-named asset from an earlier attempt, or the upload 409s. - old_id="$(curl -sf -H "$auth" "$api/releases/${rel_id}/assets" \ - | python3 -c 'import sys,json; print(next((a["id"] for a in json.load(sys.stdin) if a["name"]=="latest.zip"), ""))' 2>/dev/null || true)" + # A same-named asset from an earlier attempt has to go, or the upload + # collides with it. This is the state a recovery run finds. + curl -sf -H "$auth" "$api/releases/${rel_id}/assets" -o assets.json + old_id="$(python3 -c 'import json; print(next((a["id"] for a in json.load(open("assets.json")) if a["name"]=="latest.zip"), ""))')" if [ -n "$old_id" ]; then curl -sf -X DELETE -H "$auth" "$api/releases/${rel_id}/assets/${old_id}" echo "Replaced existing latest.zip (asset $old_id)" @@ -203,5 +218,5 @@ jobs: curl -sf -X POST -H "$auth" \ -F "attachment=@${ZIP_PATH};filename=latest.zip" \ - "$api/releases/${rel_id}/assets?name=latest.zip" \ - | python3 -c 'import sys,json; a=json.load(sys.stdin); print("Attached", a["name"], a["size"], "bytes")' + "$api/releases/${rel_id}/assets?name=latest.zip" -o uploaded.json + python3 -c 'import json; a=json.load(open("uploaded.json")); print("Attached", a["name"], a["size"], "bytes")'