#!/bin/bash # Regression harness for pr-merge.sh Gitea exact-head API path and input safety. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" WORK_ROOT="${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}" SANDBOX="$WORK_ROOT/pr-merge-empty-uid-test-$$" MOCK_BIN="$SANDBOX/bin" REPO_DIR="$SANDBOX/repo" HOME_DIR="$SANDBOX/home" LOG_FILE="$SANDBOX/mock.log" cleanup() { rm -rf "$SANDBOX" } trap cleanup EXIT mkdir -p "$MOCK_BIN" "$REPO_DIR" "$HOME_DIR" : > "$LOG_FILE" cat > "$MOCK_BIN/tea" <<'EOF' #!/bin/bash set -euo pipefail printf 'tea %q ' "$@" >> "$PR_MERGE_TEST_LOG" printf '\n' >> "$PR_MERGE_TEST_LOG" if [[ "$*" == *"login list"* ]]; then echo '[{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]' exit 0 fi if [[ "$*" == *"pr merge"* ]]; then echo 'user does not exist [uid: 0, name: ]' >&2 exit 1 fi exit 0 EOF chmod +x "$MOCK_BIN/tea" cat > "$MOCK_BIN/curl" <<'EOF' #!/bin/bash set -euo pipefail printf 'curl %q ' "$@" >> "$PR_MERGE_TEST_LOG" printf '\n' >> "$PR_MERGE_TEST_LOG" args=" $* " out_file="" write_code=false post_data="" prev="" for arg in "$@"; do if [[ "$prev" == "-o" ]]; then out_file="$arg" prev="" continue fi if [[ "$prev" == "data" ]]; then post_data="$arg" [[ "$post_data" == @* ]] && post_data=$(<"${post_data#@}") prev="" continue fi if [[ "$prev" == "config" ]]; then [[ "$arg" == "-" ]] && cat >/dev/null prev="" continue fi case "$arg" in -o) prev="-o" ;; -d|--data|--data-binary) prev="data" ;; -K|--config) prev="config" ;; -w) write_code=true ;; esac done emit_response() { local body="$1" if [[ -n "$out_file" ]]; then printf '%s' "$body" > "$out_file" else printf '%s' "$body" fi if [[ "$write_code" == true ]]; then printf '200' fi } if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/commits/0123456789abcdef0123456789abcdef01234567/status"* ]]; then emit_response '{"state":"success","statuses":[{"context":"ci/test","status":"success"}]}' exit 0 fi if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123"* && "$args" != *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then emit_response '{"number":123,"title":"mock","state":"open","user":{"login":"tester"},"head":{"ref":"feature/mock","sha":"0123456789abcdef0123456789abcdef01234567","repo":{"full_name":"mosaicstack/stack"}},"base":{"ref":"main"},"labels":[],"assignees":[],"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/pulls/123","mergeable":true}' exit 0 fi if [[ "$args" == *"-X POST"* && "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then POST_DATA="$post_data" python3 - <<'PY' import json import os payload = json.loads(os.environ["POST_DATA"]) assert payload == { "Do": "squash", "head_commit_id": "0123456789abcdef0123456789abcdef01234567", }, payload PY emit_response '{"merged":true,"message":"mock merge complete"}' exit 0 fi echo "unexpected curl invocation: $*" >&2 exit 97 EOF chmod +x "$MOCK_BIN/curl" cd "$REPO_DIR" git init -q git remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git # HERMETICITY (#1007) — TWO mechanisms with DIFFERENT jobs; do not conflate them. # # OPERATIVE: the empty repo-local `mosaic.gitIdentity` below. get_gitea_token() # step 0 resolves a per-agent identity from `git config --get mosaic.gitIdentity`, # which on a provisioned agent seat is set GLOBALLY and so leaks into this fresh # repo. Step 0 runs BEFORE the credential loader AND before the GITEA_TOKEN env # check, so the `GITEA_TOKEN=redacted-test-token` exported below is silently # overridden and a REAL per-slot token from $HOME is what flows through the # wrapper. Measured on a provisioned seat before this pin: all 5 mock-curl calls # carried the real per-slot token in argv and the fixture token was never used at # ALL. Three consequences specific to this suite: # 1. pr-merge.sh passes the token as `-H "Authorization: token $token"` and the # mock curl logs full argv, so the real credential is written to $LOG_FILE # on disk — transiently: the suite truncates that file between phases and # the EXIT trap removes $SANDBOX, so it leaves NO post-hoc trace. That is # why this suite was the hardest of the three to detect; observing it needs # an instrument that captures argv while the run is live. # 2. Every failure path dumps $OUTPUT/$LOG_FILE to stderr through # `sed 's/redacted-test-token/***REDACTED***/g'` — a redaction pattern that # is the literal fixture string and therefore CANNOT match the token # actually in use. # 3. The leak assertion at "Token leaked to pr-merge.sh output" greps for that # same fixture string, so on a provisioned seat it passes vacuously: it is # searching for a value the run never used. # An empty repo-local value shadows the global one and reads back empty at rc=0. # # CONTAINMENT: the sandboxed HOME exported below. It only has to bound a failure # that the pin should already have prevented. # # NOTE FOR ANYONE AUDITING THIS SUITE: the sandboxed HOME is containment, NOT an # assay. Running a suite under a decoy HOME to test for this defect REMOVES the # trigger — ~/.gitconfig is where the global identity lives, so step 0 is skipped # by construction and every suite reads clean however vulnerable it is. To measure, # REPLICATE a seat (a decoy HOME whose .gitconfig sets mosaic.gitIdentity, with no # per-slot token) so step 0 reaches its fail-loud branch. # # Note the env-var route does NOT work: detect-platform.sh reads # "${MOSAIC_GIT_IDENTITY:-}", and `:-` treats set-but-empty identically to unset. git -C "$REPO_DIR" config mosaic.gitIdentity "" # $SANDBOX/$HOME_DIR were derived from the real $HOME above, before this export. export HOME="$HOME_DIR" export PATH="$MOCK_BIN:$PATH" export PR_MERGE_TEST_LOG="$LOG_FILE" export GITEA_LOGIN="git.mosaicstack.dev" export GITEA_URL="https://git.mosaicstack.dev" export GITEA_TOKEN="redacted-test-token" OUTPUT="$SANDBOX/output.log" if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash > "$OUTPUT" 2>&1; then echo "Expected pr-merge.sh to use the exact-head Gitea API path." >&2 echo "--- output ---" >&2 sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2 echo "--- mock log ---" >&2 sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2 exit 1 fi if ! grep -q '/api/v1/repos/mosaicstack/stack/pulls/123/merge' "$LOG_FILE"; then echo "Expected authenticated Gitea merge API endpoint to be called." >&2 sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2 exit 1 fi if grep -q 'redacted-test-token' "$OUTPUT"; then echo "Token leaked to pr-merge.sh output." >&2 exit 1 fi cat > "$MOCK_BIN/tea" <<'EOF' #!/bin/bash set -euo pipefail printf 'tea %q ' "$@" >> "$PR_MERGE_TEST_LOG" printf '\n' >> "$PR_MERGE_TEST_LOG" if [[ "$*" == *"login list"* ]]; then echo '[]' exit 0 fi if [[ "$*" == *"pr merge"* ]]; then echo 'tea merge should not run without a configured host login' >&2 exit 99 fi exit 0 EOF chmod +x "$MOCK_BIN/tea" unset GITEA_LOGIN : > "$LOG_FILE" if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash > "$OUTPUT" 2>&1; then echo "Expected the exact-head API path not to depend on a tea login." >&2 sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2 sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2 exit 1 fi if ! grep -q '/api/v1/repos/mosaicstack/stack/pulls/123/merge' "$LOG_FILE"; then echo "Expected missing tea login path to call Gitea API merge endpoint." >&2 sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2 exit 1 fi SENTINEL="$SANDBOX/injected-sentinel" INJECTION="123; touch $SENTINEL #" cat > "$MOCK_BIN/gh" <<'EOF' #!/bin/bash set -euo pipefail printf 'gh %q ' "$@" >> "$PR_MERGE_TEST_LOG" printf '\n' >> "$PR_MERGE_TEST_LOG" if [[ "$*" == *"pr view"* ]]; then cat <<'JSON' {"number":123,"title":"mock","baseRefName":"main","headRefName":"feature/mock"} JSON exit 0 fi if [[ "$*" == *"pr merge"* ]]; then exit 0 fi echo "unexpected gh invocation: $*" >&2 exit 98 EOF chmod +x "$MOCK_BIN/gh" cd "$REPO_DIR" git remote set-url origin https://github.com/mosaicstack/stack.git : > "$LOG_FILE" rm -f "$SENTINEL" if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash > "$OUTPUT" 2>&1; then echo "Expected GitHub metacharacter PR number to be rejected." >&2 sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2 exit 1 fi if [[ -e "$SENTINEL" ]]; then echo "GitHub metacharacter PR number executed injected shell command." >&2 exit 1 fi if [[ -s "$LOG_FILE" ]]; then echo "GitHub metacharacter PR number should be rejected before gh calls." >&2 sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2 exit 1 fi if ! grep -q 'Invalid PR number' "$OUTPUT"; then echo "Expected invalid PR number error for GitHub metacharacter input." >&2 sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2 exit 1 fi cd "$REPO_DIR" git remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git export GITEA_LOGIN="git.mosaicstack.dev" : > "$LOG_FILE" rm -f "$SENTINEL" if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash > "$OUTPUT" 2>&1; then echo "Expected Gitea metacharacter PR number to be rejected." >&2 sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2 exit 1 fi if [[ -e "$SENTINEL" ]]; then echo "Gitea metacharacter PR number executed injected shell command." >&2 exit 1 fi if [[ -s "$LOG_FILE" ]]; then echo "Gitea metacharacter PR number should be rejected before tea/curl calls." >&2 sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2 exit 1 fi if ! grep -q 'Invalid PR number' "$OUTPUT"; then echo "Expected invalid PR number error for Gitea metacharacter input." >&2 sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2 exit 1 fi echo "pr-merge.sh Gitea exact-head API regression passed"