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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user