fix(git): fail closed on unquoted non-string YAML token scalars (#865)

Round-7 blocker-1 residual: the PyYAML-absent line-parser fallback in
detect-platform.sh (_strip_scalar) returned a stringified scalar for
UNQUOTED YAML values that PyYAML's implicit resolver types as a
non-string (int/null/bool/float/timestamp). That bypassed _accept's
isinstance(str) guard and could surface a garbage credential (e.g.
"12345", "null", "true") where the PyYAML path resolves NO token and
fails closed -- violating the module invariant that the fallback is only
ever MORE conservative than PyYAML, never less.

Root cause fix: mirror PyYAML 6.0.3's SafeLoader implicit resolver. An
unquoted plain scalar matching the null/bool/int/float/timestamp forms
now returns None (fail closed); a quoted scalar is always a string and is
accepted verbatim (quote-stripped) as before. Quoted-string handling,
scope-aware attribution, indentation, and inline-comment stripping are
unchanged. The predicate was fuzzed against real PyYAML over ~800k random
tokens with zero fail-open divergences.

Extends the forced-PyYAML-absence parser-equivalence harness with
token: 12345/null/~/yes/true/3.14 (each fails closed identically to
PyYAML) and token: "12345"/'abc' (quoted literals still accepted).

Blockers 2/3/4 (port-bound host, origin+full-path URL pin, review-body
binding) are untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hermes Agent
2026-07-21 22:17:09 -05:00
parent 99856c5567
commit 3012b5c5e5
2 changed files with 102 additions and 1 deletions

View File

@@ -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):

View File

@@ -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"