fix(git-tools): env-robust token parse, host-bound creds, real Gitea URL verify (#865 round-5)
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
CI-red root cause (classification a: my round-4 change fails in the clean/cold CI env): get_gitea_token_for_login hard-required PyYAML (`import yaml`), which is absent on CI's node:24-alpine (python3 without py3-yaml). Round-4's --login override cases were the first to exercise that path, turning the mosaic package test (test:framework-shell -> test-pr-review-gitea-comment.sh) RED. Fix: add an indentation-aware line-parser fallback that resolves the SAME per-name token PyYAML would from tea's flat `logins:` list; PyYAML stays the fast path. This also repairs a latent production defect (--login overrides were silently unusable on any PyYAML-less host). Auditor blockers folded into the same round-5: 1. issue_url vs pull_request_url shape (correctness): Gitea populates WEB (html) URLs in issue_url/pull_request_url, not API paths, and a PR-conversation comment carries pull_request_url (issue_url empty). Verification now accepts either web shape scoped to the repo slug + number, so a durable write is never rejected for URL shape. Test stubs now emit the REAL Gitea web shapes. 2. Cross-host credential binding (security): get_gitea_token_for_login now takes the repo host and requires the matched login's configured URL host to equal it; an override login configured for a different host FAILS CLOSED instead of sending a cross-host credential. Regression tests added to both suites. 3. Non-exhaustive enumeration (false-fail): removed the redundant, non-exhaustive post-verification list enumeration (gitea_fetch_all + confirm_*_enumerable) from both wrappers; the exact-id GET is authoritative. Pagination cases dropped; a guard asserts no list enumeration is performed. 4. Trap clobbering / temp-file leak (security/hygiene): removing the nested enumeration eliminates the RETURN-trap nesting that clobbered caller cleanup; remaining RETURN traps are single/non-nested and clean up on all exit paths. Temp-file leak regression tests (success + failure paths) added to both suites. 5. README: corrected the exhaustive-pagination claim and documented host-bound --login selection. Preserves every round-2/3/4 fix (explicit --login fail-closed at all write sites, token->identity attribution seam). Gates: cold `pnpm turbo run test --filter=@mosaicstack/mosaic` green (14/14); full test-*.sh suite green with AND without PyYAML; bash -n, shellcheck -x -S warning, prettier --check README clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -146,7 +146,7 @@ PY
|
||||
|
||||
EXPECTED_COMMENT_ID="$created_id" EXPECTED_COMMENT_BODY="$comment_body" \
|
||||
ACTING_LOGIN="$acting_login" EXPECTED_REPO_SLUG="${GITEA_API_BASE##*/repos/}" \
|
||||
EXPECTED_PR_NUMBER="$pr_number" \
|
||||
EXPECTED_NUMBER="$pr_number" \
|
||||
python3 - "$readback_file" <<'PY' || return 1
|
||||
import json
|
||||
import os
|
||||
@@ -161,18 +161,25 @@ try:
|
||||
expected_id = int(os.environ["EXPECTED_COMMENT_ID"])
|
||||
expected_body = os.environ["EXPECTED_COMMENT_BODY"]
|
||||
acting_login = os.environ["ACTING_LOGIN"]
|
||||
expected_suffix = (
|
||||
f"/repos/{os.environ['EXPECTED_REPO_SLUG']}"
|
||||
f"/issues/{os.environ['EXPECTED_PR_NUMBER']}"
|
||||
)
|
||||
issue_path = urlparse(comment.get("issue_url", "")).path.rstrip("/")
|
||||
slug = os.environ["EXPECTED_REPO_SLUG"]
|
||||
number = os.environ["EXPECTED_NUMBER"]
|
||||
# Gitea populates WEB (html) URLs here, not API paths. A PR-conversation
|
||||
# comment carries pull_request_url = <app>/<owner>/<repo>/pulls/<n> (with
|
||||
# issue_url empty), while a plain issue comment carries
|
||||
# issue_url = <app>/<owner>/<repo>/issues/<n> (with pull_request_url empty).
|
||||
# Accept whichever the provider populated — scoped to THIS repo slug and
|
||||
# number — so a genuine write is never rejected merely for URL shape.
|
||||
issue_suffix = f"/{slug}/issues/{number}"
|
||||
pr_suffix = f"/{slug}/pulls/{number}"
|
||||
issue_path = urlparse(comment.get("issue_url") or "").path.rstrip("/")
|
||||
pr_path = urlparse(comment.get("pull_request_url") or "").path.rstrip("/")
|
||||
if comment.get("id") != expected_id:
|
||||
raise ValueError("read-back id does not match the created id")
|
||||
if (comment.get("user") or {}).get("login") != acting_login:
|
||||
raise ValueError("created comment is not authored by the acting identity")
|
||||
if comment.get("body") != expected_body:
|
||||
raise ValueError("created comment body does not match")
|
||||
if not issue_path.endswith(expected_suffix):
|
||||
if not (issue_path.endswith(issue_suffix) or pr_path.endswith(pr_suffix)):
|
||||
raise ValueError("created comment does not belong to this PR")
|
||||
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
|
||||
print(f"Error: Gitea comment persistence verification failed: {error}", file=sys.stderr)
|
||||
@@ -203,12 +210,12 @@ gitea_resolve_api_for_login() {
|
||||
|
||||
host=$(get_remote_host)
|
||||
if [[ -n "$override_explicit" ]]; then
|
||||
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login") || {
|
||||
echo "Error: could not resolve a Gitea token for --login '$effective_login'; refusing to fall back to the host default identity (review write/read-back)" >&2
|
||||
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") || {
|
||||
echo "Error: could not resolve a host-matched Gitea token for --login '$effective_login' on host '$host'; refusing to fall back to the host default identity or a cross-host credential (review write/read-back)" >&2
|
||||
return 1
|
||||
}
|
||||
else
|
||||
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login") \
|
||||
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") \
|
||||
|| GITEA_API_TOKEN=$(get_gitea_token "$host") || {
|
||||
echo "Error: Gitea token not found for login '$effective_login' (review write/read-back)" >&2
|
||||
return 1
|
||||
@@ -227,62 +234,6 @@ gitea_resolve_api_for_login() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# Fetch every page of a Gitea list endpoint into $2 (merged into one JSON
|
||||
# array). Gitea paginates list responses, so a single-page read would
|
||||
# false-negative once a newly created review/comment lands beyond page 1.
|
||||
# Walks page=1,2,… until a short or empty page is returned so the merged array
|
||||
# is exhaustive. $1 is the endpoint URL with NO query string. Returns non-zero
|
||||
# (clear stderr) on any transport / HTTP / parse failure.
|
||||
gitea_fetch_all() {
|
||||
local base_url="$1" dest="$2" page=1 limit=50 status page_file count
|
||||
|
||||
printf '[]' > "$dest"
|
||||
while :; do
|
||||
page_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-page.XXXXXX")
|
||||
if ! status=$(curl -sS -o "$page_file" -w '%{http_code}' \
|
||||
-H "Authorization: token $GITEA_API_TOKEN" \
|
||||
"${base_url}?limit=${limit}&page=${page}"); then
|
||||
rm -f "$page_file"
|
||||
echo "Error: Gitea list read transport failed" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ "$status" != "200" ]]; then
|
||||
rm -f "$page_file"
|
||||
echo "Error: Gitea list read failed with HTTP $status" >&2
|
||||
return 1
|
||||
fi
|
||||
count=$(DEST="$dest" python3 - "$page_file" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
with open(os.environ["DEST"], encoding="utf-8") as merged_file:
|
||||
merged = json.load(merged_file)
|
||||
with open(sys.argv[1], encoding="utf-8") as page_file:
|
||||
page = json.load(page_file)
|
||||
if not isinstance(page, list):
|
||||
raise ValueError("page response is not a list")
|
||||
merged.extend(item for item in page if isinstance(item, dict))
|
||||
with open(os.environ["DEST"], "w", encoding="utf-8") as merged_file:
|
||||
json.dump(merged, merged_file)
|
||||
except (OSError, json.JSONDecodeError, TypeError, ValueError) as error:
|
||||
print(f"Error: could not merge Gitea list page: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
print(len(page))
|
||||
PY
|
||||
) || { rm -f "$page_file"; return 1; }
|
||||
rm -f "$page_file"
|
||||
[[ "$count" -lt "$limit" ]] && break
|
||||
page=$((page + 1))
|
||||
if [[ "$page" -gt 1000 ]]; then
|
||||
echo "Error: Gitea list pagination exceeded 1000 pages" >&2
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
# Resolve the login of the identity the API token authenticates as (GET
|
||||
# /user). Used to attribute a read-back review to THIS action's reviewer so a
|
||||
# concurrent review from a DIFFERENT identity cannot satisfy verification.
|
||||
@@ -358,56 +309,6 @@ print(head_sha)
|
||||
PY
|
||||
}
|
||||
|
||||
# Confirm that the review CREATED by this action ($2 = its provider id) is
|
||||
# enumerable in the PR's full, paginated review listing, authored by the acting
|
||||
# identity, in the expected state. Gitea paginates review lists, so a review
|
||||
# created beyond page 1 must still be found; walking every page also proves the
|
||||
# created id is durably indexed against THIS PR rather than merely retrievable
|
||||
# by id. Returns non-zero (clear stderr) if the exact created id is absent or
|
||||
# does not match author/state.
|
||||
gitea_confirm_review_enumerable() {
|
||||
local pr_number="$1" created_id="$2" expected_state="$3" acting_login="$4" merged_file
|
||||
|
||||
merged_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-list.XXXXXX")
|
||||
trap 'rm -f "$merged_file"' RETURN
|
||||
|
||||
gitea_fetch_all "$GITEA_API_BASE/pulls/$pr_number/reviews" "$merged_file" || return 1
|
||||
|
||||
CREATED_REVIEW_ID="$created_id" EXPECTED_STATE="$expected_state" ACTING_LOGIN="$acting_login" \
|
||||
python3 - "$merged_file" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
with open(sys.argv[1], encoding="utf-8") as response:
|
||||
reviews = json.load(response)
|
||||
if not isinstance(reviews, list):
|
||||
raise ValueError("response is not a review list")
|
||||
created_id = int(os.environ["CREATED_REVIEW_ID"])
|
||||
expected_state = os.environ["EXPECTED_STATE"]
|
||||
acting_login = os.environ["ACTING_LOGIN"]
|
||||
match = next(
|
||||
(
|
||||
r for r in reviews
|
||||
if isinstance(r, dict)
|
||||
and r.get("id") == created_id
|
||||
and (r.get("user") or {}).get("login") == acting_login
|
||||
and r.get("state") == expected_state
|
||||
),
|
||||
None,
|
||||
)
|
||||
if match is None:
|
||||
raise ValueError(
|
||||
f"created review id {created_id} is not enumerable in the PR's "
|
||||
"paginated review list under the acting identity/state"
|
||||
)
|
||||
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
|
||||
print(f"Error: Gitea review enumeration check failed: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
}
|
||||
|
||||
# Submit a review to a Gitea PR via the supported REST API and verify it against
|
||||
# a PROVIDER-RETURNED created id. tea 0.11.1's `pr approve`/`reject` cannot emit
|
||||
# the id of the review it created and can silently no-op while exiting 0 (#865
|
||||
@@ -513,8 +414,6 @@ except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as error
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
|
||||
gitea_confirm_review_enumerable "$pr_number" "$created_id" "$event" "$acting_login" || return 1
|
||||
|
||||
echo "$created_id"
|
||||
return 0
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user