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

@@ -569,44 +569,137 @@ get_gitea_token() {
# per-login tokens by `name` in $XDG_CONFIG_HOME/tea/config.yml (default
# ~/.config/tea/config.yml), exactly as the `tea` CLI resolves them, so a
# --login override and its REST read-back bind to the SAME credential/identity.
# Prints the token on success; returns non-zero (no output) if the config or a
# matching login token cannot be found. Callers must not log the result.
#
# $2 (repo host) binds the selected credential to the TARGET host: a tea login
# also records the `url` it authenticates against, and the matched login's URL
# host MUST equal the repo host. This fails closed when an override login is
# configured for a DIFFERENT host than the repo remote, so a login name shared
# across hosts (or a mistargeted override) can never send one host's credential
# to another host (cross-host credential leak). When $2 is empty the host bind
# is skipped (host-agnostic lookup) — callers that write should always pass it.
#
# Prints the token on success; returns non-zero (no output) if the config, a
# matching login token, or the host bind cannot be satisfied. Callers must not
# log the result.
get_gitea_token_for_login() {
local login_name="$1" config_file
local login_name="$1" repo_host="${2:-}" config_file
[[ -n "$login_name" ]] || return 1
config_file="${XDG_CONFIG_HOME:-$HOME/.config}/tea/config.yml"
[[ -f "$config_file" ]] || return 1
LOGIN_NAME="$login_name" python3 - "$config_file" <<'PY'
LOGIN_NAME="$login_name" REPO_HOST="$repo_host" python3 - "$config_file" <<'PY'
import os
import re
import sys
try:
import yaml
except ImportError:
raise SystemExit(1)
try:
with open(sys.argv[1], encoding="utf-8") as handle:
config = yaml.safe_load(handle)
except (OSError, yaml.YAMLError):
raise SystemExit(1)
from urllib.parse import urlparse
wanted = os.environ["LOGIN_NAME"]
logins = config.get("logins") if isinstance(config, dict) else None
if not isinstance(logins, list):
repo_host = os.environ.get("REPO_HOST", "").strip().lower()
config_path = sys.argv[1]
def _strip_scalar(value):
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
value = value[1:-1]
return value
def _host_of(url):
if not isinstance(url, str) or not url:
return None
parsed = urlparse(url if "//" in url else f"//{url}")
host = parsed.hostname
return host.lower() if host else None
def _accept(token, url):
# Enforce the host bind before surfacing a token. When a repo host is given,
# the login's recorded URL host must match it exactly; a login with no
# usable URL (or a mismatched one) is rejected (fail closed) so a cross-host
# credential is never emitted.
if not isinstance(token, str) or not token:
return None
if repo_host:
if _host_of(url) != repo_host:
return None
return token
def _token_via_pyyaml():
# Preferred, fully general path when PyYAML is installed. Raises ImportError
# (caught by the caller) when the module is unavailable so the environment
# -robust fallback can take over instead of failing closed on every host
# that lacks PyYAML.
import yaml
with open(config_path, encoding="utf-8") as handle:
config = yaml.safe_load(handle)
logins = config.get("logins") if isinstance(config, dict) else None
if not isinstance(logins, list):
return None
for login in logins:
if isinstance(login, dict) and str(login.get("name") or "") == wanted:
return _accept(login.get("token"), login.get("url"))
return None
def _token_via_lines():
# Conservative fallback for hosts without PyYAML. tea writes config.yml in a
# fixed, flat shape (a `logins:` list of maps with scalar name/url/token
# fields), so a small indentation-aware scan resolves the SAME token PyYAML
# would. It only ever returns the `token` of the entry whose `name` EXACTLY
# equals the requested login AND whose url host matches the repo host, so it
# cannot misattribute to another identity or host; anything it cannot parse
# yields None (fail closed).
with open(config_path, encoding="utf-8") as handle:
lines = handle.read().splitlines()
logins_indent = None
start = len(lines)
for index, line in enumerate(lines):
match = re.match(r"^(\s*)logins\s*:\s*$", line)
if match:
logins_indent = len(match.group(1))
start = index + 1
break
if logins_indent is None:
return None
entries = []
current = None
for line in lines[start:]:
if not line.strip() or line.lstrip().startswith("#"):
continue
indent = len(line) - len(line.lstrip(" "))
if indent <= logins_indent:
break
item = re.match(r"^\s*-\s*(.*)$", line)
rest = item.group(1) if item else line
if item:
current = {}
entries.append(current)
pair = re.match(r"^([A-Za-z0-9_]+)\s*:\s*(.*)$", rest.strip())
if pair and current is not None:
current[pair.group(1)] = _strip_scalar(pair.group(2))
for entry in entries:
if str(entry.get("name") or "") == wanted:
return _accept(entry.get("token"), entry.get("url"))
return None
try:
try:
token = _token_via_pyyaml()
except ImportError:
token = _token_via_lines()
except Exception:
raise SystemExit(1)
for login in logins:
if not isinstance(login, dict):
continue
if str(login.get("name") or "") == wanted:
token = login.get("token")
if isinstance(token, str) and token:
print(token)
raise SystemExit(0)
break
if isinstance(token, str) and token:
print(token)
raise SystemExit(0)
raise SystemExit(1)
PY
}