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