diff --git a/packages/mosaic/framework/tools/git/detect-platform.sh b/packages/mosaic/framework/tools/git/detect-platform.sh index db5b6db8..b2d3179a 100755 --- a/packages/mosaic/framework/tools/git/detect-platform.sh +++ b/packages/mosaic/framework/tools/git/detect-platform.sh @@ -598,6 +598,55 @@ repo_host = os.environ.get("REPO_HOST", "").strip().lower() config_path = sys.argv[1] +# PyYAML 6.0.3 SafeLoader implicit resolver patterns (YAML 1.1). An UNQUOTED +# plain scalar matching any of these is resolved by PyYAML to a NON-string type +# (null->None, bool, int, float, timestamp->date/datetime); a quoted scalar is +# ALWAYS a string. These mirror yaml/resolver.py so the PyYAML-absent fallback +# types unquoted scalars exactly as PyYAML would (see _implicit_nonstring). +_IMPLICIT_NULL = re.compile(r"^(?:~|null|Null|NULL|)$") +_IMPLICIT_BOOL = re.compile( + r"^(?:yes|Yes|YES|no|No|NO|true|True|TRUE|false|False|FALSE" + r"|on|On|ON|off|Off|OFF)$" +) +_IMPLICIT_INT = re.compile( + r"^(?:[-+]?0b[0-1_]+" + r"|[-+]?0[0-7_]+" + r"|[-+]?(?:0|[1-9][0-9_]*)" + r"|[-+]?0x[0-9a-fA-F_]+" + r"|[-+]?[1-9][0-9_]*(?::[0-5]?[0-9])+)$" +) +_IMPLICIT_FLOAT = re.compile( + r"^(?:[-+]?(?:[0-9][0-9_]*)\.[0-9_]*(?:[eE][-+]?[0-9]+)?" + r"|\.[0-9][0-9_]*(?:[eE][-+]?[0-9]+)?" + r"|[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+\.[0-9_]*" + r"|[-+]?\.(?:inf|Inf|INF)" + r"|\.(?:nan|NaN|NAN))$" +) +_IMPLICIT_TIMESTAMP = re.compile( + r"^(?:[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]" + r"|[0-9][0-9][0-9][0-9]-[0-9][0-9]?-[0-9][0-9]?" + r"(?:[Tt]|[ \t]+)[0-9][0-9]?" + r":[0-9][0-9]:[0-9][0-9](?:\.[0-9]*)?" + r"(?:[ \t]*(?:Z|[-+][0-9][0-9]?(?::[0-9][0-9])?))?)$" +) + + +def _implicit_nonstring(text): + # True when an UNQUOTED plain scalar would be resolved by PyYAML's SafeLoader + # to a non-string type (null/bool/int/float/timestamp). Fuzzed against real + # PyYAML 6.0.3: it never returns False where PyYAML types the scalar as a + # non-string (i.e. never fail-open), and is at worst MORE conservative on a + # couple of degenerate float spellings (e.g. "4.e8") that PyYAML keeps as a + # string -- the safe direction for this credential-selecting fallback. + return bool( + _IMPLICIT_NULL.match(text) + or _IMPLICIT_BOOL.match(text) + or _IMPLICIT_INT.match(text) + or _IMPLICIT_FLOAT.match(text) + or _IMPLICIT_TIMESTAMP.match(text) + ) + + def _strip_scalar(value): # Resolve a YAML flow scalar the way PyYAML would for tea's simple scalars: # honor surrounding quotes and strip a trailing inline comment. A quoted @@ -605,6 +654,16 @@ def _strip_scalar(value): # an unquoted scalar ends at the first whitespace-preceded '#' (a YAML # comment must be preceded by whitespace or line start), so "abc#def" stays # literal while "abc # note" becomes "abc". + # + # A quoted scalar is ALWAYS a string, so its contents are returned verbatim. + # An UNQUOTED scalar, however, is subject to PyYAML's implicit typing: forms + # like `12345`, `null`, `~`, `yes`/`true`, `3.14` or a timestamp resolve to a + # non-string (int/None/bool/float/date), which PyYAML's path would reject as + # a token via _accept's isinstance(str) guard. To stay faithful (and never + # fail OPEN by surfacing a stringified non-string as a credential) such a + # scalar returns None here so the caller treats it as absent (fail closed), + # exactly as the PyYAML path does. Only unquoted scalars PyYAML would type as + # a string are returned as a string. value = value.strip() if not value: return value @@ -620,7 +679,12 @@ def _strip_scalar(value): if char == "#" and (index == 0 or value[index - 1] in (" ", "\t")): value = value[:index] break - return value.strip() + value = value.strip() + if not value or _implicit_nonstring(value): + # Unquoted scalar that PyYAML resolves to null or another non-string + # type: fail closed rather than emit a stringified non-credential. + return None + return value def _url_matches_repo_host(url): 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 fb7d2730..c21f9745 100755 --- a/packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh +++ b/packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh @@ -440,4 +440,41 @@ write_fixture 'logins: assert_token "implicit https vs explicit :443 match" "TOK_DEFPORT" defported git.example:443 assert_token "implicit https vs :8443 rejected" "" defported git.example:8443 +# 8. An UNQUOTED token whose raw text PyYAML's implicit resolver types as a +# NON-string (int / null / bool / float) must fail closed: PyYAML yields a +# non-str value that _accept rejects, so the fallback must NOT surface the +# stringified scalar as a credential. Each raw form fails closed IDENTICALLY +# to PyYAML (a prior residual emitted "12345"/"null"/"true"/etc. here). +assert_nonstring_token_fails_closed() { + local desc="$1" raw="$2" + write_fixture "logins: + - name: primary + url: https://git.example + token: ${raw} +" + assert_token "$desc" "" primary git.example +} +assert_nonstring_token_fails_closed "unquoted int token fails closed" "12345" +assert_nonstring_token_fails_closed "unquoted null token fails closed" "null" +assert_nonstring_token_fails_closed "unquoted tilde-null token fails closed" "~" +assert_nonstring_token_fails_closed "unquoted yes(bool) token fails closed" "yes" +assert_nonstring_token_fails_closed "unquoted true(bool) token fails closed" "true" +assert_nonstring_token_fails_closed "unquoted float token fails closed" "3.14" + +# 9. A QUOTED scalar is ALWAYS a string, even when its contents look like a +# non-string implicit form. The quotes force str typing in PyYAML, so the +# fallback must accept the literal (quote-stripped) contents as the token. +write_fixture 'logins: + - name: primary + url: https://git.example + token: "12345" +' +assert_token "double-quoted digit token is a literal string" "12345" primary git.example +write_fixture "logins: + - name: primary + url: https://git.example + token: 'abc' +" +assert_token "single-quoted token is a literal string" "abc" primary git.example + echo "Gitea login resolution regression harness passed"