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

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:
Hermes Agent
2026-07-21 20:49:48 -05:00
parent 6168f9ac86
commit 2bb3ac4549
6 changed files with 373 additions and 337 deletions

View File

@@ -97,12 +97,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 (comment 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 (comment 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' (comment write/read-back)" >&2
return 1
@@ -121,63 +121,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 record lands beyond page 1. Walks
# page=1,2,… until a short page (fewer than the requested limit) or an 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-issue-comment-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 record to THIS invocation's writer so
# a concurrent write from a DIFFERENT identity cannot satisfy verification.
@@ -216,54 +159,6 @@ print(login)
PY
}
# Confirm that the comment CREATED by this invocation ($2 = its provider id) is
# enumerable in the issue's full, paginated comment listing and is authored by
# the acting identity. Gitea paginates list responses, so a comment created
# beyond page 1 must still be found; walking every page also proves the created
# id is durably indexed against THIS issue rather than merely retrievable by id.
# Returns non-zero (clear stderr) if the exact created id is not present with a
# matching author.
gitea_confirm_comment_enumerable() {
local issue_number="$1" created_id="$2" acting_login="$3" merged_file
merged_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-readback.XXXXXX")
trap 'rm -f "$merged_file"' RETURN
gitea_fetch_all "$GITEA_API_BASE/issues/$issue_number/comments" "$merged_file" || return 1
CREATED_COMMENT_ID="$created_id" 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:
comments = json.load(response)
if not isinstance(comments, list):
raise ValueError("response is not a comment list")
created_id = int(os.environ["CREATED_COMMENT_ID"])
acting_login = os.environ["ACTING_LOGIN"]
match = next(
(
c for c in comments
if isinstance(c, dict)
and c.get("id") == created_id
and (c.get("user") or {}).get("login") == acting_login
),
None,
)
if match is None:
raise ValueError(
f"created comment id {created_id} is not enumerable in the issue's "
"paginated comment list under the acting identity"
)
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
print(f"Error: Gitea comment enumeration check failed: {error}", file=sys.stderr)
raise SystemExit(1)
PY
}
# Post a comment to a Gitea issue via the supported REST API and verify it
# durably against a PROVIDER-RETURNED created id — never trust an exit code
# (#865 defect class: tea's non-existent `tea issue comment` no-ops yet exits
@@ -335,7 +230,7 @@ PY
EXPECTED_COMMENT_ID="$created_id" EXPECTED_COMMENT_BODY="$comment_body" \
ACTING_LOGIN="$acting_login" EXPECTED_REPO_SLUG="${GITEA_API_BASE##*/repos/}" \
EXPECTED_ISSUE_NUMBER="$issue_number" \
EXPECTED_NUMBER="$issue_number" \
python3 - "$readback_file" <<'PY' || return 1
import json
import os
@@ -350,26 +245,31 @@ 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_ISSUE_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 plain issue comment
# carries issue_url = <app>/<owner>/<repo>/issues/<n> (pull_request_url
# empty); a comment posted to a PR's conversation carries
# pull_request_url = <app>/<owner>/<repo>/pulls/<n> (issue_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 issue")
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
print(f"Error: Gitea comment persistence verification failed: {error}", file=sys.stderr)
raise SystemExit(1)
PY
gitea_confirm_comment_enumerable "$issue_number" "$created_id" "$acting_login" || return 1
echo "$created_id"
return 0
}