From 044744339b6d08b1c69fe8da75b6343a9540852b Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Tue, 21 Jul 2026 23:42:31 -0500 Subject: [PATCH] fix(865): fail closed on non-constructible typed scalars and bare indicators Round-8: the PyYAML-absent conservative recognizer proved STRUCTURE and implicit-resolver TYPE but not CONSTRUCTOR VALIDITY. PyYAML safe_load raises a constructor ValueError on the WHOLE document when a plain scalar matches a typed implicit resolver but is not constructible (e.g. bad calendar date 2023-99-99, empty-radix ints 0b_/0x_), yielding no token; the fallback instead treated such a scalar as a null-equivalent, ignored the malformed key, and still emitted the valid login token -- a credential fail-open in the dangerous direction. _scalar now checks constructibility via _constructible (replicating PyYAML 6.0.3 construct_yaml_int/float/timestamp, stdlib-only): a typed scalar that is not constructor-valid returns _FAIL, which the parser turns into _Bail so the WHOLE document fails closed exactly as PyYAML does. int and timestamp resolver patterns are byte-identical to PyYAML's; the float pattern is a strict superset whose extras are all float()-constructible (fuzz-verified 0/400k raise), so it never fails closed where PyYAML would emit. Also fail closed on plain scalars beginning with an indicator a plain scalar may not start with: '%' (directive) and ',' (flow), and the conditional block indicators '-'/'?'/':' when followed by whitespace or end-of-value (bare sequence/complex-key/value indicators PyYAML rejects), while '-x'/'-1'/'?x'/':x' remain valid plain strings. Differential fuzz through the real function: 0 fail-opens across 1499 diverse non-tab cases; targeted fixtures assert fallback == PyYAML == fail-closed for 2023-99-99, 2023-13-01, bad-hour timestamp, 0b_, 0x_, 0x__, %broken, ',bad', bare '-'/'- '/'? key', and assert the token STILL resolves for constructible/ string look-alikes (valid date/datetime, 0o_, 4.e8, 0x1f, -x, :x). Both PyYAML present and forced-absent. Co-Authored-By: Claude Opus 4.8 --- .../framework/tools/git/detect-platform.sh | 156 +++++++++++++++++- .../tools/git/test-gitea-login-resolution.sh | 98 +++++++++++ 2 files changed, 251 insertions(+), 3 deletions(-) diff --git a/packages/mosaic/framework/tools/git/detect-platform.sh b/packages/mosaic/framework/tools/git/detect-platform.sh index 5ea889a7..0281b5ca 100755 --- a/packages/mosaic/framework/tools/git/detect-platform.sh +++ b/packages/mosaic/framework/tools/git/detect-platform.sh @@ -612,6 +612,7 @@ get_gitea_token_for_login() { [[ -f "$config_file" ]] || return 1 LOGIN_NAME="$login_name" REPO_HOST="$repo_host" python3 - "$config_file" <<'PY' +import datetime import os import re import sys @@ -671,6 +672,138 @@ def _implicit_nonstring(text): ) +# PyYAML 6.0.3 SafeConstructor timestamp regexp (yaml/constructor.py). The +# constructor RE-parses a timestamp-tagged scalar with THIS pattern and then +# builds a datetime.date/datetime, which raises ValueError for an out-of-range +# calendar field (e.g. month 99, hour 25). _IMPLICIT_TIMESTAMP (the RESOLVER +# pattern) is byte-identical to PyYAML's resolver, so anything it tags is also +# tagged by PyYAML and re-matched here. +_TIMESTAMP_CONSTRUCT = re.compile( + r"""^(?P[0-9][0-9][0-9][0-9]) + -(?P[0-9][0-9]?) + -(?P[0-9][0-9]?) + (?:(?:[Tt]|[ \t]+) + (?P[0-9][0-9]?) + :(?P[0-9][0-9]) + :(?P[0-9][0-9]) + (?:\.(?P[0-9]*))? + (?:[ \t]*(?PZ|(?P[-+])(?P[0-9][0-9]?) + (?::(?P[0-9][0-9]))?))?)?$""", + re.X, +) + + +def _int_constructible(text): + # Replicate PyYAML SafeConstructor.construct_yaml_int and report whether it + # would succeed. A resolver-tagged int whose radix body is empty after + # underscore removal (e.g. "0b_", "0x_", "0x__") makes int(base) raise, so + # PyYAML fails the WHOLE document -> the fallback must fail closed too. + value = text.replace("_", "") + if value[:1] in ("+", "-"): + value = value[1:] + if value == "0": + return True + try: + if value.startswith("0b"): + int(value[2:], 2) + elif value.startswith("0x"): + int(value[2:], 16) + elif value[:1] == "0": + int(value, 8) + elif ":" in value: + [int(part) for part in value.split(":")] + else: + int(value) + except ValueError: + return False + return True + + +def _float_constructible(text): + # Replicate PyYAML SafeConstructor.construct_yaml_float. Retained for the + # WHOLE-document invariant even though PyYAML's float-tagged set is always + # float()-constructible: the fallback's float RESOLVER pattern is a strict + # superset of PyYAML's (it also matches unsigned-exponent spellings PyYAML + # keeps as strings), and every such extra is likewise constructible, so this + # never fails closed where PyYAML would emit a token. + value = text.replace("_", "").lower() + if value[:1] in ("+", "-"): + value = value[1:] + if value in (".inf", ".nan"): + return True + try: + if ":" in value: + [float(part) for part in value.split(":")] + else: + float(value) + except ValueError: + return False + return True + + +def _timestamp_constructible(text): + # Replicate PyYAML SafeConstructor.construct_yaml_timestamp: build the same + # datetime.date/datetime and report whether it raises. Returns False for an + # out-of-range calendar field (month/day/hour/...), matching PyYAML's + # whole-document ValueError. + match = _TIMESTAMP_CONSTRUCT.match(text) + if not match: + return False + values = match.groupdict() + try: + year = int(values["year"]) + month = int(values["month"]) + day = int(values["day"]) + if not values["hour"]: + datetime.date(year, month, day) + return True + hour = int(values["hour"]) + minute = int(values["minute"]) + second = int(values["second"]) + fraction = 0 + if values["fraction"]: + frac = values["fraction"][:6] + frac += "0" * (6 - len(frac)) + fraction = int(frac) + tzinfo = None + if values["tz_sign"]: + tz_hour = int(values["tz_hour"]) + tz_minute = int(values["tz_minute"] or 0) + delta = datetime.timedelta(hours=tz_hour, minutes=tz_minute) + if values["tz_sign"] == "-": + delta = -delta + tzinfo = datetime.timezone(delta) + elif values["tz"]: + tzinfo = datetime.timezone.utc + datetime.datetime( + year, month, day, hour, minute, second, fraction, tzinfo=tzinfo + ) + except (ValueError, OverflowError): + return False + return True + + +def _constructible(text): + # WHOLE-DOCUMENT INVARIANT: the fallback resolves the SAME login token as + # PyYAML safe_load or fails closed -- never less conservative -- INCLUDING + # when PyYAML raises a CONSTRUCTOR error anywhere in the document. A plain + # scalar can match a typed implicit resolver (int/float/timestamp) yet NOT be + # constructible (e.g. 2023-99-99, 0b_, 0x_); PyYAML then raises on the whole + # load and yields no token, so the fallback MUST fail closed for the whole + # document too. This returns True only when PyYAML's constructor would build + # the scalar (null/bool token sets are always constructible), else False so + # the caller fails closed. `text` is assumed to satisfy _implicit_nonstring. + if _IMPLICIT_NULL.match(text) or _IMPLICIT_BOOL.match(text): + return True + if _IMPLICIT_TIMESTAMP.match(text): + return _timestamp_constructible(text) + if _IMPLICIT_INT.match(text): + return _int_constructible(text) + if _IMPLICIT_FLOAT.match(text): + return _float_constructible(text) + return True + + # Sentinel: "outside the supported subset -> fail closed". Distinct from a # genuine null (None), which is a valid resolved value. _FAIL = object() @@ -724,14 +857,31 @@ def _scalar(raw): value = value.strip() if not value: return None - if value[0] in _FLOW or value[0] in ("|", ">", "?", "@", "`", '"', "'"): - return _FAIL # flow / block-scalar / reserved / anchor / quote indicator + if value[0] in _FLOW or value[0] in ("|", ">", "?", "@", "`", '"', "'", "%", ","): + return _FAIL # flow / block-scalar / reserved / directive / anchor / quote + if value[0] in ("-", "?", ":") and (len(value) == 1 or value[1] in (" ", "\t")): + # A bare block indicator, not a plain scalar: '-'/'- ' opens a sequence + # entry, '?'/'? ' a complex mapping key, ':'/': ' a mapping value -- all + # illegal in a value position, where PyYAML raises a scanner error on the + # whole document. "-x"/"-1"/"?x"/":x" (indicator NOT followed by space) + # remain valid plain scalars and fall through. Fail closed on the bare + # indicator so the fallback never emits a token PyYAML would refuse. + return _FAIL if any(ch in _FLOW for ch in value): return _FAIL if ": " in value or value.endswith(":") or "\t" in value: return _FAIL # nested-mapping-in-scalar / ambiguous if _implicit_nonstring(value): - return None # non-string implicit type -> null-equivalent for a token + # A plain scalar PyYAML would tag as a non-string (null/bool/int/float/ + # timestamp). If PyYAML's CONSTRUCTOR would build it, the value is a + # non-string -> null-equivalent for a token field: return None and keep + # parsing (as before). But if it matches a typed implicit resolver yet is + # NOT constructible (e.g. 2023-99-99, 0b_, 0x_), PyYAML raises on the + # WHOLE document and yields no token, so the fallback MUST fail closed + # for the whole document too -> _FAIL (which the caller turns into _Bail). + if not _constructible(value): + return _FAIL + return None return value diff --git a/packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh b/packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh index c18eef0c..ad571b57 100755 --- a/packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh +++ b/packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh @@ -621,4 +621,102 @@ trailing: 1 ' assert_token "end marker then more content fails closed" "" primary git.example +# 16. CONSTRUCTOR-VALIDITY / INVALID-INDICATOR: a plain scalar can match a typed +# implicit resolver (int/float/timestamp) yet be NON-constructible, or begin +# with an indicator a plain scalar may not start with. PyYAML then RAISES on +# the WHOLE document (constructor error / scanner error) and yields NO token, +# so the fallback must ALSO fail closed for the whole document -- even though +# the (unrelated) malformed key sits alongside an otherwise-valid logins +# block whose token is itself well-formed. A prior residual proved STRUCTURE +# and implicit TYPE but not constructor validity, so it ignored the malformed +# key and still emitted the valid login token (fail-open in the dangerous +# direction). assert_both_fail_closed asserts fallback == PyYAML == no token. +assert_both_fail_closed() { + local desc="$1" login="$2" host="$3" got + got=$(token_fallback "$login" "$host") + if [[ -n "$got" ]]; then + echo "FAIL fallback [$desc]: expected fail-closed, got a token" >&2 + exit 1 + fi + if [[ "$HAVE_PYYAML" == true ]]; then + got=$(token_pyyaml "$login" "$host") + if [[ -n "$got" ]]; then + echo "FAIL pyyaml [$desc]: expected PyYAML to also fail closed (raise/no token), got a token" >&2 + exit 1 + fi + fi +} + +# write_bad_key_fixture: an unrelated root key carrying $1 as its plain scalar, +# followed by an otherwise-valid logins block whose token is well-formed. +write_bad_key_fixture() { + write_fixture "bad: $1 +logins: + - name: primary + url: https://git.example + token: TOK_PLAIN +" +} + +# Non-constructible TIMESTAMP-tagged scalars: match the resolver, but the +# calendar field is out of range so PyYAML's datetime construction raises. +write_bad_key_fixture '2023-99-99' # month 99 / day 99 invalid +assert_both_fail_closed "bad-date 2023-99-99 fails closed like PyYAML" primary git.example +write_bad_key_fixture '2023-13-01' # month 13 invalid +assert_both_fail_closed "bad-month 2023-13-01 fails closed like PyYAML" primary git.example +write_bad_key_fixture '2023-01-15T25:00:00' # hour 25 invalid +assert_both_fail_closed "bad-hour timestamp fails closed like PyYAML" primary git.example + +# Non-constructible INT-tagged scalars: match the int resolver, but the radix +# body is empty after underscore removal so int(base) raises. +write_bad_key_fixture '0b_' +assert_both_fail_closed "empty-binary 0b_ fails closed like PyYAML" primary git.example +write_bad_key_fixture '0x_' +assert_both_fail_closed "empty-hex 0x_ fails closed like PyYAML" primary git.example +write_bad_key_fixture '0x__' +assert_both_fail_closed "empty-hex 0x__ (multi-underscore) fails closed" primary git.example + +# Invalid plain-scalar INDICATOR forms: a plain scalar may not begin with '%' +# (directive) or ',' (flow) -- PyYAML raises a scanner/parser error on the whole +# document, so the fallback fails closed on the leading indicator. +write_bad_key_fixture '%broken' +assert_both_fail_closed "leading-%% directive indicator fails closed" primary git.example +write_bad_key_fixture ',bad' +assert_both_fail_closed "leading-comma flow indicator fails closed" primary git.example +# Bare block indicators in a value position ('-'/'- ', '?'/'? ', ':'/': '): +# PyYAML raises a scanner error on the whole document, so the fallback must fail +# closed rather than accept the indicator as a plain-scalar string. +write_bad_key_fixture '-' +assert_both_fail_closed "bare dash (seq indicator) fails closed" primary git.example +write_bad_key_fixture '- x' +assert_both_fail_closed "dash-space (seq entry) fails closed" primary git.example +write_bad_key_fixture '? key' +assert_both_fail_closed "question-space (complex key) fails closed" primary git.example +# ...but an indicator NOT followed by whitespace is a valid plain scalar string, +# so the token still resolves (no over-broad fail-close). +write_bad_key_fixture '-x' +assert_token "dash-not-space is a plain string, token resolves" "TOK_PLAIN" primary git.example +write_bad_key_fixture ':x' +assert_token "colon-not-space is a plain string, token resolves" "TOK_PLAIN" primary git.example + +# NOT over-broad: a genuinely CONSTRUCTIBLE typed scalar (or a look-alike PyYAML +# keeps as a plain string) leaves the document valid, so BOTH still resolve the +# login token -- the fix must not fail closed on these. +write_bad_key_fixture '2023-01-15' +assert_token "valid date unrelated key still resolves token" "TOK_PLAIN" primary git.example +write_bad_key_fixture '2023-01-15 10:00:00' +assert_token "valid datetime unrelated key still resolves token" "TOK_PLAIN" primary git.example +# '0o_' is NOT matched by PyYAML's int resolver (YAML 1.1 octal is 0[0-7]+, not +# 0o...), so PyYAML keeps it a STRING and resolves the token; the fallback must +# agree (no spurious fail-close). +write_bad_key_fixture '0o_' +assert_token "0o_ is a plain string in PyYAML, token still resolves" "TOK_PLAIN" primary git.example +# '4.e8' matches the fallback's (superset) float pattern but PyYAML keeps it a +# string; either way it is constructible, so the token still resolves in both. +write_bad_key_fixture '4.e8' +assert_token "4.e8 float look-alike still resolves token" "TOK_PLAIN" primary git.example +# A valid radix int as an unrelated key must not fail closed. +write_bad_key_fixture '0x1f' +assert_token "valid hex int unrelated key still resolves token" "TOK_PLAIN" primary git.example + echo "Gitea login resolution regression harness passed"